Compare commits

...

39 Commits

Author SHA1 Message Date
Dhruwang Jariwala
345b282733 chore: sync epic dashboards with main (#7398) 2026-03-02 13:48:04 +05:30
Dhruwang
c7c30a9d58 Merge branch 'epic/dashboards' of https://github.com/formbricks/formbricks into sync/epic-dashboards-with-main 2026-03-02 12:48:41 +05:30
Dhruwang
08510659de chore: merge main into sync branch to update epic/dashboards 2026-03-02 12:45:58 +05:30
Dhruwang Jariwala
7eb94f0bd5 fix: theme styling preview, option border color, and enable custom styling behavior (#7387)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-02 06:17:52 +00:00
Johannes
6dd2e707fe feat: display Formbricks version alongside organization ID in settings (#7363) 2026-03-02 05:54:23 +00:00
Matti Nannt
58d5de7d45 fix: resolve Dependabot Next.js deserialization alert (#7393) 2026-02-27 22:18:38 +01:00
Dhruwang Jariwala
7c3fa8b5ea fix: restore bullet points in survey preview and public survey (#7356) (#7360)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-27 18:24:15 +00:00
Harsh Bhat
2601169877 docs: add advanced CSS variable updates (#7389)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-02-27 17:19:22 +00:00
bharath kumar
aecf85815a fix(js-core): use closest() fallback for nested click target matching (#7327)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-27 06:24:58 +00:00
Dhruwang Jariwala
f8fa29d56e feat: charts ui (#7332)
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-02-26 12:21:48 +00:00
Bhagya Amarasinghe
c6ebaea989 fix: set success_action_status on S3 presigned POST to fix CORS on Ceph-based providers (#7362) 2026-02-26 10:26:49 +00:00
Bhagya Amarasinghe
68c1422733 fix: copy database package.json to Docker runner stage (#7371) 2026-02-26 10:25:28 +00:00
Dhruwang Jariwala
6942502baf fix: slack missing redirect uri (#7372) 2026-02-26 10:01:25 +00:00
Theodór Tómas
ff6176df0a chore: sync epic/dashboards with main (#7368) 2026-02-26 15:24:39 +07:00
TheodorTomas
d0f4228b45 chore: resolve merge conflicts syncing main into epic/dashboards
- segments.ts: take main's sequential WHERE clause building to prevent pool saturation
- package.json: update zod to 3.25.76 from main
- pnpm-lock.yaml: resolve zod version references, keep @suspensive/react from epic
2026-02-26 13:46:39 +07:00
Theodór Tómas
a4bd217761 chore: update to zod 3.25.76 (#7366) 2026-02-26 05:17:20 +00:00
Bhagya Amarasinghe
fee770358c perf(contacts): build segment WHERE clauses sequentially to prevent pool saturation (#7354)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-25 15:25:32 +00:00
Dhruwang Jariwala
44f8f80cac docs: clarify startAt is block-based, not question-based (#1404) (#7352)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 13:19:30 +00:00
Dhruwang Jariwala
3a802810e3 feat: Charts list page with demo create/edit and real delete/duplicate (#7353)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-02-25 11:20:34 +00:00
Dhruwang Jariwala
fbbf917093 chore: sync dashboard epic (#7351)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Theodór Tómas <theodortomas@gmail.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Gulshan <gulshanbahadur002@gmail.com>
2026-02-25 13:24:51 +05:30
Dhruwang
a7e42bfd29 Merge main into epic/dashboards to sync with latest changes 2026-02-25 12:11:43 +05:30
Dhruwang
562fdec899 Merge branch 'epic/dashboards' of https://github.com/formbricks/formbricks into epic/dashboards 2026-02-25 12:08:10 +05:30
Chowdhury Tafsir Ahmed Siddiki
858a7f7aa9 fix: replace toSorted in breadcrumb switchers for compatibility (#7325)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-25 06:29:31 +00:00
Gulshan
ac40b90e81 fix: made "Filter" string translatable (#7301)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-25 06:28:51 +00:00
Balázs Úr
aa21b4e442 fix: made Contact's page titles and table headers translatable (#7313)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-24 14:07:05 +00:00
Dhruwang Jariwala
fa72296de5 fix: error state for multi select question (#7335) 2026-02-24 13:34:48 +00:00
Theodór Tómas
d670d5de31 feat: (dashboards) listing page (#7330) 2026-02-23 20:26:03 +07:00
Theodór Tómas
5ccb4af249 feat: (dashboards) crud charts/dashboard server actions (#7307)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-23 11:44:26 +05:30
Theodór Tómas
62aa186a81 chore: merge main into dashboard epic (#7321)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-20 12:22:54 +00:00
Dhruwang
cb094761ca Merge branch 'main' of https://github.com/formbricks/formbricks into epic/dashboards 2026-02-20 17:38:35 +05:30
Theodór Tómas
f35e54f21d feat: (dashboards) adding analysis tab to sidebar along with placeholder pages (#7311) 2026-02-20 09:58:26 +05:30
Theodór Tómas
f49f40610b feat: add Cube.js dev setup and analytics client (#7287) 2026-02-18 21:10:52 +07:00
Theodór Tómas
9e754bad9c feat: add Chart, Dashboard, DashboardWidget schema and migration (#7286) 2026-02-18 21:10:36 +07:00
Dhruwang
4dcf6fda40 fix: code rabbit feedback 2026-02-18 18:44:24 +05:30
Dhruwang
1b8ccd7199 feat: add JSON type definitions for Chart and Dashboard fields
Add Zod schemas and TypeScript types for ChartQuery, ChartConfig,
WidgetLayout. ChartQuery mirrors Cube.js REST API query format.
Register types with prisma-json-types-generator.

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:21:34 +05:30
157 changed files with 12649 additions and 777 deletions

View File

@@ -229,5 +229,24 @@ REDIS_URL=redis://localhost:6379
# AUDIT_LOG_GET_USER_IP=0
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
# CUBEJS_API_SECRET=
# URL where the Cube.js instance is running
# CUBEJS_API_URL=http://localhost:4000
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
# CUBEJS_API_TOKEN=
#
# Cube connects to the Hub DB. When using docker-compose.dev.yml with the hub network,
# use the container name and internal port. Hub credentials: formbricks/formbricks_dev, db: hub
# CUBEJS_DB_HOST=formbricks_hub_postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=hub
# CUBEJS_DB_USER=formbricks
# CUBEJS_DB_PASS=formbricks_dev
#
# Alternative (when not on same Docker network): host.docker.internal and port 5433
# Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here

View File

@@ -32,6 +32,7 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
We are using SonarQube to identify code smells and security hotspots.
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
## Architecture & Patterns

View File

@@ -101,6 +101,9 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
# Create packages/database directory structure with proper ownership for runtime migrations
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma

View File

@@ -228,7 +228,7 @@ export const ProjectSettings = ({
</FormProvider>
</div>
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
<div className="relative flex w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 p-6 shadow">
{logoUrl && (
<Image
src={logoUrl}
@@ -239,18 +239,16 @@ export const ProjectSettings = ({
/>
)}
<p className="text-sm text-slate-400">{t("common.preview")}</p>
<div className="z-0 h-3/4 w-3/4">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
onFileUpload={async (file) => file.name}
autoFocus={false}
/>
</div>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || t("common.my_product"), t)}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
onFileUpload={async (file) => file.name}
autoFocus={false}
/>
</div>
<CreateTeamModal
open={createTeamModalOpen}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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} />

View File

@@ -81,7 +81,7 @@ export const OrganizationBreadcrumb = ({
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort organizations by name
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
// Handle server errors or validation errors

View File

@@ -82,7 +82,7 @@ export const ProjectBreadcrumb = ({
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
// Handle server errors or validation errors

View File

@@ -9,6 +9,7 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
@@ -81,7 +82,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard>
)}
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<div className="space-y-2">
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
</div>
</PageContentWrapper>
);
};

View File

@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
},
{
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
},
]}
/>

View File

@@ -241,7 +241,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<PopoverTriggerButton isOpen={isOpen}>
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
{t("common.filter")} <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
</PopoverTriggerButton>
</PopoverTrigger>
<PopoverContent
@@ -329,7 +329,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
</div>
{i !== filterValue.filter.length - 1 && (
<div className="my-4 flex items-center">
<p className="mr-4 font-semibold text-slate-800">and</p>
<p className="mr-4 font-semibold text-slate-800">{t("common.and")}</p>
<hr className="w-full text-slate-600" />
</div>
)}

View File

@@ -6,7 +6,7 @@ import {
} from "@formbricks/types/integration/slack";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
@@ -56,6 +56,7 @@ export const GET = withV1ApiWrapper({
code,
client_id: SLACK_CLIENT_ID,
client_secret: SLACK_CLIENT_SECRET,
redirect_uri: SLACK_REDIRECT_URI,
};
const formBody: string[] = [];
for (const property in formData) {

View File

@@ -4854,6 +4854,17 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
}),
isDraft: true,
},
{
...buildOpenTextElement({
id: "preview-open-text-01",
headline: t("templates.preview_survey_question_open_text_headline"),
subheader: t("templates.preview_survey_question_open_text_subheader"),
placeholder: t("templates.preview_survey_question_open_text_placeholder"),
inputType: "text",
required: false,
}),
isDraft: true,
},
],
buttonLabel: createI18nString(t("templates.next"), []),
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),

View File

@@ -106,6 +106,7 @@ checksums:
common/allow: 3e39cc5940255e6bff0fea95c817dd43
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
common/and: dc75b95c804b16dc617a5f16f7393bca
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
@@ -122,6 +123,8 @@ checksums:
common/bottom_right: aaef9a70ef795affc806c6d1853d8373
common/cancel: 2e2a849c2223911717de8caa2c71bade
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
common/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
common/charts: 1da4564d89264c89de4ed28d7451b43e
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
common/choose_environment: 5762cd499529815fc3e6a7feea39f90b
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
@@ -151,6 +154,7 @@ checksums:
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
common/count_responses: 690118a456c01c5b4d437ae82b50b131
common/create: 757ccd28dd533ff3a933355273c1e32a
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
common/create_survey: 1cfbba08d34876566d84b2960054a987
@@ -160,6 +164,8 @@ checksums:
common/created_by: 6775c2fa7d495fea48f1ad816daea93b
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
common/dashboard: c9380ea68c8c76ea451bd9613329a07c
common/dashboards: 4bc47e48559a6b688684dcb7ac4babc9
common/date: 56f41c5d30a76295bb087b20b7bee4c3
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
@@ -198,7 +204,9 @@ checksums:
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
common/filter: 626325a05e4c8800f7ede7012b0cadaf
common/finish: ffa7a10f71182b48fefed7135bee24fa
common/first_name: cf040a5d6a9fd696be400380cc99f54b
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
@@ -210,7 +218,9 @@ checksums:
common/hidden: fa290c6ada5869d744ed35e9cca64699
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
common/hide: a6088b934651055bb27314d111be510b
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
common/id: c8886d38aeea2ed5f785aba4fc96784b
common/image: 048ba7a239de0fbd883ade8558415830
common/images: 9305827c28694866f49db42b4c51831f
common/import: 348b8ab981de5b7f1fca6d7302263bbd
@@ -228,6 +238,7 @@ checksums:
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
common/label: a5c71bf158481233f8215dbd38cc196b
common/language: 277fd1a41cc237a437cd1d5e4a80463b
common/last_name: 2c9a7de7738ca007ba9023c385149c26
common/learn_more: e598091d132f890c37a6d4ed94f6d794
common/license_expired: 7af13535e320e4197989472c01387d2c
common/light_overlay: 0499907ea7b8405f4267b117998b5a78
@@ -254,6 +265,7 @@ checksums:
common/move_down: 4f4de55743043355ad4a839aff2c48ff
common/move_up: 69f25b205c677abdb26cbb69d97cd10b
common/multiple_languages: 7d8ddd4b40d32fcd7bd6f7bac6485b1f
common/my_product: ad022177062f9ef6e9acf33b13e889aa
common/name: 9368b5a047572b6051f334af5aa76819
common/new: 126d036fae5fb6b629728ecb97e6195b
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
@@ -277,6 +289,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
@@ -419,6 +432,7 @@ checksums:
common/variables: ffd3eec5497af36d7b4e4185bad1313a
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
common/video: 8050c90e4289b105a0780f0fdda6ff66
common/view: 36a9b5e3dc153c036d320460d72a03c3
common/warning: 6618da2c7e5e93bb4ea0e16d29ab8c4c
common/we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable: f29f2e0286195dab170b9806bcd74fc9
common/webhook: 70f95b2c27f2c3840b500fcaf79ee83c
@@ -576,6 +590,170 @@ 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/OR: 0208d355f231c386b19390f0bea41b95
environments/analysis/charts/add_chart_to_dashboard: c2a517ada86cdda60e49bec655ca9a6d
environments/analysis/charts/add_chart_to_dashboard_description: 08980a1849757e9aec21fca5881c6be4
environments/analysis/charts/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
environments/analysis/charts/add_to_dashboard: 9941c3d30895bb8e25ce8d4e03d33a08
environments/analysis/charts/advanced_chart_builder_config_prompt: c2fe2c1a076f27d3ae62a4db75474b0a
environments/analysis/charts/ai_query_placeholder: 24c3d18f514cb3a9953f04c3b04503a2
environments/analysis/charts/ai_query_section_description: 66d06342f29bf6658793403856521fd7
environments/analysis/charts/ai_query_section_title: c0e450a47af7c2a516b77f73cf54db1b
environments/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3
environments/analysis/charts/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
environments/analysis/charts/chart_added_to_dashboard: 7bc429ab605cb89a9232c26be008cc00
environments/analysis/charts/chart_builder_choose_chart_type: 1376de2dcafac573a2df9e4c007b0ec8
environments/analysis/charts/chart_data: 6739a9576b357a58d73ff0c9bf8db0e4
environments/analysis/charts/chart_data_tab: b7b46ab6ce9606032c8f81f6f6afbb9b
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_name: cdb36e2f121a7b9c28298e15ab8218dc
environments/analysis/charts/chart_name_placeholder: 7370d4f88f27aea337ba1c36465c3f8b
environments/analysis/charts/chart_preview: 1b7faae244d31e43f758f50b94132413
environments/analysis/charts/chart_render_error: 01e9ece0c86a1fedf301afa0dbbf6aeb
environments/analysis/charts/chart_saved_successfully: 2489c853c0b36790e3592ac6ea31cc61
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_not_supported: 7ff0afc493b36f3f3c12c7c230df9757
environments/analysis/charts/chart_type_pie: 068a797404233ccf68d07ad63af7b50c
environments/analysis/charts/chart_updated_successfully: a2c210523902c726aa1328bbeda0b357
environments/analysis/charts/configure_description: 2939321f78e4ffbc57b4259ddaddb09d
environments/analysis/charts/configure_title: ab767b11da1d386b98b3f634f79d3abe
environments/analysis/charts/configure_type_label: cd13e4b37fb2021af55903e7690a9856
environments/analysis/charts/contains: 06dd606c0a8f81f9a03b414e9ae89440
environments/analysis/charts/create_chart: ca7fdcc964e01f42ea9709924221edba
environments/analysis/charts/create_chart_description: b9680bd8905dea180fa59a86f61de34e
environments/analysis/charts/custom_range: 99f4d72b64621406acc162cceeb1fed7
environments/analysis/charts/dashboard: c9380ea68c8c76ea451bd9613329a07c
environments/analysis/charts/dashboard_select_placeholder: 9b875f2f10050d650ae63be53fe0d4e8
environments/analysis/charts/data_label: b7b46ab6ce9606032c8f81f6f6afbb9b
environments/analysis/charts/date_preset_last_30_days: a738894cfc5e592052f1e16787744568
environments/analysis/charts/date_preset_last_7_days: 3631df3109bfecfe358ba15dcf8bd6f5
environments/analysis/charts/date_preset_last_month: 848086395b28875c050d56e3933dae61
environments/analysis/charts/date_preset_this_month: 50845a38865204a97773c44dcd2ebb90
environments/analysis/charts/date_preset_this_quarter: 9c77d94783dff2269c069389122cd7bd
environments/analysis/charts/date_preset_this_year: 1e69651c2ac722f8ce138f43cf2e02f9
environments/analysis/charts/date_preset_today: 142173f9752e18e92109623a3ee68cad
environments/analysis/charts/date_preset_yesterday: eeb58908e68ff96c1b7e8f90e389afb7
environments/analysis/charts/date_range: 9b3aa5954144de586931f60ef9594e99
environments/analysis/charts/delete_chart_confirmation: f7fd7b0a08e81c9b392b08c9c1ad2147
environments/analysis/charts/dimensions: f09d837ac25f58986a769bd48ea15022
environments/analysis/charts/dimensions_toggle_description: 50d1c6e73d2cb7320c9e29cec11b4c76
environments/analysis/charts/edit_chart_description: 822890e4b6068096e2fe8b7b78b4474f
environments/analysis/charts/edit_chart_title: fd3e7f8c53280bfad8f4034c055f4c71
environments/analysis/charts/enable_time_dimension: cfcf0af2d22bccd197319c07680c2cb8
environments/analysis/charts/end_date: acbea5a9fd7a6fadf5aa1b4f47188203
environments/analysis/charts/enter_a_name_for_your_chart: b6e992a23d0628136121ebf26eec4a50
environments/analysis/charts/enter_value: a4554ed67c02872e302b0042724f859d
environments/analysis/charts/equals: 264ec282f7f5b67da622cc37f2b57b8a
environments/analysis/charts/failed_to_add_chart_to_dashboard: 355a5606399edcbb3e6d0ba0b66f12a6
environments/analysis/charts/failed_to_execute_query: d1153133aa4cd3d1cd02e39942413168
environments/analysis/charts/failed_to_load_chart: abea098fbf8e728f95414d3ae8bb63a4
environments/analysis/charts/failed_to_load_chart_data: ea980a6d12b1b1efed90d991dd0dd0fd
environments/analysis/charts/failed_to_save_chart: e237cf1a56a8f9ee30067fdb0757f7c5
environments/analysis/charts/field: cfd632297d7809a3539e90c9cd4728d9
environments/analysis/charts/field_label_average_score: 5b5aa7322549521d1e813b1c8312d443
environments/analysis/charts/field_label_collected_at: b41902ddb4586ba4a4611d726b5014aa
environments/analysis/charts/field_label_count: 9c5848662eb8024ddf360f7e4001a968
environments/analysis/charts/field_label_detractor_count: eedb15bc383eb0f14d43043e6666c62a
environments/analysis/charts/field_label_emotion: eb3a31ead51b5c8a8d365d5f904e9206
environments/analysis/charts/field_label_field_type: 2581066dc304c853a4a817c20996fa08
environments/analysis/charts/field_label_nps_score: 9c8d0b0b460f9689bd66e81d45e0a2df
environments/analysis/charts/field_label_nps_value: cb7404025044400e3d7d5600f3133e4f
environments/analysis/charts/field_label_passive_count: ceb71da8d1382eb2097089dc3ecf76da
environments/analysis/charts/field_label_promoter_count: c393131a4bd3a25bf6b297beed20e34f
environments/analysis/charts/field_label_response_id: 73375099cc976dc7203b8e27f5f709e0
environments/analysis/charts/field_label_sentiment: 9ba5719c80c0136c2d0644217619aff6
environments/analysis/charts/field_label_source_name: 157675beca12efcd8ec512c5256b1a61
environments/analysis/charts/field_label_source_type: d1ff69af76c687eb189db72030717570
environments/analysis/charts/field_label_topic: 7f542b783cd528f00f4f485e35b48dc1
environments/analysis/charts/field_label_user_identifier: b0174469c95038766744fb7e64005aec
environments/analysis/charts/filters: acf5accc113ff3c1992688058576732c
environments/analysis/charts/filters_toggle_description: ea18bdb212a6a85620125cab89a4b1c1
environments/analysis/charts/generate_chart: 8b0ca95be31a8401b13eafa26cf01d31
environments/analysis/charts/granularity: 9eb09aef092e7803ce4acb7965cbbaa9
environments/analysis/charts/granularity_day: 47648cd60fc313bc3f05b70357a1d675
environments/analysis/charts/granularity_hour: ec3113f22fc51d01f0c615c5496f8f87
environments/analysis/charts/granularity_month: ae7bef950efc406ff0980affabc1a64c
environments/analysis/charts/granularity_quarter: 7a68ec90d7c90b92b7bb873834a00381
environments/analysis/charts/granularity_week: 436fdd694160827dd6ea4644cdd0a8f8
environments/analysis/charts/granularity_year: ed86f5f60583f9d8ffdbeed306aa0ec7
environments/analysis/charts/greater_than: a4c18b3b45fcaf7c83bf489cf2b506d4
environments/analysis/charts/greater_than_or_equal: d453e26d136847560148168797fece51
environments/analysis/charts/group_by: 3f1cedea7783018ce83f2fab0051a738
environments/analysis/charts/group_by_description: c54368c05d71c1bdbd2a5c0629c1dc03
environments/analysis/charts/guide_button: 3c5e2e28f6d9f1a644759c9c19878539
environments/analysis/charts/guide_chart_type: 1fd60a98a0b5a7f54521e7671772e4a3
environments/analysis/charts/guide_chart_type_desc: 4630292b6955c930a9c6d4169bf656a2
environments/analysis/charts/guide_dimensions: 746caf6f43a222f3ffdaae578323d36a
environments/analysis/charts/guide_dimensions_desc: 909a149ef47c2f811d65f437b34ea719
environments/analysis/charts/guide_filters: acf5accc113ff3c1992688058576732c
environments/analysis/charts/guide_filters_desc: 0c18f563b477cd9a0f2309c31174cd93
environments/analysis/charts/guide_measures: 2e4d2701ebb196e5a9122f03727e93d7
environments/analysis/charts/guide_measures_predefined: cb8b80a960a466aca9ad75d3e870f74b
environments/analysis/charts/guide_quick_ref: 6538588cf9323d85bf11b794448d846d
environments/analysis/charts/guide_term_dimension: 64bd5923ae7aa2cdbf967aca977e4945
environments/analysis/charts/guide_term_filter: c8dc27ccd08e7ec1e268dfd286660e79
environments/analysis/charts/guide_term_measure: ca94a6e1afcb8a7ddb0d79039ecd3bfb
environments/analysis/charts/guide_term_time: ddd5b6a7a0f8525b0fe2b7c3431319f2
environments/analysis/charts/guide_time_dimension: c6fed7f718296b2f23230a918bfe6196
environments/analysis/charts/guide_time_dimension_desc: 4565fd19f4346e0f0d52f79640d7d749
environments/analysis/charts/guide_title: e887f73e68a76c88fdef859bafc866a1
environments/analysis/charts/is_not_set: 906801489132487ef457652af4835142
environments/analysis/charts/is_set: 9850468156356f95884bbaf56b6687aa
environments/analysis/charts/less_than: fb41255dd44bb6de78617b078610c91b
environments/analysis/charts/less_than_or_equal: da4a2816aadf788d33efcdcc3c61802e
environments/analysis/charts/measures: b1e6cf0f356dda0052c4fef4ad4957a2
environments/analysis/charts/no_charts_found: d4a27d5b56e49ebdd38bf28791dbcc42
environments/analysis/charts/no_dashboards_available: f88389b6c5278cfc4d5b360031205dfe
environments/analysis/charts/no_dashboards_create_first: 28ded0d72247191eb23f6f77925df539
environments/analysis/charts/no_data_available: fe1d34a45e22b5611d255b84b2d67232
environments/analysis/charts/no_data_returned: 683acf7b4f3b32aa85fa26f1bb948d4f
environments/analysis/charts/no_data_returned_for_chart: b9ff6c85697c683f40b3d0c05eeb2046
environments/analysis/charts/no_grouping: e3a6943e61407600cae057e0833a482d
environments/analysis/charts/no_valid_data_to_display: d1ba2b0686520c0a2c62ee73daa1c9c9
environments/analysis/charts/not_contains: 5894f5474271b8902d7892e43500d227
environments/analysis/charts/not_equals: 427715f1ea349965c36f5c628784eb08
environments/analysis/charts/open_chart: bc3bed1517ad63c1bcccfbbc430ab333
environments/analysis/charts/open_options: 2c6a35fec9b9d008e41728594bcd07d7
environments/analysis/charts/or_filter_logic: 0208d355f231c386b19390f0bea41b95
environments/analysis/charts/original: 7e55782bdf7cb49f5616b326c003c278
environments/analysis/charts/please_enter_chart_name: 9258b71b2cb09d22ffe33de1755e7309
environments/analysis/charts/please_select_at_least_one_measure: d4163ede267f71ee65945f453e14ff7b
environments/analysis/charts/please_select_dashboard: 8f062db96f815ed8268584dd8d292fa6
environments/analysis/charts/predefined_measures: 7651141f62c991954edcff70899b2a8b
environments/analysis/charts/preset: a17bb0bf56f3326c9567be3ea896ee19
environments/analysis/charts/query_executed_successfully: 9d6f9dad526fcfe0161757c2d2fe2c69
environments/analysis/charts/reset_to_ai_suggestion: 51ced8dd7c0eea8b7fc4e08b35cfbf30
environments/analysis/charts/save_chart: 2e4505f7bf3d1c35b0b37b1e9d3dc566
environments/analysis/charts/save_chart_dialog_title: 2e4505f7bf3d1c35b0b37b1e9d3dc566
environments/analysis/charts/select_dimensions: 6d0d038d027ef9e641bf9b7700edac9f
environments/analysis/charts/select_field: 45665a44f7d5707506364f17f28db3bf
environments/analysis/charts/select_measures: c9f101aeb53bf0d4abdd652aaf60a1bf
environments/analysis/charts/select_preset: e68bad9a209a6ca35c62184f1f1d829c
environments/analysis/charts/showing_first_n_of: 4dec3215fd3150a16ad5c72f17ae02bc
environments/analysis/charts/start_date: 881de78c79b56f5ceb9b7103bf23cb2c
environments/analysis/charts/time_dimension: 5c967f2a6a875b00825068df5cb2ef84
environments/analysis/charts/time_dimension_toggle_description: 28da119989e3c73b098c650fe279ee4a
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
@@ -1171,6 +1349,7 @@ checksums:
environments/surveys/edit/add_fallback_placeholder: 0e77ea487ddd7bc7fc2f1574b018dc08
environments/surveys/edit/add_hidden_field_id: a8f55b51b790cf5f4d898af7770ad1ed
environments/surveys/edit/add_highlight_border: 66f52b21fbb9aa6561c98a090abaaf8f
environments/surveys/edit/add_highlight_border_description: fe548fe03ea10ef5cd9e553d6812b3c2
environments/surveys/edit/add_logic: f234c9f1393a9ed4792dfbd15838c951
environments/surveys/edit/add_none_of_the_above: dbe1ada4512d6c3f80c54c8fac107ec6
environments/surveys/edit/add_option: 143c54f0b201067fe5159284d6daeca2
@@ -1369,7 +1548,6 @@ checksums:
environments/surveys/edit/follow_ups_modal_updated_successfull_toast: 61204fada3231f4f1fe3866e87e1130a
environments/surveys/edit/follow_ups_new: 224c779d252b3e75086e4ed456ba2548
environments/surveys/edit/follow_ups_upgrade_button_text: 4cd167527fc6cdb5b0bfc9b486b142a8
environments/surveys/edit/form_styling: 1278a2db4257b5500474161133acc857
environments/surveys/edit/formbricks_sdk_is_not_connected: 35165b0cac182a98408007a378cc677e
environments/surveys/edit/four_points: b289628a6b8a6cd0f7d17a14ca6cd7bf
environments/surveys/edit/heading: 79e9dfa461f38a239d34b9833ca103f1
@@ -1540,7 +1718,7 @@ checksums:
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
environments/surveys/edit/roundness_description: bde131aa5674836416dcdf2ff517d899
environments/surveys/edit/roundness_description: 03940a6871ae43efa4810cba7cadb74b
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
environments/surveys/edit/rows: 8f41f34e6ca28221cf1ebd948af4c151
environments/surveys/edit/save_and_close: 6ede705b3f82f30269ff3054a5049e34
@@ -1586,6 +1764,7 @@ checksums:
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
environments/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
@@ -2039,7 +2218,7 @@ checksums:
environments/workspace/look/advanced_styling_field_description_size: a0d51c3ab7dc56320ecedc2b27917842
environments/workspace/look/advanced_styling_field_description_size_description: ff880ea1beddd1b1ec7416d0b8a69cf3
environments/workspace/look/advanced_styling_field_description_weight: 514680cc7202ad29835c1cbcde3def1c
environments/workspace/look/advanced_styling_field_description_weight_description: 441ac8db1a32557813eb68fbfd759061
environments/workspace/look/advanced_styling_field_description_weight_description: aa95bc81b5336a548e256bce49350683
environments/workspace/look/advanced_styling_field_font_size: ca44d14429b2175a1b194793b4ab8f6b
environments/workspace/look/advanced_styling_field_font_weight: bfef83778146cf40550df9650d8a07da
environments/workspace/look/advanced_styling_field_headline_color: 4ccf3935ad90c88ad4add24f498673ce
@@ -2053,7 +2232,7 @@ checksums:
environments/workspace/look/advanced_styling_field_indicator_bg_description: 7eb3b54a8b331354ec95c0dc1545c620
environments/workspace/look/advanced_styling_field_input_border_radius_description: 0007f1bb572b35d9a3720daeb7a55617
environments/workspace/look/advanced_styling_field_input_font_size_description: 5311f95dcbd083623e35c98ea5374c3b
environments/workspace/look/advanced_styling_field_input_height_description: e19ec0dc432478def0fd1199ad765e38
environments/workspace/look/advanced_styling_field_input_height_description: bb7439d42ec3848a8fa9edb8b001b69a
environments/workspace/look/advanced_styling_field_input_padding_x_description: 10e14296468321c13fda77fd1ba58dfd
environments/workspace/look/advanced_styling_field_input_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
environments/workspace/look/advanced_styling_field_input_placeholder_opacity_description: f55a6700884d24014404e58876121ddf
@@ -2062,6 +2241,8 @@ checksums:
environments/workspace/look/advanced_styling_field_input_text_description: 460450df24ea0cc902710118a5000feb
environments/workspace/look/advanced_styling_field_option_bg: 0ceaed10d99ed4ad83cb0934ab970174
environments/workspace/look/advanced_styling_field_option_bg_description: 6cd6ccecbbb9f2f19439d7c682eb67c1
environments/workspace/look/advanced_styling_field_option_border: aa478eb148515b6a2637fb144ff72028
environments/workspace/look/advanced_styling_field_option_border_description: 8f75b740e8dcb7f6cfeff2e5d5ca7c92
environments/workspace/look/advanced_styling_field_option_border_radius_description: 23f81c25b2681a7c9e2c4f2e7d2e0656
environments/workspace/look/advanced_styling_field_option_font_size_description: 5430fd9b08819972f0a613bf3fa659da
environments/workspace/look/advanced_styling_field_option_label: 2767a5db32742073a01aac16488e93dc
@@ -2843,6 +3024,9 @@ checksums:
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
templates/preview_survey_question_open_text_headline: a9509a47e0456ae98ec3ddac3d6fad2c
templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
templates/prioritize_features_description: 1eae41fad0e3947f803d8539081e59ec
templates/prioritize_features_name: 4ca59ff1f9c319aaa68c3106d820fd6a

View File

@@ -63,7 +63,8 @@ export const INVITE_DISABLED = env.INVITE_DISABLED === "1";
export const SLACK_CLIENT_SECRET = env.SLACK_CLIENT_SECRET;
export const SLACK_CLIENT_ID = env.SLACK_CLIENT_ID;
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read`;
export const SLACK_REDIRECT_URI = `${WEBAPP_URL}/api/v1/integrations/slack/callback`;
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read&redirect_uri=${SLACK_REDIRECT_URI}`;
export const GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID;
export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET;

View File

@@ -60,6 +60,7 @@ export const getSuggestedColors = (brandColor: string = DEFAULT_BRAND_COLOR) =>
// Options (Radio / Checkbox)
"optionBgColor.light": inputBg,
"optionLabelColor.light": questionColor,
"optionBorderColor.light": inputBorder,
// Card
"cardBackgroundColor.light": cardBg,
@@ -138,6 +139,7 @@ export const STYLE_DEFAULTS: TProjectStyling = {
// Options
optionBgColor: { light: _colors["optionBgColor.light"] },
optionLabelColor: { light: _colors["optionLabelColor.light"] },
optionBorderColor: { light: _colors["optionBorderColor.light"] },
optionBorderRadius: 8,
optionPaddingX: 16,
optionPaddingY: 16,
@@ -169,6 +171,7 @@ export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Recor
const q = light("questionColor");
const b = light("brandColor");
const i = light("inputColor");
const inputBorder = light("inputBorderColor");
return {
...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }),
@@ -179,9 +182,9 @@ export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Recor
...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }),
...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }),
...(i && !saved.optionBgColor && { optionBgColor: { light: i } }),
...(inputBorder && !saved.optionBorderColor && { optionBorderColor: { light: inputBorder } }),
...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }),
...(b &&
!saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
...(b && !saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
};
};
@@ -211,6 +214,7 @@ export const buildStylingFromBrandColor = (brandColor: string = DEFAULT_BRAND_CO
inputTextColor: { light: colors["inputTextColor.light"] },
optionBgColor: { light: colors["optionBgColor.light"] },
optionLabelColor: { light: colors["optionLabelColor.light"] },
optionBorderColor: { light: colors["optionBorderColor.light"] },
cardBackgroundColor: { light: colors["cardBackgroundColor.light"] },
cardBorderColor: { light: colors["cardBorderColor.light"] },
highlightBorderColor: { light: colors["highlightBorderColor.light"] },

View File

@@ -22,6 +22,9 @@ export type AuditLoggingCtx = {
quotaId?: string;
teamId?: string;
integrationId?: string;
chartId?: string;
dashboardId?: string;
dashboardWidgetId?: string;
};
export type ActionClientCtx = {

View File

@@ -133,6 +133,7 @@
"allow": "erlauben",
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken",
"an_unknown_error_occurred_while_deleting_table_items": "Beim Löschen von {type}s ist ein unbekannter Fehler aufgetreten",
"analysis": "Analyse",
"and": "und",
"and_response_limit_of": "und Antwortlimit von",
"anonymous": "Anonym",
@@ -149,6 +150,8 @@
"bottom_right": "Unten rechts",
"cancel": "Abbrechen",
"centered_modal": "Zentriertes Modalfenster",
"chart": "Diagramm",
"charts": "Diagramme",
"choices": "Entscheidungen",
"choose_environment": "Umgebung auswählen",
"choose_organization": "Organisation auswählen",
@@ -178,6 +181,7 @@
"count_attributes": "{value, plural, one {{value} Attribut} other {{value} Attribute}}",
"count_contacts": "{value, plural, one {{value} Kontakt} other {{value} Kontakte}}",
"count_responses": "{value, plural, one {{value} Antwort} other {{value} Antworten}}",
"create": "Erstellen",
"create_new_organization": "Neue Organisation erstellen",
"create_segment": "Segment erstellen",
"create_survey": "Umfrage erstellen",
@@ -187,6 +191,8 @@
"created_by": "Erstellt von",
"customer_success": "Kundenerfolg",
"dark_overlay": "Dunkle Überlagerung",
"dashboard": "Dashboard",
"dashboards": "Dashboards",
"date": "Datum",
"days": "Tage",
"default": "Standard",
@@ -225,7 +231,9 @@
"failed_to_copy_to_clipboard": "Fehler beim Kopieren in die Zwischenablage",
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
"filter": "Filter",
"finish": "Fertigstellen",
"first_name": "Vorname",
"follow_these": "Folge diesen",
"formbricks_version": "Formbricks Version",
"full_name": "Name",
@@ -237,7 +245,9 @@
"hidden": "Versteckt",
"hidden_field": "Verstecktes Feld",
"hidden_fields": "Versteckte Felder",
"hide": "Ausblenden",
"hide_column": "Spalte ausblenden",
"id": "ID",
"image": "Bild",
"images": "Bilder",
"import": "Importieren",
@@ -255,6 +265,7 @@
"key": "Schlüssel",
"label": "Bezeichnung",
"language": "Sprache",
"last_name": "Nachname",
"learn_more": "Mehr erfahren",
"license_expired": "License Expired",
"light_overlay": "Helle Überlagerung",
@@ -281,6 +292,7 @@
"move_down": "Nach unten bewegen",
"move_up": "Nach oben bewegen",
"multiple_languages": "Mehrsprachigkeit",
"my_product": "mein Produkt",
"name": "Name",
"new": "Neu",
"new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!",
@@ -304,6 +316,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",
@@ -446,6 +459,7 @@
"variables": "Variablen",
"verified_email": "Verifizierte E-Mail",
"video": "Video",
"view": "Ansehen",
"warning": "Warnung",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Wir konnten Ihre Lizenz nicht überprüfen, da der Lizenzserver nicht erreichbar ist.",
"webhook": "Webhook",
@@ -609,6 +623,176 @@
"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": {
"OR": "ODER",
"add_chart_to_dashboard": "Diagramm zum Dashboard hinzufügen",
"add_chart_to_dashboard_description": "Wähle ein Dashboard aus, um dieses Diagramm hinzuzufügen. Das Diagramm wird automatisch gespeichert.",
"add_filter": "Filter hinzufügen",
"add_to_dashboard": "Zum Dashboard hinzufügen",
"advanced_chart_builder_config_prompt": "Konfiguriere dein Diagramm und klicke auf \"Abfrage ausführen\", um eine Vorschau zu sehen",
"ai_query_placeholder": "z.B. Wie viele Nutzer haben sich letzte Woche angemeldet?",
"ai_query_section_description": "Beschreibe, was du sehen möchtest, und lass die KI das Diagramm erstellen.",
"ai_query_section_title": "Frag deine Daten",
"apply_changes": "Änderungen übernehmen",
"chart": "Diagramm",
"chart_added_to_dashboard": "Diagramm zum Dashboard hinzugefügt!",
"chart_builder_choose_chart_type": "Diagrammtyp auswählen",
"chart_data": "Diagrammdaten",
"chart_data_tab": "Daten",
"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_name": "Diagrammname",
"chart_name_placeholder": "Diagrammname",
"chart_preview": "Diagrammvorschau",
"chart_render_error": "Beim Rendern dieses Diagramms ist etwas schiefgelaufen.",
"chart_saved_successfully": "Diagramm erfolgreich gespeichert!",
"chart_type_area": "Flächendiagramm",
"chart_type_bar": "Balkendiagramm",
"chart_type_big_number": "Große Zahl",
"chart_type_line": "Liniendiagramm",
"chart_type_not_supported": "Diagrammtyp \"{{chartType}}\" wird noch nicht unterstützt",
"chart_type_pie": "Kreisdiagramm",
"chart_updated_successfully": "Diagramm erfolgreich aktualisiert!",
"configure_description": "Ändere den Diagrammtyp und andere Einstellungen für diese Visualisierung.",
"configure_title": "Diagramm konfigurieren",
"configure_type_label": "Diagrammtyp",
"contains": "enthält",
"create_chart": "Diagramm erstellen",
"create_chart_description": "Nutze KI, um ein Diagramm zu generieren, oder erstelle es manuell.",
"custom_range": "Benutzerdefinierter Bereich",
"dashboard": "Dashboard",
"dashboard_select_placeholder": "Wähle ein Dashboard aus",
"data_label": "Daten",
"date_preset_last_30_days": "Letzte 30 Tage",
"date_preset_last_7_days": "Letzte 7 Tage",
"date_preset_last_month": "Letzter Monat",
"date_preset_this_month": "Dieser Monat",
"date_preset_this_quarter": "Dieses Quartal",
"date_preset_this_year": "Dieses Jahr",
"date_preset_today": "Heute",
"date_preset_yesterday": "Gestern",
"date_range": "Datumsbereich",
"delete_chart_confirmation": "Bist du sicher, dass du dieses Diagramm löschen möchtest?",
"dimensions": "Dimensionen",
"dimensions_toggle_description": "Gruppiere Daten nach Kategorien. Die Reihenfolge ist bei mehrdimensionalen Diagrammen wichtig.",
"edit_chart_description": "Sieh dir deine Diagrammkonfiguration an und bearbeite sie.",
"edit_chart_title": "Diagramm bearbeiten",
"enable_time_dimension": "Zeitdimension aktivieren",
"end_date": "Enddatum",
"enter_a_name_for_your_chart": "Gib einen Namen für dein Diagramm ein, um es zu speichern.",
"enter_value": "Wert eingeben",
"equals": "gleich",
"failed_to_add_chart_to_dashboard": "Diagramm konnte nicht zum Dashboard hinzugefügt werden",
"failed_to_execute_query": "Abfrage konnte nicht ausgeführt werden",
"failed_to_load_chart": "Diagramm konnte nicht geladen werden",
"failed_to_load_chart_data": "Diagrammdaten konnten nicht geladen werden",
"failed_to_save_chart": "Diagramm konnte nicht gespeichert werden",
"field": "Feld",
"field_label_average_score": "Durchschnittliche Bewertung",
"field_label_collected_at": "Erfasst am",
"field_label_count": "Anzahl",
"field_label_detractor_count": "Anzahl Kritiker",
"field_label_emotion": "Emotion",
"field_label_field_type": "Feldtyp",
"field_label_nps_score": "NPS-Score",
"field_label_nps_value": "NPS-Wert",
"field_label_passive_count": "Anzahl Passive",
"field_label_promoter_count": "Anzahl Promoter",
"field_label_response_id": "Antwort-ID",
"field_label_sentiment": "Stimmung",
"field_label_source_name": "Quellenname",
"field_label_source_type": "Quellentyp",
"field_label_topic": "Thema",
"field_label_user_identifier": "Benutzerkennung",
"filters": "Filter",
"filters_toggle_description": "Nur Daten einbeziehen, die die folgenden Bedingungen erfüllen.",
"generate_chart": "Diagramm generieren",
"granularity": "Granularität",
"granularity_day": "Tag",
"granularity_hour": "Stunde",
"granularity_month": "Monat",
"granularity_quarter": "Quartal",
"granularity_week": "Woche",
"granularity_year": "Jahr",
"greater_than": "größer als",
"greater_than_or_equal": "größer als oder gleich",
"group_by": "Gruppieren nach",
"group_by_description": "Wähle Dimensionen aus, um deine Daten aufzuschlüsseln. Die Reihenfolge ist bei mehrdimensionalen Diagrammen wichtig.",
"guide_button": "Feldleitfaden anzeigen",
"guide_chart_type": "Diagrammtyp",
"guide_chart_type_desc": "Wie die Daten visualisiert werden: Fläche, Balken, Linie, Kreis oder große Zahl. Wähle basierend darauf, was du zeigen möchtest (Trends, Vergleiche, Teile eines Ganzen usw.).",
"guide_dimensions": "Dimensionen (Gruppieren nach)",
"guide_dimensions_desc": "Wie du die Daten aufteilst oder gruppierst. Jede Dimension wird zu einer Kategorie im Diagramm (z. B. Stimmung, Quellentyp, Umfragename, Kanal, Thema). Die Reihenfolge ist bei mehrdimensionalen Diagrammen wichtig.",
"guide_filters": "Filter",
"guide_filters_desc": "Bedingungen, die einschränken, welche Daten einbezogen werden. Jeder Filter hat ein Feld, einen Operator (gleich, enthält, größer als usw.) und Werte. Und = alle müssen übereinstimmen; Oder = beliebige können übereinstimmen.",
"guide_measures": "Kennzahlen (was du zählst oder aggregierst)",
"guide_measures_predefined": "Vordefinierte Kennzahlen sind vorgefertigte Metriken aus deinen Feedback-Daten: Count (Gesamtantworten), Promoter/Detractor/Passive Count (NPS-Segmente), NPS Score, Average Score, Completion Rate.",
"guide_quick_ref": "Schnellreferenz",
"guide_term_dimension": "Kategorisches Feld, das zum Gruppieren oder Aufteilen von Daten verwendet wird",
"guide_term_filter": "Bedingung, die einschränkt, welche Zeilen einbezogen werden",
"guide_term_measure": "Numerischer Wert, den du aggregierst (count, sum, avg usw.)",
"guide_term_time": "Zeitbasierte Gruppierung mit Granularität und Datumsbereich",
"guide_time_dimension": "Zeitdimension",
"guide_time_dimension_desc": "Zeitbasierte Gruppierung: Wähle ein Zeitfeld (normalerweise Erfasst am), Granularität (Stunde, Tag, Woche, Monat usw.) und Datumsbereich (Voreinstellung oder benutzerdefiniert). Verwende dies für Trends im Zeitverlauf.",
"guide_title": "Diagramm-Builder Feldleitfaden",
"is_not_set": "ist nicht festgelegt",
"is_set": "ist festgelegt",
"less_than": "kleiner als",
"less_than_or_equal": "kleiner oder gleich",
"measures": "Kennzahlen",
"no_charts_found": "Keine Diagramme gefunden.",
"no_dashboards_available": "Keine Dashboards verfügbar",
"no_dashboards_create_first": "Erstelle zuerst ein Dashboard, um Diagramme hinzuzufügen.",
"no_data_available": "Keine Daten verfügbar",
"no_data_returned": "Keine Daten von der Abfrage zurückgegeben",
"no_data_returned_for_chart": "Keine Daten für Diagramm zurückgegeben",
"no_grouping": "Keine (nur Filter)",
"no_valid_data_to_display": "Keine gültigen Daten zur Anzeige",
"not_contains": "enthält nicht",
"not_equals": "ist nicht gleich",
"open_chart": "Diagramm {{name}} öffnen",
"open_options": "Diagrammoptionen öffnen",
"or_filter_logic": "ODER",
"original": "Original",
"please_enter_chart_name": "Bitte gib einen Diagrammnamen ein",
"please_select_at_least_one_measure": "Bitte wähle mindestens eine Kennzahl aus",
"please_select_dashboard": "Bitte wähle ein Dashboard aus",
"predefined_measures": "Vordefinierte Kennzahlen",
"preset": "Vorlage",
"query_executed_successfully": "Abfrage erfolgreich ausgeführt",
"reset_to_ai_suggestion": "Auf KI-Vorschlag zurücksetzen",
"save_chart": "Diagramm speichern",
"save_chart_dialog_title": "Diagramm speichern",
"select_dimensions": "Dimensionen auswählen...",
"select_field": "Feld auswählen",
"select_measures": "Metriken auswählen...",
"select_preset": "Vorlage auswählen",
"showing_first_n_of": "Zeige die ersten {{n}} von {{count}} Zeilen",
"start_date": "Startdatum",
"time_dimension": "Zeitdimension",
"time_dimension_toggle_description": "Füge zeitbasierte Gruppierung hinzu, um Trends im Zeitverlauf zu sehen."
},
"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.",
@@ -1242,6 +1426,7 @@
"add_fallback_placeholder": "Platzhalter hinzufügen, falls kein Wert zur Verfügung steht.",
"add_hidden_field_id": "Verstecktes Feld ID hinzufügen",
"add_highlight_border": "Rahmen hinzufügen",
"add_highlight_border_description": "Gilt nur für In-Product-Umfragen.",
"add_logic": "Logik hinzufügen",
"add_none_of_the_above": "Füge \"Keine der oben genannten Optionen\" hinzu",
"add_option": "Option hinzufügen",
@@ -1440,7 +1625,6 @@
"follow_ups_modal_updated_successfull_toast": "Nachverfolgung aktualisiert und wird gespeichert, sobald du die Umfrage speicherst.",
"follow_ups_new": "Neues Follow-up",
"follow_ups_upgrade_button_text": "Upgrade, um Follow-ups zu aktivieren",
"form_styling": "Umfrage Styling",
"formbricks_sdk_is_not_connected": "Formbricks SDK ist nicht verbunden",
"four_points": "4 Punkte",
"heading": "Überschrift",
@@ -1613,7 +1797,7 @@
"response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.",
"response_options": "Antwortoptionen",
"roundness": "Rundheit",
"roundness_description": "Steuert, wie abgerundet die Kartenecken sind.",
"roundness_description": "Steuert, wie abgerundet die Ecken sind.",
"row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
"rows": "Zeilen",
"save_and_close": "Speichern & Schließen",
@@ -1659,6 +1843,7 @@
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
"survey_placement": "Platzierung der Umfrage",
"survey_styling": "Umfrage Styling",
"survey_trigger": "Auslöser der Umfrage",
"switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉",
"target_block_not_found": "Zielblock nicht gefunden",
@@ -2170,7 +2355,7 @@
"advanced_styling_field_indicator_bg_description": "Färbt den gefüllten Teil des Balkens.",
"advanced_styling_field_input_border_radius_description": "Rundet die Eingabeecken ab.",
"advanced_styling_field_input_font_size_description": "Skaliert den eingegebenen Text in Eingabefeldern.",
"advanced_styling_field_input_height_description": "Legt die Mindesthöhe des Eingabefelds fest.",
"advanced_styling_field_input_height_description": "Steuert die Mindesthöhe der Eingabe.",
"advanced_styling_field_input_padding_x_description": "Fügt links und rechts Abstand hinzu.",
"advanced_styling_field_input_padding_y_description": "Fügt oben und unten Abstand hinzu.",
"advanced_styling_field_input_placeholder_opacity_description": "Blendet den Platzhaltertext aus.",
@@ -2179,6 +2364,8 @@
"advanced_styling_field_input_text_description": "Färbt den eingegebenen Text in Eingabefeldern.",
"advanced_styling_field_option_bg": "Hintergrund",
"advanced_styling_field_option_bg_description": "Füllt die Optionselemente.",
"advanced_styling_field_option_border": "Rahmenfarbe",
"advanced_styling_field_option_border_description": "Umrandet Radio- und Checkbox-Optionen.",
"advanced_styling_field_option_border_radius_description": "Rundet die Ecken der Optionen ab.",
"advanced_styling_field_option_font_size_description": "Skaliert den Text der Optionsbeschriftung.",
"advanced_styling_field_option_label": "Label-Farbe",
@@ -2998,6 +3185,9 @@
"preview_survey_question_2_choice_2_label": "Nein, danke!",
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
"preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.",
"preview_survey_question_open_text_headline": "Möchtest Du noch etwas teilen?",
"preview_survey_question_open_text_placeholder": "Tippe deine Antwort hier...",
"preview_survey_question_open_text_subheader": "Dein Feedback hilft uns, besser zu werden.",
"preview_survey_welcome_card_headline": "Willkommen!",
"prioritize_features_description": "Identifiziere die Funktionen, die deine Nutzer am meisten und am wenigsten brauchen.",
"prioritize_features_name": "Funktionen priorisieren",

View File

@@ -133,6 +133,7 @@
"allow": "Allow",
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
"an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s",
"analysis": "Analysis",
"and": "And",
"and_response_limit_of": "and response limit of",
"anonymous": "Anonymous",
@@ -149,6 +150,8 @@
"bottom_right": "Bottom Right",
"cancel": "Cancel",
"centered_modal": "Centered Modal",
"chart": "Chart",
"charts": "Charts",
"choices": "Choices",
"choose_environment": "Choose environment",
"choose_organization": "Choose organization",
@@ -178,6 +181,7 @@
"count_attributes": "{value, plural, one {{value} attribute} other {{value} attributes}}",
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}",
"count_responses": "{value, plural, one {{value} response} other {{value} responses}}",
"create": "Create",
"create_new_organization": "Create new organization",
"create_segment": "Create segment",
"create_survey": "Create survey",
@@ -187,6 +191,8 @@
"created_by": "Created by",
"customer_success": "Customer Success",
"dark_overlay": "Dark overlay",
"dashboard": "Dashboard",
"dashboards": "Dashboards",
"date": "Date",
"days": "days",
"default": "Default",
@@ -225,7 +231,9 @@
"failed_to_copy_to_clipboard": "Failed to copy to clipboard",
"failed_to_load_organizations": "Failed to load organizations",
"failed_to_load_workspaces": "Failed to load workspaces",
"filter": "Filter",
"finish": "Finish",
"first_name": "First Name",
"follow_these": "Follow these",
"formbricks_version": "Formbricks Version",
"full_name": "Full name",
@@ -237,7 +245,9 @@
"hidden": "Hidden",
"hidden_field": "Hidden field",
"hidden_fields": "Hidden fields",
"hide": "Hide",
"hide_column": "Hide column",
"id": "ID",
"image": "Image",
"images": "Images",
"import": "Import",
@@ -255,6 +265,7 @@
"key": "Key",
"label": "Label",
"language": "Language",
"last_name": "Last Name",
"learn_more": "Learn more",
"license_expired": "License Expired",
"light_overlay": "Light overlay",
@@ -281,6 +292,7 @@
"move_down": "Move down",
"move_up": "Move up",
"multiple_languages": "Multiple languages",
"my_product": "my Product",
"name": "Name",
"new": "New",
"new_version_available": "Formbricks {version} is here. Upgrade now!",
@@ -304,6 +316,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",
@@ -446,6 +459,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",
@@ -609,6 +623,176 @@
"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": {
"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_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",
"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_deletion_error": "Failed to delete chart",
"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_render_error": "Something went wrong while rendering this chart.",
"chart_saved_successfully": "Chart saved successfully!",
"chart_type_area": "Area Chart",
"chart_type_bar": "Bar Chart",
"chart_type_big_number": "Big Number",
"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.",
"custom_range": "Custom Range",
"dashboard": "Dashboard",
"dashboard_select_placeholder": "Select a dashboard",
"data_label": "Data",
"date_preset_last_30_days": "Last 30 days",
"date_preset_last_7_days": "Last 7 days",
"date_preset_last_month": "Last month",
"date_preset_this_month": "This month",
"date_preset_this_quarter": "This quarter",
"date_preset_this_year": "This year",
"date_preset_today": "Today",
"date_preset_yesterday": "Yesterday",
"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",
"field_label_average_score": "Average Score",
"field_label_collected_at": "Collected At",
"field_label_count": "Count",
"field_label_detractor_count": "Detractor Count",
"field_label_emotion": "Emotion",
"field_label_field_type": "Field Type",
"field_label_nps_score": "NPS Score",
"field_label_nps_value": "NPS Value",
"field_label_passive_count": "Passive Count",
"field_label_promoter_count": "Promoter Count",
"field_label_response_id": "Response ID",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Source Name",
"field_label_source_type": "Source Type",
"field_label_topic": "Topic",
"field_label_user_identifier": "User Identifier",
"filters": "Filters",
"filters_toggle_description": "Only include data that meets the following conditions.",
"generate_chart": "Generate Chart",
"granularity": "Granularity",
"granularity_day": "Day",
"granularity_hour": "Hour",
"granularity_month": "Month",
"granularity_quarter": "Quarter",
"granularity_week": "Week",
"granularity_year": "Year",
"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_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_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",
"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_select_at_least_one_measure": "Please select at least one measure",
"please_select_dashboard": "Please select a dashboard",
"predefined_measures": "Predefined Measures",
"preset": "Preset",
"query_executed_successfully": "Query executed successfully",
"reset_to_ai_suggestion": "Reset to AI suggestion",
"save_chart": "Save Chart",
"save_chart_dialog_title": "Save Chart",
"select_dimensions": "Select dimensions...",
"select_field": "Select field",
"select_measures": "Select measures...",
"select_preset": "Select preset",
"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."
},
"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.",
@@ -1242,6 +1426,7 @@
"add_fallback_placeholder": "Add a placeholder to show if there is no value to recall.",
"add_hidden_field_id": "Add hidden field ID",
"add_highlight_border": "Add highlight border",
"add_highlight_border_description": "Only applies to in-product surveys.",
"add_logic": "Add logic",
"add_none_of_the_above": "Add “None of the Above”",
"add_option": "Add option",
@@ -1440,7 +1625,6 @@
"follow_ups_modal_updated_successfull_toast": "Follow-up updated and will be saved once you save the survey.",
"follow_ups_new": "New follow-up",
"follow_ups_upgrade_button_text": "Upgrade to enable follow-ups",
"form_styling": "Form styling",
"formbricks_sdk_is_not_connected": "Formbricks SDK is not connected",
"four_points": "4 points",
"heading": "Heading",
@@ -1613,7 +1797,7 @@
"response_limits_redirections_and_more": "Response limits, redirections and more.",
"response_options": "Response Options",
"roundness": "Roundness",
"roundness_description": "Controls how rounded the card corners are.",
"roundness_description": "Controls how rounded corners are.",
"row_used_in_logic_error": "This row is used in logic of question {questionIndex}. Please remove it from logic first.",
"rows": "Rows",
"save_and_close": "Save & Close",
@@ -1659,6 +1843,7 @@
"survey_completed_subheading": "This free & open-source survey has been closed",
"survey_display_settings": "Survey Display Settings",
"survey_placement": "Survey Placement",
"survey_styling": "Survey styling",
"survey_trigger": "Survey Trigger",
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
"target_block_not_found": "Target block not found",
@@ -2156,7 +2341,7 @@
"advanced_styling_field_description_size": "Description Font Size",
"advanced_styling_field_description_size_description": "Scales the description text.",
"advanced_styling_field_description_weight": "Description Font Weight",
"advanced_styling_field_description_weight_description": "Makes description text lighter or bolder.",
"advanced_styling_field_description_weight_description": "Makes descr. text lighter or bolder.",
"advanced_styling_field_font_size": "Font Size",
"advanced_styling_field_font_weight": "Font Weight",
"advanced_styling_field_headline_color": "Headline Color",
@@ -2170,7 +2355,7 @@
"advanced_styling_field_indicator_bg_description": "Colors the filled portion of the bar.",
"advanced_styling_field_input_border_radius_description": "Rounds the input corners.",
"advanced_styling_field_input_font_size_description": "Scales the typed text in inputs.",
"advanced_styling_field_input_height_description": "Controls the minimum height of the input field.",
"advanced_styling_field_input_height_description": "Controls the min. height of the input.",
"advanced_styling_field_input_padding_x_description": "Adds space on the left and right.",
"advanced_styling_field_input_padding_y_description": "Adds space on the top and bottom.",
"advanced_styling_field_input_placeholder_opacity_description": "Fades the placeholder hint text.",
@@ -2179,6 +2364,8 @@
"advanced_styling_field_input_text_description": "Colors the typed text in inputs.",
"advanced_styling_field_option_bg": "Background",
"advanced_styling_field_option_bg_description": "Fills the option items.",
"advanced_styling_field_option_border": "Border Color",
"advanced_styling_field_option_border_description": "Outlines radio and checkbox options.",
"advanced_styling_field_option_border_radius_description": "Rounds the option corners.",
"advanced_styling_field_option_font_size_description": "Scales the option label text.",
"advanced_styling_field_option_label": "Label Color",
@@ -2998,6 +3185,9 @@
"preview_survey_question_2_choice_2_label": "No, thank you!",
"preview_survey_question_2_headline": "Want to stay in the loop?",
"preview_survey_question_2_subheader": "This is an example description.",
"preview_survey_question_open_text_headline": "Anything else you'd like to share?",
"preview_survey_question_open_text_placeholder": "Type your answer here…",
"preview_survey_question_open_text_subheader": "Your feedback helps us improve.",
"preview_survey_welcome_card_headline": "Welcome!",
"prioritize_features_description": "Identify features your users need most and least.",
"prioritize_features_name": "Prioritize Features",

View File

@@ -133,6 +133,7 @@
"allow": "Permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta",
"an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s",
"analysis": "Análisis",
"and": "Y",
"and_response_limit_of": "y límite de respuesta de",
"anonymous": "Anónimo",
@@ -149,6 +150,8 @@
"bottom_right": "Inferior derecha",
"cancel": "Cancelar",
"centered_modal": "Modal centrado",
"chart": "Gráfico",
"charts": "Gráficos",
"choices": "Opciones",
"choose_environment": "Elegir entorno",
"choose_organization": "Elegir organización",
@@ -178,6 +181,7 @@
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {{value} contacto} other {{value} contactos}}",
"count_responses": "{value, plural, one {{value} respuesta} other {{value} respuestas}}",
"create": "Crear",
"create_new_organization": "Crear organización nueva",
"create_segment": "Crear segmento",
"create_survey": "Crear encuesta",
@@ -187,6 +191,8 @@
"created_by": "Creado por",
"customer_success": "Éxito del cliente",
"dark_overlay": "Superposición oscura",
"dashboard": "Panel de control",
"dashboards": "Paneles",
"date": "Fecha",
"days": "días",
"default": "Predeterminado",
@@ -225,7 +231,9 @@
"failed_to_copy_to_clipboard": "Error al copiar al portapapeles",
"failed_to_load_organizations": "Error al cargar organizaciones",
"failed_to_load_workspaces": "Error al cargar los proyectos",
"filter": "Filtro",
"finish": "Finalizar",
"first_name": "Nombre",
"follow_these": "Sigue estos",
"formbricks_version": "Versión de Formbricks",
"full_name": "Nombre completo",
@@ -237,7 +245,9 @@
"hidden": "Oculto",
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide": "Ocultar",
"hide_column": "Ocultar columna",
"id": "ID",
"image": "Imagen",
"images": "Imágenes",
"import": "Importar",
@@ -255,6 +265,7 @@
"key": "Clave",
"label": "Etiqueta",
"language": "Idioma",
"last_name": "Apellido",
"learn_more": "Saber más",
"license_expired": "License Expired",
"light_overlay": "Superposición clara",
@@ -281,6 +292,7 @@
"move_down": "Mover hacia abajo",
"move_up": "Mover hacia arriba",
"multiple_languages": "Múltiples idiomas",
"my_product": "mi producto",
"name": "Nombre",
"new": "Nuevo",
"new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!",
@@ -304,6 +316,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",
@@ -446,6 +459,7 @@
"variables": "Variables",
"verified_email": "Correo electrónico verificado",
"video": "Vídeo",
"view": "Ver",
"warning": "Advertencia",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "No pudimos verificar tu licencia porque el servidor de licencias no está accesible.",
"webhook": "Webhook",
@@ -609,6 +623,176 @@
"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": {
"OR": "O",
"add_chart_to_dashboard": "Añadir gráfico al panel de control",
"add_chart_to_dashboard_description": "Selecciona un panel de control para añadir este gráfico. El gráfico se guardará automáticamente.",
"add_filter": "Añadir filtro",
"add_to_dashboard": "Añadir al panel de control",
"advanced_chart_builder_config_prompt": "Configura tu gráfico y haz clic en \"Ejecutar consulta\" para previsualizar",
"ai_query_placeholder": "p. ej. ¿Cuántos usuarios se registraron la semana pasada?",
"ai_query_section_description": "Describe lo que quieres ver y deja que la IA construya el gráfico.",
"ai_query_section_title": "Pregunta a tus datos",
"apply_changes": "Aplicar cambios",
"chart": "Gráfico",
"chart_added_to_dashboard": "¡Gráfico añadido al panel de control!",
"chart_builder_choose_chart_type": "Elige el tipo de gráfico",
"chart_data": "Datos del gráfico",
"chart_data_tab": "Datos",
"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_name": "Nombre del gráfico",
"chart_name_placeholder": "Nombre del gráfico",
"chart_preview": "Vista previa del gráfico",
"chart_render_error": "Algo salió mal al renderizar este gráfico.",
"chart_saved_successfully": "¡Gráfico guardado correctamente!",
"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_not_supported": "El tipo de gráfico \"{{chartType}}\" aún no está soportado",
"chart_type_pie": "Gráfico circular",
"chart_updated_successfully": "¡Gráfico actualizado correctamente!",
"configure_description": "Modifica el tipo de gráfico y otros ajustes para esta visualización.",
"configure_title": "Configurar gráfico",
"configure_type_label": "Tipo de gráfico",
"contains": "contiene",
"create_chart": "Crear gráfico",
"create_chart_description": "Usa IA para generar un gráfico o créalo manualmente.",
"custom_range": "Rango personalizado",
"dashboard": "Panel de control",
"dashboard_select_placeholder": "Selecciona un panel de control",
"data_label": "Datos",
"date_preset_last_30_days": "Últimos 30 días",
"date_preset_last_7_days": "Últimos 7 días",
"date_preset_last_month": "Último mes",
"date_preset_this_month": "Este mes",
"date_preset_this_quarter": "Este trimestre",
"date_preset_this_year": "Este año",
"date_preset_today": "Hoy",
"date_preset_yesterday": "Ayer",
"date_range": "Rango de fechas",
"delete_chart_confirmation": "¿Estás seguro de que quieres eliminar este gráfico?",
"dimensions": "Dimensiones",
"dimensions_toggle_description": "Agrupa datos por categorías. El orden importa para gráficos multidimensionales.",
"edit_chart_description": "Visualiza y edita la configuración de tu gráfico.",
"edit_chart_title": "Editar gráfico",
"enable_time_dimension": "Activar dimensión temporal",
"end_date": "Fecha de finalización",
"enter_a_name_for_your_chart": "Introduce un nombre para tu gráfico para guardarlo.",
"enter_value": "Introduce un valor",
"equals": "es igual a",
"failed_to_add_chart_to_dashboard": "Error al añadir el gráfico al panel de control",
"failed_to_execute_query": "Error al ejecutar la consulta",
"failed_to_load_chart": "Error al cargar el gráfico",
"failed_to_load_chart_data": "Error al cargar los datos del gráfico",
"failed_to_save_chart": "Error al guardar el gráfico",
"field": "Campo",
"field_label_average_score": "Puntuación media",
"field_label_collected_at": "Recopilado el",
"field_label_count": "Recuento",
"field_label_detractor_count": "Recuento de detractores",
"field_label_emotion": "Emoción",
"field_label_field_type": "Tipo de campo",
"field_label_nps_score": "Puntuación NPS",
"field_label_nps_value": "Valor NPS",
"field_label_passive_count": "Recuento de pasivos",
"field_label_promoter_count": "Recuento de promotores",
"field_label_response_id": "ID de respuesta",
"field_label_sentiment": "Sentimiento",
"field_label_source_name": "Nombre de origen",
"field_label_source_type": "Tipo de origen",
"field_label_topic": "Tema",
"field_label_user_identifier": "Identificador de usuario",
"filters": "Filtros",
"filters_toggle_description": "Incluye solo los datos que cumplan las siguientes condiciones.",
"generate_chart": "Generar gráfico",
"granularity": "Granularidad",
"granularity_day": "Día",
"granularity_hour": "Hora",
"granularity_month": "Mes",
"granularity_quarter": "Trimestre",
"granularity_week": "Semana",
"granularity_year": "Año",
"greater_than": "mayor que",
"greater_than_or_equal": "mayor o igual que",
"group_by": "Agrupar por",
"group_by_description": "Selecciona dimensiones para desglosar tus datos. El orden importa para gráficos multidimensionales.",
"guide_button": "Ver guía de campos",
"guide_chart_type": "Tipo de gráfico",
"guide_chart_type_desc": "Cómo se visualizan los datos: área, barras, líneas, circular o número grande. Elige según lo que quieras mostrar (tendencias, comparaciones, partes de un todo, etc.).",
"guide_dimensions": "Dimensiones (agrupar por)",
"guide_dimensions_desc": "Cómo divides o agrupas los datos. Cada dimensión se convierte en una categoría en el gráfico (p. ej., sentimiento, tipo de fuente, nombre de encuesta, canal, tema). El orden importa para gráficos multidimensionales.",
"guide_filters": "Filtros",
"guide_filters_desc": "Condiciones que limitan qué datos se incluyen. Cada filtro tiene un campo, un operador (igual, contiene, mayor que, etc.) y valores. Y = todos deben coincidir; O = cualquiera puede coincidir.",
"guide_measures": "Medidas (qué cuentas o agregas)",
"guide_measures_predefined": "Las medidas predefinidas son métricas preconstruidas de tus datos de feedback: recuento (respuestas totales), recuento de promotores/detractores/pasivos (segmentos NPS), puntuación NPS, puntuación promedio, tasa de finalización.",
"guide_quick_ref": "Referencia rápida",
"guide_term_dimension": "Campo categórico usado para agrupar o dividir datos",
"guide_term_filter": "Condición que limita qué filas se incluyen",
"guide_term_measure": "Valor numérico que agregas (contar, sumar, promediar, etc.)",
"guide_term_time": "Agrupación basada en tiempo con granularidad y rango de fechas",
"guide_time_dimension": "Dimensión temporal",
"guide_time_dimension_desc": "Agrupación basada en tiempo: elige un campo temporal (normalmente Recopilado en), granularidad (Hora, Día, Semana, Mes, etc.) y rango de fechas (predefinido o personalizado). Úsalo para tendencias a lo largo del tiempo.",
"guide_title": "Guía de campos del constructor de gráficos",
"is_not_set": "no está establecido",
"is_set": "está establecido",
"less_than": "menor que",
"less_than_or_equal": "menor o igual que",
"measures": "Medidas",
"no_charts_found": "No se encontraron gráficos.",
"no_dashboards_available": "No hay paneles de control disponibles",
"no_dashboards_create_first": "Crea primero un panel de control para añadirle gráficos.",
"no_data_available": "No hay datos disponibles",
"no_data_returned": "La consulta no ha devuelto datos",
"no_data_returned_for_chart": "No se han devuelto datos para el gráfico",
"no_grouping": "Ninguno (solo filtro)",
"no_valid_data_to_display": "No hay datos válidos para mostrar",
"not_contains": "no contiene",
"not_equals": "no es igual a",
"open_chart": "Abrir gráfico {{name}}",
"open_options": "Abrir opciones del gráfico",
"or_filter_logic": "O",
"original": "Original",
"please_enter_chart_name": "Introduce un nombre para el gráfico",
"please_select_at_least_one_measure": "Selecciona al menos una medida",
"please_select_dashboard": "Selecciona un panel de control",
"predefined_measures": "Medidas predefinidas",
"preset": "Preajuste",
"query_executed_successfully": "Consulta ejecutada correctamente",
"reset_to_ai_suggestion": "Restablecer a sugerencia de IA",
"save_chart": "Guardar gráfico",
"save_chart_dialog_title": "Guardar gráfico",
"select_dimensions": "Seleccionar dimensiones...",
"select_field": "Seleccionar campo",
"select_measures": "Seleccionar medidas...",
"select_preset": "Seleccionar preajuste",
"showing_first_n_of": "Mostrando las primeras {{n}} de {{count}} filas",
"start_date": "Fecha de inicio",
"time_dimension": "Dimensión temporal",
"time_dimension_toggle_description": "Añade agrupación basada en tiempo para tendencias a lo largo del tiempo."
},
"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.",
@@ -1242,6 +1426,7 @@
"add_fallback_placeholder": "Añadir un marcador de posición para mostrar si no hay valor que recuperar.",
"add_hidden_field_id": "Añadir ID de campo oculto",
"add_highlight_border": "Añadir borde destacado",
"add_highlight_border_description": "Solo se aplica a encuestas dentro del producto.",
"add_logic": "Añadir lógica",
"add_none_of_the_above": "Añadir \"Ninguna de las anteriores\"",
"add_option": "Añadir opción",
@@ -1440,7 +1625,6 @@
"follow_ups_modal_updated_successfull_toast": "Seguimiento actualizado y se guardará cuando guardes la encuesta.",
"follow_ups_new": "Nuevo seguimiento",
"follow_ups_upgrade_button_text": "Actualiza para habilitar seguimientos",
"form_styling": "Estilo del formulario",
"formbricks_sdk_is_not_connected": "El SDK de Formbricks no está conectado",
"four_points": "4 puntos",
"heading": "Encabezado",
@@ -1613,7 +1797,7 @@
"response_limits_redirections_and_more": "Límites de respuestas, redirecciones y más.",
"response_options": "Opciones de respuesta",
"roundness": "Redondez",
"roundness_description": "Controla qué tan redondeadas están las esquinas de la tarjeta.",
"roundness_description": "Controla qué tan redondeadas están las esquinas.",
"row_used_in_logic_error": "Esta fila se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
"rows": "Filas",
"save_and_close": "Guardar y cerrar",
@@ -1659,6 +1843,7 @@
"survey_completed_subheading": "Esta encuesta gratuita y de código abierto ha sido cerrada",
"survey_display_settings": "Ajustes de visualización de la encuesta",
"survey_placement": "Ubicación de la encuesta",
"survey_styling": "Estilo del formulario",
"survey_trigger": "Activador de la encuesta",
"switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉",
"target_block_not_found": "Bloque objetivo no encontrado",
@@ -2156,7 +2341,7 @@
"advanced_styling_field_description_size": "Tamaño de fuente de la descripción",
"advanced_styling_field_description_size_description": "Escala el texto de la descripción.",
"advanced_styling_field_description_weight": "Grosor de fuente de la descripción",
"advanced_styling_field_description_weight_description": "Hace el texto de la descripción más ligero o más grueso.",
"advanced_styling_field_description_weight_description": "Hace el texto de descripción más ligero o más grueso.",
"advanced_styling_field_font_size": "Tamaño de fuente",
"advanced_styling_field_font_weight": "Grosor de fuente",
"advanced_styling_field_headline_color": "Color del titular",
@@ -2170,7 +2355,7 @@
"advanced_styling_field_indicator_bg_description": "Colorea la porción rellena de la barra.",
"advanced_styling_field_input_border_radius_description": "Redondea las esquinas del campo.",
"advanced_styling_field_input_font_size_description": "Escala el texto escrito en los campos.",
"advanced_styling_field_input_height_description": "Controla la altura mínima del campo de entrada.",
"advanced_styling_field_input_height_description": "Controla la altura mínima de la entrada.",
"advanced_styling_field_input_padding_x_description": "Añade espacio a la izquierda y a la derecha.",
"advanced_styling_field_input_padding_y_description": "Añade espacio en la parte superior e inferior.",
"advanced_styling_field_input_placeholder_opacity_description": "Atenúa el texto de sugerencia del marcador de posición.",
@@ -2179,6 +2364,8 @@
"advanced_styling_field_input_text_description": "Colorea el texto escrito en los campos de entrada.",
"advanced_styling_field_option_bg": "Fondo",
"advanced_styling_field_option_bg_description": "Rellena los elementos de opción.",
"advanced_styling_field_option_border": "Color del borde",
"advanced_styling_field_option_border_description": "Delimita las opciones de radio y casillas de verificación.",
"advanced_styling_field_option_border_radius_description": "Redondea las esquinas de las opciones.",
"advanced_styling_field_option_font_size_description": "Escala el texto de la etiqueta de opción.",
"advanced_styling_field_option_label": "Color de la etiqueta",
@@ -2998,6 +3185,9 @@
"preview_survey_question_2_choice_2_label": "¡No, gracias!",
"preview_survey_question_2_headline": "¿Quieres estar al tanto?",
"preview_survey_question_2_subheader": "Esta es una descripción de ejemplo.",
"preview_survey_question_open_text_headline": "¿Hay algo más que te gustaría compartir?",
"preview_survey_question_open_text_placeholder": "Escribe tu respuesta aquí...",
"preview_survey_question_open_text_subheader": "Tus comentarios nos ayudan a mejorar.",
"preview_survey_welcome_card_headline": "¡Bienvenido!",
"prioritize_features_description": "Identifica las funciones que tus usuarios necesitan más y menos.",
"prioritize_features_name": "Priorizar funciones",

View File

@@ -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,6 +191,8 @@
"created_by": "Créé par",
"customer_success": "Succès Client",
"dark_overlay": "Foncée",
"dashboard": "Tableau de bord",
"dashboards": "Tableaux de bord",
"date": "Date",
"days": "jours",
"default": "Par défaut",
@@ -225,7 +231,9 @@
"failed_to_copy_to_clipboard": "Échec de la copie dans le presse-papiers",
"failed_to_load_organizations": "Échec du chargement des organisations",
"failed_to_load_workspaces": "Échec du chargement des projets",
"filter": "Filtre",
"finish": "Terminer",
"first_name": "Prénom",
"follow_these": "Suivez ceci",
"formbricks_version": "Version de Formbricks",
"full_name": "Nom complet",
@@ -237,7 +245,9 @@
"hidden": "Caché",
"hidden_field": "Champ caché",
"hidden_fields": "Champs cachés",
"hide": "Masquer",
"hide_column": "Cacher la colonne",
"id": "ID",
"image": "Image",
"images": "Images",
"import": "Importer",
@@ -255,6 +265,7 @@
"key": "Clé",
"label": "Étiquette",
"language": "Langue",
"last_name": "Nom de famille",
"learn_more": "En savoir plus",
"license_expired": "License Expired",
"light_overlay": "Claire",
@@ -281,6 +292,7 @@
"move_down": "Déplacer vers le bas",
"move_up": "Déplacer vers le haut",
"multiple_languages": "Plusieurs langues",
"my_product": "mon produit",
"name": "Nom",
"new": "Nouveau",
"new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !",
@@ -304,6 +316,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",
@@ -446,6 +459,7 @@
"variables": "Variables",
"verified_email": "Email vérifié",
"video": "Vidéo",
"view": "Afficher",
"warning": "Avertissement",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nous n'avons pas pu vérifier votre licence car le serveur de licence est inaccessible.",
"webhook": "Webhook",
@@ -609,6 +623,176 @@
"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": {
"OR": "OU",
"add_chart_to_dashboard": "Ajouter le graphique au tableau de bord",
"add_chart_to_dashboard_description": "Sélectionnez un tableau de bord pour y ajouter ce graphique. Le graphique sera enregistré automatiquement.",
"add_filter": "Ajouter un filtre",
"add_to_dashboard": "Ajouter au tableau de bord",
"advanced_chart_builder_config_prompt": "Configurez votre graphique et cliquez sur «Exécuter la requête» pour prévisualiser",
"ai_query_placeholder": "ex. Combien d'utilisateurs se sont inscrits la semaine dernière?",
"ai_query_section_description": "Décrivez ce que vous souhaitez voir et laissez l'IA créer le graphique.",
"ai_query_section_title": "Interrogez vos données",
"apply_changes": "Appliquer les modifications",
"chart": "Graphique",
"chart_added_to_dashboard": "Graphique ajouté au tableau de bord!",
"chart_builder_choose_chart_type": "Choisir le type de graphique",
"chart_data": "Données du graphique",
"chart_data_tab": "Données",
"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_name": "Nom du graphique",
"chart_name_placeholder": "Nom du graphique",
"chart_preview": "Aperçu du graphique",
"chart_render_error": "Une erreur s'est produite lors du rendu de ce graphique.",
"chart_saved_successfully": "Graphique enregistré avec succès!",
"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_not_supported": "Le type de graphique « {{chartType}} » n'est pas encore pris en charge",
"chart_type_pie": "Graphique circulaire",
"chart_updated_successfully": "Graphique mis à jour avec succès!",
"configure_description": "Modifiez le type de graphique et d'autres paramètres pour cette visualisation.",
"configure_title": "Configurer le graphique",
"configure_type_label": "Type de graphique",
"contains": "contient",
"create_chart": "Créer un graphique",
"create_chart_description": "Utilisez l'IA pour générer un graphique ou créez-en un manuellement.",
"custom_range": "Plage personnalisée",
"dashboard": "Tableau de bord",
"dashboard_select_placeholder": "Sélectionnez un tableau de bord",
"data_label": "Données",
"date_preset_last_30_days": "30 derniers jours",
"date_preset_last_7_days": "7 derniers jours",
"date_preset_last_month": "Le mois dernier",
"date_preset_this_month": "Ce mois-ci",
"date_preset_this_quarter": "Ce trimestre",
"date_preset_this_year": "Cette année",
"date_preset_today": "Aujourd'hui",
"date_preset_yesterday": "Hier",
"date_range": "Plage de dates",
"delete_chart_confirmation": "Êtes-vous sûr de vouloir supprimer ce graphique?",
"dimensions": "Dimensions",
"dimensions_toggle_description": "Regroupez les données par catégories. L'ordre est important pour les graphiques multidimensionnels.",
"edit_chart_description": "Consultez et modifiez la configuration de votre graphique.",
"edit_chart_title": "Modifier le graphique",
"enable_time_dimension": "Activer la dimension temporelle",
"end_date": "Date de fin",
"enter_a_name_for_your_chart": "Saisissez un nom pour votre graphique afin de l'enregistrer.",
"enter_value": "Saisissez une valeur",
"equals": "égal",
"failed_to_add_chart_to_dashboard": "Échec de l'ajout du graphique au tableau de bord",
"failed_to_execute_query": "Échec de l'exécution de la requête",
"failed_to_load_chart": "Échec du chargement du graphique",
"failed_to_load_chart_data": "Échec du chargement des données du graphique",
"failed_to_save_chart": "Échec de l'enregistrement du graphique",
"field": "Champ",
"field_label_average_score": "Score moyen",
"field_label_collected_at": "Collecté le",
"field_label_count": "Nombre",
"field_label_detractor_count": "Nombre de détracteurs",
"field_label_emotion": "Émotion",
"field_label_field_type": "Type de champ",
"field_label_nps_score": "Score NPS",
"field_label_nps_value": "Valeur NPS",
"field_label_passive_count": "Nombre de passifs",
"field_label_promoter_count": "Nombre de promoteurs",
"field_label_response_id": "ID de réponse",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Nom de la source",
"field_label_source_type": "Type de source",
"field_label_topic": "Sujet",
"field_label_user_identifier": "Identifiant utilisateur",
"filters": "Filtres",
"filters_toggle_description": "Inclure uniquement les données qui répondent aux conditions suivantes.",
"generate_chart": "Générer le graphique",
"granularity": "Granularité",
"granularity_day": "Jour",
"granularity_hour": "Heure",
"granularity_month": "Mois",
"granularity_quarter": "Trimestre",
"granularity_week": "Semaine",
"granularity_year": "Année",
"greater_than": "supérieur à",
"greater_than_or_equal": "supérieur ou égal à",
"group_by": "Regrouper par",
"group_by_description": "Sélectionnez les dimensions pour décomposer vos données. L'ordre est important pour les graphiques multidimensionnels.",
"guide_button": "Voir le guide des champs",
"guide_chart_type": "Type de graphique",
"guide_chart_type_desc": "Comment les données sont visualisées: aire, barres, lignes, secteurs ou grand nombre. Choisissez en fonction de ce que vous souhaitez montrer (tendances, comparaisons, parties d'un tout, etc.).",
"guide_dimensions": "Dimensions (regrouper par)",
"guide_dimensions_desc": "Comment vous divisez ou regroupez les données. Chaque dimension devient une catégorie sur le graphique (par ex. sentiment, type de source, nom du sondage, canal, sujet). L'ordre est important pour les graphiques multidimensionnels.",
"guide_filters": "Filtres",
"guide_filters_desc": "Conditions qui limitent les données incluses. Chaque filtre a un champ, un opérateur (égal à, contient, supérieur à, etc.) et des valeurs. Et = tous doivent correspondre; Ou = n'importe lequel peut correspondre.",
"guide_measures": "Mesures (ce que vous comptez ou agrégez)",
"guide_measures_predefined": "Les mesures prédéfinies sont des métriques préconstruites à partir de vos données de feedback: count (total des réponses), promoter/detractor/passive count (segments NPS), NPS score, average score, completion rate.",
"guide_quick_ref": "Référence rapide",
"guide_term_dimension": "Champ catégoriel utilisé pour regrouper ou diviser les données",
"guide_term_filter": "Condition qui limite les lignes incluses",
"guide_term_measure": "Valeur numérique que vous agrégez (count, sum, avg, etc.)",
"guide_term_time": "Regroupement temporel avec granularité et plage de dates",
"guide_time_dimension": "Dimension temporelle",
"guide_time_dimension_desc": "Regroupement temporel: choisissez un champ temporel (généralement Collecté le), une granularité (Heure, Jour, Semaine, Mois, etc.) et une plage de dates (prédéfinie ou personnalisée). Utilisez pour les tendances dans le temps.",
"guide_title": "Guide des champs du créateur de graphiques",
"is_not_set": "n'est pas défini",
"is_set": "est défini",
"less_than": "inférieur à",
"less_than_or_equal": "inférieur ou égal à",
"measures": "Mesures",
"no_charts_found": "Aucun graphique trouvé.",
"no_dashboards_available": "Aucun tableau de bord disponible",
"no_dashboards_create_first": "Créez d'abord un tableau de bord pour y ajouter des graphiques.",
"no_data_available": "Aucune donnée disponible",
"no_data_returned": "Aucune donnée retournée par la requête",
"no_data_returned_for_chart": "Aucune donnée retournée pour le graphique",
"no_grouping": "Aucun (filtre uniquement)",
"no_valid_data_to_display": "Aucune donnée valide à afficher",
"not_contains": "ne contient pas",
"not_equals": "n'est pas égal à",
"open_chart": "Ouvrir le graphique {{name}}",
"open_options": "Ouvrir les options du graphique",
"or_filter_logic": "OU",
"original": "Original",
"please_enter_chart_name": "Veuillez saisir un nom de graphique",
"please_select_at_least_one_measure": "Veuillez sélectionner au moins une mesure",
"please_select_dashboard": "Veuillez sélectionner un tableau de bord",
"predefined_measures": "Mesures prédéfinies",
"preset": "Préréglage",
"query_executed_successfully": "Requête exécutée avec succès",
"reset_to_ai_suggestion": "Réinitialiser à la suggestion IA",
"save_chart": "Enregistrer le graphique",
"save_chart_dialog_title": "Enregistrer le graphique",
"select_dimensions": "Sélectionner les dimensions...",
"select_field": "Sélectionner un champ",
"select_measures": "Sélectionner les mesures...",
"select_preset": "Sélectionner un préréglage",
"showing_first_n_of": "Affichage des {{n}} premières lignes sur {{count}}",
"start_date": "Date de début",
"time_dimension": "Dimension temporelle",
"time_dimension_toggle_description": "Ajouter un regroupement temporel pour les tendances dans le temps."
},
"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.",
@@ -1242,6 +1426,7 @@
"add_fallback_placeholder": "Ajouter un espace réservé à afficher s'il n'y a pas de valeur à rappeler.",
"add_hidden_field_id": "Ajouter un champ caché ID",
"add_highlight_border": "Ajouter une bordure de surlignage",
"add_highlight_border_description": "S'applique uniquement aux sondages intégrés au produit.",
"add_logic": "Ajouter de la logique",
"add_none_of_the_above": "Ajouter \"Aucun des éléments ci-dessus\"",
"add_option": "Ajouter une option",
@@ -1440,7 +1625,6 @@
"follow_ups_modal_updated_successfull_toast": "\"Suivi mis à jour et sera enregistré une fois que vous sauvegarderez le sondage.\"",
"follow_ups_new": "Nouveau suivi",
"follow_ups_upgrade_button_text": "Passez à la version supérieure pour activer les relances",
"form_styling": "Style de formulaire",
"formbricks_sdk_is_not_connected": "Le SDK Formbricks n'est pas connecté",
"four_points": "4 points",
"heading": "En-tête",
@@ -1613,7 +1797,7 @@
"response_limits_redirections_and_more": "Limites de réponse, redirections et plus.",
"response_options": "Options de réponse",
"roundness": "Rondeur",
"roundness_description": "Contrôle l'arrondi des coins de la carte.",
"roundness_description": "Contrôle l'arrondi des coins.",
"row_used_in_logic_error": "Cette ligne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
"rows": "Lignes",
"save_and_close": "Enregistrer et fermer",
@@ -1659,6 +1843,7 @@
"survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée",
"survey_display_settings": "Paramètres d'affichage de l'enquête",
"survey_placement": "Placement de l'enquête",
"survey_styling": "Style de formulaire",
"survey_trigger": "Déclencheur d'enquête",
"switch_multi_language_on_to_get_started": "Activez le mode multilingue pour commencer 👉",
"target_block_not_found": "Bloc cible non trouvé",
@@ -2170,7 +2355,7 @@
"advanced_styling_field_indicator_bg_description": "Colore la partie remplie de la barre.",
"advanced_styling_field_input_border_radius_description": "Arrondit les coins du champ de saisie.",
"advanced_styling_field_input_font_size_description": "Ajuste la taille du texte saisi dans les champs.",
"advanced_styling_field_input_height_description": "Contrôle la hauteur minimale du champ de saisie.",
"advanced_styling_field_input_height_description": "Contrôle la hauteur min. du champ de saisie.",
"advanced_styling_field_input_padding_x_description": "Ajoute de l'espace à gauche et à droite.",
"advanced_styling_field_input_padding_y_description": "Ajoute de l'espace en haut et en bas.",
"advanced_styling_field_input_placeholder_opacity_description": "Atténue le texte d'indication du placeholder.",
@@ -2179,6 +2364,8 @@
"advanced_styling_field_input_text_description": "Colore le texte saisi dans les champs.",
"advanced_styling_field_option_bg": "Arrière-plan",
"advanced_styling_field_option_bg_description": "Remplit les éléments d'option.",
"advanced_styling_field_option_border": "Couleur de bordure",
"advanced_styling_field_option_border_description": "Contours des options de boutons radio et de cases à cocher.",
"advanced_styling_field_option_border_radius_description": "Arrondit les coins des options.",
"advanced_styling_field_option_font_size_description": "Ajuste la taille du texte des libellés d'option.",
"advanced_styling_field_option_label": "Couleur de l'étiquette",
@@ -2998,6 +3185,9 @@
"preview_survey_question_2_choice_2_label": "Non, merci !",
"preview_survey_question_2_headline": "Souhaitez-vous être informé ?",
"preview_survey_question_2_subheader": "Ceci est un exemple de description.",
"preview_survey_question_open_text_headline": "Autre chose que vous aimeriez partager?",
"preview_survey_question_open_text_placeholder": "Entrez votre réponse ici...",
"preview_survey_question_open_text_subheader": "Vos commentaires nous aident à nous améliorer.",
"preview_survey_welcome_card_headline": "Bienvenue !",
"prioritize_features_description": "Identifiez les fonctionnalités dont vos utilisateurs ont le plus et le moins besoin.",
"prioritize_features_name": "Prioriser les fonctionnalités",

View File

@@ -133,6 +133,7 @@
"allow": "Engedélyezés",
"allow_users_to_exit_by_clicking_outside_the_survey": "Lehetővé tétel a felhasználók számára, hogy a kérdőíven kívülre kattintva kilépjenek",
"an_unknown_error_occurred_while_deleting_table_items": "{type} típusok törlésekor ismeretlen hiba történt",
"analysis": "Elemzés",
"and": "És",
"and_response_limit_of": "és kérdéskorlátja ennek:",
"anonymous": "Névtelen",
@@ -149,6 +150,8 @@
"bottom_right": "Jobbra lent",
"cancel": "Mégse",
"centered_modal": "Középre helyezett kizárólagos",
"chart": "Diagram",
"charts": "Diagramok",
"choices": "Választási lehetőségek",
"choose_environment": "Környezet kiválasztása",
"choose_organization": "Szervezet kiválasztása",
@@ -178,6 +181,7 @@
"count_attributes": "{value, plural, one {{value} attribútum} other {{value} attribútum}}",
"count_contacts": "{value, plural, one {{value} partner} other {{value} partner}}",
"count_responses": "{value, plural, one {{value} válasz} other {{value} válasz}}",
"create": "Létrehozás",
"create_new_organization": "Új szervezet létrehozása",
"create_segment": "Szakasz létrehozása",
"create_survey": "Kérdőív létrehozása",
@@ -187,6 +191,8 @@
"created_by": "Létrehozta",
"customer_success": "Ügyfélsiker",
"dark_overlay": "Sötét rávetítés",
"dashboard": "Vezérlőpult",
"dashboards": "Irányítópultok",
"date": "Dátum",
"days": "napok",
"default": "Alapértelmezett",
@@ -225,7 +231,9 @@
"failed_to_copy_to_clipboard": "Nem sikerült másolni a vágólapra",
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
"filter": "Szűrő",
"finish": "Befejezés",
"first_name": "Keresztnév",
"follow_these": "Ezek követése",
"formbricks_version": "Formbricks verziója",
"full_name": "Teljes név",
@@ -237,7 +245,9 @@
"hidden": "Rejtett",
"hidden_field": "Rejtett mező",
"hidden_fields": "Rejtett mezők",
"hide": "Elrejtés",
"hide_column": "Oszlop elrejtése",
"id": "ID",
"image": "Kép",
"images": "Képek",
"import": "Importálás",
@@ -255,6 +265,7 @@
"key": "Kulcs",
"label": "Címke",
"language": "Nyelv",
"last_name": "Vezetéknév",
"learn_more": "Tudjon meg többet",
"license_expired": "A licenc lejárt",
"light_overlay": "Világos rávetítés",
@@ -281,6 +292,7 @@
"move_down": "Mozgatás le",
"move_up": "Mozgatás fel",
"multiple_languages": "Több nyelv",
"my_product": "saját termék",
"name": "Név",
"new": "Új",
"new_version_available": "A Formbricks {version} megérkezett. Frissítsen most!",
@@ -304,6 +316,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ó",
@@ -446,6 +459,7 @@
"variables": "Változók",
"verified_email": "Ellenőrzött e-mail-cím",
"video": "Videó",
"view": "Megtekintés",
"warning": "Figyelmeztetés",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nem tudtuk ellenőrizni a licencét, mert a licenckiszolgáló nem érhető el.",
"webhook": "Webhorog",
@@ -609,6 +623,176 @@
"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": {
"OR": "VAGY",
"add_chart_to_dashboard": "Diagram hozzáadása a vezérlőpulthoz",
"add_chart_to_dashboard_description": "Válassz egy vezérlőpultot, amelyhez hozzáadod ezt a diagramot. A diagram automatikusan mentésre kerül.",
"add_filter": "Szűrő hozzáadása",
"add_to_dashboard": "Hozzáadás a vezérlőpulthoz",
"advanced_chart_builder_config_prompt": "Állítsd be a diagramot, és kattints a \"Lekérdezés futtatása\" gombra az előnézethez",
"ai_query_placeholder": "pl. Hány felhasználó regisztrált a múlt héten?",
"ai_query_section_description": "Írd le, mit szeretnél látni, és hagyd, hogy az AI elkészítse a diagramot.",
"ai_query_section_title": "Kérdezd meg az adataidat",
"apply_changes": "Módosítások alkalmazása",
"chart": "Diagram",
"chart_added_to_dashboard": "A diagram hozzáadva a vezérlőpulthoz!",
"chart_builder_choose_chart_type": "Válassz diagramtípust",
"chart_data": "Diagram adatai",
"chart_data_tab": "Adatok",
"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_name": "Diagram neve",
"chart_name_placeholder": "Diagram neve",
"chart_preview": "Diagram előnézete",
"chart_render_error": "Hiba történt a diagram megjelenítése során.",
"chart_saved_successfully": "A diagram sikeresen mentve!",
"chart_type_area": "Területdiagram",
"chart_type_bar": "Oszlopdiagram",
"chart_type_big_number": "Nagy szám",
"chart_type_line": "Vonaldiagram",
"chart_type_not_supported": "A(z) \"{{chartType}}\" diagramtípus még nem támogatott",
"chart_type_pie": "Kördiagram",
"chart_updated_successfully": "A diagram sikeresen frissítve!",
"configure_description": "Módosítsd a diagram típusát és egyéb beállításait ehhez a vizualizációhoz.",
"configure_title": "Diagram konfigurálása",
"configure_type_label": "Diagram típusa",
"contains": "tartalmazza",
"create_chart": "Diagram létrehozása",
"create_chart_description": "Használj mesterséges intelligenciát diagram generálásához, vagy készíts egyet manuálisan.",
"custom_range": "Egyéni tartomány",
"dashboard": "Vezérlőpult",
"dashboard_select_placeholder": "Válassz egy vezérlőpultot",
"data_label": "Adat",
"date_preset_last_30_days": "Elmúlt 30 nap",
"date_preset_last_7_days": "Elmúlt 7 nap",
"date_preset_last_month": "Elmúlt hónap",
"date_preset_this_month": "Ez a hónap",
"date_preset_this_quarter": "Ez a negyedév",
"date_preset_this_year": "Ez az év",
"date_preset_today": "Ma",
"date_preset_yesterday": "Tegnap",
"date_range": "Dátumtartomány",
"delete_chart_confirmation": "Biztosan törölni szeretnéd ezt a diagramot?",
"dimensions": "Dimenziók",
"dimensions_toggle_description": "Csoportosítsd az adatokat kategóriák szerint. A sorrend számít többdimenziós diagramoknál.",
"edit_chart_description": "Tekintsd meg és szerkeszd a diagram konfigurációját.",
"edit_chart_title": "Diagram szerkesztése",
"enable_time_dimension": "Időbeli dimenzió engedélyezése",
"end_date": "Befejezési dátum",
"enter_a_name_for_your_chart": "Add meg a diagram nevét a mentéshez.",
"enter_value": "Adj meg értéket",
"equals": "egyenlő",
"failed_to_add_chart_to_dashboard": "A diagram hozzáadása a vezérlőpulthoz sikertelen",
"failed_to_execute_query": "A lekérdezés végrehajtása sikertelen",
"failed_to_load_chart": "A diagram betöltése sikertelen",
"failed_to_load_chart_data": "A diagram adatainak betöltése sikertelen",
"failed_to_save_chart": "A diagram mentése sikertelen",
"field": "Mező",
"field_label_average_score": "Átlagos pontszám",
"field_label_collected_at": "Gyűjtve",
"field_label_count": "Darabszám",
"field_label_detractor_count": "Kritikusok száma",
"field_label_emotion": "Érzelem",
"field_label_field_type": "Mező típusa",
"field_label_nps_score": "NPS pontszám",
"field_label_nps_value": "NPS érték",
"field_label_passive_count": "Passzívak száma",
"field_label_promoter_count": "Támogatók száma",
"field_label_response_id": "Válaszazonosító",
"field_label_sentiment": "Hangulat",
"field_label_source_name": "Forrás neve",
"field_label_source_type": "Forrás típusa",
"field_label_topic": "Téma",
"field_label_user_identifier": "Felhasználóazonosító",
"filters": "Szűrők",
"filters_toggle_description": "Csak azokat az adatokat tartalmazza, amelyek megfelelnek a következő feltételeknek.",
"generate_chart": "Diagram generálása",
"granularity": "Részletesség",
"granularity_day": "Nap",
"granularity_hour": "óra",
"granularity_month": "hónap",
"granularity_quarter": "negyedév",
"granularity_week": "hét",
"granularity_year": "év",
"greater_than": "nagyobb mint",
"greater_than_or_equal": "nagyobb vagy egyenlő",
"group_by": "Csoportosítás",
"group_by_description": "Válassz dimenziókat az adatok lebontásához. A sorrend számít többdimenziós diagramok esetén.",
"guide_button": "Mezőkalauz megtekintése",
"guide_chart_type": "Diagram típusa",
"guide_chart_type_desc": "Az adatok megjelenítésének módja: terület, oszlop, vonal, kör vagy nagy szám. Válaszd azt, ami alapján meg szeretnéd jeleníteni (trendek, összehasonlítások, részek az egészből stb.).",
"guide_dimensions": "Dimenziók (csoportosítás)",
"guide_dimensions_desc": "Az adatok felosztásának vagy csoportosításának módja. Minden dimenzió egy kategóriává válik a diagramon (pl. hangulat, forrás típusa, felmérés neve, csatorna, téma). A sorrend számít többdimenziós diagramok esetén.",
"guide_filters": "Szűrők",
"guide_filters_desc": "Feltételek, amelyek korlátozzák, hogy mely adatok kerüljenek be. Minden szűrő tartalmaz egy mezőt, operátort (egyenlő, tartalmaz, nagyobb mint stb.) és értékeket. És = mindegyiknek egyeznie kell; Vagy = bármelyik egyezhet.",
"guide_measures": "Mértékek (amit számolsz vagy aggregálsz)",
"guide_measures_predefined": "Az előre definiált mértékek előre elkészített metrikák a visszajelzési adataidból: darabszám (összes válasz), támogató/ellenzői/passzív darabszám (NPS szegmensek), NPS pontszám, átlagos pontszám, befejezési arány.",
"guide_quick_ref": "Gyors referencia",
"guide_term_dimension": "Kategorikus mező, amelyet az adatok csoportosítására vagy felosztására használnak",
"guide_term_filter": "Feltétel, amely korlátozza, hogy mely sorok kerüljenek be",
"guide_term_measure": "Numerikus érték, amelyet aggregálsz (darabszám, összeg, átlag stb.)",
"guide_term_time": "Időalapú csoportosítás részletességgel és dátumtartománnyal",
"guide_time_dimension": "Idődimenziós",
"guide_time_dimension_desc": "Időalapú csoportosítás: válassz egy időmezőt (általában Gyűjtés időpontja), részletességet (Óra, Nap, Hét, Hónap stb.) és dátumtartományt (előre beállított vagy egyéni). Használd időbeli trendekhez.",
"guide_title": "Diagramkészítő útmutató",
"is_not_set": "nincs beállítva",
"is_set": "beállítva",
"less_than": "kisebb mint",
"less_than_or_equal": "kisebb vagy egyenlő",
"measures": "Mérőszámok",
"no_charts_found": "Nem található diagram.",
"no_dashboards_available": "Nincsenek elérhető vezérlőpultok",
"no_dashboards_create_first": "Először hozz létre egy vezérlőpultot, hogy diagramokat adhass hozzá.",
"no_data_available": "Nincsenek elérhető adatok",
"no_data_returned": "A lekérdezés nem adott vissza adatokat",
"no_data_returned_for_chart": "A diagram nem adott vissza adatokat",
"no_grouping": "Nincs (csak szűrés)",
"no_valid_data_to_display": "Nincsenek megjeleníthető érvényes adatok",
"not_contains": "nem tartalmazza",
"not_equals": "nem egyenlő",
"open_chart": "{{name}} diagram megnyitása",
"open_options": "Diagram beállításainak megnyitása",
"or_filter_logic": "VAGY",
"original": "Eredeti",
"please_enter_chart_name": "Kérjük, add meg a diagram nevét",
"please_select_at_least_one_measure": "Kérjük, válassz ki legalább egy mérőszámot",
"please_select_dashboard": "Kérjük, válassz egy vezérlőpultot",
"predefined_measures": "Előre definiált mérőszámok",
"preset": "Előbeállítás",
"query_executed_successfully": "Lekérdezés sikeresen végrehajtva",
"reset_to_ai_suggestion": "Visszaállítás AI javaslatra",
"save_chart": "Diagram mentése",
"save_chart_dialog_title": "Diagram mentése",
"select_dimensions": "Dimenziók kiválasztása...",
"select_field": "Mező kiválasztása",
"select_measures": "Mérőszámok kiválasztása...",
"select_preset": "Előbeállítás kiválasztása",
"showing_first_n_of": "Az első {{n}} sor megjelenítése {{count}} sorból",
"start_date": "Kezdési dátum",
"time_dimension": "Időbeli dimenzió",
"time_dimension_toggle_description": "Időalapú csoportosítás hozzáadása az időbeli trendek megjelenítéséhez."
},
"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.",
@@ -1242,6 +1426,7 @@
"add_fallback_placeholder": "Helykitöltő hozzáadása annak megjelenítéshez, hogy nincs visszahívandó érték.",
"add_hidden_field_id": "Rejtett mezőazonosító hozzáadása",
"add_highlight_border": "Kiemelési szegély hozzáadása",
"add_highlight_border_description": "Csak a terméken belüli felmérésekre vonatkozik.",
"add_logic": "Logika hozzáadása",
"add_none_of_the_above": "„A fentiek közül egyik sem” hozzáadása",
"add_option": "Lehetőség hozzáadása",
@@ -1440,7 +1625,6 @@
"follow_ups_modal_updated_successfull_toast": "A követés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_new": "Új követés",
"follow_ups_upgrade_button_text": "Magasabb csomagra váltás a követések engedélyezéséhez",
"form_styling": "Űrlap stílusának beállítása",
"formbricks_sdk_is_not_connected": "A Formbricks SDK nincs csatlakoztatva",
"four_points": "4 pont",
"heading": "Címsor",
@@ -1613,7 +1797,7 @@
"response_limits_redirections_and_more": "Válaszkorlátok, átirányítások és egyebek.",
"response_options": "Válasz beállításai",
"roundness": "Kerekesség",
"roundness_description": "Annak vezérlése, hogy a kártya sarkai mennyire legyenek lekerekítve.",
"roundness_description": "Szabályozza a sarkok lekerekítését.",
"row_used_in_logic_error": "Ez a sor használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
"rows": "Sorok",
"save_and_close": "Mentés és bezárás",
@@ -1659,6 +1843,7 @@
"survey_completed_subheading": "Ez a szabad és nyílt forráskódú kérdőív le lett zárva",
"survey_display_settings": "Kérdőív megjelenítésének beállításai",
"survey_placement": "Kérdőív elhelyezése",
"survey_styling": "Űrlap stílusának beállítása",
"survey_trigger": "Kérdőív aktiválója",
"switch_multi_language_on_to_get_started": "Kapcsolja be a többnyelvűséget a kezdéshez 👉",
"target_block_not_found": "A célblokk nem található",
@@ -2170,7 +2355,7 @@
"advanced_styling_field_indicator_bg_description": "Kiszínezi a sáv kitöltött részét.",
"advanced_styling_field_input_border_radius_description": "Lekerekíti a beviteli mező sarkait.",
"advanced_styling_field_input_font_size_description": "Átméretezi a beviteli mezőkbe beírt szöveget.",
"advanced_styling_field_input_height_description": "A beviteli mező minimális magasságát szabályozza.",
"advanced_styling_field_input_height_description": "Szabályozza a beviteli mező minimális magasságát.",
"advanced_styling_field_input_padding_x_description": "Térközt ad hozzá balra és jobbra.",
"advanced_styling_field_input_padding_y_description": "Térközt ad hozzá fent és lent.",
"advanced_styling_field_input_placeholder_opacity_description": "Elhalványítja a helykitöltő súgószöveget.",
@@ -2179,6 +2364,8 @@
"advanced_styling_field_input_text_description": "Kiszínezi a beviteli mezőkbe beírt szöveget.",
"advanced_styling_field_option_bg": "Háttér",
"advanced_styling_field_option_bg_description": "Kitölti a választási lehetőség elemeit.",
"advanced_styling_field_option_border": "Szegély színe",
"advanced_styling_field_option_border_description": "A rádiógomb és jelölőnégyzet opciók körvonalát határozza meg.",
"advanced_styling_field_option_border_radius_description": "Lekerekíti a választási lehetőség sarkait.",
"advanced_styling_field_option_font_size_description": "Átméretezi a választási lehetőség címkéjének szövegét.",
"advanced_styling_field_option_label": "Címke színe",
@@ -2998,6 +3185,9 @@
"preview_survey_question_2_choice_2_label": "Nem, köszönöm!",
"preview_survey_question_2_headline": "Szeretne naprakész maradni?",
"preview_survey_question_2_subheader": "Ez egy példa a leírásra.",
"preview_survey_question_open_text_headline": "Bármi egyéb, amit meg szeretne osztani?",
"preview_survey_question_open_text_placeholder": "Írja be ide a válaszát…",
"preview_survey_question_open_text_subheader": "A visszajelzése segít nekünk a fejlődésben.",
"preview_survey_welcome_card_headline": "Üdvözöljük!",
"prioritize_features_description": "A felhasználóknak leginkább és legkevésbé szükséges funkciók azonosítása.",
"prioritize_features_name": "Funkciók rangsorolása",

View File

@@ -133,6 +133,7 @@
"allow": "許可",
"allow_users_to_exit_by_clicking_outside_the_survey": "フォームの外側をクリックしてユーザーが終了できるようにする",
"an_unknown_error_occurred_while_deleting_table_items": "{type}の削除中に不明なエラーが発生しました",
"analysis": "分析",
"and": "および",
"and_response_limit_of": "と回答数の上限",
"anonymous": "匿名",
@@ -149,6 +150,8 @@
"bottom_right": "右下",
"cancel": "キャンセル",
"centered_modal": "中央モーダル",
"chart": "チャート",
"charts": "チャート",
"choices": "選択肢",
"choose_environment": "環境を選択",
"choose_organization": "組織を選択",
@@ -178,6 +181,7 @@
"count_attributes": "{value, plural, other {{value}個の属性}}",
"count_contacts": "{count, plural, other {# 件の連絡先}}",
"count_responses": "{count, plural, other {# 件の回答}}",
"create": "作成",
"create_new_organization": "新しい組織を作成",
"create_segment": "セグメントを作成",
"create_survey": "フォームを作成",
@@ -187,6 +191,8 @@
"created_by": "作成者",
"customer_success": "カスタマーサクセス",
"dark_overlay": "暗いオーバーレイ",
"dashboard": "ダッシュボード",
"dashboards": "ダッシュボード",
"date": "日付",
"days": "日",
"default": "デフォルト",
@@ -225,7 +231,9 @@
"failed_to_copy_to_clipboard": "クリップボードへのコピーに失敗しました",
"failed_to_load_organizations": "組織の読み込みに失敗しました",
"failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました",
"filter": "フィルター",
"finish": "完了",
"first_name": "名",
"follow_these": "こちらの手順に従って",
"formbricks_version": "Formbricksバージョン",
"full_name": "氏名",
@@ -237,7 +245,9 @@
"hidden": "非表示",
"hidden_field": "非表示フィールド",
"hidden_fields": "非表示フィールド",
"hide": "非表示",
"hide_column": "列を非表示",
"id": "ID",
"image": "画像",
"images": "画像",
"import": "インポート",
@@ -255,6 +265,7 @@
"key": "キー",
"label": "ラベル",
"language": "言語",
"last_name": "姓",
"learn_more": "詳細を見る",
"license_expired": "License Expired",
"light_overlay": "明るいオーバーレイ",
@@ -281,6 +292,7 @@
"move_down": "下に移動",
"move_up": "上に移動",
"multiple_languages": "多言語",
"my_product": "マイプロダクト",
"name": "名前",
"new": "新規",
"new_version_available": "Formbricks {version} が利用可能です。今すぐアップグレード!",
@@ -304,6 +316,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": "任意",
@@ -446,6 +459,7 @@
"variables": "変数",
"verified_email": "認証済みメールアドレス",
"video": "動画",
"view": "表示",
"warning": "警告",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "ライセンスサーバーにアクセスできないため、ライセンスを認証できませんでした。",
"webhook": "Webhook",
@@ -609,6 +623,176 @@
"your_survey_would_be_shown_on_this_url": "あなたのフォームはこのURLに表示されます。",
"your_survey_would_not_be_shown": "あなたのフォームは表示されません。"
},
"analysis": {
"charts": {
"OR": "OR",
"add_chart_to_dashboard": "ダッシュボードにチャートを追加",
"add_chart_to_dashboard_description": "このチャートを追加するダッシュボードを選択してください。チャートは自動的に保存されます。",
"add_filter": "フィルターを追加",
"add_to_dashboard": "ダッシュボードに追加",
"advanced_chart_builder_config_prompt": "チャートを設定して「クエリを実行」をクリックしてプレビューを表示",
"ai_query_placeholder": "例: 先週何人のユーザーが登録しましたか?",
"ai_query_section_description": "表示したい内容を説明すると、AIがチャートを作成します。",
"ai_query_section_title": "データに質問する",
"apply_changes": "変更を適用",
"chart": "チャート",
"chart_added_to_dashboard": "チャートをダッシュボードに追加しました!",
"chart_builder_choose_chart_type": "チャートタイプを選択",
"chart_data": "チャートデータ",
"chart_data_tab": "データ",
"chart_deleted_successfully": "チャートを削除しました",
"chart_deletion_error": "チャートの削除に失敗しました",
"chart_duplicated_successfully": "チャートを複製しました",
"chart_duplication_error": "チャートの複製に失敗しました",
"chart_name": "チャート名",
"chart_name_placeholder": "チャート名",
"chart_preview": "チャートプレビュー",
"chart_render_error": "このチャートの表示中に問題が発生しました。",
"chart_saved_successfully": "チャートを保存しました!",
"chart_type_area": "エリアチャート",
"chart_type_bar": "棒グラフ",
"chart_type_big_number": "大きな数値",
"chart_type_line": "折れ線グラフ",
"chart_type_not_supported": "チャートタイプ「{{chartType}}」はまだサポートされていません",
"chart_type_pie": "円グラフ",
"chart_updated_successfully": "チャートを更新しました!",
"configure_description": "このビジュアライゼーションのチャートタイプやその他の設定を変更します。",
"configure_title": "チャートを設定",
"configure_type_label": "チャートタイプ",
"contains": "を含む",
"create_chart": "チャートを作成",
"create_chart_description": "AIを使用してチャートを生成するか、手動で作成します。",
"custom_range": "カスタム範囲",
"dashboard": "ダッシュボード",
"dashboard_select_placeholder": "ダッシュボードを選択",
"data_label": "データ",
"date_preset_last_30_days": "過去30日間",
"date_preset_last_7_days": "過去7日間",
"date_preset_last_month": "先月",
"date_preset_this_month": "今月",
"date_preset_this_quarter": "今四半期",
"date_preset_this_year": "今年",
"date_preset_today": "今日",
"date_preset_yesterday": "昨日",
"date_range": "日付範囲",
"delete_chart_confirmation": "このチャートを削除してもよろしいですか?",
"dimensions": "ディメンション",
"dimensions_toggle_description": "カテゴリ別にデータをグループ化します。多次元チャートでは順序が重要です。",
"edit_chart_description": "チャート設定を表示および編集します。",
"edit_chart_title": "チャートを編集",
"enable_time_dimension": "時間ディメンションを有効化",
"end_date": "終了日",
"enter_a_name_for_your_chart": "チャートを保存するには名前を入力してください。",
"enter_value": "値を入力",
"equals": "と等しい",
"failed_to_add_chart_to_dashboard": "ダッシュボードへのチャート追加に失敗しました",
"failed_to_execute_query": "クエリの実行に失敗しました",
"failed_to_load_chart": "チャートの読み込みに失敗しました",
"failed_to_load_chart_data": "チャートデータの読み込みに失敗しました",
"failed_to_save_chart": "チャートの保存に失敗しました",
"field": "フィールド",
"field_label_average_score": "平均スコア",
"field_label_collected_at": "収集日時",
"field_label_count": "カウント",
"field_label_detractor_count": "批判者数",
"field_label_emotion": "感情",
"field_label_field_type": "フィールドタイプ",
"field_label_nps_score": "NPSスコア",
"field_label_nps_value": "NPS値",
"field_label_passive_count": "中立者数",
"field_label_promoter_count": "推奨者数",
"field_label_response_id": "回答ID",
"field_label_sentiment": "感情分析",
"field_label_source_name": "ソース名",
"field_label_source_type": "ソースタイプ",
"field_label_topic": "トピック",
"field_label_user_identifier": "ユーザー識別子",
"filters": "フィルター",
"filters_toggle_description": "以下の条件を満たすデータのみを含めます。",
"generate_chart": "チャートを生成",
"granularity": "粒度",
"granularity_day": "日",
"granularity_hour": "時間",
"granularity_month": "月",
"granularity_quarter": "四半期",
"granularity_week": "週",
"granularity_year": "年",
"greater_than": "より大きい",
"greater_than_or_equal": "以上",
"group_by": "グループ化",
"group_by_description": "データを分類するディメンションを選択します。多次元チャートでは順序が重要です。",
"guide_button": "フィールドガイドを表示",
"guide_chart_type": "チャートタイプ",
"guide_chart_type_desc": "データの視覚化方法:エリア、棒グラフ、折れ線グラフ、円グラフ、またはビッグナンバー。表示したい内容(トレンド、比較、全体の一部など)に基づいて選択します。",
"guide_dimensions": "ディメンション(グループ化)",
"guide_dimensions_desc": "データを分割またはグループ化する方法。各ディメンションはチャート上のカテゴリになります(例:センチメント、ソースタイプ、サーベイ名、チャネル、トピック)。多次元チャートでは順序が重要です。",
"guide_filters": "フィルター",
"guide_filters_desc": "含めるデータを制限する条件。各フィルターにはフィールド、演算子(等しい、含む、より大きいなど)、および値があります。And = すべて一致する必要があります。Or = いずれか一致すればよい。",
"guide_measures": "メジャー(カウントまたは集計する内容)",
"guide_measures_predefined": "事前定義メジャーは、フィードバックデータから構築済みのメトリクスです:カウント(総回答数)、プロモーター/デトラクター/パッシブカウント(NPSセグメント)、NPSスコア、平均スコア、完了率。",
"guide_quick_ref": "クイックリファレンス",
"guide_term_dimension": "データをグループ化または分割するために使用されるカテゴリフィールド",
"guide_term_filter": "含める行を制限する条件",
"guide_term_measure": "集計する数値(count、sum、avgなど)",
"guide_term_time": "粒度と日付範囲を持つ時間ベースのグループ化",
"guide_time_dimension": "時間ディメンション",
"guide_time_dimension_desc": "時間ベースのグループ化:時間フィールド(通常は収集日時)、粒度(時間、日、週、月など)、日付範囲(プリセットまたはカスタム)を選択します。時系列のトレンド分析に使用します。",
"guide_title": "チャートビルダーフィールドガイド",
"is_not_set": "設定されていない",
"is_set": "設定されている",
"less_than": "より小さい",
"less_than_or_equal": "以下",
"measures": "メジャー",
"no_charts_found": "チャートが見つかりません。",
"no_dashboards_available": "利用可能なダッシュボードがありません",
"no_dashboards_create_first": "チャートを追加するには、まずダッシュボードを作成してください。",
"no_data_available": "利用可能なデータがありません",
"no_data_returned": "クエリからデータが返されませんでした",
"no_data_returned_for_chart": "チャートのデータが返されませんでした",
"no_grouping": "なし(フィルターのみ)",
"no_valid_data_to_display": "表示する有効なデータがありません",
"not_contains": "を含まない",
"not_equals": "と等しくない",
"open_chart": "チャート{{name}}を開く",
"open_options": "チャートオプションを開く",
"or_filter_logic": "OR",
"original": "オリジナル",
"please_enter_chart_name": "チャート名を入力してください",
"please_select_at_least_one_measure": "少なくとも1つのメジャーを選択してください",
"please_select_dashboard": "ダッシュボードを選択してください",
"predefined_measures": "事前定義されたメジャー",
"preset": "プリセット",
"query_executed_successfully": "クエリが正常に実行されました",
"reset_to_ai_suggestion": "AIの提案にリセット",
"save_chart": "チャートを保存",
"save_chart_dialog_title": "チャートを保存",
"select_dimensions": "ディメンションを選択...",
"select_field": "フィールドを選択",
"select_measures": "メジャーを選択...",
"select_preset": "プリセットを選択",
"showing_first_n_of": "{{count}}行中、最初の{{n}}行を表示",
"start_date": "開始日",
"time_dimension": "時間ディメンション",
"time_dimension_toggle_description": "時系列のトレンド分析のために時間ベースのグループ化を追加します。"
},
"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": "うまくいきました!接続されました。",
@@ -1242,6 +1426,7 @@
"add_fallback_placeholder": "質問がスキップされた場合に表示するプレースホルダーを追加:",
"add_hidden_field_id": "非表示フィールドIDを追加",
"add_highlight_border": "ハイライトボーダーを追加",
"add_highlight_border_description": "プロダクト内サーベイにのみ適用されます。",
"add_logic": "ロジックを追加",
"add_none_of_the_above": "\"いずれも該当しません\" を追加",
"add_option": "オプションを追加",
@@ -1440,7 +1625,6 @@
"follow_ups_modal_updated_successfull_toast": "フォローアップ が 更新され、 アンケートを 保存すると保存されます。",
"follow_ups_new": "新しいフォローアップ",
"follow_ups_upgrade_button_text": "フォローアップを有効にするためにアップグレード",
"form_styling": "フォームのスタイル",
"formbricks_sdk_is_not_connected": "Formbricks SDKが接続されていません",
"four_points": "4点",
"heading": "見出し",
@@ -1613,7 +1797,7 @@
"response_limits_redirections_and_more": "回答数の上限、リダイレクトなど。",
"response_options": "回答オプション",
"roundness": "丸み",
"roundness_description": "カードの角の丸みを調整します。",
"roundness_description": "角の丸みを調整します。",
"row_used_in_logic_error": "この行は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"rows": "行",
"save_and_close": "保存して閉じる",
@@ -1659,6 +1843,7 @@
"survey_completed_subheading": "この無料のオープンソースフォームは閉鎖されました",
"survey_display_settings": "フォーム表示設定",
"survey_placement": "フォームの配置",
"survey_styling": "フォームのスタイル",
"survey_trigger": "フォームのトリガー",
"switch_multi_language_on_to_get_started": "多言語機能をオンにして開始 👉",
"target_block_not_found": "対象ブロックが見つかりません",
@@ -2170,7 +2355,7 @@
"advanced_styling_field_indicator_bg_description": "バーの塗りつぶし部分に色を付けます。",
"advanced_styling_field_input_border_radius_description": "入力フィールドの角を丸めます。",
"advanced_styling_field_input_font_size_description": "入力フィールド内の入力テキストのサイズを調整します。",
"advanced_styling_field_input_height_description": "入力フィールドの最小の高さを制御します。",
"advanced_styling_field_input_height_description": "入力の最小の高さを調整します。",
"advanced_styling_field_input_padding_x_description": "左右にスペースを追加します。",
"advanced_styling_field_input_padding_y_description": "上下にスペースを追加します。",
"advanced_styling_field_input_placeholder_opacity_description": "プレースホルダーのヒントテキストを薄くします。",
@@ -2179,6 +2364,8 @@
"advanced_styling_field_input_text_description": "入力フィールドに入力されたテキストの色を設定します。",
"advanced_styling_field_option_bg": "背景",
"advanced_styling_field_option_bg_description": "オプション項目を塗りつぶします。",
"advanced_styling_field_option_border": "枠線の色",
"advanced_styling_field_option_border_description": "ラジオボタンとチェックボックスの選択肢の輪郭を設定します。",
"advanced_styling_field_option_border_radius_description": "オプションの角を丸くします。",
"advanced_styling_field_option_font_size_description": "オプションラベルのテキストサイズを調整します。",
"advanced_styling_field_option_label": "ラベルの色",
@@ -2998,6 +3185,9 @@
"preview_survey_question_2_choice_2_label": "いいえ、結構です!",
"preview_survey_question_2_headline": "最新情報を知りたいですか?",
"preview_survey_question_2_subheader": "これは説明の例です。",
"preview_survey_question_open_text_headline": "他に共有したいことはありますか?",
"preview_survey_question_open_text_placeholder": "ここに回答を入力してください...",
"preview_survey_question_open_text_subheader": "あなたのフィードバックは、私たちの改善に役立ちます。",
"preview_survey_welcome_card_headline": "ようこそ!",
"prioritize_features_description": "ユーザーが最も必要とする機能と最も必要としない機能を特定する。",
"prioritize_features_name": "機能の優先順位付け",

View File

@@ -133,6 +133,7 @@
"allow": "Toestaan",
"allow_users_to_exit_by_clicking_outside_the_survey": "Laat gebruikers afsluiten door buiten de enquête te klikken",
"an_unknown_error_occurred_while_deleting_table_items": "Er is een onbekende fout opgetreden bij het verwijderen van {type}s",
"analysis": "Analyse",
"and": "En",
"and_response_limit_of": "en responslimiet van",
"anonymous": "Anoniem",
@@ -149,6 +150,8 @@
"bottom_right": "Rechtsonder",
"cancel": "Annuleren",
"centered_modal": "Gecentreerd modaal",
"chart": "Grafiek",
"charts": "Grafieken",
"choices": "Keuzes",
"choose_environment": "Kies omgeving",
"choose_organization": "Kies organisatie",
@@ -178,6 +181,7 @@
"count_attributes": "{value, plural, one {{value} attribuut} other {{value} attributen}}",
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacten}}",
"count_responses": "{value, plural, one {{value} reactie} other {{value} reacties}}",
"create": "Creëren",
"create_new_organization": "Creëer een nieuwe organisatie",
"create_segment": "Segment maken",
"create_survey": "Enquête maken",
@@ -187,6 +191,8 @@
"created_by": "Gemaakt door",
"customer_success": "Klant succes",
"dark_overlay": "Donkere overlay",
"dashboard": "Dashboard",
"dashboards": "Dashboards",
"date": "Datum",
"days": "dagen",
"default": "Standaard",
@@ -225,7 +231,9 @@
"failed_to_copy_to_clipboard": "Kopiëren naar klembord mislukt",
"failed_to_load_organizations": "Laden van organisaties mislukt",
"failed_to_load_workspaces": "Laden van werkruimtes mislukt",
"filter": "Filter",
"finish": "Finish",
"first_name": "Voornaam",
"follow_these": "Volg deze",
"formbricks_version": "Formbricks-versie",
"full_name": "Volledige naam",
@@ -237,7 +245,9 @@
"hidden": "Verborgen",
"hidden_field": "Verborgen veld",
"hidden_fields": "Verborgen velden",
"hide": "Verbergen",
"hide_column": "Kolom verbergen",
"id": "ID",
"image": "Afbeelding",
"images": "Afbeeldingen",
"import": "Importeren",
@@ -255,6 +265,7 @@
"key": "Sleutel",
"label": "Label",
"language": "Taal",
"last_name": "Achternaam",
"learn_more": "Meer informatie",
"license_expired": "License Expired",
"light_overlay": "Lichte overlay",
@@ -281,6 +292,7 @@
"move_down": "Ga naar beneden",
"move_up": "Ga omhoog",
"multiple_languages": "Meerdere talen",
"my_product": "mijn product",
"name": "Naam",
"new": "Nieuw",
"new_version_available": "Formbricks {version} is hier. Upgrade nu!",
@@ -304,6 +316,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",
@@ -446,6 +459,7 @@
"variables": "Variabelen",
"verified_email": "Geverifieerde e-mail",
"video": "Video",
"view": "Bekijken",
"warning": "Waarschuwing",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We kunnen uw licentie niet verifiëren omdat de licentieserver niet bereikbaar is.",
"webhook": "Webhook",
@@ -609,6 +623,176 @@
"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": {
"OR": "OF",
"add_chart_to_dashboard": "Grafiek toevoegen aan dashboard",
"add_chart_to_dashboard_description": "Selecteer een dashboard om deze grafiek aan toe te voegen. De grafiek wordt automatisch opgeslagen.",
"add_filter": "Filter toevoegen",
"add_to_dashboard": "Toevoegen aan dashboard",
"advanced_chart_builder_config_prompt": "Configureer je grafiek en klik op \"Query uitvoeren\" om een voorbeeld te zien",
"ai_query_placeholder": "bijv. Hoeveel gebruikers hebben zich vorige week aangemeld?",
"ai_query_section_description": "Beschrijf wat je wilt zien en laat AI de grafiek bouwen.",
"ai_query_section_title": "Vraag het aan je data",
"apply_changes": "Wijzigingen toepassen",
"chart": "Grafiek",
"chart_added_to_dashboard": "Grafiek toegevoegd aan dashboard!",
"chart_builder_choose_chart_type": "Kies grafiektype",
"chart_data": "Grafiekdata",
"chart_data_tab": "Data",
"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_name": "Grafieknaam",
"chart_name_placeholder": "Grafieknaam",
"chart_preview": "Grafiekvoorbeeld",
"chart_render_error": "Er is iets misgegaan bij het weergeven van deze grafiek.",
"chart_saved_successfully": "Grafiek succesvol opgeslagen!",
"chart_type_area": "Vlakdiagram",
"chart_type_bar": "Staafdiagram",
"chart_type_big_number": "Groot getal",
"chart_type_line": "Lijndiagram",
"chart_type_not_supported": "Grafiektype \"{{chartType}}\" wordt nog niet ondersteund",
"chart_type_pie": "Cirkeldiagram",
"chart_updated_successfully": "Grafiek succesvol bijgewerkt!",
"configure_description": "Pas het diagramtype en andere instellingen voor deze visualisatie aan.",
"configure_title": "Diagram configureren",
"configure_type_label": "Diagramtype",
"contains": "bevat",
"create_chart": "Diagram maken",
"create_chart_description": "Gebruik AI om een diagram te genereren of bouw er handmatig een.",
"custom_range": "Aangepast bereik",
"dashboard": "Dashboard",
"dashboard_select_placeholder": "Selecteer een dashboard",
"data_label": "Data",
"date_preset_last_30_days": "Laatste 30 dagen",
"date_preset_last_7_days": "Laatste 7 dagen",
"date_preset_last_month": "Vorige maand",
"date_preset_this_month": "Deze maand",
"date_preset_this_quarter": "Dit kwartaal",
"date_preset_this_year": "Dit jaar",
"date_preset_today": "Vandaag",
"date_preset_yesterday": "Gisteren",
"date_range": "Datumbereik",
"delete_chart_confirmation": "Weet je zeker dat je deze grafiek wilt verwijderen?",
"dimensions": "Dimensies",
"dimensions_toggle_description": "Groepeer data op categorieën. Volgorde is belangrijk voor multidimensionale diagrammen.",
"edit_chart_description": "Bekijk en bewerk je diagramconfiguratie.",
"edit_chart_title": "Diagram bewerken",
"enable_time_dimension": "Tijdsdimensie inschakelen",
"end_date": "Einddatum",
"enter_a_name_for_your_chart": "Voer een naam in voor je diagram om het op te slaan.",
"enter_value": "Voer waarde in",
"equals": "is gelijk aan",
"failed_to_add_chart_to_dashboard": "Diagram toevoegen aan dashboard mislukt",
"failed_to_execute_query": "Query uitvoeren mislukt",
"failed_to_load_chart": "Diagram laden mislukt",
"failed_to_load_chart_data": "Diagramdata laden mislukt",
"failed_to_save_chart": "Opslaan van diagram mislukt",
"field": "Veld",
"field_label_average_score": "Gemiddelde score",
"field_label_collected_at": "Verzameld op",
"field_label_count": "Aantal",
"field_label_detractor_count": "Aantal detractors",
"field_label_emotion": "Emotie",
"field_label_field_type": "Veldtype",
"field_label_nps_score": "NPS-score",
"field_label_nps_value": "NPS-waarde",
"field_label_passive_count": "Aantal passieven",
"field_label_promoter_count": "Aantal promoters",
"field_label_response_id": "Antwoord-ID",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Bronnaam",
"field_label_source_type": "Brontype",
"field_label_topic": "Onderwerp",
"field_label_user_identifier": "Gebruikersidentificatie",
"filters": "Filters",
"filters_toggle_description": "Neem alleen gegevens op die aan de volgende voorwaarden voldoen.",
"generate_chart": "Diagram genereren",
"granularity": "Granulariteit",
"granularity_day": "Dag",
"granularity_hour": "Uur",
"granularity_month": "Maand",
"granularity_quarter": "Kwartaal",
"granularity_week": "Week",
"granularity_year": "Jaar",
"greater_than": "groter dan",
"greater_than_or_equal": "groter dan of gelijk aan",
"group_by": "Groeperen op",
"group_by_description": "Selecteer dimensies om je gegevens op te splitsen. De volgorde is belangrijk voor multidimensionale diagrammen.",
"guide_button": "Veldgids bekijken",
"guide_chart_type": "Diagramtype",
"guide_chart_type_desc": "Hoe de gegevens worden gevisualiseerd: vlak, staaf, lijn, cirkel of groot getal. Kies op basis van wat je wilt tonen (trends, vergelijkingen, delen van een geheel, etc.).",
"guide_dimensions": "Dimensies (groeperen op)",
"guide_dimensions_desc": "Hoe je de gegevens splitst of groepeert. Elke dimensie wordt een categorie in het diagram (bijv. sentiment, brontype, enquêtenaam, kanaal, onderwerp). De volgorde is belangrijk voor multidimensionale diagrammen.",
"guide_filters": "Filters",
"guide_filters_desc": "Voorwaarden die bepalen welke gegevens worden opgenomen. Elk filter heeft een veld, operator (is gelijk aan, bevat, groter dan, etc.) en waarden. En = alle moeten overeenkomen; Of = één mag overeenkomen.",
"guide_measures": "Metingen (wat je telt of aggregeert)",
"guide_measures_predefined": "Voorgedefinieerde metingen zijn vooraf gebouwde statistieken uit je feedbackgegevens: aantal (totaal aantal reacties), promotor-/detractor-/passief aantal (NPS-segmenten), NPS-score, gemiddelde score, voltooiingspercentage.",
"guide_quick_ref": "Snelle referentie",
"guide_term_dimension": "Categorisch veld gebruikt om gegevens te groeperen of splitsen",
"guide_term_filter": "Voorwaarde die bepaalt welke rijen worden opgenomen",
"guide_term_measure": "Numerieke waarde die je aggregeert (count, sum, avg, etc.)",
"guide_term_time": "Tijdgebaseerde groepering met granulariteit en datumbereik",
"guide_time_dimension": "Tijddimensie",
"guide_time_dimension_desc": "Tijdgebaseerde groepering: kies een tijdveld (meestal Verzameld op), granulariteit (Uur, Dag, Week, Maand, etc.) en datumbereik (vooraf ingesteld of aangepast). Gebruik voor trends over tijd.",
"guide_title": "Veldgids voor diagrambouwer",
"is_not_set": "is niet ingesteld",
"is_set": "is ingesteld",
"less_than": "kleiner dan",
"less_than_or_equal": "kleiner dan of gelijk aan",
"measures": "Metingen",
"no_charts_found": "Geen diagrammen gevonden.",
"no_dashboards_available": "Geen dashboards beschikbaar",
"no_dashboards_create_first": "Maak eerst een dashboard aan om er diagrammen aan toe te voegen.",
"no_data_available": "Geen gegevens beschikbaar",
"no_data_returned": "Geen gegevens geretourneerd uit query",
"no_data_returned_for_chart": "Geen gegevens geretourneerd voor diagram",
"no_grouping": "Geen (alleen filteren)",
"no_valid_data_to_display": "Geen geldige gegevens om weer te geven",
"not_contains": "bevat niet",
"not_equals": "is niet gelijk aan",
"open_chart": "Open diagram {{name}}",
"open_options": "Open diagramopties",
"or_filter_logic": "OF",
"original": "Origineel",
"please_enter_chart_name": "Voer een diagramnaam in",
"please_select_at_least_one_measure": "Selecteer ten minste één meting",
"please_select_dashboard": "Selecteer een dashboard",
"predefined_measures": "Vooraf gedefinieerde metingen",
"preset": "Voorinstelling",
"query_executed_successfully": "Query succesvol uitgevoerd",
"reset_to_ai_suggestion": "Herstel naar AI-suggestie",
"save_chart": "Diagram opslaan",
"save_chart_dialog_title": "Diagram opslaan",
"select_dimensions": "Selecteer dimensies...",
"select_field": "Selecteer veld",
"select_measures": "Selecteer metingen...",
"select_preset": "Selecteer voorinstelling",
"showing_first_n_of": "Eerste {{n}} van {{count}} rijen worden getoond",
"start_date": "Startdatum",
"time_dimension": "Tijdsdimensie",
"time_dimension_toggle_description": "Voeg tijdgebaseerde groepering toe voor trends over tijd."
},
"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.",
@@ -1242,6 +1426,7 @@
"add_fallback_placeholder": "Voeg een tijdelijke aanduiding toe om aan te geven of er geen waarde is om te onthouden.",
"add_hidden_field_id": "Voeg een verborgen veld-ID toe",
"add_highlight_border": "Markeerrand toevoegen",
"add_highlight_border_description": "Geldt alleen voor in-product enquêtes.",
"add_logic": "Voeg logica toe",
"add_none_of_the_above": "Voeg 'Geen van bovenstaande' toe",
"add_option": "Optie toevoegen",
@@ -1440,7 +1625,6 @@
"follow_ups_modal_updated_successfull_toast": "Follow-up bijgewerkt en wordt opgeslagen zodra u de enquête opslaat.",
"follow_ups_new": "Nieuw vervolg",
"follow_ups_upgrade_button_text": "Upgrade om follow-ups mogelijk te maken",
"form_styling": "Vorm styling",
"formbricks_sdk_is_not_connected": "Formbricks SDK is niet verbonden",
"four_points": "4 punten",
"heading": "Rubriek",
@@ -1613,7 +1797,7 @@
"response_limits_redirections_and_more": "Reactielimieten, omleidingen en meer.",
"response_options": "Reactieopties",
"roundness": "Rondheid",
"roundness_description": "Bepaalt hoe afgerond de kaarthoeken zijn.",
"roundness_description": "Bepaalt hoe afgerond de hoeken zijn.",
"row_used_in_logic_error": "Deze rij wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
"rows": "Rijen",
"save_and_close": "Opslaan en sluiten",
@@ -1659,6 +1843,7 @@
"survey_completed_subheading": "Deze gratis en open source-enquête is gesloten",
"survey_display_settings": "Enquêteweergave-instellingen",
"survey_placement": "Enquête plaatsing",
"survey_styling": "Vorm styling",
"survey_trigger": "Enquêtetrigger",
"switch_multi_language_on_to_get_started": "Schakel meertaligheid in om te beginnen 👉",
"target_block_not_found": "Doelblok niet gevonden",
@@ -2170,7 +2355,7 @@
"advanced_styling_field_indicator_bg_description": "Kleurt het gevulde deel van de balk.",
"advanced_styling_field_input_border_radius_description": "Rondt de invoerhoeken af.",
"advanced_styling_field_input_font_size_description": "Schaalt de getypte tekst in invoervelden.",
"advanced_styling_field_input_height_description": "Bepaalt de minimale hoogte van het invoerveld.",
"advanced_styling_field_input_height_description": "Bepaalt de min. hoogte van het invoerveld.",
"advanced_styling_field_input_padding_x_description": "Voegt ruimte toe aan de linker- en rechterkant.",
"advanced_styling_field_input_padding_y_description": "Voegt ruimte toe aan de boven- en onderkant.",
"advanced_styling_field_input_placeholder_opacity_description": "Vervaagt de tijdelijke aanwijzingstekst.",
@@ -2179,6 +2364,8 @@
"advanced_styling_field_input_text_description": "Kleurt de getypte tekst in invoervelden.",
"advanced_styling_field_option_bg": "Achtergrond",
"advanced_styling_field_option_bg_description": "Vult de optie-items.",
"advanced_styling_field_option_border": "Randkleur",
"advanced_styling_field_option_border_description": "Omlijnt radio- en checkboxopties.",
"advanced_styling_field_option_border_radius_description": "Rondt de hoeken van opties af.",
"advanced_styling_field_option_font_size_description": "Schaalt de tekst van optielabels.",
"advanced_styling_field_option_label": "Labelkleur",
@@ -2998,6 +3185,9 @@
"preview_survey_question_2_choice_2_label": "Nee, dank je!",
"preview_survey_question_2_headline": "Wil je op de hoogte blijven?",
"preview_survey_question_2_subheader": "Dit is een voorbeeldbeschrijving.",
"preview_survey_question_open_text_headline": "Wil je nog iets delen?",
"preview_survey_question_open_text_placeholder": "Typ hier je antwoord...",
"preview_survey_question_open_text_subheader": "Je feedback helpt ons verbeteren.",
"preview_survey_welcome_card_headline": "Welkom!",
"prioritize_features_description": "Identificeer functies die uw gebruikers het meest en het minst nodig hebben.",
"prioritize_features_name": "Geef prioriteit aan functies",

View File

@@ -133,6 +133,7 @@
"allow": "permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa",
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao deletar {type}s",
"analysis": "Análise",
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anônimo",
@@ -149,6 +150,8 @@
"bottom_right": "Canto Inferior Direito",
"cancel": "Cancelar",
"centered_modal": "Modal Centralizado",
"chart": "Gráfico",
"charts": "Gráficos",
"choices": "Escolhas",
"choose_environment": "Escolher ambiente",
"choose_organization": "Escolher organização",
@@ -178,6 +181,7 @@
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {# contato} other {# contatos} }",
"count_responses": "{value, plural, other {# respostas}}",
"create": "Criar",
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar pesquisa",
@@ -187,6 +191,8 @@
"created_by": "Criado por",
"customer_success": "Sucesso do Cliente",
"dark_overlay": "sobreposição escura",
"dashboard": "Painel",
"dashboards": "Painéis",
"date": "Encontro",
"days": "dias",
"default": "Padrão",
@@ -225,7 +231,9 @@
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_workspaces": "Falha ao carregar projetos",
"filter": "Filtro",
"finish": "Terminar",
"first_name": "Primeiro nome",
"follow_these": "Siga esses",
"formbricks_version": "Versão do Formbricks",
"full_name": "Nome completo",
@@ -237,7 +245,9 @@
"hidden": "Escondido",
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide": "Ocultar",
"hide_column": "Ocultar coluna",
"id": "ID",
"image": "imagem",
"images": "Imagens",
"import": "importar",
@@ -255,6 +265,7 @@
"key": "Chave",
"label": "Etiqueta",
"language": "Língua",
"last_name": "Sobrenome",
"learn_more": "Saiba mais",
"license_expired": "License Expired",
"light_overlay": "sobreposição leve",
@@ -281,6 +292,7 @@
"move_down": "Descer",
"move_up": "Subir",
"multiple_languages": "Vários idiomas",
"my_product": "meu produto",
"name": "Nome",
"new": "Novo",
"new_version_available": "Formbricks {version} chegou. Atualize agora!",
@@ -304,6 +316,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",
@@ -446,6 +459,7 @@
"variables": "Variáveis",
"verified_email": "Email Verificado",
"video": "vídeo",
"view": "Visualizar",
"warning": "Aviso",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Não conseguimos verificar sua licença porque o servidor de licenças está inacessível.",
"webhook": "webhook",
@@ -609,6 +623,176 @@
"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": {
"OR": "OU",
"add_chart_to_dashboard": "Adicionar gráfico ao painel",
"add_chart_to_dashboard_description": "Selecione um painel para adicionar este gráfico. O gráfico será salvo automaticamente.",
"add_filter": "Adicionar filtro",
"add_to_dashboard": "Adicionar ao painel",
"advanced_chart_builder_config_prompt": "Configure seu gráfico e clique em \"Executar consulta\" para visualizar",
"ai_query_placeholder": "ex: Quantos usuários se cadastraram na semana passada?",
"ai_query_section_description": "Descreva o que você quer ver e deixe a IA construir o gráfico.",
"ai_query_section_title": "Pergunte aos seus dados",
"apply_changes": "Aplicar alterações",
"chart": "Gráfico",
"chart_added_to_dashboard": "Gráfico adicionado ao painel!",
"chart_builder_choose_chart_type": "Escolher tipo de gráfico",
"chart_data": "Dados do gráfico",
"chart_data_tab": "Dados",
"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_name": "Nome do gráfico",
"chart_name_placeholder": "Nome do gráfico",
"chart_preview": "Visualização do gráfico",
"chart_render_error": "Algo deu errado ao renderizar este gráfico.",
"chart_saved_successfully": "Gráfico salvo com sucesso!",
"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_not_supported": "Tipo de gráfico \"{{chartType}}\" ainda não suportado",
"chart_type_pie": "Gráfico de pizza",
"chart_updated_successfully": "Gráfico atualizado com sucesso!",
"configure_description": "Modifique o tipo de gráfico e outras configurações para esta visualização.",
"configure_title": "Configurar gráfico",
"configure_type_label": "Tipo de gráfico",
"contains": "contém",
"create_chart": "Criar gráfico",
"create_chart_description": "Use IA para gerar um gráfico ou crie um manualmente.",
"custom_range": "Intervalo personalizado",
"dashboard": "Painel",
"dashboard_select_placeholder": "Selecione um painel",
"data_label": "Dados",
"date_preset_last_30_days": "Últimos 30 dias",
"date_preset_last_7_days": "Últimos 7 dias",
"date_preset_last_month": "Último mês",
"date_preset_this_month": "Este mês",
"date_preset_this_quarter": "Este trimestre",
"date_preset_this_year": "Este ano",
"date_preset_today": "Hoje",
"date_preset_yesterday": "Ontem",
"date_range": "Intervalo de datas",
"delete_chart_confirmation": "Tem certeza de que deseja excluir este gráfico?",
"dimensions": "Dimensões",
"dimensions_toggle_description": "Agrupe dados por categorias. A ordem importa para gráficos multidimensionais.",
"edit_chart_description": "Visualize e edite a configuração do seu gráfico.",
"edit_chart_title": "Editar gráfico",
"enable_time_dimension": "Ativar dimensão de tempo",
"end_date": "Data final",
"enter_a_name_for_your_chart": "Digite um nome para o seu gráfico para salvá-lo.",
"enter_value": "Digite o valor",
"equals": "igual",
"failed_to_add_chart_to_dashboard": "Falha ao adicionar gráfico ao painel",
"failed_to_execute_query": "Falha ao executar consulta",
"failed_to_load_chart": "Falha ao carregar gráfico",
"failed_to_load_chart_data": "Falha ao carregar dados do gráfico",
"failed_to_save_chart": "Falha ao salvar gráfico",
"field": "Campo",
"field_label_average_score": "Pontuação média",
"field_label_collected_at": "Coletado em",
"field_label_count": "Contagem",
"field_label_detractor_count": "Contagem de detratores",
"field_label_emotion": "Emoção",
"field_label_field_type": "Tipo de campo",
"field_label_nps_score": "Pontuação de NPS",
"field_label_nps_value": "Valor de NPS",
"field_label_passive_count": "Contagem de passivos",
"field_label_promoter_count": "Contagem de promotores",
"field_label_response_id": "ID da resposta",
"field_label_sentiment": "Sentimento",
"field_label_source_name": "Nome da fonte",
"field_label_source_type": "Tipo de fonte",
"field_label_topic": "Tópico",
"field_label_user_identifier": "Identificador do usuário",
"filters": "Filtros",
"filters_toggle_description": "Incluir apenas dados que atendam às seguintes condições.",
"generate_chart": "Gerar gráfico",
"granularity": "Granularidade",
"granularity_day": "Dia",
"granularity_hour": "Hora",
"granularity_month": "Mês",
"granularity_quarter": "Trimestre",
"granularity_week": "Semana",
"granularity_year": "Ano",
"greater_than": "maior que",
"greater_than_or_equal": "maior ou igual a",
"group_by": "Agrupar por",
"group_by_description": "Selecione dimensões para detalhar seus dados. A ordem importa para gráficos multidimensionais.",
"guide_button": "Ver guia de campos",
"guide_chart_type": "Tipo de gráfico",
"guide_chart_type_desc": "Como os dados são visualizados: área, barra, linha, pizza ou número grande. Escolha com base no que você deseja mostrar (tendências, comparações, partes de um todo, etc.).",
"guide_dimensions": "Dimensões (agrupar por)",
"guide_dimensions_desc": "Como você divide ou agrupa os dados. Cada dimensão se torna uma categoria no gráfico (ex.: sentimento, tipo de origem, nome da pesquisa, canal, tópico). A ordem importa para gráficos multidimensionais.",
"guide_filters": "Filtros",
"guide_filters_desc": "Condições que limitam quais dados são incluídos. Cada filtro tem um campo, operador (igual a, contém, maior que, etc.) e valores. E = todos devem corresponder; Ou = qualquer um pode corresponder.",
"guide_measures": "Medidas (o que você conta ou agrega)",
"guide_measures_predefined": "Medidas predefinidas são métricas pré-construídas dos seus dados de feedback: contagem (total de respostas), contagem de promotores/detratores/passivos (segmentos NPS), pontuação NPS, pontuação média, taxa de conclusão.",
"guide_quick_ref": "Referência rápida",
"guide_term_dimension": "Campo categórico usado para agrupar ou dividir dados",
"guide_term_filter": "Condição que limita quais linhas são incluídas",
"guide_term_measure": "Valor numérico que você agrega (count, sum, avg, etc.)",
"guide_term_time": "Agrupamento baseado em tempo com granularidade e intervalo de datas",
"guide_time_dimension": "Dimensão de tempo",
"guide_time_dimension_desc": "Agrupamento baseado em tempo: escolha um campo de tempo (geralmente Coletado em), granularidade (Hora, Dia, Semana, Mês, etc.) e intervalo de datas (predefinido ou personalizado). Use para tendências ao longo do tempo.",
"guide_title": "Guia de campos do construtor de gráficos",
"is_not_set": "não está definido",
"is_set": "está definido",
"less_than": "menor que",
"less_than_or_equal": "menor ou igual a",
"measures": "Medidas",
"no_charts_found": "Nenhum gráfico encontrado.",
"no_dashboards_available": "Nenhum painel disponível",
"no_dashboards_create_first": "Crie um painel primeiro para adicionar gráficos a ele.",
"no_data_available": "Nenhum dado disponível",
"no_data_returned": "Nenhum dado retornado da consulta",
"no_data_returned_for_chart": "Nenhum dado retornado para o gráfico",
"no_grouping": "Nenhum (apenas filtro)",
"no_valid_data_to_display": "Nenhum dado válido para exibir",
"not_contains": "não contém",
"not_equals": "diferente de",
"open_chart": "Abrir gráfico {{name}}",
"open_options": "Abrir opções do gráfico",
"or_filter_logic": "OU",
"original": "Original",
"please_enter_chart_name": "Por favor, insira um nome para o gráfico",
"please_select_at_least_one_measure": "Por favor, selecione pelo menos uma medida",
"please_select_dashboard": "Por favor, selecione um painel",
"predefined_measures": "Medidas predefinidas",
"preset": "Predefinição",
"query_executed_successfully": "Consulta executada com sucesso",
"reset_to_ai_suggestion": "Redefinir para sugestão da IA",
"save_chart": "Salvar gráfico",
"save_chart_dialog_title": "Salvar gráfico",
"select_dimensions": "Selecionar dimensões...",
"select_field": "Selecionar campo",
"select_measures": "Selecionar medidas...",
"select_preset": "Selecionar predefinição",
"showing_first_n_of": "Mostrando os primeiros {{n}} de {{count}} registros",
"start_date": "Data inicial",
"time_dimension": "Dimensão temporal",
"time_dimension_toggle_description": "Adicione agrupamento baseado em tempo para visualizar tendências ao longo do tempo."
},
"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.",
@@ -1242,6 +1426,7 @@
"add_fallback_placeholder": "Adicionar um texto padrão para mostrar se a pergunta for ignorada:",
"add_hidden_field_id": "Adicionar campo oculto ID",
"add_highlight_border": "Adicionar borda de destaque",
"add_highlight_border_description": "Aplica-se apenas a pesquisas no produto.",
"add_logic": "Adicionar lógica",
"add_none_of_the_above": "Adicionar \"Nenhuma das opções acima\"",
"add_option": "Adicionar opção",
@@ -1440,7 +1625,6 @@
"follow_ups_modal_updated_successfull_toast": "Acompanhamento atualizado e será salvo assim que você salvar a pesquisa.",
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para habilitar os Acompanhamentos",
"form_styling": "Estilização de Formulários",
"formbricks_sdk_is_not_connected": "O SDK do Formbricks não está conectado",
"four_points": "4 pontos",
"heading": "Título",
@@ -1613,7 +1797,7 @@
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta",
"roundness": "Circularidade",
"roundness_description": "Controla o arredondamento dos cantos do cartão.",
"roundness_description": "Controla o arredondamento dos cantos.",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"rows": "linhas",
"save_and_close": "Salvar e Fechar",
@@ -1659,6 +1843,7 @@
"survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada",
"survey_display_settings": "Configurações de Exibição da Pesquisa",
"survey_placement": "Posicionamento da Pesquisa",
"survey_styling": "Estilização de Formulários",
"survey_trigger": "Gatilho de Pesquisa",
"switch_multi_language_on_to_get_started": "Ative o modo multilíngue para começar 👉",
"target_block_not_found": "Bloco de destino não encontrado",
@@ -2170,7 +2355,7 @@
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
"advanced_styling_field_input_height_description": "Controla a altura mínima da entrada.",
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
"advanced_styling_field_input_padding_y_description": "Adiciona espaço na parte superior e inferior.",
"advanced_styling_field_input_placeholder_opacity_description": "Esmaece o texto de dica do placeholder.",
@@ -2179,6 +2364,8 @@
"advanced_styling_field_input_text_description": "Colore o texto digitado nos campos de entrada.",
"advanced_styling_field_option_bg": "Fundo",
"advanced_styling_field_option_bg_description": "Preenche os itens de opção.",
"advanced_styling_field_option_border": "Cor da borda",
"advanced_styling_field_option_border_description": "Contorna as opções de botões de rádio e caixas de seleção.",
"advanced_styling_field_option_border_radius_description": "Arredonda os cantos das opções.",
"advanced_styling_field_option_font_size_description": "Ajusta o tamanho do texto do rótulo da opção.",
"advanced_styling_field_option_label": "Cor do rótulo",
@@ -2998,6 +3185,9 @@
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer ficar por dentro?",
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
"preview_survey_question_open_text_headline": "Tem mais alguma coisa que você gostaria de compartilhar?",
"preview_survey_question_open_text_placeholder": "Digite sua resposta aqui...",
"preview_survey_question_open_text_subheader": "Seu feedback nos ajuda a melhorar.",
"preview_survey_welcome_card_headline": "Bem-vindo!",
"prioritize_features_description": "Identifique os recursos que seus usuários mais e menos precisam.",
"prioritize_features_name": "Priorizar Funcionalidades",

View File

@@ -133,6 +133,7 @@
"allow": "Permitir",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'",
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s",
"analysis": "Análise",
"and": "E",
"and_response_limit_of": "e limite de resposta de",
"anonymous": "Anónimo",
@@ -149,6 +150,8 @@
"bottom_right": "Inferior Direito",
"cancel": "Cancelar",
"centered_modal": "Modal Centralizado",
"chart": "Gráfico",
"charts": "Gráficos",
"choices": "Escolhas",
"choose_environment": "Escolha o ambiente",
"choose_organization": "Escolher organização",
@@ -178,6 +181,7 @@
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {# contacto} other {# contactos} }",
"count_responses": "{value, plural, other {# respostas}}",
"create": "Criar",
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar inquérito",
@@ -187,6 +191,8 @@
"created_by": "Criado por",
"customer_success": "Sucesso do Cliente",
"dark_overlay": "Sobreposição escura",
"dashboard": "Painel",
"dashboards": "Dashboards",
"date": "Data",
"days": "dias",
"default": "Padrão",
@@ -225,7 +231,9 @@
"failed_to_copy_to_clipboard": "Falha ao copiar para a área de transferência",
"failed_to_load_organizations": "Falha ao carregar organizações",
"failed_to_load_workspaces": "Falha ao carregar projetos",
"filter": "Filtro",
"finish": "Concluir",
"first_name": "Primeiro nome",
"follow_these": "Siga estes",
"formbricks_version": "Versão do Formbricks",
"full_name": "Nome completo",
@@ -237,7 +245,9 @@
"hidden": "Oculto",
"hidden_field": "Campo oculto",
"hidden_fields": "Campos ocultos",
"hide": "Ocultar",
"hide_column": "Ocultar coluna",
"id": "ID",
"image": "Imagem",
"images": "Imagens",
"import": "Importar",
@@ -255,6 +265,7 @@
"key": "Chave",
"label": "Etiqueta",
"language": "Idioma",
"last_name": "Apelido",
"learn_more": "Saiba mais",
"license_expired": "License Expired",
"light_overlay": "Sobreposição leve",
@@ -281,6 +292,7 @@
"move_down": "Mover para baixo",
"move_up": "Mover para cima",
"multiple_languages": "Várias línguas",
"my_product": "o meu produto",
"name": "Nome",
"new": "Novo",
"new_version_available": "Formbricks {version} está aqui. Atualize agora!",
@@ -304,6 +316,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",
@@ -446,6 +459,7 @@
"variables": "Variáveis",
"verified_email": "Email verificado",
"video": "Vídeo",
"view": "Ver",
"warning": "Aviso",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Não foi possível verificar a sua licença porque o servidor de licenças está inacessível.",
"webhook": "Webhook",
@@ -609,6 +623,176 @@
"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": {
"OR": "OU",
"add_chart_to_dashboard": "Adicionar gráfico ao painel",
"add_chart_to_dashboard_description": "Seleciona um painel para adicionar este gráfico. O gráfico será guardado automaticamente.",
"add_filter": "Adicionar filtro",
"add_to_dashboard": "Adicionar ao painel",
"advanced_chart_builder_config_prompt": "Configura o teu gráfico e clica em \"Executar consulta\" para pré-visualizar",
"ai_query_placeholder": "ex: Quantos utilizadores se registaram na semana passada?",
"ai_query_section_description": "Descreve o que queres ver e deixa a IA construir o gráfico.",
"ai_query_section_title": "Pergunta aos teus dados",
"apply_changes": "Aplicar alterações",
"chart": "Gráfico",
"chart_added_to_dashboard": "Gráfico adicionado ao painel!",
"chart_builder_choose_chart_type": "Escolher tipo de gráfico",
"chart_data": "Dados do gráfico",
"chart_data_tab": "Dados",
"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_name": "Nome do gráfico",
"chart_name_placeholder": "Nome do gráfico",
"chart_preview": "Pré-visualização do gráfico",
"chart_render_error": "Algo correu mal ao renderizar este gráfico.",
"chart_saved_successfully": "Gráfico guardado com sucesso!",
"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_not_supported": "Tipo de gráfico \"{{chartType}}\" ainda não suportado",
"chart_type_pie": "Gráfico circular",
"chart_updated_successfully": "Gráfico atualizado com sucesso!",
"configure_description": "Modifique o tipo de gráfico e outras definições para esta visualização.",
"configure_title": "Configurar gráfico",
"configure_type_label": "Tipo de gráfico",
"contains": "contém",
"create_chart": "Criar gráfico",
"create_chart_description": "Use IA para gerar um gráfico ou crie um manualmente.",
"custom_range": "Intervalo personalizado",
"dashboard": "Painel",
"dashboard_select_placeholder": "Selecione um painel",
"data_label": "Dados",
"date_preset_last_30_days": "Últimos 30 dias",
"date_preset_last_7_days": "Últimos 7 dias",
"date_preset_last_month": "Último mês",
"date_preset_this_month": "Este mês",
"date_preset_this_quarter": "Este trimestre",
"date_preset_this_year": "Este ano",
"date_preset_today": "Hoje",
"date_preset_yesterday": "Ontem",
"date_range": "Intervalo de datas",
"delete_chart_confirmation": "Tens a certeza de que queres eliminar este gráfico?",
"dimensions": "Dimensões",
"dimensions_toggle_description": "Agrupe dados por categorias. A ordem é importante para gráficos multidimensionais.",
"edit_chart_description": "Visualize e edite a configuração do seu gráfico.",
"edit_chart_title": "Editar gráfico",
"enable_time_dimension": "Ativar dimensão temporal",
"end_date": "Data de fim",
"enter_a_name_for_your_chart": "Introduza um nome para o seu gráfico para o guardar.",
"enter_value": "Introduza o valor",
"equals": "igual",
"failed_to_add_chart_to_dashboard": "Falha ao adicionar gráfico ao painel",
"failed_to_execute_query": "Falha ao executar consulta",
"failed_to_load_chart": "Falha ao carregar gráfico",
"failed_to_load_chart_data": "Falha ao carregar dados do gráfico",
"failed_to_save_chart": "Falha ao guardar gráfico",
"field": "Campo",
"field_label_average_score": "Pontuação média",
"field_label_collected_at": "Recolhido em",
"field_label_count": "Contagem",
"field_label_detractor_count": "Contagem de detratores",
"field_label_emotion": "Emoção",
"field_label_field_type": "Tipo de campo",
"field_label_nps_score": "Pontuação NPS",
"field_label_nps_value": "Valor NPS",
"field_label_passive_count": "Contagem de passivos",
"field_label_promoter_count": "Contagem de promotores",
"field_label_response_id": "ID de resposta",
"field_label_sentiment": "Sentimento",
"field_label_source_name": "Nome da origem",
"field_label_source_type": "Tipo de origem",
"field_label_topic": "Tópico",
"field_label_user_identifier": "Identificador de utilizador",
"filters": "Filtros",
"filters_toggle_description": "Incluir apenas dados que cumpram as seguintes condições.",
"generate_chart": "Gerar gráfico",
"granularity": "Granularidade",
"granularity_day": "Dia",
"granularity_hour": "Hora",
"granularity_month": "Mês",
"granularity_quarter": "Trimestre",
"granularity_week": "Semana",
"granularity_year": "Ano",
"greater_than": "maior que",
"greater_than_or_equal": "maior ou igual a",
"group_by": "Agrupar por",
"group_by_description": "Selecione dimensões para desagregar os seus dados. A ordem é importante para gráficos multidimensionais.",
"guide_button": "Ver guia de campos",
"guide_chart_type": "Tipo de gráfico",
"guide_chart_type_desc": "Como os dados são visualizados: área, barras, linhas, circular ou número grande. Escolha com base no que pretende mostrar (tendências, comparações, partes de um todo, etc.).",
"guide_dimensions": "Dimensões (agrupar por)",
"guide_dimensions_desc": "Como divide ou agrupa os dados. Cada dimensão torna-se uma categoria no gráfico (por exemplo, sentimento, tipo de origem, nome do inquérito, canal, tópico). A ordem é importante para gráficos multidimensionais.",
"guide_filters": "Filtros",
"guide_filters_desc": "Condições que limitam quais dados são incluídos. Cada filtro tem um campo, operador (igual, contém, maior que, etc.) e valores. E = todos devem corresponder; Ou = qualquer um pode corresponder.",
"guide_measures": "Medidas (o que conta ou agrega)",
"guide_measures_predefined": "As medidas predefinidas são métricas pré-construídas a partir dos seus dados de feedback: contagem (total de respostas), contagem de promotores/detratores/passivos (segmentos NPS), pontuação NPS, pontuação média, taxa de conclusão.",
"guide_quick_ref": "Referência rápida",
"guide_term_dimension": "Campo categórico usado para agrupar ou dividir dados",
"guide_term_filter": "Condição que limita quais linhas são incluídas",
"guide_term_measure": "Valor numérico que agrega (contar, somar, média, etc.)",
"guide_term_time": "Agrupamento baseado em tempo com granularidade e intervalo de datas",
"guide_time_dimension": "Dimensão temporal",
"guide_time_dimension_desc": "Agrupamento baseado em tempo: escolha um campo temporal (geralmente Recolhido em), granularidade (Hora, Dia, Semana, Mês, etc.) e intervalo de datas (predefinido ou personalizado). Use para tendências ao longo do tempo.",
"guide_title": "Guia de campos do criador de gráficos",
"is_not_set": "não está definido",
"is_set": "está definido",
"less_than": "menor que",
"less_than_or_equal": "menor ou igual a",
"measures": "Medidas",
"no_charts_found": "Nenhum gráfico encontrado.",
"no_dashboards_available": "Nenhum painel disponível",
"no_dashboards_create_first": "Cria primeiro um painel para adicionar gráficos.",
"no_data_available": "Nenhum dado disponível",
"no_data_returned": "Nenhum dado devolvido pela consulta",
"no_data_returned_for_chart": "Nenhum dado devolvido para o gráfico",
"no_grouping": "Nenhum (apenas filtro)",
"no_valid_data_to_display": "Nenhum dado válido para exibir",
"not_contains": "não contém",
"not_equals": "não é igual a",
"open_chart": "Abrir gráfico {{name}}",
"open_options": "Abrir opções do gráfico",
"or_filter_logic": "OU",
"original": "Original",
"please_enter_chart_name": "Por favor, introduz um nome para o gráfico",
"please_select_at_least_one_measure": "Por favor, seleciona pelo menos uma medida",
"please_select_dashboard": "Por favor, seleciona um painel",
"predefined_measures": "Medidas predefinidas",
"preset": "Predefinição",
"query_executed_successfully": "Consulta executada com sucesso",
"reset_to_ai_suggestion": "Repor sugestão da IA",
"save_chart": "Guardar gráfico",
"save_chart_dialog_title": "Guardar gráfico",
"select_dimensions": "Selecionar dimensões...",
"select_field": "Selecionar campo",
"select_measures": "Selecionar medidas...",
"select_preset": "Selecionar predefinição",
"showing_first_n_of": "A mostrar as primeiras {{n}} de {{count}} linhas",
"start_date": "Data de início",
"time_dimension": "Dimensão temporal",
"time_dimension_toggle_description": "Adicionar agrupamento temporal para tendências ao longo do tempo."
},
"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.",
@@ -1242,6 +1426,7 @@
"add_fallback_placeholder": "Adicionar um espaço reservado para mostrar se não houver valor para recordar.",
"add_hidden_field_id": "Adicionar ID do campo oculto",
"add_highlight_border": "Adicionar borda de destaque",
"add_highlight_border_description": "Aplica-se apenas a inquéritos no produto.",
"add_logic": "Adicionar lógica",
"add_none_of_the_above": "Adicionar \"Nenhuma das Opções Acima\"",
"add_option": "Adicionar opção",
@@ -1440,7 +1625,6 @@
"follow_ups_modal_updated_successfull_toast": "Seguimento atualizado e será guardado assim que guardar o questionário.",
"follow_ups_new": "Novo acompanhamento",
"follow_ups_upgrade_button_text": "Atualize para ativar os acompanhamentos",
"form_styling": "Estilo do formulário",
"formbricks_sdk_is_not_connected": "O SDK do Formbricks não está conectado",
"four_points": "4 pontos",
"heading": "Cabeçalho",
@@ -1613,7 +1797,7 @@
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta",
"roundness": "Arredondamento",
"roundness_description": "Controla o arredondamento dos cantos do cartão.",
"roundness_description": "Controla o arredondamento dos cantos.",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"rows": "Linhas",
"save_and_close": "Guardar e Fechar",
@@ -1659,6 +1843,7 @@
"survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado",
"survey_display_settings": "Configurações de Exibição do Inquérito",
"survey_placement": "Colocação do Inquérito",
"survey_styling": "Estilo do formulário",
"survey_trigger": "Desencadeador de Inquérito",
"switch_multi_language_on_to_get_started": "Ative o modo multilingue para começar 👉",
"target_block_not_found": "Bloco de destino não encontrado",
@@ -2170,7 +2355,7 @@
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
"advanced_styling_field_input_height_description": "Controla a altura mínima da entrada.",
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
"advanced_styling_field_input_padding_y_description": "Adiciona espaço no topo e na base.",
"advanced_styling_field_input_placeholder_opacity_description": "Atenua o texto de sugestão do placeholder.",
@@ -2179,6 +2364,8 @@
"advanced_styling_field_input_text_description": "Colore o texto digitado nos campos de entrada.",
"advanced_styling_field_option_bg": "Fundo",
"advanced_styling_field_option_bg_description": "Preenche os itens de opção.",
"advanced_styling_field_option_border": "Cor do contorno",
"advanced_styling_field_option_border_description": "Contorna as opções de botões de rádio e caixas de seleção.",
"advanced_styling_field_option_border_radius_description": "Arredonda os cantos das opções.",
"advanced_styling_field_option_font_size_description": "Ajusta o tamanho do texto da etiqueta da opção.",
"advanced_styling_field_option_label": "Cor da etiqueta",
@@ -2998,6 +3185,9 @@
"preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer manter-se atualizado?",
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
"preview_survey_question_open_text_headline": "Mais alguma coisa que gostaria de partilhar?",
"preview_survey_question_open_text_placeholder": "Escreva a sua resposta aqui...",
"preview_survey_question_open_text_subheader": "O seu feedback ajuda-nos a melhorar.",
"preview_survey_welcome_card_headline": "Bem-vindo!",
"prioritize_features_description": "Identifique as funcionalidades que os seus utilizadores precisam mais e menos.",
"prioritize_features_name": "Priorizar Funcionalidades",

View File

@@ -133,6 +133,7 @@
"allow": "Permite",
"allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului",
"an_unknown_error_occurred_while_deleting_table_items": "A apărut o eroare necunoscută la ștergerea elementelor de tipul {type}",
"analysis": "Analiză",
"and": "Și",
"and_response_limit_of": "și limită răspuns",
"anonymous": "Anonim",
@@ -149,6 +150,8 @@
"bottom_right": "Dreapta Jos",
"cancel": "Anulare",
"centered_modal": "Modală centralizată",
"chart": "Grafic",
"charts": "Grafice",
"choices": "Alegeri",
"choose_environment": "Alege mediul",
"choose_organization": "Alege organizația",
@@ -178,6 +181,7 @@
"count_attributes": "{value, plural, one {{value} atribut} few {{value} atribute} other {{value} de atribute}}",
"count_contacts": "{value, plural, one {# contact} other {# contacte} }",
"count_responses": "{value, plural, one {# răspuns} other {# răspunsuri} }",
"create": "Creează",
"create_new_organization": "Creează organizație nouă",
"create_segment": "Creați segment",
"create_survey": "Creează sondaj",
@@ -187,6 +191,8 @@
"created_by": "Creat de",
"customer_success": "Succesul Clientului",
"dark_overlay": "Suprapunere întunecată",
"dashboard": "Tablou de bord",
"dashboards": "Tablouri de bord",
"date": "Dată",
"days": "zile",
"default": "Implicit",
@@ -225,7 +231,9 @@
"failed_to_copy_to_clipboard": "Nu s-a reușit copierea în clipboard",
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
"failed_to_load_workspaces": "Nu s-au putut încărca workspaces",
"filter": "Filtru",
"finish": "Finalizează",
"first_name": "Prenume",
"follow_these": "Urmați acestea",
"formbricks_version": "Versiunea Formbricks",
"full_name": "Nume complet",
@@ -237,7 +245,9 @@
"hidden": "Ascuns",
"hidden_field": "Câmp ascuns",
"hidden_fields": "Câmpuri ascunse",
"hide": "Ascunde",
"hide_column": "Ascunde coloana",
"id": "ID",
"image": "Imagine",
"images": "Imagini",
"import": "Import",
@@ -255,6 +265,7 @@
"key": "Cheie",
"label": "Etichetă",
"language": "Limba",
"last_name": "Nume de familie",
"learn_more": "Află mai multe",
"license_expired": "License Expired",
"light_overlay": "Suprapunere ușoară",
@@ -281,6 +292,7 @@
"move_down": "Mută în jos",
"move_up": "Mută sus",
"multiple_languages": "Mai multe limbi",
"my_product": "produsul meu",
"name": "Nume",
"new": "Nou",
"new_version_available": "Formbricks {version} este disponibil. Actualizați acum!",
@@ -304,6 +316,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",
@@ -446,6 +459,7 @@
"variables": "Variante",
"verified_email": "Email verificat",
"video": "Video",
"view": "Vezi",
"warning": "Avertisment",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Nu am putut verifica licența dvs. deoarece serverul de licențe este inaccesibil.",
"webhook": "Webhook",
@@ -609,6 +623,176 @@
"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": {
"OR": "SAU",
"add_chart_to_dashboard": "Adaugă grafic la Tablou de Bord",
"add_chart_to_dashboard_description": "Selectează un tablou de bord la care să adaugi acest grafic. Graficul va fi salvat automat.",
"add_filter": "Adaugă filtru",
"add_to_dashboard": "Adaugă la Tablou de Bord",
"advanced_chart_builder_config_prompt": "Configurează graficul și apasă pe \"Rulează interogarea\" pentru previzualizare",
"ai_query_placeholder": "ex: Câți utilizatori s-au înscris săptămâna trecută?",
"ai_query_section_description": "Descrie ce vrei să vezi și lasă AI-ul să construiască graficul.",
"ai_query_section_title": "Întreabă-ți datele",
"apply_changes": "Aplică modificările",
"chart": "Grafic",
"chart_added_to_dashboard": "Grafic adăugat la tablou de bord!",
"chart_builder_choose_chart_type": "Alege tipul de grafic",
"chart_data": "Datele graficului",
"chart_data_tab": "Date",
"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_name": "Numele graficului",
"chart_name_placeholder": "Numele graficului",
"chart_preview": "Previzualizare grafic",
"chart_render_error": "Ceva nu a mers bine la afișarea acestui grafic.",
"chart_saved_successfully": "Graficul a fost salvat cu succes!",
"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_not_supported": "Tipul de grafic \"{{chartType}}\" nu este încă suportat",
"chart_type_pie": "Grafic de tip plăcintă",
"chart_updated_successfully": "Graficul a fost actualizat cu succes!",
"configure_description": "Modifică tipul graficului și alte setări pentru această vizualizare.",
"configure_title": "Configurează graficul",
"configure_type_label": "Tip grafic",
"contains": "conține",
"create_chart": "Creează grafic",
"create_chart_description": "Folosește AI pentru a genera un grafic sau creează unul manual.",
"custom_range": "Interval personalizat",
"dashboard": "Tablou de bord",
"dashboard_select_placeholder": "Selectează un tablou de bord",
"data_label": "Date",
"date_preset_last_30_days": "Ultimele 30 de zile",
"date_preset_last_7_days": "Ultimele 7 zile",
"date_preset_last_month": "Ultima lună",
"date_preset_this_month": "Luna aceasta",
"date_preset_this_quarter": "Trimestrul acesta",
"date_preset_this_year": "Anul acesta",
"date_preset_today": "Astăzi",
"date_preset_yesterday": "Ieri",
"date_range": "Interval de date",
"delete_chart_confirmation": "Ești sigur că vrei să ștergi acest grafic?",
"dimensions": "Dimensiuni",
"dimensions_toggle_description": "Grupează datele pe categorii. Ordinea contează pentru graficele multidimensionale.",
"edit_chart_description": "Vezi și editează configurația graficului tău.",
"edit_chart_title": "Editează graficul",
"enable_time_dimension": "Activează dimensiunea de timp",
"end_date": "Data de sfârșit",
"enter_a_name_for_your_chart": "Introdu un nume pentru grafic ca să îl salvezi.",
"enter_value": "Introdu valoarea",
"equals": "egal",
"failed_to_add_chart_to_dashboard": "Nu s-a putut adăuga graficul în tablou de bord",
"failed_to_execute_query": "Nu s-a putut executa interogarea",
"failed_to_load_chart": "Nu s-a putut încărca graficul",
"failed_to_load_chart_data": "Nu s-au putut încărca datele graficului",
"failed_to_save_chart": "Nu s-a putut salva graficul",
"field": "Câmp",
"field_label_average_score": "Scor mediu",
"field_label_collected_at": "Colectat la",
"field_label_count": "Număr",
"field_label_detractor_count": "Număr de detractori",
"field_label_emotion": "Emoție",
"field_label_field_type": "Tip câmp",
"field_label_nps_score": "Scor NPS",
"field_label_nps_value": "Valoare NPS",
"field_label_passive_count": "Număr de pasivi",
"field_label_promoter_count": "Număr de promotori",
"field_label_response_id": "ID răspuns",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Nume sursă",
"field_label_source_type": "Tip sursă",
"field_label_topic": "Subiect",
"field_label_user_identifier": "Identificator utilizator",
"filters": "Filtre",
"filters_toggle_description": "Include doar datele care îndeplinesc următoarele condiții.",
"generate_chart": "Generează grafic",
"granularity": "Granularitate",
"granularity_day": "Zi",
"granularity_hour": "Oră",
"granularity_month": "Lună",
"granularity_quarter": "Trimestru",
"granularity_week": "Săptămână",
"granularity_year": "An",
"greater_than": "mai mare decât",
"greater_than_or_equal": "mai mare sau egal cu",
"group_by": "Grupează după",
"group_by_description": "Selectează dimensiunile după care să împarți datele. Ordinea contează pentru graficele multidimensionale.",
"guide_button": "Vezi ghidul de câmpuri",
"guide_chart_type": "Tip grafic",
"guide_chart_type_desc": "Cum sunt vizualizate datele: Zonă, Bară, Linie, Plăcintă sau Număr mare. Alege în funcție de ce vrei să arăți (tendințe, comparații, părți dintr-un întreg etc.).",
"guide_dimensions": "Dimensiuni (Grupează după)",
"guide_dimensions_desc": "Cum împarți sau grupezi datele. Fiecare dimensiune devine o categorie pe grafic (ex: Sentiment, Tip sursă, Nume sondaj, Canal, Subiect). Ordinea contează pentru graficele multidimensionale.",
"guide_filters": "Filtre",
"guide_filters_desc": "Condiții care limitează ce date sunt incluse. Fiecare filtru are un câmp, un operator (egal, conține, mai mare decât etc.) și valori. Și = toate trebuie să se potrivească; Sau = oricare poate să se potrivească.",
"guide_measures": "Măsuri (ce numeri sau agregi)",
"guide_measures_predefined": "Măsurile predefinite sunt metrici gata făcute din datele tale de feedback: Număr (răspunsuri totale), Număr Promoter/Detractor/Passive (segmente NPS), Scor NPS, Scor mediu, Rată de completare.",
"guide_quick_ref": "Referință rapidă",
"guide_term_dimension": "Câmp categorial folosit pentru a grupa sau împărți datele",
"guide_term_filter": "Condiție care limitează ce rânduri sunt incluse",
"guide_term_measure": "Valoare numerică pe care o agregi (count, sum, avg etc.)",
"guide_term_time": "Grupare pe bază de timp cu granularitate și interval de date",
"guide_time_dimension": "Dimensiune de timp",
"guide_time_dimension_desc": "Grupare pe bază de timp: alege un câmp de timp (de obicei Colectat la), granularitate (Oră, Zi, Săptămână, Lună etc.) și interval de date (presetat sau personalizat). Folosește pentru a vedea tendințe în timp.",
"guide_title": "Ghid pentru crearea graficelor",
"is_not_set": "nu este setat",
"is_set": "este setat",
"less_than": "mai mic decât",
"less_than_or_equal": "mai mic sau egal",
"measures": "Măsurători",
"no_charts_found": "Nu s-au găsit grafice.",
"no_dashboards_available": "Nu există tablouri de bord disponibile",
"no_dashboards_create_first": "Creează mai întâi un tablou de bord pentru a adăuga grafice.",
"no_data_available": "Nu există date disponibile",
"no_data_returned": "Nu au fost returnate date din interogare",
"no_data_returned_for_chart": "Nu au fost returnate date pentru grafic",
"no_grouping": "Niciuna (doar filtru)",
"no_valid_data_to_display": "Nu există date valide de afișat",
"not_contains": "nu conține",
"not_equals": "nu este egal",
"open_chart": "Deschide graficul {{name}}",
"open_options": "Deschide opțiunile graficului",
"or_filter_logic": "SAU",
"original": "Original",
"please_enter_chart_name": "Te rugăm să introduci un nume pentru grafic",
"please_select_at_least_one_measure": "Te rugăm să selectezi cel puțin o măsurătoare",
"please_select_dashboard": "Te rugăm să selectezi un tablou de bord",
"predefined_measures": "Măsurători predefinite",
"preset": "Presetare",
"query_executed_successfully": "Interogarea a fost executată cu succes",
"reset_to_ai_suggestion": "Resetează la sugestia AI",
"save_chart": "Salvează graficul",
"save_chart_dialog_title": "Salvează graficul",
"select_dimensions": "Selectează dimensiuni...",
"select_field": "Selectează câmpul",
"select_measures": "Selectează măsuri...",
"select_preset": "Selectează presetarea",
"showing_first_n_of": "Se afișează primele {{n}} din {{count}} rânduri",
"start_date": "Data de început",
"time_dimension": "Dimensiune temporală",
"time_dimension_toggle_description": "Adaugă grupare pe bază de timp pentru tendințe în timp."
},
"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.",
@@ -1242,6 +1426,7 @@
"add_fallback_placeholder": "Adaugă un placeholder pentru a afișa dacă nu există valoare de reamintit",
"add_hidden_field_id": "Adăugați ID câmp ascuns",
"add_highlight_border": "Adaugă bordură evidențiată",
"add_highlight_border_description": "Se aplică doar sondajelor din produs.",
"add_logic": "Adaugă logică",
"add_none_of_the_above": "Adăugați \"Niciuna dintre cele de mai sus\"",
"add_option": "Adăugați opțiune",
@@ -1440,7 +1625,6 @@
"follow_ups_modal_updated_successfull_toast": "Urmărirea a fost actualizată și va fi salvată odată ce salvați sondajul.",
"follow_ups_new": "Follow-up nou",
"follow_ups_upgrade_button_text": "Actualizați pentru a activa urmărările",
"form_styling": "Stilizare formular",
"formbricks_sdk_is_not_connected": "SDK Formbricks nu este conectat",
"four_points": "4 puncte",
"heading": "Titlu",
@@ -1613,7 +1797,7 @@
"response_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.",
"response_options": "Opțiuni răspuns",
"roundness": "Rotunjire",
"roundness_description": "Controlează cât de rotunjite sunt colțurile cardului.",
"roundness_description": "Controlează cât de rotunjite sunt colțurile.",
"row_used_in_logic_error": "Această linie este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"rows": "Rânduri",
"save_and_close": "Salvează & Închide",
@@ -1659,6 +1843,7 @@
"survey_completed_subheading": "Acest sondaj gratuit și open-source a fost închis",
"survey_display_settings": "Setări de afișare a sondajului",
"survey_placement": "Amplasarea sondajului",
"survey_styling": "Stilizare formular",
"survey_trigger": "Declanșator sondaj",
"switch_multi_language_on_to_get_started": "Activați opțiunea multi-limbă pentru a începe 👉",
"target_block_not_found": "Blocul țintă nu a fost găsit",
@@ -2179,6 +2364,8 @@
"advanced_styling_field_input_text_description": "Colorează textul introdus în câmpuri.",
"advanced_styling_field_option_bg": "Fundal",
"advanced_styling_field_option_bg_description": "Umple elementele de opțiune.",
"advanced_styling_field_option_border": "Culoare contur",
"advanced_styling_field_option_border_description": "Evidențiază opțiunile radio și checkbox.",
"advanced_styling_field_option_border_radius_description": "Rotunjește colțurile opțiunilor.",
"advanced_styling_field_option_font_size_description": "Redimensionează textul etichetei opțiunii.",
"advanced_styling_field_option_label": "Culoare etichetă",
@@ -2998,6 +3185,9 @@
"preview_survey_question_2_choice_2_label": "Nu, mulţumesc!",
"preview_survey_question_2_headline": "Vrei să fii în temă?",
"preview_survey_question_2_subheader": "Aceasta este o descriere exemplu.",
"preview_survey_question_open_text_headline": "Mai vrei să împărtășești ceva?",
"preview_survey_question_open_text_placeholder": "Tastează răspunsul aici...",
"preview_survey_question_open_text_subheader": "Feedbackul tău ne ajută să ne îmbunătățim.",
"preview_survey_welcome_card_headline": "Bun venit!",
"prioritize_features_description": "Identificați caracteristicile de care utilizatorii dumneavoastră au cel mai mult și cel mai puțin nevoie.",
"prioritize_features_name": "Prioritizați caracteristicile",

View File

@@ -133,6 +133,7 @@
"allow": "Разрешить",
"allow_users_to_exit_by_clicking_outside_the_survey": "Разрешить пользователям выходить, кликнув вне опроса",
"an_unknown_error_occurred_while_deleting_table_items": "Произошла неизвестная ошибка при удалении {type}ов",
"analysis": "Аналитика",
"and": "и",
"and_response_limit_of": "и лимит ответов",
"anonymous": "Аноним",
@@ -149,6 +150,8 @@
"bottom_right": "Внизу справа",
"cancel": "Отмена",
"centered_modal": "Центрированное модальное окно",
"chart": "График",
"charts": "Графики",
"choices": "Варианты",
"choose_environment": "Выберите среду",
"choose_organization": "Выберите организацию",
@@ -178,6 +181,7 @@
"count_attributes": "{value, plural, one {{value} атрибут} 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,6 +191,8 @@
"created_by": "Создано пользователем",
"customer_success": "Customer Success",
"dark_overlay": "Тёмный оверлей",
"dashboard": "Панель управления",
"dashboards": "Дашборды",
"date": "Дата",
"days": "дни",
"default": "По умолчанию",
@@ -225,7 +231,9 @@
"failed_to_copy_to_clipboard": "Не удалось скопировать в буфер обмена",
"failed_to_load_organizations": "Не удалось загрузить организации",
"failed_to_load_workspaces": "Не удалось загрузить рабочие пространства",
"filter": "Фильтр",
"finish": "Завершить",
"first_name": "Имя",
"follow_these": "Выполните следующие действия",
"formbricks_version": "Версия Formbricks",
"full_name": "Полное имя",
@@ -237,7 +245,9 @@
"hidden": "Скрыто",
"hidden_field": "Скрытое поле",
"hidden_fields": "Скрытые поля",
"hide": "Скрыть",
"hide_column": "Скрыть столбец",
"id": "ID",
"image": "Изображение",
"images": "Изображения",
"import": "Импорт",
@@ -255,6 +265,7 @@
"key": "Ключ",
"label": "Метка",
"language": "Язык",
"last_name": "Фамилия",
"learn_more": "Подробнее",
"license_expired": "License Expired",
"light_overlay": "Светлый оверлей",
@@ -281,6 +292,7 @@
"move_down": "Переместить вниз",
"move_up": "Переместить вверх",
"multiple_languages": "Несколько языков",
"my_product": "мой продукт",
"name": "Имя",
"new": "Новый",
"new_version_available": "Formbricks {version} уже здесь. Обновитесь сейчас!",
@@ -304,6 +316,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": "Необязательно",
@@ -446,6 +459,7 @@
"variables": "Переменные",
"verified_email": "Подтверждённый email",
"video": "Видео",
"view": "Просмотреть",
"warning": "Предупреждение",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Не удалось проверить вашу лицензию, так как сервер лицензий недоступен.",
"webhook": "Webhook",
@@ -609,6 +623,176 @@
"your_survey_would_be_shown_on_this_url": "Ваш опрос будет отображаться по этому URL.",
"your_survey_would_not_be_shown": "Ваш опрос не будет отображаться."
},
"analysis": {
"charts": {
"OR": "ИЛИ",
"add_chart_to_dashboard": "Добавить график на панель",
"add_chart_to_dashboard_description": "Выбери панель, чтобы добавить на неё этот график. График будет сохранён автоматически.",
"add_filter": "Добавить фильтр",
"add_to_dashboard": "Добавить на панель",
"advanced_chart_builder_config_prompt": "Настрой график и нажми «Выполнить запрос», чтобы посмотреть предварительный просмотр",
"ai_query_placeholder": "например: Сколько пользователей зарегистрировались на прошлой неделе?",
"ai_query_section_description": "Опиши, что хочешь увидеть, и AI построит график.",
"ai_query_section_title": "Спроси свои данные",
"apply_changes": "Применить изменения",
"chart": "График",
"chart_added_to_dashboard": "График добавлен на панель!",
"chart_builder_choose_chart_type": "Выбери тип графика",
"chart_data": "Данные графика",
"chart_data_tab": "Данные",
"chart_deleted_successfully": "График успешно удалён",
"chart_deletion_error": "Не удалось удалить график",
"chart_duplicated_successfully": "График успешно дублирован",
"chart_duplication_error": "Не удалось дублировать график",
"chart_name": "Название графика",
"chart_name_placeholder": "Название графика",
"chart_preview": "Предпросмотр графика",
"chart_render_error": "Что-то пошло не так при отображении этой диаграммы.",
"chart_saved_successfully": "График успешно сохранён!",
"chart_type_area": "График областью",
"chart_type_bar": "Столбчатая диаграмма",
"chart_type_big_number": "Большое число",
"chart_type_line": "Линейный график",
"chart_type_not_supported": "Тип графика «{{chartType}}» пока не поддерживается",
"chart_type_pie": "Круговая диаграмма",
"chart_updated_successfully": "График успешно обновлён!",
"configure_description": "Измени тип графика и другие настройки этой визуализации.",
"configure_title": "Настроить график",
"configure_type_label": "Тип графика",
"contains": "содержит",
"create_chart": "Создать график",
"create_chart_description": "Используй ИИ для создания графика или собери его вручную.",
"custom_range": "Произвольный диапазон",
"dashboard": "Панель управления",
"dashboard_select_placeholder": "Выбери панель управления",
"data_label": "Данные",
"date_preset_last_30_days": "Последние 30 дней",
"date_preset_last_7_days": "Последние 7 дней",
"date_preset_last_month": "Прошлый месяц",
"date_preset_this_month": "В этом месяце",
"date_preset_this_quarter": "В этом квартале",
"date_preset_this_year": "В этом году",
"date_preset_today": "Сегодня",
"date_preset_yesterday": "Вчера",
"date_range": "Диапазон дат",
"delete_chart_confirmation": "Ты уверен, что хочешь удалить этот график?",
"dimensions": "Измерения",
"dimensions_toggle_description": "Группируй данные по категориям. Порядок важен для многомерных графиков.",
"edit_chart_description": "Просмотри и измени настройки своего графика.",
"edit_chart_title": "Редактировать график",
"enable_time_dimension": "Включить временное измерение",
"end_date": "Дата окончания",
"enter_a_name_for_your_chart": "Введи название для графика, чтобы сохранить его.",
"enter_value": "Введи значение",
"equals": "равно",
"failed_to_add_chart_to_dashboard": "Не удалось добавить график на панель управления",
"failed_to_execute_query": "Не удалось выполнить запрос",
"failed_to_load_chart": "Не удалось загрузить график",
"failed_to_load_chart_data": "Не удалось загрузить данные графика",
"failed_to_save_chart": "Не удалось сохранить график",
"field": "Поле",
"field_label_average_score": "Средний балл",
"field_label_collected_at": "Дата сбора",
"field_label_count": "Количество",
"field_label_detractor_count": "Количество критиков",
"field_label_emotion": "Эмоция",
"field_label_field_type": "Тип поля",
"field_label_nps_score": "Оценка NPS",
"field_label_nps_value": "Значение NPS",
"field_label_passive_count": "Количество пассивных",
"field_label_promoter_count": "Количество промоутеров",
"field_label_response_id": "ID ответа",
"field_label_sentiment": "Тональность",
"field_label_source_name": "Название источника",
"field_label_source_type": "Тип источника",
"field_label_topic": "Тема",
"field_label_user_identifier": "Идентификатор пользователя",
"filters": "Фильтры",
"filters_toggle_description": "Включай только те данные, которые соответствуют следующим условиям.",
"generate_chart": "Сгенерировать график",
"granularity": "Детализация",
"granularity_day": "День",
"granularity_hour": "Час",
"granularity_month": "Месяц",
"granularity_quarter": "Квартал",
"granularity_week": "Неделя",
"granularity_year": "Год",
"greater_than": "больше чем",
"greater_than_or_equal": "больше или равно",
"group_by": "Группировать по",
"group_by_description": "Выбери измерения для детализации данных. Порядок важен для многомерных графиков.",
"guide_button": "Открыть гайд по полям",
"guide_chart_type": "Тип графика",
"guide_chart_type_desc": "Как визуализируются данные: область, столбцы, линия, круговая диаграмма или большое число. Выбирай в зависимости от того, что хочешь показать (тренды, сравнения, части целого и т. д.).",
"guide_dimensions": "Измерения (Группировка)",
"guide_dimensions_desc": "Как ты разбиваешь или группируешь данные. Каждое измерение становится категорией на графике (например, настроение, тип источника, название опроса, канал, тема). Порядок важен для многомерных графиков.",
"guide_filters": "Фильтры",
"guide_filters_desc": "Условия, ограничивающие включаемые данные. Каждый фильтр состоит из поля, оператора (равно, содержит, больше чем и т. д.) и значений. И = все должны совпасть; Или = достаточно любого совпадения.",
"guide_measures": "Показатели (что считаешь или агрегируешь)",
"guide_measures_predefined": "Предустановленные показатели — это готовые метрики из твоих данных обратной связи: количество (всего ответов), количество промоутеров/детракторов/нейтралов (сегменты NPS), NPS-оценка, средний балл, процент завершения.",
"guide_quick_ref": "Быстрая справка",
"guide_term_dimension": "Категориальное поле для группировки или разбивки данных",
"guide_term_filter": "Условие, ограничивающее включаемые строки",
"guide_term_measure": "Числовое значение, которое агрегируется (count, сумма, среднее и т. д.)",
"guide_term_time": "Группировка по времени с выбором детализации и диапазона дат",
"guide_time_dimension": "Временное измерение",
"guide_time_dimension_desc": "Группировка по времени: выбери поле времени (обычно \"Собрано в\"), детализацию (час, день, неделя, месяц и т. д.) и диапазон дат (предустановленный или свой). Используй для анализа трендов во времени.",
"guide_title": "Путеводитель по конструктору графиков",
"is_not_set": "не задано",
"is_set": "задано",
"less_than": "меньше чем",
"less_than_or_equal": "меньше или равно",
"measures": "Показатели",
"no_charts_found": "Графики не найдены.",
"no_dashboards_available": "Нет доступных панелей управления",
"no_dashboards_create_first": "Сначала создай панель управления, чтобы добавить на неё графики.",
"no_data_available": "Нет доступных данных",
"no_data_returned": "Запрос не вернул данных",
"no_data_returned_for_chart": "Для графика не получено данных",
"no_grouping": "Нет (только фильтр)",
"no_valid_data_to_display": "Нет корректных данных для отображения",
"not_contains": "не содержит",
"not_equals": "не равно",
"open_chart": "Открыть график {{name}}",
"open_options": "Открыть настройки графика",
"or_filter_logic": "ИЛИ",
"original": "Оригинал",
"please_enter_chart_name": "Пожалуйста, введи название графика",
"please_select_at_least_one_measure": "Пожалуйста, выбери хотя бы один показатель",
"please_select_dashboard": "Пожалуйста, выбери панель управления",
"predefined_measures": "Предустановленные показатели",
"preset": "Пресет",
"query_executed_successfully": "Запрос успешно выполнен",
"reset_to_ai_suggestion": "Сбросить к предложению ИИ",
"save_chart": "Сохранить график",
"save_chart_dialog_title": "Сохранить график",
"select_dimensions": "Выберите измерения...",
"select_field": "Выберите поле",
"select_measures": "Выберите показатели...",
"select_preset": "Выберите пресет",
"showing_first_n_of": "Показаны первые {{n}} из {{count}} строк",
"start_date": "Дата начала",
"time_dimension": "Временное измерение",
"time_dimension_toggle_description": "Добавьте группировку по времени для анализа трендов."
},
"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": "Отлично! Мы подключены.",
@@ -1242,6 +1426,7 @@
"add_fallback_placeholder": "Добавить плейсхолдер, который будет показан, если нет значения для отображения.",
"add_hidden_field_id": "Добавить скрытый ID поля",
"add_highlight_border": "Добавить выделяющую рамку",
"add_highlight_border_description": "Применяется только к опросам внутри продукта.",
"add_logic": "Добавить логику",
"add_none_of_the_above": "Добавить вариант «Ничего из вышеперечисленного»",
"add_option": "Добавить вариант",
@@ -1440,7 +1625,6 @@
"follow_ups_modal_updated_successfull_toast": "Фоллоу-ап обновлён и будет сохранён после сохранения опроса.",
"follow_ups_new": "Новый фоллоу-ап",
"follow_ups_upgrade_button_text": "Обновите тариф для активации фоллоу-апов",
"form_styling": "Оформление формы",
"formbricks_sdk_is_not_connected": "Formbricks SDK не подключён",
"four_points": "4 балла",
"heading": "Заголовок",
@@ -1613,7 +1797,7 @@
"response_limits_redirections_and_more": "Лимиты ответов, перенаправления и другое.",
"response_options": "Параметры ответа",
"roundness": "Скругление",
"roundness_description": "Определяет степень скругления углов карточки.",
"roundness_description": "Определяет степень скругления углов.",
"row_used_in_logic_error": "Эта строка используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите её из логики.",
"rows": "Строки",
"save_and_close": "Сохранить и закрыть",
@@ -1659,6 +1843,7 @@
"survey_completed_subheading": "Этот бесплатный и открытый опрос был закрыт",
"survey_display_settings": "Настройки отображения опроса",
"survey_placement": "Размещение опроса",
"survey_styling": "Оформление формы",
"survey_trigger": "Триггер опроса",
"switch_multi_language_on_to_get_started": "Включите многоязычный режим, чтобы начать 👉",
"target_block_not_found": "Целевой блок не найден",
@@ -2170,7 +2355,7 @@
"advanced_styling_field_indicator_bg_description": "Задаёт цвет заполненной части полосы.",
"advanced_styling_field_input_border_radius_description": "Скругляет углы полей ввода.",
"advanced_styling_field_input_font_size_description": "Масштабирует введённый текст в полях ввода.",
"advanced_styling_field_input_height_description": "Определяет минимальную высоту поля ввода.",
"advanced_styling_field_input_height_description": "Управляет минимальной высотой поля ввода.",
"advanced_styling_field_input_padding_x_description": "Добавляет отступы слева и справа.",
"advanced_styling_field_input_padding_y_description": "Добавляет пространство сверху и снизу.",
"advanced_styling_field_input_placeholder_opacity_description": "Делает текст подсказки менее заметным.",
@@ -2179,6 +2364,8 @@
"advanced_styling_field_input_text_description": "Задаёт цвет введённого текста в полях.",
"advanced_styling_field_option_bg": "Фон",
"advanced_styling_field_option_bg_description": "Заливает фон элементов опций.",
"advanced_styling_field_option_border": "Цвет границы",
"advanced_styling_field_option_border_description": "Обводка для вариантов radio и checkbox.",
"advanced_styling_field_option_border_radius_description": "Скругляет углы опций.",
"advanced_styling_field_option_font_size_description": "Изменяет размер текста метки опции.",
"advanced_styling_field_option_label": "Цвет метки",
@@ -2998,6 +3185,9 @@
"preview_survey_question_2_choice_2_label": "Нет, спасибо!",
"preview_survey_question_2_headline": "Хотите быть в курсе событий?",
"preview_survey_question_2_subheader": "Это пример описания.",
"preview_survey_question_open_text_headline": "Есть ли ещё что-то, чем хочешь поделиться?",
"preview_survey_question_open_text_placeholder": "Введи свой ответ здесь...",
"preview_survey_question_open_text_subheader": "Твой отзыв помогает нам становиться лучше.",
"preview_survey_welcome_card_headline": "Добро пожаловать!",
"prioritize_features_description": "Определите, какие функции наиболее и наименее важны для ваших пользователей.",
"prioritize_features_name": "Приоритизация функций",

View File

@@ -133,6 +133,7 @@
"allow": "Tillåt",
"allow_users_to_exit_by_clicking_outside_the_survey": "Tillåt användare att avsluta genom att klicka utanför enkäten",
"an_unknown_error_occurred_while_deleting_table_items": "Ett okänt fel uppstod vid borttagning av {type}",
"analysis": "Analys",
"and": "Och",
"and_response_limit_of": "och svarsgräns på",
"anonymous": "Anonym",
@@ -149,6 +150,8 @@
"bottom_right": "Nedre höger",
"cancel": "Avbryt",
"centered_modal": "Centrerad modal",
"chart": "Diagram",
"charts": "Diagram",
"choices": "Val",
"choose_environment": "Välj miljö",
"choose_organization": "Välj organisation",
@@ -178,6 +181,7 @@
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attribut}}",
"count_contacts": "{value, plural, one {{value} kontakt} other {{value} kontakter}}",
"count_responses": "{value, plural, one {{value} svar} other {{value} svar}}",
"create": "Skapa",
"create_new_organization": "Skapa ny organisation",
"create_segment": "Skapa segment",
"create_survey": "Skapa enkät",
@@ -187,6 +191,8 @@
"created_by": "Skapad av",
"customer_success": "Kundframgång",
"dark_overlay": "Mörkt överlägg",
"dashboard": "Instrumentpanel",
"dashboards": "Instrumentpaneler",
"date": "Datum",
"days": "dagar",
"default": "Standard",
@@ -225,7 +231,9 @@
"failed_to_copy_to_clipboard": "Misslyckades att kopiera till urklipp",
"failed_to_load_organizations": "Misslyckades att ladda organisationer",
"failed_to_load_workspaces": "Det gick inte att ladda arbetsytor",
"filter": "Filter",
"finish": "Slutför",
"first_name": "Förnamn",
"follow_these": "Följ dessa",
"formbricks_version": "Formbricks-version",
"full_name": "Fullständigt namn",
@@ -237,7 +245,9 @@
"hidden": "Dold",
"hidden_field": "Dolt fält",
"hidden_fields": "Dolda fält",
"hide": "Dölj",
"hide_column": "Dölj kolumn",
"id": "ID",
"image": "Bild",
"images": "Bilder",
"import": "Importera",
@@ -255,6 +265,7 @@
"key": "Nyckel",
"label": "Etikett",
"language": "Språk",
"last_name": "Efternamn",
"learn_more": "Läs mer",
"license_expired": "License Expired",
"light_overlay": "Ljust överlägg",
@@ -281,6 +292,7 @@
"move_down": "Flytta ner",
"move_up": "Flytta upp",
"multiple_languages": "Flera språk",
"my_product": "min produkt",
"name": "Namn",
"new": "Ny",
"new_version_available": "Formbricks {version} är här. Uppgradera nu!",
@@ -304,6 +316,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",
@@ -446,6 +459,7 @@
"variables": "Variabler",
"verified_email": "Verifierad e-post",
"video": "Video",
"view": "Visa",
"warning": "Varning",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Vi kunde inte verifiera din licens eftersom licensservern inte kan nås.",
"webhook": "Webhook",
@@ -609,6 +623,176 @@
"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": {
"OR": "ELLER",
"add_chart_to_dashboard": "Lägg till diagram på instrumentpanelen",
"add_chart_to_dashboard_description": "Välj en instrumentpanel att lägga till det här diagrammet på. Diagrammet sparas automatiskt.",
"add_filter": "Lägg till filter",
"add_to_dashboard": "Lägg till på instrumentpanelen",
"advanced_chart_builder_config_prompt": "Konfigurera ditt diagram och klicka på \"Kör fråga\" för att förhandsgranska",
"ai_query_placeholder": "t.ex. Hur många användare registrerade sig förra veckan?",
"ai_query_section_description": "Beskriv vad du vill se så bygger AI diagrammet åt dig.",
"ai_query_section_title": "Fråga din data",
"apply_changes": "Verkställ ändringar",
"chart": "Diagram",
"chart_added_to_dashboard": "Diagram tillagt på instrumentpanelen!",
"chart_builder_choose_chart_type": "Välj diagramtyp",
"chart_data": "Diagramdata",
"chart_data_tab": "Data",
"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_name": "Diagramnamn",
"chart_name_placeholder": "Diagramnamn",
"chart_preview": "Förhandsgranska diagram",
"chart_render_error": "Något gick fel när diagrammet skulle visas.",
"chart_saved_successfully": "Diagram sparat!",
"chart_type_area": "Ytdiagram",
"chart_type_bar": "Stapeldiagram",
"chart_type_big_number": "Stort tal",
"chart_type_line": "Linjediagram",
"chart_type_not_supported": "Diagramtypen \"{{chartType}}\" stöds inte än",
"chart_type_pie": "Cirkeldiagram",
"chart_updated_successfully": "Diagram uppdaterat!",
"configure_description": "Ändra diagramtyp och andra inställningar för den här visualiseringen.",
"configure_title": "Konfigurera diagram",
"configure_type_label": "Diagramtyp",
"contains": "innehåller",
"create_chart": "Skapa diagram",
"create_chart_description": "Använd AI för att skapa ett diagram eller bygg ett manuellt.",
"custom_range": "Anpassat intervall",
"dashboard": "Instrumentpanel",
"dashboard_select_placeholder": "Välj en instrumentpanel",
"data_label": "Data",
"date_preset_last_30_days": "Senaste 30 dagarna",
"date_preset_last_7_days": "Senaste 7 dagarna",
"date_preset_last_month": "Senaste månaden",
"date_preset_this_month": "Denna månad",
"date_preset_this_quarter": "Detta kvartal",
"date_preset_this_year": "Detta år",
"date_preset_today": "Idag",
"date_preset_yesterday": "Igår",
"date_range": "Datumintervall",
"delete_chart_confirmation": "Är du säker på att du vill ta bort det här diagrammet?",
"dimensions": "Dimensioner",
"dimensions_toggle_description": "Gruppera data efter kategorier. Ordningen är viktig för flerdimensionella diagram.",
"edit_chart_description": "Visa och redigera din diagramkonfiguration.",
"edit_chart_title": "Redigera diagram",
"enable_time_dimension": "Aktivera tidsdimension",
"end_date": "Slutdatum",
"enter_a_name_for_your_chart": "Ange ett namn för ditt diagram för att spara det.",
"enter_value": "Ange värde",
"equals": "är lika med",
"failed_to_add_chart_to_dashboard": "Det gick inte att lägga till diagrammet i instrumentpanelen",
"failed_to_execute_query": "Det gick inte att köra frågan",
"failed_to_load_chart": "Det gick inte att ladda diagrammet",
"failed_to_load_chart_data": "Det gick inte att ladda diagramdata",
"failed_to_save_chart": "Det gick inte att spara diagrammet",
"field": "Fält",
"field_label_average_score": "Genomsnittligt betyg",
"field_label_collected_at": "Insamlad",
"field_label_count": "Antal",
"field_label_detractor_count": "Antal kritiker",
"field_label_emotion": "Känsla",
"field_label_field_type": "Fälttyp",
"field_label_nps_score": "NPS-poäng",
"field_label_nps_value": "NPS-värde",
"field_label_passive_count": "Antal passiva",
"field_label_promoter_count": "Antal förespråkare",
"field_label_response_id": "Svar-ID",
"field_label_sentiment": "Sentiment",
"field_label_source_name": "Källnamn",
"field_label_source_type": "Källtyp",
"field_label_topic": "Ämne",
"field_label_user_identifier": "Användar-ID",
"filters": "Filter",
"filters_toggle_description": "Inkludera bara data som uppfyller följande villkor.",
"generate_chart": "Generera diagram",
"granularity": "Detaljnivå",
"granularity_day": "Dag",
"granularity_hour": "timme",
"granularity_month": "månad",
"granularity_quarter": "kvartal",
"granularity_week": "vecka",
"granularity_year": "år",
"greater_than": "större än",
"greater_than_or_equal": "större än eller lika med",
"group_by": "Gruppera efter",
"group_by_description": "Välj dimensioner för att bryta ner din data. Ordningen är viktig för flerdimensionella diagram.",
"guide_button": "Visa fältguide",
"guide_chart_type": "Diagramtyp",
"guide_chart_type_desc": "Hur datan visualiseras: Yta, Stapel, Linje, Tårta eller Stort tal. Välj utifrån vad du vill visa (trender, jämförelser, delar av helhet, etc.).",
"guide_dimensions": "Dimensioner (Gruppera efter)",
"guide_dimensions_desc": "Hur du delar upp eller grupperar datan. Varje dimension blir en kategori i diagrammet (t.ex. Sentiment, Källtyp, Enkätnamn, Kanal, Ämne). Ordningen är viktig för flerdimensionella diagram.",
"guide_filters": "Filter",
"guide_filters_desc": "Villkor som begränsar vilken data som tas med. Varje filter har ett fält, en operator (lika med, innehåller, större än, etc.) och värden. Och = alla måste matcha; Eller = någon kan matcha.",
"guide_measures": "Mått (vad du räknar eller aggregerar)",
"guide_measures_predefined": "Fördefinierade mått är färdiga mätvärden från din feedbackdata: Antal (totala svar), Promotor/Detraktor/Passiv antal (NPS-segment), NPS-poäng, Medelpoäng, Slutförandegrad.",
"guide_quick_ref": "Snabbreferens",
"guide_term_dimension": "Kategorifält som används för att gruppera eller dela upp data",
"guide_term_filter": "Villkor som begränsar vilka rader som tas med",
"guide_term_measure": "Numeriskt värde du aggregerar (count, sum, avg, etc.)",
"guide_term_time": "Tidsbaserad gruppering med detaljnivå och datumintervall",
"guide_time_dimension": "Tidsdimension",
"guide_time_dimension_desc": "Tidsbaserad gruppering: välj ett tidsfält (oftast Insamlad vid), detaljnivå (timme, dag, vecka, månad osv.) och datumintervall (förinställt eller anpassat). Används för att visa trender över tid.",
"guide_title": "Fältguide för diagrambyggaren",
"is_not_set": "är inte satt",
"is_set": "är satt",
"less_than": "mindre än",
"less_than_or_equal": "mindre än eller lika med",
"measures": "Mått",
"no_charts_found": "Inga diagram hittades.",
"no_dashboards_available": "Inga instrumentpaneler tillgängliga",
"no_dashboards_create_first": "Skapa först en instrumentpanel för att lägga till diagram.",
"no_data_available": "Ingen data tillgänglig",
"no_data_returned": "Ingen data returnerades från frågan",
"no_data_returned_for_chart": "Ingen data returnerades för diagrammet",
"no_grouping": "Ingen (endast filter)",
"no_valid_data_to_display": "Ingen giltig data att visa",
"not_contains": "innehåller inte",
"not_equals": "är inte lika med",
"open_chart": "Öppna diagram {{name}}",
"open_options": "Öppna diagramalternativ",
"or_filter_logic": "ELLER",
"original": "Original",
"please_enter_chart_name": "Ange ett diagramnamn",
"please_select_at_least_one_measure": "Välj minst ett mått",
"please_select_dashboard": "Välj en instrumentpanel",
"predefined_measures": "Fördefinierade mått",
"preset": "Förinställning",
"query_executed_successfully": "Frågan kördes utan problem",
"reset_to_ai_suggestion": "Återställ till AI-förslag",
"save_chart": "Spara diagram",
"save_chart_dialog_title": "Spara diagram",
"select_dimensions": "Välj dimensioner...",
"select_field": "Välj fält",
"select_measures": "Välj mått...",
"select_preset": "Välj förinställning",
"showing_first_n_of": "Visar de första {{n}} av {{count}} raderna",
"start_date": "Startdatum",
"time_dimension": "Tidsdimension",
"time_dimension_toggle_description": "Lägg till tidsbaserad gruppering för trender över tid."
},
"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.",
@@ -1242,6 +1426,7 @@
"add_fallback_placeholder": "Lägg till en platshållare att visa om det inte finns något värde att återkalla.",
"add_hidden_field_id": "Lägg till dolt fält-ID",
"add_highlight_border": "Lägg till markerad kant",
"add_highlight_border_description": "Gäller bara för undersökningar i produkten.",
"add_logic": "Lägg till logik",
"add_none_of_the_above": "Lägg till \"Inget av ovanstående\"",
"add_option": "Lägg till alternativ",
@@ -1440,7 +1625,6 @@
"follow_ups_modal_updated_successfull_toast": "Uppföljning uppdaterad och sparas när du sparar enkäten.",
"follow_ups_new": "Ny uppföljning",
"follow_ups_upgrade_button_text": "Uppgradera för att aktivera uppföljningar",
"form_styling": "Formulärstil",
"formbricks_sdk_is_not_connected": "Formbricks SDK är inte anslutet",
"four_points": "4 poäng",
"heading": "Rubrik",
@@ -1613,7 +1797,7 @@
"response_limits_redirections_and_more": "Svarsgränser, omdirigeringar och mer.",
"response_options": "Svarsalternativ",
"roundness": "Rundhet",
"roundness_description": "Styr hur rundade kortets hörn är.",
"roundness_description": "Styr hur rundade hörnen är.",
"row_used_in_logic_error": "Denna rad används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
"rows": "Rader",
"save_and_close": "Spara och stäng",
@@ -1659,6 +1843,7 @@
"survey_completed_subheading": "Denna gratis och öppenkällkodsenkät har stängts",
"survey_display_settings": "Visningsinställningar för enkät",
"survey_placement": "Enkätplacering",
"survey_styling": "Formulärstil",
"survey_trigger": "Enkätutlösare",
"switch_multi_language_on_to_get_started": "Slå på flerspråkighet för att komma igång 👉",
"target_block_not_found": "Målblock hittades inte",
@@ -2170,7 +2355,7 @@
"advanced_styling_field_indicator_bg_description": "Färglägger den fyllda delen av stapeln.",
"advanced_styling_field_input_border_radius_description": "Rundar av hörnen på inmatningsfält.",
"advanced_styling_field_input_font_size_description": "Ändrar storleken på texten i inmatningsfält.",
"advanced_styling_field_input_height_description": "Styr den minsta höjden på inmatningsfältet.",
"advanced_styling_field_input_height_description": "Styr minsta höjden på inmatningsfältet.",
"advanced_styling_field_input_padding_x_description": "Lägger till utrymme till vänster och höger.",
"advanced_styling_field_input_padding_y_description": "Lägger till utrymme upptill och nedtill.",
"advanced_styling_field_input_placeholder_opacity_description": "Tonar ut platshållartexten.",
@@ -2179,6 +2364,8 @@
"advanced_styling_field_input_text_description": "Färgar den inmatade texten i fälten.",
"advanced_styling_field_option_bg": "Bakgrund",
"advanced_styling_field_option_bg_description": "Fyller alternativraderna.",
"advanced_styling_field_option_border": "Kantfärg",
"advanced_styling_field_option_border_description": "Markerar radio- och kryssrutealternativ.",
"advanced_styling_field_option_border_radius_description": "Rundar hörnen på alternativen.",
"advanced_styling_field_option_font_size_description": "Skalar textstorleken på alternativetiketten.",
"advanced_styling_field_option_label": "Etikettfärg",
@@ -2998,6 +3185,9 @@
"preview_survey_question_2_choice_2_label": "Nej, tack!",
"preview_survey_question_2_headline": "Vill du hållas uppdaterad?",
"preview_survey_question_2_subheader": "Det här är ett exempel på en beskrivning.",
"preview_survey_question_open_text_headline": "Något mer du vill dela med dig av?",
"preview_survey_question_open_text_placeholder": "Skriv ditt svar här...",
"preview_survey_question_open_text_subheader": "Din feedback hjälper oss att bli bättre.",
"preview_survey_welcome_card_headline": "Välkommen!",
"prioritize_features_description": "Identifiera vilka funktioner dina användare behöver mest och minst.",
"prioritize_features_name": "Prioritera funktioner",

View File

@@ -133,6 +133,7 @@
"allow": "允许",
"allow_users_to_exit_by_clicking_outside_the_survey": "允许 用户 通过 点击 调查 外部 退出",
"an_unknown_error_occurred_while_deleting_table_items": "删除 {type} 时发生未知错误",
"analysis": "分析",
"and": "和",
"and_response_limit_of": "和 响应限制",
"anonymous": "匿名",
@@ -149,6 +150,8 @@
"bottom_right": "右下",
"cancel": "取消",
"centered_modal": "居中 模态",
"chart": "图表",
"charts": "图表",
"choices": "选项",
"choose_environment": "选择 环境",
"choose_organization": "选择 组织",
@@ -178,6 +181,7 @@
"count_attributes": "{value, plural, one {{value} 个属性} other {{value} 个属性}}",
"count_contacts": "{value, plural, other {{value} 联系人} }",
"count_responses": "{value, plural, other {{value} 回复} }",
"create": "创建",
"create_new_organization": "创建 新的 组织",
"create_segment": "创建 细分",
"create_survey": "创建 调查",
@@ -187,6 +191,8 @@
"created_by": "由 创建",
"customer_success": "客户成功",
"dark_overlay": "深色遮罩层",
"dashboard": "Dashboard",
"dashboards": "仪表盘",
"date": "日期",
"days": "天",
"default": "默认",
@@ -225,7 +231,9 @@
"failed_to_copy_to_clipboard": "复制到剪贴板失败",
"failed_to_load_organizations": "加载组织失败",
"failed_to_load_workspaces": "加载工作区失败",
"filter": "筛选",
"finish": "完成",
"first_name": "名字",
"follow_these": "遵循 这些",
"formbricks_version": "Formbricks 版本",
"full_name": "全名",
@@ -237,7 +245,9 @@
"hidden": "隐藏",
"hidden_field": "隐藏 字段",
"hidden_fields": "隐藏 字段",
"hide": "隐藏",
"hide_column": "隐藏 列",
"id": "ID",
"image": "图片",
"images": "图片",
"import": "导入",
@@ -255,6 +265,7 @@
"key": "键",
"label": "标签",
"language": "语言",
"last_name": "姓",
"learn_more": "了解 更多",
"license_expired": "License Expired",
"light_overlay": "浅色遮罩层",
@@ -281,6 +292,7 @@
"move_down": "下移",
"move_up": "上移",
"multiple_languages": "多种 语言",
"my_product": "我的产品",
"name": "名称",
"new": "新建",
"new_version_available": "Formbricks {version} 在 这里。立即 升级!",
@@ -304,6 +316,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": "可选",
@@ -446,6 +459,7 @@
"variables": "变量",
"verified_email": "已验证 电子邮件",
"video": "视频",
"view": "查看",
"warning": "警告",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "我们无法验证您的许可证,因为许可证服务器无法访问。",
"webhook": "Webhook",
@@ -609,6 +623,176 @@
"your_survey_would_be_shown_on_this_url": "您的 调查 会 显示 在 此 URL 上",
"your_survey_would_not_be_shown": "您的 调查 不会 显示。"
},
"analysis": {
"charts": {
"OR": "或",
"add_chart_to_dashboard": "添加图表到 Dashboard",
"add_chart_to_dashboard_description": "选择一个 Dashboard将此图表添加进去。图表会自动保存。",
"add_filter": "添加过滤器",
"add_to_dashboard": "添加到 Dashboard",
"advanced_chart_builder_config_prompt": "配置你的图表,然后点击“运行查询”预览",
"ai_query_placeholder": "例如:上周有多少用户注册?",
"ai_query_section_description": "描述你想要看到的内容,让 AI 帮你生成图表。",
"ai_query_section_title": "向你的数据提问",
"apply_changes": "应用更改",
"chart": "图表",
"chart_added_to_dashboard": "图表已添加到 Dashboard",
"chart_builder_choose_chart_type": "选择图表类型",
"chart_data": "图表数据",
"chart_data_tab": "数据",
"chart_deleted_successfully": "图表删除成功",
"chart_deletion_error": "图表删除失败",
"chart_duplicated_successfully": "图表复制成功",
"chart_duplication_error": "图表复制失败",
"chart_name": "图表名称",
"chart_name_placeholder": "图表名称",
"chart_preview": "图表预览",
"chart_render_error": "渲染此图表时出现了问题。",
"chart_saved_successfully": "图表保存成功!",
"chart_type_area": "面积图",
"chart_type_bar": "柱状图",
"chart_type_big_number": "大数字",
"chart_type_line": "折线图",
"chart_type_not_supported": "暂不支持图表类型“{{chartType}}”",
"chart_type_pie": "饼图",
"chart_updated_successfully": "图表更新成功!",
"configure_description": "修改此可视化的图表类型和其他设置。",
"configure_title": "配置图表",
"configure_type_label": "图表类型",
"contains": "包含",
"create_chart": "创建图表",
"create_chart_description": "使用 AI 生成图表或手动创建。",
"custom_range": "自定义范围",
"dashboard": "Dashboard",
"dashboard_select_placeholder": "请选择一个 Dashboard",
"data_label": "数据",
"date_preset_last_30_days": "最近 30 天",
"date_preset_last_7_days": "最近 7 天",
"date_preset_last_month": "上个月",
"date_preset_this_month": "本月",
"date_preset_this_quarter": "本季度",
"date_preset_this_year": "今年",
"date_preset_today": "今天",
"date_preset_yesterday": "昨天",
"date_range": "日期范围",
"delete_chart_confirmation": "你确定要删除这个图表吗?",
"dimensions": "维度",
"dimensions_toggle_description": "按类别分组数据。对于多维图表,顺序很重要。",
"edit_chart_description": "查看并编辑你的图表配置。",
"edit_chart_title": "编辑图表",
"enable_time_dimension": "启用时间维度",
"end_date": "结束日期",
"enter_a_name_for_your_chart": "请输入图表名称以保存。",
"enter_value": "输入值",
"equals": "等于",
"failed_to_add_chart_to_dashboard": "添加图表到 Dashboard 失败",
"failed_to_execute_query": "查询执行失败",
"failed_to_load_chart": "加载图表失败",
"failed_to_load_chart_data": "加载图表数据失败",
"failed_to_save_chart": "图表保存失败",
"field": "字段",
"field_label_average_score": "平均分",
"field_label_collected_at": "收集时间",
"field_label_count": "数量",
"field_label_detractor_count": "贬损者数量",
"field_label_emotion": "情感",
"field_label_field_type": "字段类型",
"field_label_nps_score": "NPS 得分",
"field_label_nps_value": "NPS 值",
"field_label_passive_count": "中立者数量",
"field_label_promoter_count": "推荐者数量",
"field_label_response_id": "响应 ID",
"field_label_sentiment": "情绪",
"field_label_source_name": "来源名称",
"field_label_source_type": "来源类型",
"field_label_topic": "主题",
"field_label_user_identifier": "用户标识",
"filters": "筛选条件",
"filters_toggle_description": "仅包含符合以下条件的数据。",
"generate_chart": "生成图表",
"granularity": "粒度",
"granularity_day": "天",
"granularity_hour": "小时",
"granularity_month": "月",
"granularity_quarter": "季度",
"granularity_week": "周",
"granularity_year": "年",
"greater_than": "大于",
"greater_than_or_equal": "大于或等于",
"group_by": "分组依据",
"group_by_description": "选择用于细分数据的维度。对于多维图表,顺序很重要。",
"guide_button": "查看字段指南",
"guide_chart_type": "图表类型",
"guide_chart_type_desc": "数据的可视化方式:面积图、柱状图、折线图、饼图或大数字。根据你想展示的内容(趋势、对比、整体组成等)进行选择。",
"guide_dimensions": "维度(分组依据)",
"guide_dimensions_desc": "你如何拆分或分组数据。每个维度会成为图表上的一个类别(如情感、来源类型、问卷名称、渠道、主题)。对于多维图表,顺序很重要。",
"guide_filters": "筛选条件",
"guide_filters_desc": "限制包含哪些数据的条件。每个筛选包含字段、运算符等于、包含、大于等和数值。And = 全部满足Or = 满足任意一个即可。",
"guide_measures": "度量(你统计或汇总的内容)",
"guide_measures_predefined": "预设度量是从你的反馈数据中预先构建的指标:计数(总回复数)、推荐者/贬损者/中立者计数NPS分段、NPS得分、平均分、完成率。",
"guide_quick_ref": "快速参考",
"guide_term_dimension": "用于分组或拆分数据的分类字段",
"guide_term_filter": "限制包含哪些行的条件",
"guide_term_measure": "你要汇总的数值(计数、求和、平均等)",
"guide_term_time": "基于时间的分组,包含粒度和日期范围",
"guide_time_dimension": "时间维度",
"guide_time_dimension_desc": "基于时间的分组:选择一个时间字段(通常为“收集时间”),粒度(小时、天、周、月等)和日期范围(预设或自定义)。适用于查看随时间变化的趋势。",
"guide_title": "图表生成器使用指南",
"is_not_set": "未设置",
"is_set": "已设置",
"less_than": "小于",
"less_than_or_equal": "小于或等于",
"measures": "度量",
"no_charts_found": "未找到图表。",
"no_dashboards_available": "暂无可用 Dashboard",
"no_dashboards_create_first": "请先创建一个 Dashboard然后再添加图表。",
"no_data_available": "暂无数据",
"no_data_returned": "查询未返回数据",
"no_data_returned_for_chart": "该图表未返回数据",
"no_grouping": "无(仅筛选)",
"no_valid_data_to_display": "无有效数据可显示",
"not_contains": "不包含",
"not_equals": "不等于",
"open_chart": "打开图表 {{name}}",
"open_options": "打开图表选项",
"or_filter_logic": "或",
"original": "原始",
"please_enter_chart_name": "请输入图表名称",
"please_select_at_least_one_measure": "请至少选择一个度量",
"please_select_dashboard": "请选择一个 Dashboard",
"predefined_measures": "预设度量",
"preset": "预设",
"query_executed_successfully": "查询执行成功",
"reset_to_ai_suggestion": "重置为 AI 建议",
"save_chart": "保存图表",
"save_chart_dialog_title": "保存图表",
"select_dimensions": "选择维度...",
"select_field": "选择字段",
"select_measures": "选择度量...",
"select_preset": "选择预设",
"showing_first_n_of": "显示前 {{n}} 行,共 {{count}} 行",
"start_date": "开始日期",
"time_dimension": "时间维度",
"time_dimension_toggle_description": "添加基于时间的分组,用于趋势分析。"
},
"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": "做得好 !我们 已经 连接。",
@@ -1242,6 +1426,7 @@
"add_fallback_placeholder": "添加 占位符 显示 如果 没有 值以 回忆",
"add_hidden_field_id": "添加 隐藏 字段 ID",
"add_highlight_border": "添加 高亮 边框",
"add_highlight_border_description": "仅适用于产品内调查。",
"add_logic": "添加逻辑",
"add_none_of_the_above": "添加 “以上 都 不 是”",
"add_option": "添加 选项",
@@ -1440,7 +1625,6 @@
"follow_ups_modal_updated_successfull_toast": "后续 操作 已 更新, 并且 在 你 保存 调查 后 将 被 保存。",
"follow_ups_new": "新的跟进",
"follow_ups_upgrade_button_text": "升级 以启用 跟进",
"form_styling": "表单 样式",
"formbricks_sdk_is_not_connected": "Formbricks SDK 未连接",
"four_points": "4 分",
"heading": "标题",
@@ -1613,7 +1797,7 @@
"response_limits_redirections_and_more": "响应 限制 、 重定向 和 更多 。",
"response_options": "响应 选项",
"roundness": "圆度",
"roundness_description": "控制卡片角的圆润程度。",
"roundness_description": "控制圆角的弧度。",
"row_used_in_logic_error": "\"这个 行 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"rows": "行",
"save_and_close": "保存 和 关闭",
@@ -1659,6 +1843,7 @@
"survey_completed_subheading": "此 免费 & 开源 调查 已 关闭",
"survey_display_settings": "调查显示设置",
"survey_placement": "调查 放置",
"survey_styling": "表单 样式",
"survey_trigger": "调查 触发",
"switch_multi_language_on_to_get_started": "开启多语言以开始使用 👉",
"target_block_not_found": "未找到目标区块",
@@ -2170,7 +2355,7 @@
"advanced_styling_field_indicator_bg_description": "设置进度条已填充部分的颜色。",
"advanced_styling_field_input_border_radius_description": "设置输入框圆角。",
"advanced_styling_field_input_font_size_description": "调整输入框内文字大小。",
"advanced_styling_field_input_height_description": "设置输入框的最小高度。",
"advanced_styling_field_input_height_description": "控制输入框的最小高度。",
"advanced_styling_field_input_padding_x_description": "增加输入框左右间距。",
"advanced_styling_field_input_padding_y_description": "为输入框上下添加间距。",
"advanced_styling_field_input_placeholder_opacity_description": "调整占位提示文字的透明度。",
@@ -2179,6 +2364,8 @@
"advanced_styling_field_input_text_description": "设置输入框内已输入文字的颜色。",
"advanced_styling_field_option_bg": "背景色",
"advanced_styling_field_option_bg_description": "设置选项项的背景色。",
"advanced_styling_field_option_border": "边框颜色",
"advanced_styling_field_option_border_description": "为单选框和复选框选项添加轮廓。",
"advanced_styling_field_option_border_radius_description": "设置选项的圆角。",
"advanced_styling_field_option_font_size_description": "调整选项标签文字的大小。",
"advanced_styling_field_option_label": "标签颜色",
@@ -2998,6 +3185,9 @@
"preview_survey_question_2_choice_2_label": "不,谢谢!",
"preview_survey_question_2_headline": "想 了解 最新信息吗?",
"preview_survey_question_2_subheader": "这是一个示例描述。",
"preview_survey_question_open_text_headline": "还有什么想和我们分享的吗?",
"preview_survey_question_open_text_placeholder": "请在这里输入你的答案...",
"preview_survey_question_open_text_subheader": "你的反馈能帮助我们改进。",
"preview_survey_welcome_card_headline": "欢迎!",
"prioritize_features_description": "确定 用户 最 需要 和 最 不 需要 的 功能。",
"prioritize_features_name": "优先 功能",

View File

@@ -133,6 +133,7 @@
"allow": "允許",
"allow_users_to_exit_by_clicking_outside_the_survey": "允許使用者點擊問卷外退出",
"an_unknown_error_occurred_while_deleting_table_items": "刪除 '{'type'}' 時發生未知錯誤",
"analysis": "分析",
"and": "且",
"and_response_limit_of": "且回應上限為",
"anonymous": "匿名",
@@ -149,6 +150,8 @@
"bottom_right": "右下",
"cancel": "取消",
"centered_modal": "置中彈窗",
"chart": "圖表",
"charts": "圖表",
"choices": "選項",
"choose_environment": "選擇環境",
"choose_organization": "選擇 組織",
@@ -178,6 +181,7 @@
"count_attributes": "{value, plural, one {{value} 個屬性} other {{value} 個屬性}}",
"count_contacts": "{value, plural, other {{value} 聯絡人} }",
"count_responses": "{value, plural, other {{value} 回應} }",
"create": "建立",
"create_new_organization": "建立新組織",
"create_segment": "建立區隔",
"create_survey": "建立問卷",
@@ -187,6 +191,8 @@
"created_by": "建立者",
"customer_success": "客戶成功",
"dark_overlay": "深色覆蓋",
"dashboard": "儀表板",
"dashboards": "儀表板",
"date": "日期",
"days": "天",
"default": "預設",
@@ -225,7 +231,9 @@
"failed_to_copy_to_clipboard": "無法複製到剪貼簿",
"failed_to_load_organizations": "無法載入組織",
"failed_to_load_workspaces": "載入工作區失敗",
"filter": "篩選",
"finish": "完成",
"first_name": "名字",
"follow_these": "按照這些步驟",
"formbricks_version": "Formbricks 版本",
"full_name": "全名",
@@ -237,7 +245,9 @@
"hidden": "隱藏",
"hidden_field": "隱藏欄位",
"hidden_fields": "隱藏欄位",
"hide": "隱藏",
"hide_column": "隱藏欄位",
"id": "ID",
"image": "圖片",
"images": "圖片",
"import": "匯入",
@@ -255,6 +265,7 @@
"key": "金鑰",
"label": "標籤",
"language": "語言",
"last_name": "姓氏",
"learn_more": "瞭解更多",
"license_expired": "License Expired",
"light_overlay": "淺色覆蓋",
@@ -281,6 +292,7 @@
"move_down": "下移",
"move_up": "上移",
"multiple_languages": "多種語言",
"my_product": "我的產品",
"name": "名稱",
"new": "新增",
"new_version_available": "Formbricks '{'version'}' 已推出。立即升級!",
@@ -304,6 +316,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": "選填",
@@ -446,6 +459,7 @@
"variables": "變數",
"verified_email": "已驗證的電子郵件",
"video": "影片",
"view": "檢視",
"warning": "警告",
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "我們無法驗證您的授權,因為授權伺服器無法連線。",
"webhook": "Webhook",
@@ -609,6 +623,176 @@
"your_survey_would_be_shown_on_this_url": "您的問卷將顯示在此網址。",
"your_survey_would_not_be_shown": "您的問卷將不會顯示。"
},
"analysis": {
"charts": {
"OR": "或",
"add_chart_to_dashboard": "新增圖表到儀表板",
"add_chart_to_dashboard_description": "請選擇一個儀表板來新增此圖表。圖表會自動儲存。",
"add_filter": "新增篩選器",
"add_to_dashboard": "新增到儀表板",
"advanced_chart_builder_config_prompt": "設定你的圖表,然後點擊「執行查詢」預覽",
"ai_query_placeholder": "例如:上週有多少用戶註冊?",
"ai_query_section_description": "描述你想看到的內容,讓 AI 幫你建立圖表。",
"ai_query_section_title": "詢問你的數據",
"apply_changes": "套用變更",
"chart": "圖表",
"chart_added_to_dashboard": "圖表已新增到儀表板!",
"chart_builder_choose_chart_type": "選擇圖表類型",
"chart_data": "圖表資料",
"chart_data_tab": "資料",
"chart_deleted_successfully": "圖表已成功刪除",
"chart_deletion_error": "刪除圖表失敗",
"chart_duplicated_successfully": "圖表已成功複製",
"chart_duplication_error": "圖表複製失敗",
"chart_name": "圖表名稱",
"chart_name_placeholder": "圖表名稱",
"chart_preview": "圖表預覽",
"chart_render_error": "這個圖表在顯示時發生錯誤。",
"chart_saved_successfully": "圖表已成功儲存!",
"chart_type_area": "區域圖",
"chart_type_bar": "長條圖",
"chart_type_big_number": "大數字",
"chart_type_line": "折線圖",
"chart_type_not_supported": "尚未支援圖表類型「{{chartType}}」",
"chart_type_pie": "圓餅圖",
"chart_updated_successfully": "圖表已成功更新!",
"configure_description": "修改此視覺化的圖表類型及其他設定。",
"configure_title": "設定圖表",
"configure_type_label": "圖表類型",
"contains": "包含",
"create_chart": "建立圖表",
"create_chart_description": "使用 AI 產生圖表或手動建立。",
"custom_range": "自訂範圍",
"dashboard": "儀表板",
"dashboard_select_placeholder": "請選擇儀表板",
"data_label": "資料",
"date_preset_last_30_days": "過去 30 天",
"date_preset_last_7_days": "過去 7 天",
"date_preset_last_month": "上個月",
"date_preset_this_month": "本月",
"date_preset_this_quarter": "本季",
"date_preset_this_year": "今年",
"date_preset_today": "今天",
"date_preset_yesterday": "昨天",
"date_range": "日期範圍",
"delete_chart_confirmation": "你確定要刪除此圖表嗎?",
"dimensions": "維度",
"dimensions_toggle_description": "依類別分組資料。多維圖表時,順序會影響結果。",
"edit_chart_description": "檢視並編輯你的圖表設定。",
"edit_chart_title": "編輯圖表",
"enable_time_dimension": "啟用時間維度",
"end_date": "結束日期",
"enter_a_name_for_your_chart": "請輸入圖表名稱以儲存。",
"enter_value": "請輸入數值",
"equals": "等於",
"failed_to_add_chart_to_dashboard": "新增圖表到儀表板失敗",
"failed_to_execute_query": "查詢執行失敗",
"failed_to_load_chart": "載入圖表失敗",
"failed_to_load_chart_data": "載入圖表資料失敗",
"failed_to_save_chart": "儲存圖表失敗",
"field": "欄位",
"field_label_average_score": "平均分數",
"field_label_collected_at": "收集時間",
"field_label_count": "數量",
"field_label_detractor_count": "批評者數量",
"field_label_emotion": "情緒",
"field_label_field_type": "欄位類型",
"field_label_nps_score": "NPS 分數",
"field_label_nps_value": "NPS 值",
"field_label_passive_count": "中立者數量",
"field_label_promoter_count": "推廣者數量",
"field_label_response_id": "回應 ID",
"field_label_sentiment": "情感",
"field_label_source_name": "來源名稱",
"field_label_source_type": "來源類型",
"field_label_topic": "主題",
"field_label_user_identifier": "使用者識別碼",
"filters": "篩選條件",
"filters_toggle_description": "只包含符合下列條件的資料。",
"generate_chart": "產生圖表",
"granularity": "粒度",
"granularity_day": "天",
"granularity_hour": "小時",
"granularity_month": "月",
"granularity_quarter": "季",
"granularity_week": "週",
"granularity_year": "年",
"greater_than": "大於",
"greater_than_or_equal": "大於或等於",
"group_by": "分組依據",
"group_by_description": "選擇要拆解資料的維度。多維度圖表時,順序會影響結果。",
"guide_button": "查看欄位指南",
"guide_chart_type": "圖表類型",
"guide_chart_type_desc": "資料的視覺化方式:區域圖、長條圖、折線圖、圓餅圖或大數字。請根據你想呈現的內容(趨勢、比較、整體組成等)選擇。",
"guide_dimensions": "維度(分組依據)",
"guide_dimensions_desc": "你如何拆分或分組資料。每個維度都會成為圖表上的一個類別(例如:情緒、來源類型、問卷名稱、渠道、主題)。多維度圖表時,順序很重要。",
"guide_filters": "篩選條件",
"guide_filters_desc": "限制納入哪些資料的條件。每個篩選包含欄位、運算子等於、包含、大於等和數值。And全部符合Or任一符合即可。",
"guide_measures": "指標(你要計算或彙總的內容)",
"guide_measures_predefined": "預設指標是從回饋資料中預先建立的指標計數總回應數、推廣者貶抑者中立者計數NPS 分群、NPS 分數、平均分數、完成率。",
"guide_quick_ref": "快速參考",
"guide_term_dimension": "用來分組或拆分資料的類別欄位",
"guide_term_filter": "限制納入哪些資料列的條件",
"guide_term_measure": "你要彙總的數值(計數、總和、平均等)",
"guide_term_time": "依時間分組,包含粒度與日期範圍",
"guide_time_dimension": "時間維度",
"guide_time_dimension_desc": "依時間分組:選擇一個時間欄位(通常是收集時間)、粒度(小時、天、週、月等)和日期範圍(預設或自訂)。適合用來觀察趨勢變化。",
"guide_title": "圖表建立指南",
"is_not_set": "未設定",
"is_set": "已設定",
"less_than": "小於",
"less_than_or_equal": "小於或等於",
"measures": "指標",
"no_charts_found": "找不到圖表。",
"no_dashboards_available": "沒有可用的儀表板",
"no_dashboards_create_first": "請先建立儀表板,才能新增圖表。",
"no_data_available": "沒有可用資料",
"no_data_returned": "查詢沒有回傳資料",
"no_data_returned_for_chart": "此圖表沒有回傳資料",
"no_grouping": "無(僅篩選)",
"no_valid_data_to_display": "沒有可顯示的有效資料",
"not_contains": "不包含",
"not_equals": "不等於",
"open_chart": "開啟圖表 {{name}}",
"open_options": "開啟圖表選項",
"or_filter_logic": "或",
"original": "原始",
"please_enter_chart_name": "請輸入圖表名稱",
"please_select_at_least_one_measure": "請至少選擇一個指標",
"please_select_dashboard": "請選擇一個儀表板",
"predefined_measures": "預設指標",
"preset": "預設",
"query_executed_successfully": "查詢執行成功",
"reset_to_ai_suggestion": "重設為 AI 建議",
"save_chart": "儲存圖表",
"save_chart_dialog_title": "儲存圖表",
"select_dimensions": "選擇維度...",
"select_field": "選擇欄位",
"select_measures": "選擇指標...",
"select_preset": "選擇預設",
"showing_first_n_of": "顯示前 {{n}} 筆,共 {{count}} 筆資料",
"start_date": "開始日期",
"time_dimension": "時間維度",
"time_dimension_toggle_description": "新增以時間為基礎的分組,方便觀察趨勢變化。"
},
"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": "做得好!我們已連線。",
@@ -1242,6 +1426,7 @@
"add_fallback_placeholder": "新增 預設 以顯示是否沒 有 值 可 回憶 。",
"add_hidden_field_id": "新增隱藏欄位 ID",
"add_highlight_border": "新增醒目提示邊框",
"add_highlight_border_description": "僅適用於產品內調查。",
"add_logic": "新增邏輯",
"add_none_of_the_above": "新增 \"以上皆非\"",
"add_option": "新增選項",
@@ -1440,7 +1625,6 @@
"follow_ups_modal_updated_successfull_toast": "後續 動作 已 更新 並 將 在 你 儲存 調查 後 儲存",
"follow_ups_new": "新增後續追蹤",
"follow_ups_upgrade_button_text": "升級以啟用後續追蹤",
"form_styling": "表單樣式設定",
"formbricks_sdk_is_not_connected": "Formbricks SDK 未連線",
"four_points": "4 分",
"heading": "標題",
@@ -1613,7 +1797,7 @@
"response_limits_redirections_and_more": "回應限制、重新導向等。",
"response_options": "回應選項",
"roundness": "圓角",
"roundness_description": "調整卡片邊角的圓度。",
"roundness_description": "調整邊角的圓潤程度。",
"row_used_in_logic_error": "此 row 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"rows": "列",
"save_and_close": "儲存並關閉",
@@ -1659,6 +1843,7 @@
"survey_completed_subheading": "此免費且開源的問卷已關閉",
"survey_display_settings": "問卷顯示設定",
"survey_placement": "問卷位置",
"survey_styling": "表單樣式設定",
"survey_trigger": "問卷觸發器",
"switch_multi_language_on_to_get_started": "請開啟多語言功能以開始使用 👉",
"target_block_not_found": "找不到目標區塊",
@@ -2170,7 +2355,7 @@
"advanced_styling_field_indicator_bg_description": "設定進度條已填滿部分的顏色。",
"advanced_styling_field_input_border_radius_description": "調整輸入框的圓角。",
"advanced_styling_field_input_font_size_description": "調整輸入框內輸入文字的大小。",
"advanced_styling_field_input_height_description": "設定輸入欄的最小高度。",
"advanced_styling_field_input_height_description": "控制輸入欄的最小高度。",
"advanced_styling_field_input_padding_x_description": "在左右兩側增加間距。",
"advanced_styling_field_input_padding_y_description": "在上方和下方增加間距。",
"advanced_styling_field_input_placeholder_opacity_description": "讓提示文字變得更淡。",
@@ -2179,6 +2364,8 @@
"advanced_styling_field_input_text_description": "設定輸入文字的顏色。",
"advanced_styling_field_option_bg": "背景",
"advanced_styling_field_option_bg_description": "填滿選項項目背景。",
"advanced_styling_field_option_border": "邊框顏色",
"advanced_styling_field_option_border_description": "為單選框和核取方塊選項加上外框。",
"advanced_styling_field_option_border_radius_description": "讓選項的邊角變圓。",
"advanced_styling_field_option_font_size_description": "調整選項標籤文字的大小。",
"advanced_styling_field_option_label": "標籤顏色",
@@ -2998,6 +3185,9 @@
"preview_survey_question_2_choice_2_label": "不用了,謝謝!",
"preview_survey_question_2_headline": "想要緊跟最新動態嗎?",
"preview_survey_question_2_subheader": "這是一個範例說明。",
"preview_survey_question_open_text_headline": "還有什麼想和我們分享的嗎?",
"preview_survey_question_open_text_placeholder": "在此輸入您的答案...",
"preview_survey_question_open_text_subheader": "您的回饋能幫助我們進步。",
"preview_survey_welcome_card_headline": "歡迎!",
"prioritize_features_description": "找出您的使用者最需要和最不需要的功能。",
"prioritize_features_name": "優先排序功能",

View File

@@ -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 || {};

View File

@@ -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();
});
});

View File

@@ -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();
}

View File

@@ -0,0 +1,342 @@
"use server";
import { createOpenAI } from "@ai-sdk/openai";
import { Output, generateText } from "ai";
import { z } from "zod";
import { type TChartQuery, 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 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 = createOpenAI({ apiKey: process.env.OPENAI_API_KEY });
const schemaContext = generateSchemaContext();
const { output } = await generateText({
model: openai("gpt-4o-mini"),
output: Output.object({ schema: ZGenerateAIQueryResponse }),
system: schemaContext,
prompt: `User request: "${parsedInput.prompt}"`,
});
const measures = output.measures.length > 0 ? output.measures : [`${CUBE_NAME}.count`];
const { chartType, ...cubeQuery } = { ...output, measures };
// Strip nulls/empty arrays so Cube.js receives only present fields
const cleanQuery: Record<string, unknown> = {
measures: cubeQuery.measures,
...(cubeQuery.dimensions?.length && { dimensions: cubeQuery.dimensions }),
...(cubeQuery.filters?.length && {
filters: cubeQuery.filters.map(({ member, operator, values }) => ({
member,
operator,
...(values != null && { values }),
})),
}),
...(cubeQuery.timeDimensions?.length && {
timeDimensions: cubeQuery.timeDimensions.map(({ dimension, granularity, dateRange }) => ({
dimension,
...(granularity != null && { granularity }),
...(dateRange != null && { dateRange }),
})),
}),
};
validateQueryMembers(cleanQuery as TChartQuery);
const data = await executeQuery(cleanQuery);
return {
query: cleanQuery,
chartType,
data: Array.isArray(data) ? data : [],
};
}
);

View File

@@ -0,0 +1,113 @@
"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 | undefined;
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)}
maxLength={255}
/>
</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"
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.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>
);
}

View File

@@ -0,0 +1,247 @@
"use client";
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import type { TChartQuery } from "@formbricks/types/analysis";
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 { TimeDimensionPanel } from "@/modules/ee/analysis/charts/components/time-dimension-panel";
import { useChartQuery } from "@/modules/ee/analysis/charts/hooks/use-chart-query";
import {
type ChartBuilderState,
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, 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";
interface AdvancedChartBuilderProps {
environmentId: string;
chartType: TChartType;
initialQuery?: TChartQuery;
hidePreview?: boolean;
onChartGenerated?: (data: AnalyticsResponse) => void;
}
const ACTION = {
SET_MEASURES: "SET_MEASURES",
SET_DIMENSIONS: "SET_DIMENSIONS",
SET_FILTERS: "SET_FILTERS",
SET_FILTER_LOGIC: "SET_FILTER_LOGIC",
SET_TIME_DIMENSION: "SET_TIME_DIMENSION",
INIT_FROM_QUERY: "INIT_FROM_QUERY",
} as const;
type Action =
| { type: typeof ACTION.SET_MEASURES; payload: string[] }
| { type: typeof ACTION.SET_DIMENSIONS; payload: string[] }
| { type: typeof ACTION.SET_FILTERS; payload: FilterRow[] }
| { type: typeof ACTION.SET_FILTER_LOGIC; payload: "and" | "or" }
| { type: typeof ACTION.SET_TIME_DIMENSION; payload: TimeDimensionConfig | null }
| { type: typeof ACTION.INIT_FROM_QUERY; payload: Partial<ChartBuilderState> };
const initialState: ChartBuilderState = {
selectedMeasures: [],
selectedDimensions: [],
filters: [],
filterLogic: "and",
timeDimension: null,
};
const chartBuilderReducer = (state: ChartBuilderState, action: Action): ChartBuilderState => {
switch (action.type) {
case ACTION.SET_MEASURES:
return { ...state, selectedMeasures: action.payload };
case ACTION.SET_DIMENSIONS:
return { ...state, selectedDimensions: action.payload };
case ACTION.SET_FILTERS:
return { ...state, filters: action.payload };
case ACTION.SET_FILTER_LOGIC:
return { ...state, filterLogic: action.payload };
case ACTION.SET_TIME_DIMENSION:
return { ...state, timeDimension: action.payload };
case ACTION.INIT_FROM_QUERY:
return { ...state, ...action.payload };
default:
return state;
}
};
export function AdvancedChartBuilder({
environmentId,
chartType,
initialQuery,
hidePreview = false,
onChartGenerated,
}: Readonly<AdvancedChartBuilderProps>) {
const { t } = useTranslation();
const parsedInitial = initialQuery ? parseQueryToState(initialQuery) : null;
const [state, dispatch] = useReducer(
chartBuilderReducer,
initialQuery ? { ...initialState, ...parsedInitial } : initialState
);
const { chartData, query, isLoading, error, runQuery } = useChartQuery(environmentId, initialQuery);
const currentQuery = useMemo(() => buildCubeQuery(state), [state]);
const hasConfigChanged = useMemo(() => {
if (!query) return true;
return JSON.stringify(currentQuery) !== JSON.stringify(query);
}, [currentQuery, query]);
const appliedInitialQueryRef = useRef<TChartQuery | null>(null);
useEffect(() => {
if (!initialQuery) return;
if (appliedInitialQueryRef.current === initialQuery) return;
appliedInitialQueryRef.current = initialQuery;
const parsed = parseQueryToState(initialQuery);
dispatch({ type: ACTION.INIT_FROM_QUERY, payload: parsed });
setDimensionsOpen((parsed.selectedDimensions?.length ?? 0) > 0);
}, [initialQuery]);
const handleRunQuery = async () => {
if (state.selectedMeasures.length === 0) {
toast.error(t("environments.analysis.charts.please_select_at_least_one_measure"));
return;
}
const result = await runQuery(buildCubeQuery(state));
if (result) {
onChartGenerated?.({ ...result, chartType });
}
};
const [dimensionsOpen, setDimensionsOpen] = useState(
() => (parsedInitial?.selectedDimensions?.length ?? 0) > 0
);
const timeDimensionOpen = state.timeDimension != null;
const filtersOpen = state.filters.length > 0;
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={chartType} onChartTypeSelect={() => {}} />
</>
)}
<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}
onMeasuresChange={(measures) => dispatch({ type: ACTION.SET_MEASURES, payload: measures })}
/>
</div>
<AdvancedOptionToggle
isChecked={dimensionsOpen}
onToggle={(checked) => {
setDimensionsOpen(checked);
if (!checked) dispatch({ type: ACTION.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: ACTION.SET_DIMENSIONS, payload: dimensions })
}
/>
</AdvancedOptionToggle>
<AdvancedOptionToggle
isChecked={timeDimensionOpen}
onToggle={() => {
if (timeDimensionOpen) dispatch({ type: ACTION.SET_TIME_DIMENSION, payload: null });
else if (!state.timeDimension) {
dispatch({
type: ACTION.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: ACTION.SET_TIME_DIMENSION, payload: config })}
/>
</AdvancedOptionToggle>
<AdvancedOptionToggle
isChecked={filtersOpen}
onToggle={() => {
if (filtersOpen) {
dispatch({ type: ACTION.SET_FILTERS, payload: [] });
} else if (state.filters.length === 0) {
const firstField = FEEDBACK_FIELDS.dimensions[0] ?? FEEDBACK_FIELDS.measures[0];
dispatch({
type: ACTION.SET_FILTERS,
payload: [
{
id: crypto.randomUUID(),
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: ACTION.SET_FILTERS, payload: filters })}
onFilterLogicChange={(logic) => dispatch({ type: ACTION.SET_FILTER_LOGIC, payload: logic })}
/>
</AdvancedOptionToggle>
<Button onClick={handleRunQuery} disabled={isLoading || !hasConfigChanged}>
{isLoading ? <LoadingSpinner /> : t("environments.analysis.charts.generate_chart")}
</Button>
</div>
{!hidePreview && (
<AdvancedChartPreview
error={error}
isLoading={isLoading}
chartData={chartData}
chartType={chartType}
query={query}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { DatabaseIcon } from "lucide-react";
import { useState } from "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 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;
}
export function AdvancedChartPreview({
error,
isLoading,
chartData,
chartType,
query,
}: Readonly<AdvancedChartPreviewProps>) {
const { t } = useTranslation();
const [showData, setShowData] = useState(false);
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>
<Collapsible.Root open={showData} onOpenChange={setShowData}>
<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>
);
}

View File

@@ -0,0 +1,84 @@
"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
autoFocus
placeholder={t("environments.analysis.charts.ai_query_placeholder")}
value={userQuery}
onChange={(e) => setUserQuery(e.target.value)}
className="flex-1"
maxLength={2000}
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>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { type ElementType, type ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { CartesianGrid, XAxis, YAxis } from "recharts";
import {
CHART_BRAND_DARK,
formatCellValue,
formatXAxisTick,
} 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";
import type { ChartConfig } from "@/modules/ui/components/chart";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/modules/ui/components/chart";
const ChartTooltipRow = ({
value,
dataKey,
color,
}: Readonly<{ value: unknown; dataKey: string; color?: string }>) => {
const { t } = useTranslation();
const indicatorColor = color ?? CHART_BRAND_DARK;
return (
<>
<div
className="h-2.5 w-2.5 shrink-0 rounded-[2px] border border-current"
style={{
backgroundColor: indicatorColor,
borderColor: indicatorColor,
}}
/>
<div className="flex flex-1 items-center justify-between leading-none">
<span className="text-muted-foreground">{formatCubeColumnHeader(dataKey, t)}</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. */
const createTooltipFormatter = (dataKey: string) => {
const Formatter = (value: unknown) => <ChartTooltipRow value={value} dataKey={dataKey} />;
Formatter.displayName = "ChartTooltipFormatter";
return Formatter;
};
/** Tooltip content for single-measure Cartesian charts. */
const SingleMeasureTooltip = ({ dataKey }: Readonly<{ dataKey: string }>) => {
const formatter = useMemo(() => createTooltipFormatter(dataKey), [dataKey]);
return <ChartTooltipContent labelFormatter={formatXAxisTick} formatter={formatter} />;
};
/** Tooltip formatter for multi-measure charts; uses each payload item's dataKey and color. */
const multiMeasureTooltipFormatter = (
value: unknown,
name: string,
item: { dataKey?: string; color?: string; payload?: { fill?: string } }
) => {
const key = item?.dataKey ?? name;
const color = item?.color ?? item?.payload?.fill;
return <ChartTooltipRow value={value} dataKey={key} color={color} />;
};
export interface CartesianChartProps {
data: TChartDataRow[];
xAxisKey: string;
dataKeys: string[];
chartConfig: ChartConfig;
chart: ElementType;
children: ReactNode;
showLegend?: boolean;
chartProps?: Record<string, unknown>;
}
/** Shared layout for bar, line, and area charts. Supports single or multiple measures. */
export function CartesianChart({
data,
xAxisKey,
dataKeys,
chartConfig,
chart: Chart,
children,
showLegend = false,
chartProps = {},
}: Readonly<CartesianChartProps>) {
const isMultiMeasure = dataKeys.length > 1;
const tooltipContent = isMultiMeasure ? (
<ChartTooltipContent labelFormatter={formatXAxisTick} formatter={multiMeasureTooltipFormatter} />
) : (
<SingleMeasureTooltip dataKey={dataKeys[0]} />
);
return (
<div className="h-64 w-full">
<ChartContainer config={chartConfig} className="h-full w-full">
<Chart data={data} {...chartProps}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey={xAxisKey}
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisTick}
/>
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={tooltipContent} />
{showLegend && <ChartLegend content={<ChartLegendContent />} verticalAlign="top" height={36} />}
{children}
</Chart>
</ChartContainer>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"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="text-sm text-gray-600">
{t("environments.analysis.charts.guide_measures_predefined")}
</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">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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,129 @@
"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?: () => void;
}
export function ChartDropdownMenu({ environmentId, chart, onEdit }: Readonly<ChartDropdownMenuProps>) {
const { t } = useTranslation();
const router = useRouter();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDuplicating, setIsDuplicating] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = 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"));
setIsDeleteDialogOpen(false);
router.refresh();
} else {
const msg =
getFormattedErrorMessage(result) || t("environments.analysis.charts.chart_deletion_error");
toast.error(msg);
}
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsDeleting(false);
}
};
const handleDuplicateChart = async () => {
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);
}
};
return (
<div id={`chart-${chart.id}-actions`} data-testid="chart-dropdown-menu">
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
<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>
{onEdit && (
<DropdownMenuItem
icon={<SquarePenIcon className="size-4" />}
onClick={() => {
setIsDropDownOpen(false);
onEdit();
}}>
{t("common.edit")}
</DropdownMenuItem>
)}
<DropdownMenuItem
icon={<CopyIcon className="size-4" />}
onClick={() => {
setIsDropDownOpen(false);
handleDuplicateChart();
}}
disabled={isDuplicating}>
{t("common.duplicate")}
</DropdownMenuItem>
<DropdownMenuItem
icon={<TrashIcon className="size-4" />}
onClick={() => {
setIsDropDownOpen(false);
setIsDeleteDialogOpen(true);
}}
disabled={isDeleting}>
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat={t("common.chart")}
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
onDelete={handleDeleteChart}
text={t("environments.analysis.charts.delete_chart_confirmation")}
isDeleting={isDeleting}
/>
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import { Component, type ErrorInfo, type ReactNode } from "react";
interface ChartErrorBoundaryProps {
fallbackMessage: string;
children: ReactNode;
}
interface ChartErrorBoundaryState {
hasError: boolean;
}
export class ChartErrorBoundary extends Component<ChartErrorBoundaryProps, ChartErrorBoundaryState> {
constructor(props: ChartErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): ChartErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("ChartRenderer error:", error, info.componentStack);
}
override render() {
if (this.state.hasError) {
return (
<div className="text-muted-foreground flex h-64 items-center justify-center text-sm">
{this.props.fallbackMessage}
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,88 @@
"use client";
import { BarChart, DatabaseIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChartErrorBoundary } from "@/modules/ee/analysis/charts/components/chart-error-boundary";
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">
<ChartErrorBoundary fallbackMessage={t("environments.analysis.charts.chart_render_error")}>
<ChartRenderer chartType={chartData.chartType} data={data} query={chartData.query} />
</ChartErrorBoundary>
</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>
);
}

View File

@@ -0,0 +1,202 @@
"use client";
import { useTranslation } from "react-i18next";
import { Area, AreaChart, Bar, BarChart, Cell, Line, LineChart, Pie, PieChart } from "recharts";
import type { TChartQuery } from "@formbricks/types/analysis";
import { CartesianChart } from "@/modules/ee/analysis/charts/components/cartesian-chart";
import {
CHART_BRAND_DARK,
CHART_MEASURE_COLORS,
formatXAxisTick,
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";
const formatPieLabel = ({ name, percent }: { name: string; percent?: number }): string => {
if (percent == null) return "";
return `${formatXAxisTick(name)}: ${(percent * 100).toFixed(0)}%`;
};
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="text-muted-foreground flex h-64 items-center justify-center">
{t("environments.analysis.charts.no_data_available")}
</div>
);
}
const rowKeys = Object.keys(data[0] ?? {});
const timeDim = query.timeDimensions?.[0];
const timeDimKey = timeDim?.granularity
? `${timeDim.dimension}.${timeDim.granularity}`
: timeDim?.dimension;
const xAxisKey = query.dimensions?.[0] ?? timeDimKey ?? rowKeys[0] ?? "key";
const measureIds = query.measures?.filter((m) => rowKeys.includes(m)) ?? [];
const dataKeys = measureIds.length > 0 ? measureIds : rowKeys.filter((k) => k !== xAxisKey);
if (dataKeys.length === 0) {
return (
<div className="text-muted-foreground flex h-64 items-center justify-center">
{t("environments.analysis.charts.no_data_available")}
</div>
);
}
const chartConfig: ChartConfig = Object.fromEntries(
dataKeys.map((key, i) => [
key,
{
label: formatCubeColumnHeader(key, t),
color: CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length],
},
])
);
const dataKey = dataKeys[0];
const isMultiMeasure = dataKeys.length > 1;
switch (chartType) {
case "bar":
return (
<CartesianChart
chart={BarChart}
data={data}
xAxisKey={xAxisKey}
dataKeys={dataKeys}
chartConfig={chartConfig}
showLegend={isMultiMeasure}
chartProps={isMultiMeasure ? { barCategoryGap: "20%" } : {}}>
{dataKeys.map((key, i) => (
<Bar
key={key}
dataKey={key}
fill={chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]}
radius={4}
/>
))}
</CartesianChart>
);
case "line":
return (
<CartesianChart
chart={LineChart}
data={data}
xAxisKey={xAxisKey}
dataKeys={dataKeys}
chartConfig={chartConfig}
showLegend={isMultiMeasure}>
{dataKeys.map((key, i) => {
const color = chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length];
return (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={color}
strokeWidth={3}
dot={{ fill: color, r: 4 }}
activeDot={{ r: 6 }}
/>
);
})}
</CartesianChart>
);
case "area":
return (
<CartesianChart
chart={AreaChart}
data={data}
xAxisKey={xAxisKey}
dataKeys={dataKeys}
chartConfig={chartConfig}
showLegend={isMultiMeasure}>
{dataKeys.map((key, i) => (
<Area
key={key}
type="monotone"
dataKey={key}
stroke={chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]}
fill={chartConfig[key]?.color ?? CHART_MEASURE_COLORS[i % CHART_MEASURE_COLORS.length]}
fillOpacity={0.4}
strokeWidth={2}
/>
))}
</CartesianChart>
);
case "pie": {
const pieResult = preparePieData(data, dataKey);
if (!pieResult) {
return (
<div className="text-muted-foreground flex h-64 items-center justify-center">
{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={formatPieLabel}>
{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, name) => [String(value), formatCubeColumnHeader(String(name), t)]}
/>
}
/>
</PieChart>
</ChartContainer>
</div>
);
}
case "big_number": {
const total =
data.length === 1
? Number(data[0]?.[dataKey]) || 0
: data.reduce((sum, row) => sum + (Number(row[dataKey]) || 0), 0);
const formatted = total.toLocaleString();
return (
<div className="flex h-64 items-center justify-center">
<div className="text-center">
<div className="text-foreground text-4xl font-bold">{formatted}</div>
<div className="text-muted-foreground mt-2 text-sm">{formatCubeColumnHeader(dataKey, t)}</div>
</div>
</div>
);
}
default:
return (
<div className="text-muted-foreground flex h-64 items-center justify-center">
{t("environments.analysis.charts.chart_type_not_supported", { chartType })}
</div>
);
}
}

View File

@@ -0,0 +1,97 @@
"use client";
import { BarChart3Icon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { convertDateString, timeSinceDate } from "@/lib/time";
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 ChartRowProps {
chart: TChartWithCreator;
environmentId: string;
isReadOnly: boolean;
}
export function ChartRow({ chart, environmentId, isReadOnly }: Readonly<ChartRowProps>) {
const { t } = useTranslation();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const IconComponent = CHART_TYPE_ICONS[chart.type as keyof typeof CHART_TYPE_ICONS] ?? BarChart3Icon;
const handleChartClick = () => {
if (!isReadOnly) {
setIsEditDialogOpen(true);
}
};
const handleRowKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleChartClick();
}
};
return (
<>
{/* 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
role={isReadOnly ? undefined : "button"}
tabIndex={isReadOnly ? undefined : 0}
onClick={isReadOnly ? undefined : handleChartClick}
onKeyDown={isReadOnly ? undefined : handleRowKeyDown}
aria-label={
isReadOnly ? undefined : t("environments.analysis.charts.open_chart", { name: chart.name })
}
className={`grid h-12 w-full grid-cols-7 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-100 ${isReadOnly ? "" : "cursor-pointer"}`}>
<div className="col-span-6 grid grid-cols-6 content-center">
<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 // NOSONAR - stopPropagation wrapper to prevent row click when interacting with dropdown
className="col-span-1 my-auto flex items-center justify-end pr-6"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}>
{!isReadOnly && (
<ChartDropdownMenu
environmentId={environmentId}
chart={chart}
onEdit={() => setIsEditDialogOpen(true)}
/>
)}
</div>
</div>
{!isReadOnly && (
<CreateChartDialog
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
environmentId={environmentId}
chartId={chart.id}
initialChart={chart}
onSuccess={() => setIsEditDialogOpen(false)}
/>
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,43 @@
const SKELETON_ROWS = 3;
const 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-20 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, string, string, string];
}
export const 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>
);
};

View File

@@ -0,0 +1,34 @@
import { getTranslate } from "@/lingodotdev/server";
import { ChartRow } from "@/modules/ee/analysis/charts/components/chart-row";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
interface ChartsListProps {
charts: TChartWithCreator[];
environmentId: string;
isReadOnly: boolean;
}
export const ChartsList = async ({ charts, environmentId, isReadOnly }: Readonly<ChartsListProps>) => {
const t = await getTranslate();
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>
{charts.length === 0 ? (
<p className="py-6 text-center text-sm text-slate-400">
{t("environments.analysis.charts.no_charts_found")}
</p>
) : (
charts.map((chart) => (
<ChartRow key={chart.id} chart={chart} environmentId={environmentId} isReadOnly={isReadOnly} />
))
)}
</div>
);
};

View File

@@ -0,0 +1,99 @@
"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";
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: TChartType;
configuredChartType: TChartType | null;
onChartTypeSelect: (type: TChartType) => 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>
);
}

View File

@@ -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} />
</>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view";
import { EditChartView } from "@/modules/ee/analysis/charts/components/edit-chart-view";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
export interface CreateChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
chartId?: string;
initialChart?: TChartWithCreator;
onSuccess?: () => void;
}
export function CreateChartDialog({
open,
onOpenChange,
environmentId,
chartId,
initialChart,
onSuccess,
}: Readonly<CreateChartDialogProps>) {
if (chartId) {
return (
<EditChartView
open={open}
onOpenChange={onOpenChange}
environmentId={environmentId}
chartId={chartId}
initialChart={initialChart}
onSuccess={onSuccess}
/>
);
}
return (
<CreateChartView
open={open}
onOpenChange={onOpenChange}
environmentId={environmentId}
onSuccess={onSuccess}
/>
);
}

View File

@@ -0,0 +1,145 @@
"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 { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
interface CreateChartViewProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
onSuccess?: () => void;
}
export function CreateChartView({
open,
onOpenChange,
environmentId,
onSuccess,
}: Readonly<CreateChartViewProps>) {
const { t } = useTranslation();
const {
chartData,
chartName,
setChartName,
selectedChartType,
handleChartTypeChange,
handleChartGenerated,
dashboards,
selectedDashboardId,
setSelectedDashboardId,
handleAddToDashboard,
handleSaveChart,
isSaving,
isSaveDialogOpen,
setIsSaveDialogOpen,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
handleClose,
} = useChartDialog({ open, onOpenChange, environmentId, onSuccess });
const chartPreviewRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (chartData) {
chartPreviewRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, [chartData]);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
<DialogHeader>
<DialogTitle>{t("environments.analysis.charts.create_chart")}</DialogTitle>
<DialogDescription>{t("environments.analysis.charts.create_chart_description")}</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="grid gap-4">
<AIQuerySection environmentId={environmentId} onChartGenerated={handleChartGenerated} />
<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={handleChartTypeChange}
/>
</div>
{selectedChartType && (
<AdvancedChartBuilder
environmentId={environmentId}
chartType={selectedChartType}
initialQuery={chartData?.query}
hidePreview={true}
onChartGenerated={handleChartGenerated}
/>
)}
{chartData && (
<div ref={chartPreviewRef}>
<ChartPreview chartData={chartData} />
</div>
)}
</div>
</DialogBody>
{chartData && (
<>
<ChartDialogFooter
onSaveClick={() => setIsSaveDialogOpen(true)}
onAddToDashboardClick={() => setIsAddToDashboardDialogOpen(true)}
isSaving={isSaving}
/>
<SaveChartDialog
open={isSaveDialogOpen}
onOpenChange={setIsSaveDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
onSave={handleSaveChart}
isSaving={isSaving}
/>
<AddToDashboardDialog
isOpen={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onConfirm={handleAddToDashboard}
isSaving={isSaving}
/>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,76 @@
"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";
const MAX_DISPLAY_ROWS = 50;
interface DataViewerProps {
data: TChartDataRow[];
}
export function DataViewer({ data }: Readonly<DataViewerProps>) {
const { t } = useTranslation();
if (!data || data.length === 0 || Object.keys(data[0]).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, MAX_DISPLAY_ROWS);
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}
scope="col"
className="border-b border-gray-200 px-3 py-2 text-left font-semibold">
{formatCubeColumnHeader(key, t)}
</th>
))}
</tr>
</thead>
<tbody>
{displayData.map((row, index) => {
const firstValue = Object.values(row)[0];
const rowKey = firstValue ? String(firstValue) : `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 > MAX_DISPLAY_ROWS && (
<div className="px-3 py-2 text-xs text-gray-500">
{t("environments.analysis.charts.showing_first_n_of", {
n: MAX_DISPLAY_ROWS,
count: data.length,
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import { useTranslation } from "react-i18next";
import { FEEDBACK_FIELDS, getTranslatedFieldLabel } from "@/modules/ee/analysis/lib/schema-definition";
import { Label } from "@/modules/ui/components/label";
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: [getTranslatedFieldLabel(d.id, t), 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_dimensions")}
/>
<p className="text-sm text-gray-500">{t("environments.analysis.charts.group_by_description")}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,146 @@
"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 { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/chart-dialog-loading-view";
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
import { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
interface EditChartViewProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
chartId: string;
initialChart?: TChartWithCreator;
onSuccess?: () => void;
}
export function EditChartView({
open,
onOpenChange,
environmentId,
chartId,
initialChart,
onSuccess,
}: Readonly<EditChartViewProps>) {
const { t } = useTranslation();
const {
chartData,
initialQuery,
isLoadingChart,
chartLoadError,
chartName,
setChartName,
selectedChartType,
handleChartTypeChange,
handleChartGenerated,
dashboards,
selectedDashboardId,
setSelectedDashboardId,
handleAddToDashboard,
handleSaveChart,
isSaving,
isAddToDashboardDialogOpen,
setIsAddToDashboardDialogOpen,
handleClose,
} = useChartDialog({ open, onOpenChange, environmentId, chartId, initialChart, onSuccess });
if (isLoadingChart && !initialChart) {
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
}
if (!isLoadingChart && !chartData && !initialChart && chartLoadError) {
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<DialogContent width="wide">
<DialogHeader>
<DialogTitle>{t("common.error")}</DialogTitle>
<DialogDescription />
</DialogHeader>
<DialogBody>
<div className="flex flex-col items-center justify-center gap-4 py-8">
<p className="text-sm text-red-600">{chartLoadError}</p>
<Button variant="outline" onClick={handleClose}>
{t("common.close")}
</Button>
</div>
</DialogBody>
</DialogContent>
</Dialog>
);
}
const chartType = selectedChartType ?? DEFAULT_CHART_TYPE;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
<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) => setChartName(e.target.value)}
placeholder={t("environments.analysis.charts.chart_name_placeholder")}
className="w-full"
/>
</div>
<div className="space-y-2">
<ChartBuilderGuide />
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
</div>
<AdvancedChartBuilder
environmentId={environmentId}
chartType={chartType}
initialQuery={chartData?.query ?? initialQuery}
hidePreview={true}
onChartGenerated={handleChartGenerated}
/>
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
</div>
</DialogBody>
<ChartDialogFooter
onSaveClick={handleSaveChart}
onAddToDashboardClick={() => setIsAddToDashboardDialogOpen(true)}
isSaving={isSaving}
/>
<AddToDashboardDialog
isOpen={isAddToDashboardDialogOpen}
onOpenChange={setIsAddToDashboardDialogOpen}
chartName={chartName}
onChartNameChange={setChartName}
dashboards={dashboards}
selectedDashboardId={selectedDashboardId}
onDashboardSelect={setSelectedDashboardId}
onConfirm={handleAddToDashboard}
isSaving={isSaving}
/>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,217 @@
"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,
getTranslatedFieldLabel,
} 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: getTranslatedFieldLabel(d.id, t),
type: d.type,
})),
...FEEDBACK_FIELDS.measures.map((m) => ({
value: m.id,
label: getTranslatedFieldLabel(m.id, t),
type: "number" as TFilterFieldType,
})),
];
const handleAddFilter = () => {
const firstField = fieldOptions[0];
onFiltersChange([
...filters,
{
id: crypto.randomUUID(),
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 };
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;
if (filter.operator === "set" || filter.operator === "notSet") {
return null;
}
const isNumericInput =
fieldType === "number" &&
(filter.operator === "gt" ||
filter.operator === "gte" ||
filter.operator === "lt" ||
filter.operator === "lte");
return (
<Input
type={isNumericInput ? "number" : "text"}
placeholder={t("environments.analysis.charts.enter_value")}
value={filter.values?.[0] ?? ""}
onChange={(e) => {
let values: string[] | number[] | null = null;
if (e.target.value) {
values = isNumericInput ? [Number(e.target.value)] : [e.target.value];
}
handleUpdateFilter(index, { values });
}}
className={isNumericInput ? "w-[150px] bg-white" : "w-[200px] bg-white"}
/>
);
};
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-2 rounded-lg border border-gray-200 bg-white p-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.id} className="flex items-center gap-2">
<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>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import { useTranslation } from "react-i18next";
import { FEEDBACK_FIELDS, getTranslatedFieldLabel } from "@/modules/ee/analysis/lib/schema-definition";
import { MultiSelect } from "@/modules/ui/components/multi-select";
interface MeasuresPanelProps {
selectedMeasures: string[];
onMeasuresChange: (measures: string[]) => void;
hideTitle?: boolean;
}
export function MeasuresPanel({
selectedMeasures,
onMeasuresChange,
hideTitle = false,
}: Readonly<MeasuresPanelProps>) {
const { t } = useTranslation();
const measureOptions = FEEDBACK_FIELDS.measures.map((m) => ({
value: m.id,
label: [getTranslatedFieldLabel(m.id, t), m.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.measures")}</h3>
)}
<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>
</div>
);
}

View File

@@ -0,0 +1,71 @@
"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>
<label htmlFor="save-chart-name" className="sr-only">
{t("environments.analysis.charts.chart_name")}
</label>
<Input
placeholder={t("environments.analysis.charts.chart_name_placeholder")}
value={chartName}
onChange={(e) => onChartNameChange(e.target.value)}
maxLength={255}
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>
);
}

View File

@@ -0,0 +1,245 @@
"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,
getTranslatedDatePresetLabel,
getTranslatedFieldLabel,
getTranslatedGranularityLabel,
} 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";
const TIME_FIELD_OPTIONS = FEEDBACK_FIELDS.dimensions.filter((d) => d.type === "time");
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 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>
{TIME_FIELD_OPTIONS.map((field) => (
<SelectItem key={field.id} value={field.id}>
{getTranslatedFieldLabel(field.id, t)}
</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}>
{getTranslatedGranularityLabel(gran, t)}
</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}>
{getTranslatedDatePresetLabel(preset.value, t)}
</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);
const end = customEndDate ?? date;
if (timeDimension) {
onTimeDimensionChange({
...timeDimension,
dateRange: [date, end],
});
}
if (!customEndDate) setCustomEndDate(end);
}}
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);
const start = customStartDate ?? date;
if (timeDimension) {
onTimeDimensionChange({
...timeDimension,
dateRange: [start, date],
});
}
if (!customStartDate) setCustomStartDate(start);
}}
value={customEndDate || undefined}
minDate={customStartDate || undefined}
/>
</PopoverContent>
</Popover>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,346 @@
"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,
deleteChartAction,
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,
TChart,
TChartType,
TChartWithCreator,
} from "@/modules/ee/analysis/types/analysis";
export interface UseChartDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
environmentId: string;
chartId?: string;
/** Pre-loaded chart metadata; when provided for edit, skips getChartAction */
initialChart?: TChartWithCreator;
onSuccess?: () => void;
}
export function useChartDialog({
open,
onOpenChange,
environmentId,
chartId,
initialChart,
onSuccess,
}: Readonly<UseChartDialogProps>) {
const { t } = useTranslation();
const router = useRouter();
const [selectedChartType, setSelectedChartType] = useState<TChartType | undefined>();
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
const [chartName, setChartName] = useState("");
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
const [selectedDashboardId, setSelectedDashboardId] = useState<string | undefined>();
const [isSaving, setIsSaving] = useState(false);
const [isLoadingChart, setIsLoadingChart] = useState(false);
const [chartLoadError, setChartLoadError] = useState<string | null>(null);
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
useEffect(() => {
let cancelled = false;
if (isAddToDashboardDialogOpen) {
getDashboardsAction({ environmentId }).then((result) => {
if (cancelled) return;
if (result?.data) {
setDashboards(result.data.map((d) => ({ id: d.id, name: d.name })));
} else if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
}
});
}
return () => {
cancelled = true;
};
}, [isAddToDashboardDialogOpen, environmentId]);
useEffect(() => {
let cancelled = false;
if (!open) return;
if (!chartId) {
setChartData(null);
setChartName("");
setSelectedChartType(undefined);
setCurrentChartId(undefined);
return;
}
const fetchChartById = async (id: string): Promise<TChart> => {
const result = await getChartAction({ environmentId, chartId: id });
if (!result?.data) {
throw new Error(
getFormattedErrorMessage(result) || t("environments.analysis.charts.failed_to_load_chart")
);
}
return result.data;
};
const load = async () => {
setIsLoadingChart(true);
setChartLoadError(null);
try {
const chart = initialChart?.id === chartId ? initialChart : await fetchChartById(chartId);
if (cancelled) return;
setChartName(chart.name);
setSelectedChartType(resolveChartType(chart.type));
setCurrentChartId(chart.id);
const queryResult = await executeQueryAction({ environmentId, query: chart.query });
if (cancelled) return;
if (queryResult?.serverError) {
const errorMsg =
getFormattedErrorMessage(queryResult) ||
t("environments.analysis.charts.failed_to_load_chart_data");
toast.error(errorMsg);
setChartLoadError(errorMsg);
return;
}
if (!Array.isArray(queryResult?.data)) {
const errorMsg = t("environments.analysis.charts.no_data_returned_for_chart");
toast.error(errorMsg);
setChartLoadError(errorMsg);
return;
}
setChartData({
query: chart.query,
chartType: resolveChartType(chart.type),
data: queryResult.data,
});
} catch (error: unknown) {
if (cancelled) return;
const message =
error instanceof Error ? error.message : t("environments.analysis.charts.failed_to_load_chart");
toast.error(message);
setChartLoadError(message);
} finally {
if (!cancelled) setIsLoadingChart(false);
}
};
load();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, chartId, environmentId, initialChart]);
const handleChartGenerated = (data: AnalyticsResponse) => {
setChartData(data);
if (!currentChartId) {
setChartName(
data.chartType ? `${t("environments.analysis.charts.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: 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"));
} else {
const result = await createChartAction({
environmentId,
chartInput: {
name: chartName.trim(),
type: chartData.chartType,
query: chartData.query,
config: {},
},
});
if (!result?.data) {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
return;
}
setCurrentChartId(result.data.id);
toast.success(t("environments.analysis.charts.chart_saved_successfully"));
}
setIsSaveDialogOpen(false);
onOpenChange(false);
router.refresh();
onSuccess?.();
} 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 cleanupOrphanChart = async (orphanChartId: string) => {
await deleteChartAction({ environmentId, chartId: orphanChartId }).catch(() => {});
setCurrentChartId(undefined);
};
/** Returns the chart ID to use (existing or newly created), or null on failure. */
const ensureChartForDashboard = async (data: AnalyticsResponse): Promise<string | null> => {
if (currentChartId) return currentChartId;
const chartResult = await createChartAction({
environmentId,
chartInput: {
name: chartName.trim(),
type: data.chartType,
query: data.query,
config: {},
},
});
if (!chartResult?.data) {
toast.error(
(chartResult && getFormattedErrorMessage(chartResult)) ||
t("environments.analysis.charts.failed_to_save_chart")
);
return null;
}
setCurrentChartId(chartResult.data.id);
return chartResult.data.id;
};
const handleAddToDashboard = async () => {
if (!chartData || !selectedDashboardId) {
toast.error(t("environments.analysis.charts.please_select_dashboard"));
return;
}
if (!currentChartId && !chartName.trim()) {
toast.error(t("environments.analysis.charts.please_enter_chart_name"));
return;
}
setIsSaving(true);
let newlyCreatedChartId: string | null = null;
try {
const chartIdToUse = await ensureChartForDashboard(chartData);
if (!chartIdToUse) return;
if (!currentChartId) newlyCreatedChartId = chartIdToUse;
const widgetResult = await addChartToDashboardAction({
environmentId,
chartId: chartIdToUse,
dashboardId: selectedDashboardId,
title: chartName.trim(),
});
if (!widgetResult?.data) {
toast.error(
(widgetResult && getFormattedErrorMessage(widgetResult)) ||
t("environments.analysis.charts.failed_to_add_chart_to_dashboard")
);
if (newlyCreatedChartId) await cleanupOrphanChart(newlyCreatedChartId);
return;
}
toast.success(t("environments.analysis.charts.chart_added_to_dashboard"));
setIsAddToDashboardDialogOpen(false);
onOpenChange(false);
router.refresh();
onSuccess?.();
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: t("environments.analysis.charts.failed_to_add_chart_to_dashboard");
toast.error(message);
if (newlyCreatedChartId) await cleanupOrphanChart(newlyCreatedChartId);
} finally {
setIsSaving(false);
}
};
const handleClose = () => {
if (!isSaving) {
setChartData(null);
setChartName("");
setSelectedChartType(undefined);
setCurrentChartId(undefined);
setChartLoadError(null);
onOpenChange(false);
}
};
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,
handleChartGenerated,
handleSaveChart,
handleAddToDashboard,
handleClose,
handleChartTypeChange,
};
}

View File

@@ -0,0 +1,61 @@
"use client";
import { 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 type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
export interface QueryResult {
query: TChartQuery;
data: TChartDataRow[];
}
export function useChartQuery(environmentId: string, initialQuery?: TChartQuery) {
const { t } = useTranslation();
const [chartData, setChartData] = useState<TChartDataRow[] | null>(null);
const [query, setQuery] = useState<TChartQuery | null>(initialQuery ?? null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const runQuery = async (cubeQuery: TChartQuery): Promise<QueryResult | null> => {
setIsLoading(true);
setError(null);
try {
const result = await executeQueryAction({ environmentId, query: cubeQuery });
if (result?.serverError) {
const msg = getFormattedErrorMessage(result);
setError(msg);
toast.error(msg);
return null;
}
const data = Array.isArray(result?.data) ? result.data : [];
if (data.length === 0) {
const msg = t("environments.analysis.charts.no_data_returned");
setError(msg);
toast.error(msg);
return null;
}
setChartData(data);
setQuery(cubeQuery);
toast.success(t("environments.analysis.charts.query_executed_successfully"));
return { query: cubeQuery, data };
} catch (err: unknown) {
const msg =
err instanceof Error ? err.message : t("environments.analysis.charts.failed_to_execute_query");
setError(msg);
toast.error(msg);
return null;
} finally {
setIsLoading(false);
}
};
return { chartData, query, isLoading, error, runQuery };
}

View File

@@ -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");
});
});

View File

@@ -0,0 +1,35 @@
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 DEFAULT_CHART_TYPE: TChartType = "area";
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"),
},
];
}

View File

@@ -0,0 +1,145 @@
import { describe, expect, test } from "vitest";
import {
CHART_BRAND_DARK,
CHART_MEASURE_COLORS,
formatCellValue,
formatXAxisTick,
preparePieData,
resolveChartType,
validateQueryMembers,
} from "./chart-utils";
describe("chart-utils", () => {
describe("resolveChartType", () => {
test("returns valid chart types", () => {
expect(resolveChartType("area")).toBe("area");
expect(resolveChartType("bar")).toBe("bar");
expect(resolveChartType("line")).toBe("line");
expect(resolveChartType("pie")).toBe("pie");
expect(resolveChartType("big_number")).toBe("big_number");
});
test("defaults to bar for invalid type", () => {
expect(resolveChartType("invalid")).toBe("bar");
expect(resolveChartType("")).toBe("bar");
});
});
describe("preparePieData", () => {
test("returns null for empty or no valid numeric data", () => {
expect(preparePieData([], "count")).toBeNull();
expect(preparePieData([{ label: "A", count: "text" }], "count")).toBeNull();
expect(preparePieData([{ label: "A", count: null }], "count")).toBeNull();
});
test("filters to numeric rows and returns processedData with colors", () => {
const data = [
{ sentiment: "positive", count: 10 },
{ sentiment: "negative", count: 5 },
{ sentiment: "skip", count: "n/a" },
];
const result = preparePieData(data, "count");
expect(result).not.toBeNull();
expect(result!.processedData).toHaveLength(2);
expect(result!.processedData[0].count).toBe(10);
expect(result!.colors[0]).toBe(CHART_BRAND_DARK);
expect(result!.colors[1]).toBe("#00E6CA");
});
});
describe("formatXAxisTick", () => {
test("returns empty for null/undefined", () => {
expect(formatXAxisTick(null)).toBe("");
expect(formatXAxisTick(undefined)).toBe("");
});
test("formats ISO date string", () => {
expect(formatXAxisTick("2024-06-15")).toMatch(/Jun \d+, 2024/);
});
test("passes through non-date string", () => {
expect(formatXAxisTick("hello")).toBe("hello");
});
test("formats number as string when it parses as date, else passes through", () => {
expect(formatXAxisTick(1.5)).toBe("1.5");
});
test("returns empty for boolean", () => {
expect(formatXAxisTick(true)).toBe("");
});
});
describe("formatCellValue", () => {
test("returns empty for null/undefined", () => {
expect(formatCellValue(null)).toBe("");
expect(formatCellValue(undefined)).toBe("");
});
test("formats number with locale", () => {
expect(formatCellValue(1000)).toBe("1,000");
expect(formatCellValue(3.14)).toBe("3.14");
});
test("formats ISO date string", () => {
expect(formatCellValue("2024-01-15")).toMatch(/Jan \d+, 2024/);
});
test("returns string as-is when not date", () => {
expect(formatCellValue("hello")).toBe("hello");
});
test("stringifies object", () => {
expect(formatCellValue({ a: 1 })).toBe('{"a":1}');
});
test("converts boolean and bigint", () => {
expect(formatCellValue(true)).toBe("true");
expect(formatCellValue(123n)).toBe("123");
});
});
describe("validateQueryMembers", () => {
test("does not throw for valid query", () => {
expect(() =>
validateQueryMembers({
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.sentiment"],
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt" }],
filters: [{ member: "FeedbackRecords.sourceType", operator: "equals", values: ["survey"] }],
})
).not.toThrow();
});
test("throws for invalid measure", () => {
expect(() => validateQueryMembers({ measures: ["Other.count"] })).toThrow(
/Invalid query members.*Other\.count/
);
});
test("allows TopicsUnnested dimensions (joined cube)", () => {
expect(() => validateQueryMembers({ dimensions: ["TopicsUnnested.topic"] })).not.toThrow();
});
test("throws for invalid dimension", () => {
expect(() => validateQueryMembers({ dimensions: ["OtherCube.field"] })).toThrow(
/Invalid query members.*OtherCube\.field/
);
});
test("throws for invalid filter member", () => {
expect(() =>
validateQueryMembers({
filters: [{ member: "Invalid.field", operator: "equals", values: ["x"] }],
})
).toThrow(/Invalid query members/);
});
});
describe("constants", () => {
test("CHART_MEASURE_COLORS has expected length", () => {
expect(CHART_MEASURE_COLORS).toHaveLength(6);
expect(CHART_MEASURE_COLORS[0]).toBe(CHART_BRAND_DARK);
});
});
});

View File

@@ -0,0 +1,119 @@
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";
/** Palette for multi-measure charts (grouped/stacked bars, multi-series line/area). */
export const CHART_MEASURE_COLORS = [
CHART_BRAND_DARK,
"#6366f1", // indigo
"#f59e0b", // amber
"#ef4444", // red
"#8b5cf6", // violet
"#14b8a6", // teal
];
/** 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 value for x-axis ticks; ISO date strings become "MMM d, yyyy", others pass through. */
export function formatXAxisTick(value: TChartDataRow[string]): 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;
}
/**
* 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_PREFIXES = ["FeedbackRecords.", "TopicsUnnested."];
function validateMember(member: string): boolean {
return ALLOWED_CUBE_PREFIXES.some((prefix) => member.startsWith(prefix));
}
/**
* Validates that all measures, dimensions, segments, timeDimensions, and filters
* use only members from FeedbackRecords or joined cubes (e.g. TopicsUnnested).
* @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 FeedbackRecords. or TopicsUnnested.): ${invalid.join(", ")}`
);
}
}

View File

@@ -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",
});
});
});
});

View File

@@ -0,0 +1,275 @@
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,
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;
}
};

View File

@@ -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>
);
}

View File

@@ -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} />;
}

View File

@@ -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;
}
)
);

View File

@@ -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}
/>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1,125 @@
"use client";
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
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 { 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 router = useRouter();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = 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"));
router.refresh();
} else {
toast.error(
getFormattedErrorMessage(result) || 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) {
setIsDeleteDialogOpen(false);
toast.success(t("environments.analysis.dashboards.delete_success"));
router.refresh();
} else {
toast.error(getFormattedErrorMessage(result) || 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 variant="outline" className="px-2">
<span className="sr-only">{t("common.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" />} asChild>
<Link href={`/environments/${environmentId}/analysis/dashboards/${dashboardId}`}>
{t("common.edit")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
icon={<CopyIcon className="size-4" />}
disabled={isDuplicating}
onClick={() => {
setIsDropDownOpen(false);
handleDuplicateDashboard();
}}>
{t("common.duplicate")}
</DropdownMenuItem>
<DropdownMenuItem
icon={<TrashIcon className="size-4" />}
onClick={() => {
setIsDropDownOpen(false);
setIsDeleteDialogOpen(true);
}}>
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DeleteDialog
deleteWhat={t("common.dashboard")}
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
onDelete={handleDeleteDashboard}
text={t("environments.analysis.dashboards.delete_confirmation")}
isDeleting={isDeleting}
/>
</div>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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",
});
});
});
});

View File

@@ -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;
}
};

View File

@@ -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>
);
};

View File

@@ -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");
});
});

View File

@@ -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 };
};

View File

@@ -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.`;
}

View File

@@ -0,0 +1,191 @@
import { describe, expect, test } from "vitest";
import { type ChartBuilderState, buildCubeQuery, parseQueryToState } from "./query-builder";
describe("query-builder", () => {
describe("buildCubeQuery", () => {
test("builds minimal query with measures only", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: [],
filters: [],
filterLogic: "and",
timeDimension: null,
};
const query = buildCubeQuery(config);
expect(query.measures).toEqual(["FeedbackRecords.count"]);
expect(query.dimensions).toBeUndefined();
expect(query.timeDimensions).toBeUndefined();
expect(query.filters).toBeUndefined();
});
test("adds dimensions when present", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: ["FeedbackRecords.sentiment"],
filters: [],
filterLogic: "and",
timeDimension: null,
};
const query = buildCubeQuery(config);
expect(query.dimensions).toEqual(["FeedbackRecords.sentiment"]);
});
test("adds time dimension with string dateRange", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: [],
filters: [],
filterLogic: "and",
timeDimension: {
dimension: "FeedbackRecords.collectedAt",
granularity: "day",
dateRange: "last 30 days",
},
};
const query = buildCubeQuery(config);
expect(query.timeDimensions).toEqual([
{ dimension: "FeedbackRecords.collectedAt", granularity: "day", dateRange: "last 30 days" },
]);
});
test("adds time dimension with Date array dateRange", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: [],
filters: [],
filterLogic: "and",
timeDimension: {
dimension: "FeedbackRecords.collectedAt",
granularity: "month",
dateRange: [new Date("2024-01-15"), new Date("2024-06-20")],
},
};
const query = buildCubeQuery(config);
expect(query.timeDimensions).toEqual([
{
dimension: "FeedbackRecords.collectedAt",
granularity: "month",
dateRange: ["2024-01-15", "2024-06-20"],
},
]);
});
test("adds AND filters as member filters", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: [],
filters: [
{ id: "f1", field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ id: "f2", field: "FeedbackRecords.sourceType", operator: "set", values: null },
],
filterLogic: "and",
timeDimension: null,
};
const query = buildCubeQuery(config);
expect(query.filters).toEqual([
{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
{ member: "FeedbackRecords.sourceType", operator: "set" },
]);
});
test("adds OR filters wrapped in or", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: [],
filters: [{ id: "f1", field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
filterLogic: "or",
timeDimension: null,
};
const query = buildCubeQuery(config);
expect(query.filters).toEqual([
{
or: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
},
]);
});
});
describe("parseQueryToState", () => {
test("parses minimal query", () => {
const state = parseQueryToState({ measures: ["FeedbackRecords.count"] });
expect(state.selectedMeasures).toEqual(["FeedbackRecords.count"]);
expect(state.selectedDimensions).toEqual([]);
expect(state.filters).toEqual([]);
expect(state.filterLogic).toBe("and");
expect(state.timeDimension).toBeNull();
});
test("parses AND member filters", () => {
const query = {
measures: ["FeedbackRecords.count"],
filters: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
};
const state = parseQueryToState(query);
expect(state.filterLogic).toBe("and");
expect(state.filters?.map(({ field, operator, values }) => ({ field, operator, values }))).toEqual([
{ field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
]);
});
test("parses OR filters", () => {
const query = {
measures: ["FeedbackRecords.count"],
filters: [
{
or: [{ member: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] }],
},
],
};
const state = parseQueryToState(query);
expect(state.filterLogic).toBe("or");
expect(state.filters?.map(({ field, operator, values }) => ({ field, operator, values }))).toEqual([
{ field: "FeedbackRecords.sentiment", operator: "equals", values: ["positive"] },
]);
});
test("parses time dimension with granularity and dateRange", () => {
const query = {
measures: ["FeedbackRecords.count"],
timeDimensions: [
{
dimension: "FeedbackRecords.collectedAt",
granularity: "day",
dateRange: "last 30 days",
},
],
};
const state = parseQueryToState(query);
expect(state.timeDimension).toEqual({
dimension: "FeedbackRecords.collectedAt",
granularity: "day",
dateRange: "last 30 days",
});
});
});
describe("round-trip", () => {
test("buildCubeQuery then parseQueryToState restores state", () => {
const config: ChartBuilderState = {
selectedMeasures: ["FeedbackRecords.count"],
selectedDimensions: ["FeedbackRecords.sentiment"],
filters: [{ id: "f1", field: "FeedbackRecords.sourceType", operator: "equals", values: ["survey"] }],
filterLogic: "and",
timeDimension: {
dimension: "FeedbackRecords.collectedAt",
granularity: "week",
dateRange: "last 7 days",
},
};
const query = buildCubeQuery(config);
const restored = parseQueryToState(query);
expect(restored.selectedMeasures).toEqual(config.selectedMeasures);
expect(restored.selectedDimensions).toEqual(config.selectedDimensions);
expect(restored.filterLogic).toBe(config.filterLogic);
expect(restored.timeDimension).toEqual(config.timeDimension);
expect(restored.filters?.map(({ field, operator, values }) => ({ field, operator, values }))).toEqual(
config.filters.map(({ field, operator, values }) => ({ field, operator, values }))
);
});
});
});

View File

@@ -0,0 +1,146 @@
/**
* Query builder utility to construct Cube.js queries from chart builder state.
*/
import { TChartQuery, TCubeFilter, TMemberFilter, TTimeDimension } from "@formbricks/types/analysis";
export type TFilterFieldType = "string" | "number" | "time";
export interface FilterRow {
id: string;
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 {
selectedMeasures: string[];
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): Partial<ChartBuilderState> {
const state: Partial<ChartBuilderState> = {
selectedMeasures: query.measures || [],
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) => ({
id: crypto.randomUUID(),
field: f.member,
operator: f.operator,
values: f.values || null,
}));
} else {
state.filterLogic = "and";
state.filters = query.filters.filter(isMemberFilter).map((f) => ({
id: crypto.randomUUID(),
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;
}

View File

@@ -0,0 +1,78 @@
import { describe, expect, test } from "vitest";
import {
FEEDBACK_FIELDS,
formatCubeColumnHeader,
getFieldById,
getFilterOperatorsForType,
} from "./schema-definition";
describe("schema-definition", () => {
describe("getFilterOperatorsForType", () => {
test("returns string operators", () => {
const ops = getFilterOperatorsForType("string");
expect(ops).toContain("equals");
expect(ops).toContain("contains");
expect(ops).toContain("set");
});
test("returns number operators", () => {
const ops = getFilterOperatorsForType("number");
expect(ops).toContain("gt");
expect(ops).toContain("gte");
expect(ops).toContain("lt");
expect(ops).toContain("lte");
});
test("returns time operators", () => {
const ops = getFilterOperatorsForType("time");
expect(ops).toContain("equals");
expect(ops).toContain("set");
});
});
describe("getFieldById", () => {
test("returns dimension by id", () => {
const field = getFieldById("FeedbackRecords.sentiment");
expect(field).toBeDefined();
expect(field?.label).toBe("Sentiment");
expect(field?.type).toBe("string");
});
test("returns measure by id", () => {
const field = getFieldById("FeedbackRecords.count");
expect(field).toBeDefined();
expect(field?.label).toBe("Count");
});
test("returns undefined for unknown id", () => {
expect(getFieldById("Unknown.field")).toBeUndefined();
});
});
describe("formatCubeColumnHeader", () => {
test("extracts granularity label for time dimension key", () => {
expect(formatCubeColumnHeader("FeedbackRecords.collectedAt.day")).toBe("Day");
expect(formatCubeColumnHeader("FeedbackRecords.collectedAt.month")).toBe("Month");
});
test("returns field label for known dimension/measure", () => {
expect(formatCubeColumnHeader("FeedbackRecords.sentiment")).toBe("Sentiment");
expect(formatCubeColumnHeader("FeedbackRecords.count")).toBe("Count");
});
test("converts last segment to title case for unknown keys", () => {
expect(formatCubeColumnHeader("Some.camelCaseKey")).toBe("Camel Case Key");
});
test("handles key with no dots", () => {
expect(formatCubeColumnHeader("singleKey")).toBe("Single Key");
});
});
describe("FEEDBACK_FIELDS", () => {
test("has dimensions and measures", () => {
expect(FEEDBACK_FIELDS.dimensions.length).toBeGreaterThan(0);
expect(FEEDBACK_FIELDS.measures.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,259 @@
/**
* Schema definitions for FeedbackRecords fields.
* Used by the advanced chart builder to provide field metadata and operators.
*/
import type { TFunction } from "i18next";
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[],
};
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 GRANULARITY_LABELS: Record<string, string> = {
hour: "Hour",
day: "Day",
week: "Week",
month: "Month",
quarter: "Quarter",
year: "Year",
};
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);
}
/**
* Translate a field/measure ID. Each t() call uses a literal key so the i18n scanner can detect it.
*/
export function getTranslatedFieldLabel(id: string, t: TFunction): string {
const labels: Record<string, string> = {
"FeedbackRecords.sentiment": t("environments.analysis.charts.field_label_sentiment"),
"FeedbackRecords.sourceType": t("environments.analysis.charts.field_label_source_type"),
"FeedbackRecords.sourceName": t("environments.analysis.charts.field_label_source_name"),
"FeedbackRecords.fieldType": t("environments.analysis.charts.field_label_field_type"),
"FeedbackRecords.emotion": t("environments.analysis.charts.field_label_emotion"),
"FeedbackRecords.userIdentifier": t("environments.analysis.charts.field_label_user_identifier"),
"FeedbackRecords.responseId": t("environments.analysis.charts.field_label_response_id"),
"FeedbackRecords.npsValue": t("environments.analysis.charts.field_label_nps_value"),
"FeedbackRecords.collectedAt": t("environments.analysis.charts.field_label_collected_at"),
"TopicsUnnested.topic": t("environments.analysis.charts.field_label_topic"),
"FeedbackRecords.count": t("environments.analysis.charts.field_label_count"),
"FeedbackRecords.promoterCount": t("environments.analysis.charts.field_label_promoter_count"),
"FeedbackRecords.detractorCount": t("environments.analysis.charts.field_label_detractor_count"),
"FeedbackRecords.passiveCount": t("environments.analysis.charts.field_label_passive_count"),
"FeedbackRecords.npsScore": t("environments.analysis.charts.field_label_nps_score"),
"FeedbackRecords.averageScore": t("environments.analysis.charts.field_label_average_score"),
};
return labels[id] ?? getFieldById(id)?.label ?? id;
}
/**
* Translate a time granularity value.
*/
export function getTranslatedGranularityLabel(granularity: string, t: TFunction): string {
const labels: Record<string, string> = {
hour: t("environments.analysis.charts.granularity_hour"),
day: t("environments.analysis.charts.granularity_day"),
week: t("environments.analysis.charts.granularity_week"),
month: t("environments.analysis.charts.granularity_month"),
quarter: t("environments.analysis.charts.granularity_quarter"),
year: t("environments.analysis.charts.granularity_year"),
};
return labels[granularity] ?? GRANULARITY_LABELS[granularity] ?? granularity;
}
/**
* Translate a date preset value.
*/
export function getTranslatedDatePresetLabel(value: string, t: TFunction): string {
const labels: Record<string, string> = {
today: t("environments.analysis.charts.date_preset_today"),
yesterday: t("environments.analysis.charts.date_preset_yesterday"),
"last 7 days": t("environments.analysis.charts.date_preset_last_7_days"),
"last 30 days": t("environments.analysis.charts.date_preset_last_30_days"),
"this month": t("environments.analysis.charts.date_preset_this_month"),
"last month": t("environments.analysis.charts.date_preset_last_month"),
"this quarter": t("environments.analysis.charts.date_preset_this_quarter"),
"this year": t("environments.analysis.charts.date_preset_this_year"),
};
return labels[value] ?? value;
}
/**
* Format a Cube.js column key for display (e.g. FeedbackRecords.collectedAt.day → "Day").
* When `t` is provided, returns translated labels.
*/
export function formatCubeColumnHeader(key: string, t?: TFunction): string {
const granularity = TIME_GRANULARITIES.find((g) => key.endsWith(`.${g}`));
if (granularity) {
return t
? getTranslatedGranularityLabel(granularity, t)
: (GRANULARITY_LABELS[granularity] ?? granularity);
}
const field = getFieldById(key);
if (field) {
return t ? getTranslatedFieldLabel(key, t) : field.label;
}
const lastSegment = key.split(".").pop() ?? key;
return lastSegment
.replaceAll(/([A-Z])/g, " $1")
.replace(/^./, (s) => s.toUpperCase())
.trim();
}

View File

@@ -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;
}

View File

@@ -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");
});
});

View File

@@ -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;

View File

@@ -25,6 +25,9 @@ export const ZAuditTarget = z.enum([
"integration",
"file",
"quota",
"chart",
"dashboard",
"dashboardWidget",
]);
export const ZAuditAction = z.enum([
"created",

View File

@@ -52,54 +52,41 @@ export const getPersonSegmentIds = async (
return [];
}
// Phase 1: Build all Prisma where clauses concurrently.
// This converts segment filters into where clauses without per-contact DB queries.
const segmentWithClauses = await Promise.all(
segments.map(async (segment) => {
const filters = segment.filters as TBaseFilters | null;
if (!filters || filters.length === 0) {
return { segmentId: segment.id, whereClause: {} as Prisma.ContactWhereInput };
}
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
if (!queryResult.ok) {
logger.warn(
{ segmentId: segment.id, environmentId, error: queryResult.error },
"Failed to build Prisma query for segment"
);
return { segmentId: segment.id, whereClause: null };
}
return { segmentId: segment.id, whereClause: queryResult.data.whereClause };
})
);
// Separate segments into: always-match (no filters), needs-DB-check, and failed-to-build
// Phase 1: Build WHERE clauses sequentially to avoid connection pool contention.
// segmentFilterToPrismaQuery can itself hit the DB (e.g. unmigrated-row checks),
// so running all builds concurrently would saturate the pool.
const alwaysMatchIds: string[] = [];
const toCheck: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
const dbChecks: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
for (const item of segmentWithClauses) {
if (item.whereClause === null) {
for (const segment of segments) {
const filters = segment.filters as TBaseFilters;
if (!filters?.length) {
alwaysMatchIds.push(segment.id);
continue;
}
if (Object.keys(item.whereClause).length === 0) {
alwaysMatchIds.push(item.segmentId);
} else {
toCheck.push({ segmentId: item.segmentId, whereClause: item.whereClause });
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
if (!queryResult.ok) {
logger.warn(
{ segmentId: segment.id, environmentId, error: queryResult.error },
"Failed to build Prisma query for segment, skipping"
);
continue;
}
dbChecks.push({ segmentId: segment.id, whereClause: queryResult.data.whereClause });
}
if (toCheck.length === 0) {
if (dbChecks.length === 0) {
return alwaysMatchIds;
}
// Phase 2: Batch all contact-match checks into a single DB transaction.
// Replaces N individual findFirst queries with one batched round-trip.
const batchResults = await prisma.$transaction(
toCheck.map(({ whereClause }) =>
// Phase 2: Execute all membership checks in a single transaction.
// Uses one connection instead of N concurrent ones, eliminating pool contention.
const txResults = await prisma.$transaction(
dbChecks.map(({ whereClause }) =>
prisma.contact.findFirst({
where: { id: contactId, ...whereClause },
select: { id: true },
@@ -107,17 +94,12 @@ export const getPersonSegmentIds = async (
)
);
// Phase 3: Collect matching segment IDs
const dbMatchIds = toCheck.filter((_, i) => batchResults[i] !== null).map(({ segmentId }) => segmentId);
const matchedIds = dbChecks.filter((_, i) => txResults[i] !== null).map(({ segmentId }) => segmentId);
return [...alwaysMatchIds, ...dbMatchIds];
return [...alwaysMatchIds, ...matchedIds];
} catch (error) {
logger.warn(
{
environmentId,
contactId,
error,
},
{ environmentId, contactId, error },
"Failed to get person segment IDs, returning empty array"
);
return [];

View File

@@ -1,139 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TJsPersonState } from "@formbricks/types/js";
import { getPersonSegmentIds } from "./segments";
import { getUserState } from "./user-state";
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findUniqueOrThrow: vi.fn(),
},
},
}));
vi.mock("./segments", () => ({
getPersonSegmentIds: vi.fn(),
}));
const mockEnvironmentId = "test-environment-id";
const mockUserId = "test-user-id";
const mockContactId = "test-contact-id";
const mockDevice = "desktop";
describe("getUserState", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("should return user state with empty responses and displays", async () => {
const mockContactData = {
id: mockContactId,
responses: [],
displays: [],
};
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment1"]);
const result = await getUserState({
environmentId: mockEnvironmentId,
userId: mockUserId,
contactId: mockContactId,
device: mockDevice,
});
expect(prisma.contact.findUniqueOrThrow).toHaveBeenCalledWith({
where: { id: mockContactId },
select: {
id: true,
responses: {
select: { surveyId: true },
},
displays: {
select: { surveyId: true, createdAt: true },
orderBy: { createdAt: "desc" },
},
},
});
expect(getPersonSegmentIds).toHaveBeenCalledWith(
mockEnvironmentId,
mockContactId,
mockUserId,
mockDevice
);
expect(result).toEqual<TJsPersonState["data"]>({
contactId: mockContactId,
userId: mockUserId,
segments: ["segment1"],
displays: [],
responses: [],
lastDisplayAt: null,
});
});
test("should return user state with responses and displays, and sort displays by createdAt", async () => {
const mockDate1 = new Date("2023-01-01T00:00:00.000Z");
const mockDate2 = new Date("2023-01-02T00:00:00.000Z");
const mockContactData = {
id: mockContactId,
responses: [{ surveyId: "survey1" }, { surveyId: "survey2" }],
displays: [
{ surveyId: "survey4", createdAt: mockDate2 }, // most recent (already sorted by desc)
{ surveyId: "survey3", createdAt: mockDate1 },
],
};
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment2", "segment3"]);
const result = await getUserState({
environmentId: mockEnvironmentId,
userId: mockUserId,
contactId: mockContactId,
device: mockDevice,
});
expect(result).toEqual<TJsPersonState["data"]>({
contactId: mockContactId,
userId: mockUserId,
segments: ["segment2", "segment3"],
displays: [
{ surveyId: "survey4", createdAt: mockDate2 },
{ surveyId: "survey3", createdAt: mockDate1 },
],
responses: ["survey1", "survey2"],
lastDisplayAt: mockDate2,
});
});
test("should handle empty arrays from prisma", async () => {
// This case tests with proper empty arrays instead of null
const mockContactData = {
id: mockContactId,
responses: [],
displays: [],
};
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
vi.mocked(getPersonSegmentIds).mockResolvedValue([]);
const result = await getUserState({
environmentId: mockEnvironmentId,
userId: mockUserId,
contactId: mockContactId,
device: mockDevice,
});
expect(result).toEqual<TJsPersonState["data"]>({
contactId: mockContactId,
userId: mockUserId,
segments: [],
displays: [],
responses: [],
lastDisplayAt: null,
});
});
});

View File

@@ -1,80 +0,0 @@
import { prisma } from "@formbricks/database";
import { TJsPersonState } from "@formbricks/types/js";
import { getPersonSegmentIds } from "./segments";
/**
* Optimized single query to get all user state data
* Replaces multiple separate queries with one efficient query
*/
const getUserStateDataOptimized = async (contactId: string) => {
return prisma.contact.findUniqueOrThrow({
where: { id: contactId },
select: {
id: true,
responses: {
select: { surveyId: true },
},
displays: {
select: {
surveyId: true,
createdAt: true,
},
orderBy: { createdAt: "desc" },
},
},
});
};
/**
* Optimized user state fetcher without caching
* Uses single database query and efficient data processing
* NO CACHING - user state changes frequently with contact updates
*
* @param environmentId - The environment id
* @param userId - The user id
* @param device - The device type
* @returns The person state
* @throws {ValidationError} - If the input is invalid
* @throws {ResourceNotFoundError} - If the environment or organization is not found
*/
export const getUserState = async ({
environmentId,
userId,
contactId,
device,
}: {
environmentId: string;
userId: string;
contactId: string;
device: "phone" | "desktop";
}): Promise<TJsPersonState["data"]> => {
// Single optimized query for all contact data
const contactData = await getUserStateDataOptimized(contactId);
// Get segments using Prisma-based evaluation (no attributes needed - fetched from DB)
const segments = await getPersonSegmentIds(environmentId, contactId, userId, device);
// Process displays efficiently
const displays = (contactData.displays ?? []).map((display) => ({
surveyId: display.surveyId,
createdAt: display.createdAt,
}));
// Get latest display date
const lastDisplayAt =
contactData.displays && contactData.displays.length > 0 ? contactData.displays[0].createdAt : null;
// Process responses efficiently
const responses = (contactData.responses ?? []).map((response) => response.surveyId);
const userState: TJsPersonState["data"] = {
contactId,
userId,
segments,
displays,
responses,
lastDisplayAt,
};
return userState;
};

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