mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-04 10:19:31 -06:00
Compare commits
2 Commits
feat/dashb
...
fix/6658-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3f5875145 | ||
|
|
8331bd8431 |
19
.env.example
19
.env.example
@@ -229,24 +229,5 @@ REDIS_URL=redis://localhost:6379
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
|
||||
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
|
||||
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
|
||||
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
|
||||
# CUBEJS_API_SECRET=
|
||||
# URL where the Cube.js instance is running
|
||||
# CUBEJS_API_URL=http://localhost:4000
|
||||
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
|
||||
# CUBEJS_API_TOKEN=
|
||||
#
|
||||
# Cube connects to the Hub DB. When using docker-compose.dev.yml with the hub network,
|
||||
# use the container name and internal port. Hub credentials: formbricks/formbricks_dev, db: hub
|
||||
# CUBEJS_DB_HOST=formbricks_hub_postgres
|
||||
# CUBEJS_DB_PORT=5432
|
||||
# CUBEJS_DB_NAME=hub
|
||||
# CUBEJS_DB_USER=formbricks
|
||||
# CUBEJS_DB_PASS=formbricks_dev
|
||||
#
|
||||
# Alternative (when not on same Docker network): host.docker.internal and port 5433
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGODOTDEV_API_KEY=your_api_key_here
|
||||
@@ -32,7 +32,6 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
|
||||
|
||||
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
||||
We are using SonarQube to identify code smells and security hotspots.
|
||||
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
|
||||
|
||||
## Architecture & Patterns
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
|
||||
|
||||
const ChartsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const { environmentId } = await props.params;
|
||||
return <ChartsListPage environmentId={environmentId} />;
|
||||
};
|
||||
|
||||
export default ChartsPage;
|
||||
@@ -1,7 +0,0 @@
|
||||
import { DashboardDetailPage } from "@/modules/ee/analysis/dashboards/pages/dashboard-detail-page";
|
||||
|
||||
const Page = (props: { params: Promise<{ environmentId: string; dashboardId: string }> }) => {
|
||||
return <DashboardDetailPage params={props.params} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
|
||||
|
||||
const DashboardsPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const { environmentId } = await props.params;
|
||||
return <DashboardsListPage environmentId={environmentId} />;
|
||||
};
|
||||
|
||||
export default DashboardsPage;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const AnalysisPage = async (props: Readonly<{ params: Promise<{ environmentId: string }> }>) => {
|
||||
const { environmentId } = await props.params;
|
||||
return redirect(`/environments/${environmentId}/analysis/dashboards`);
|
||||
};
|
||||
|
||||
export default AnalysisPage;
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
ChartBar,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
LogOutIcon,
|
||||
@@ -115,13 +114,6 @@ export const MainNavigation = ({
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
},
|
||||
{
|
||||
name: t("common.analysis"),
|
||||
href: `/environments/${environment.id}/analysis`,
|
||||
icon: ChartBar,
|
||||
isActive: pathname?.includes("/analysis"),
|
||||
isHidden: false,
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
@@ -196,7 +188,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
|
||||
@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -241,7 +241,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isOpen}>
|
||||
{t("common.filter")} <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
|
||||
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">{t("common.and")}</p>
|
||||
<p className="mr-4 font-semibold text-slate-800">and</p>
|
||||
<hr className="w-full text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -106,7 +106,6 @@ checksums:
|
||||
common/allow: 3e39cc5940255e6bff0fea95c817dd43
|
||||
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
|
||||
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
|
||||
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
|
||||
common/and: dc75b95c804b16dc617a5f16f7393bca
|
||||
common/and_response_limit_of: 05be41a1d7e8dafa4aa012dcba77f5d4
|
||||
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
|
||||
@@ -123,8 +122,6 @@ checksums:
|
||||
common/bottom_right: aaef9a70ef795affc806c6d1853d8373
|
||||
common/cancel: 2e2a849c2223911717de8caa2c71bade
|
||||
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
|
||||
common/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
|
||||
common/charts: 1da4564d89264c89de4ed28d7451b43e
|
||||
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
|
||||
common/choose_environment: 5762cd499529815fc3e6a7feea39f90b
|
||||
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
|
||||
@@ -154,7 +151,6 @@ checksums:
|
||||
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
|
||||
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
|
||||
common/count_responses: 690118a456c01c5b4d437ae82b50b131
|
||||
common/create: 757ccd28dd533ff3a933355273c1e32a
|
||||
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
|
||||
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
|
||||
common/create_survey: 1cfbba08d34876566d84b2960054a987
|
||||
@@ -164,8 +160,6 @@ checksums:
|
||||
common/created_by: 6775c2fa7d495fea48f1ad816daea93b
|
||||
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
|
||||
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
|
||||
common/dashboard: c9380ea68c8c76ea451bd9613329a07c
|
||||
common/dashboards: 4bc47e48559a6b688684dcb7ac4babc9
|
||||
common/date: 56f41c5d30a76295bb087b20b7bee4c3
|
||||
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
|
||||
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
|
||||
@@ -204,9 +198,7 @@ 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
|
||||
@@ -218,9 +210,7 @@ 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
|
||||
@@ -238,7 +228,6 @@ 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
|
||||
@@ -288,7 +277,6 @@ checksums:
|
||||
common/on: 1929bcf2fba8003c043b446a851bcb4f
|
||||
common/only_one_file_allowed: 171be177f2e96c4bb4c4a47b3bf6c8c9
|
||||
common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709
|
||||
common/open_options: a4578c0afbfdf4a76d5952a53085b72a
|
||||
common/option_id: ed21d97b8ab035ba89fb3f5f073229bd
|
||||
common/option_ids: e68c25215ce81ea7ad82ff7be0a0bf2d
|
||||
common/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
@@ -431,7 +419,6 @@ 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
|
||||
@@ -589,170 +576,6 @@ checksums:
|
||||
environments/actions/you_can_track_code_action_anywhere_in_your_app_using: 3c0bbf160b8ddbeef142403103b70554
|
||||
environments/actions/your_survey_would_be_shown_on_this_url: 766fdeeb52d170c156af5d035a1f8c37
|
||||
environments/actions/your_survey_would_not_be_shown: af44fe160f449ff9557ebe5d3686832d
|
||||
environments/analysis/charts/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
|
||||
|
||||
@@ -22,9 +22,6 @@ export type AuditLoggingCtx = {
|
||||
quotaId?: string;
|
||||
teamId?: string;
|
||||
integrationId?: string;
|
||||
chartId?: string;
|
||||
dashboardId?: string;
|
||||
dashboardWidgetId?: string;
|
||||
};
|
||||
|
||||
export type ActionClientCtx = {
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "erlauben",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Erlaube Nutzern, die Umfrage zu verlassen, indem sie außerhalb klicken",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Beim Löschen von {type}s ist ein unbekannter Fehler aufgetreten",
|
||||
"analysis": "Analyse",
|
||||
"and": "und",
|
||||
"and_response_limit_of": "und Antwortlimit von",
|
||||
"anonymous": "Anonym",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Unten rechts",
|
||||
"cancel": "Abbrechen",
|
||||
"centered_modal": "Zentriertes Modalfenster",
|
||||
"chart": "Diagramm",
|
||||
"charts": "Diagramme",
|
||||
"choices": "Entscheidungen",
|
||||
"choose_environment": "Umgebung auswählen",
|
||||
"choose_organization": "Organisation auswählen",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} Attribut} other {{value} Attribute}}",
|
||||
"count_contacts": "{value, plural, one {{value} Kontakt} other {{value} Kontakte}}",
|
||||
"count_responses": "{value, plural, one {{value} Antwort} other {{value} Antworten}}",
|
||||
"create": "Erstellen",
|
||||
"create_new_organization": "Neue Organisation erstellen",
|
||||
"create_segment": "Segment erstellen",
|
||||
"create_survey": "Umfrage erstellen",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Erstellt von",
|
||||
"customer_success": "Kundenerfolg",
|
||||
"dark_overlay": "Dunkle Überlagerung",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "Dashboards",
|
||||
"date": "Datum",
|
||||
"days": "Tage",
|
||||
"default": "Standard",
|
||||
@@ -231,9 +225,7 @@
|
||||
"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",
|
||||
@@ -245,9 +237,7 @@
|
||||
"hidden": "Versteckt",
|
||||
"hidden_field": "Verstecktes Feld",
|
||||
"hidden_fields": "Versteckte Felder",
|
||||
"hide": "Ausblenden",
|
||||
"hide_column": "Spalte ausblenden",
|
||||
"id": "ID",
|
||||
"image": "Bild",
|
||||
"images": "Bilder",
|
||||
"import": "Importieren",
|
||||
@@ -265,7 +255,6 @@
|
||||
"key": "Schlüssel",
|
||||
"label": "Bezeichnung",
|
||||
"language": "Sprache",
|
||||
"last_name": "Nachname",
|
||||
"learn_more": "Mehr erfahren",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Helle Überlagerung",
|
||||
@@ -315,7 +304,6 @@
|
||||
"on": "An",
|
||||
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
|
||||
"open_options": "Optionen öffnen",
|
||||
"option_id": "Option-ID",
|
||||
"option_ids": "Option-IDs",
|
||||
"optional": "Optional",
|
||||
@@ -458,7 +446,6 @@
|
||||
"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",
|
||||
@@ -622,176 +609,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Ihre Umfrage wäre unter dieser URL angezeigt.",
|
||||
"your_survey_would_not_be_shown": "Ihre Umfrage wäre nicht angezeigt."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"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.",
|
||||
|
||||
@@ -122,8 +122,6 @@
|
||||
"activity": "Activity",
|
||||
"add": "Add",
|
||||
"add_action": "Add action",
|
||||
"add_charts": "Add charts",
|
||||
"add_existing_chart_description": "Search and select charts to add to this dashboard.",
|
||||
"add_filter": "Add filter",
|
||||
"add_logo": "Add logo",
|
||||
"add_member": "Add member",
|
||||
@@ -135,7 +133,6 @@
|
||||
"allow": "Allow",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Allow users to exit by clicking outside the survey",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "An unknown error occurred while deleting {type}s",
|
||||
"analysis": "Analysis",
|
||||
"and": "And",
|
||||
"and_response_limit_of": "and response limit of",
|
||||
"anonymous": "Anonymous",
|
||||
@@ -152,8 +149,6 @@
|
||||
"bottom_right": "Bottom Right",
|
||||
"cancel": "Cancel",
|
||||
"centered_modal": "Centered Modal",
|
||||
"chart": "Chart",
|
||||
"charts": "Charts",
|
||||
"choices": "Choices",
|
||||
"choose_environment": "Choose environment",
|
||||
"choose_organization": "Choose organization",
|
||||
@@ -183,8 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} attribute} other {{value} attributes}}",
|
||||
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}",
|
||||
"count_responses": "{value, plural, one {{value} response} other {{value} responses}}",
|
||||
"create": "Create",
|
||||
"create_new_chart": "Create new chart",
|
||||
"create_new_organization": "Create new organization",
|
||||
"create_segment": "Create segment",
|
||||
"create_survey": "Create survey",
|
||||
@@ -194,8 +187,6 @@
|
||||
"created_by": "Created by",
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Dark overlay",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "Dashboards",
|
||||
"date": "Date",
|
||||
"days": "days",
|
||||
"default": "Default",
|
||||
@@ -234,10 +225,7 @@
|
||||
"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",
|
||||
"feature_coming_soon": "Feature coming soon",
|
||||
"filter": "Filter",
|
||||
"finish": "Finish",
|
||||
"first_name": "First Name",
|
||||
"follow_these": "Follow these",
|
||||
"formbricks_version": "Formbricks Version",
|
||||
"full_name": "Full name",
|
||||
@@ -249,9 +237,7 @@
|
||||
"hidden": "Hidden",
|
||||
"hidden_field": "Hidden field",
|
||||
"hidden_fields": "Hidden fields",
|
||||
"hide": "Hide",
|
||||
"hide_column": "Hide column",
|
||||
"id": "ID",
|
||||
"image": "Image",
|
||||
"images": "Images",
|
||||
"import": "Import",
|
||||
@@ -269,7 +255,6 @@
|
||||
"key": "Key",
|
||||
"label": "Label",
|
||||
"language": "Language",
|
||||
"last_name": "Last Name",
|
||||
"learn_more": "Learn more",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Light overlay",
|
||||
@@ -293,7 +278,6 @@
|
||||
"mobile_overlay_surveys_look_good": "Do not worry – your surveys look great on every device and screen size!",
|
||||
"mobile_overlay_title": "Oops, tiny screen detected!",
|
||||
"months": "months",
|
||||
"more_options": "More options",
|
||||
"move_down": "Move down",
|
||||
"move_up": "Move up",
|
||||
"multiple_languages": "Multiple languages",
|
||||
@@ -302,7 +286,6 @@
|
||||
"new_version_available": "Formbricks {version} is here. Upgrade now!",
|
||||
"next": "Next",
|
||||
"no_background_image_found": "No background image found.",
|
||||
"no_changes": "No changes",
|
||||
"no_code": "No code",
|
||||
"no_files_uploaded": "No files were uploaded",
|
||||
"no_overlay": "No overlay",
|
||||
@@ -321,7 +304,6 @@
|
||||
"on": "On",
|
||||
"only_one_file_allowed": "Only one file is allowed",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
|
||||
"open_options": "Open options",
|
||||
"option_id": "Option ID",
|
||||
"option_ids": "Option IDs",
|
||||
"optional": "Optional",
|
||||
@@ -364,7 +346,6 @@
|
||||
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
|
||||
"read_docs": "Read docs",
|
||||
"recipients": "Recipients",
|
||||
"refresh": "Refresh",
|
||||
"remove": "Remove",
|
||||
"remove_from_team": "Remove from team",
|
||||
"reorder_and_hide_columns": "Reorder and hide columns",
|
||||
@@ -385,7 +366,6 @@
|
||||
"save_changes": "Save changes",
|
||||
"saving": "Saving",
|
||||
"search": "Search",
|
||||
"search_charts": "Search charts...",
|
||||
"security": "Security",
|
||||
"segment": "Segment",
|
||||
"segments": "Segments",
|
||||
@@ -450,7 +430,6 @@
|
||||
"try_again": "Try again",
|
||||
"type": "Type",
|
||||
"unknown_survey": "Unknown survey",
|
||||
"unlock": "Unlock",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Unlock more workspaces with a higher plan.",
|
||||
"update": "Update",
|
||||
"updated": "Updated",
|
||||
@@ -467,7 +446,6 @@
|
||||
"variables": "Variables",
|
||||
"verified_email": "Verified Email",
|
||||
"video": "Video",
|
||||
"view": "View",
|
||||
"warning": "Warning",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "We were unable to verify your license because the license server is unreachable.",
|
||||
"webhook": "Webhook",
|
||||
@@ -631,199 +609,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Your survey would be shown on this URL.",
|
||||
"your_survey_would_not_be_shown": "Your survey would not be shown."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"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",
|
||||
"and_filter_logic": "AND",
|
||||
"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.",
|
||||
"create_chart_with_ai": "Create chart with AI",
|
||||
"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 sentiment, question type, and other dimensions.",
|
||||
"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",
|
||||
"filter_data": "Filter data",
|
||||
"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": "Break down your data by one or more dimensions. The order is important if you choose multiple dimensions.",
|
||||
"group_data": "Group data",
|
||||
"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_title": "Add time-based grouping",
|
||||
"time_dimension_toggle_description": "Monitor trends over time."
|
||||
},
|
||||
"dashboards": {
|
||||
"add_count_charts": "Add {count} chart(s)",
|
||||
"charts_add_failed": "Failed to add charts to dashboard",
|
||||
"charts_add_partial_failure": "Failed to add {count} chart(s)",
|
||||
"charts_added_to_dashboard": "Charts added to dashboard",
|
||||
"charts_load_failed": "Failed to load charts",
|
||||
"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": "Dashboard",
|
||||
"dashboard_delete_confirmation": "Are you sure you want to delete this dashboard? This action cannot be undone.",
|
||||
"dashboard_name": "Dashboard Name",
|
||||
"dashboard_name_placeholder": "My dashboard",
|
||||
"dashboard_name_required": "Dashboard name is required",
|
||||
"dashboard_save_failed": "Failed to save dashboard",
|
||||
"dashboard_saved": "Dashboard saved successfully",
|
||||
"dashboard_update_failed": "Failed to update 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_charts_available_description": "There are no charts that can be added to this dashboard. Either no charts exist yet, or all existing charts have already been added. Go to the Charts page to create new charts.",
|
||||
"no_charts_exist": "No charts exist yet. Create one first.",
|
||||
"no_charts_to_add_message": "No charts to add to this dashboard.",
|
||||
"no_dashboards_found": "No dashboards found.",
|
||||
"no_data_description": "There is currently no information to display. Add charts to build your dashboard.",
|
||||
"no_data_message": "No Data. There is currently no information to display. Add charts to build your dashboard.",
|
||||
"no_data_title": "No Data",
|
||||
"please_enter_name": "Please enter a dashboard name",
|
||||
"widget_layouts_save_failed": "Failed to update widget layouts"
|
||||
}
|
||||
},
|
||||
"connect": {
|
||||
"congrats": "Congrats!",
|
||||
"connection_successful_message": "Well done! We are connected.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "Permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir a los usuarios salir haciendo clic fuera de la encuesta",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Se ha producido un error desconocido al eliminar {type}s",
|
||||
"analysis": "Análisis",
|
||||
"and": "Y",
|
||||
"and_response_limit_of": "y límite de respuesta de",
|
||||
"anonymous": "Anónimo",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Inferior derecha",
|
||||
"cancel": "Cancelar",
|
||||
"centered_modal": "Modal centrado",
|
||||
"chart": "Gráfico",
|
||||
"charts": "Gráficos",
|
||||
"choices": "Opciones",
|
||||
"choose_environment": "Elegir entorno",
|
||||
"choose_organization": "Elegir organización",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
|
||||
"count_contacts": "{value, plural, one {{value} contacto} other {{value} contactos}}",
|
||||
"count_responses": "{value, plural, one {{value} respuesta} other {{value} respuestas}}",
|
||||
"create": "Crear",
|
||||
"create_new_organization": "Crear organización nueva",
|
||||
"create_segment": "Crear segmento",
|
||||
"create_survey": "Crear encuesta",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Creado por",
|
||||
"customer_success": "Éxito del cliente",
|
||||
"dark_overlay": "Superposición oscura",
|
||||
"dashboard": "Panel de control",
|
||||
"dashboards": "Paneles",
|
||||
"date": "Fecha",
|
||||
"days": "días",
|
||||
"default": "Predeterminado",
|
||||
@@ -231,9 +225,7 @@
|
||||
"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",
|
||||
@@ -245,9 +237,7 @@
|
||||
"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",
|
||||
@@ -265,7 +255,6 @@
|
||||
"key": "Clave",
|
||||
"label": "Etiqueta",
|
||||
"language": "Idioma",
|
||||
"last_name": "Apellido",
|
||||
"learn_more": "Saber más",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Superposición clara",
|
||||
@@ -315,7 +304,6 @@
|
||||
"on": "Activado",
|
||||
"only_one_file_allowed": "Solo se permite un archivo",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Solo los propietarios y gestores pueden realizar esta acción.",
|
||||
"open_options": "Abrir opciones",
|
||||
"option_id": "ID de opción",
|
||||
"option_ids": "IDs de opciones",
|
||||
"optional": "Opcional",
|
||||
@@ -458,7 +446,6 @@
|
||||
"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",
|
||||
@@ -622,176 +609,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Tu encuesta se mostraría en esta URL.",
|
||||
"your_survey_would_not_be_shown": "Tu encuesta no se mostraría."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"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.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "Autoriser",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permettre aux utilisateurs de quitter en cliquant hors de l'enquête",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Une erreur inconnue est survenue lors de la suppression des {type}s",
|
||||
"analysis": "Analyse",
|
||||
"and": "Et",
|
||||
"and_response_limit_of": "et limite de réponse de",
|
||||
"anonymous": "Anonyme",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "En bas à droite",
|
||||
"cancel": "Annuler",
|
||||
"centered_modal": "Au centre",
|
||||
"chart": "Graphique",
|
||||
"charts": "Graphiques",
|
||||
"choices": "Choix",
|
||||
"choose_environment": "Choisir l'environnement",
|
||||
"choose_organization": "Choisir l'organisation",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attributs}}",
|
||||
"count_contacts": "{value, plural, one {# contact} other {# contacts} }",
|
||||
"count_responses": "{value, plural, other {# réponses}}",
|
||||
"create": "Créer",
|
||||
"create_new_organization": "Créer une nouvelle organisation",
|
||||
"create_segment": "Créer un segment",
|
||||
"create_survey": "Créer un sondage",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Créé par",
|
||||
"customer_success": "Succès Client",
|
||||
"dark_overlay": "Foncée",
|
||||
"dashboard": "Tableau de bord",
|
||||
"dashboards": "Tableaux de bord",
|
||||
"date": "Date",
|
||||
"days": "jours",
|
||||
"default": "Par défaut",
|
||||
@@ -231,9 +225,7 @@
|
||||
"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",
|
||||
@@ -245,9 +237,7 @@
|
||||
"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",
|
||||
@@ -265,7 +255,6 @@
|
||||
"key": "Clé",
|
||||
"label": "Étiquette",
|
||||
"language": "Langue",
|
||||
"last_name": "Nom de famille",
|
||||
"learn_more": "En savoir plus",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Claire",
|
||||
@@ -315,7 +304,6 @@
|
||||
"on": "Sur",
|
||||
"only_one_file_allowed": "Un seul fichier est autorisé",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
|
||||
"open_options": "Ouvrir les options",
|
||||
"option_id": "Identifiant de l'option",
|
||||
"option_ids": "Identifiants des options",
|
||||
"optional": "Facultatif",
|
||||
@@ -458,7 +446,6 @@
|
||||
"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",
|
||||
@@ -622,176 +609,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Votre enquête serait affichée sur cette URL.",
|
||||
"your_survey_would_not_be_shown": "Votre enquête ne serait pas affichée."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"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.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "Engedélyezés",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Lehetővé tétel a felhasználók számára, hogy a kérdőíven kívülre kattintva kilépjenek",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "{type} típusok törlésekor ismeretlen hiba történt",
|
||||
"analysis": "Elemzés",
|
||||
"and": "És",
|
||||
"and_response_limit_of": "és kérdéskorlátja ennek:",
|
||||
"anonymous": "Névtelen",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Jobbra lent",
|
||||
"cancel": "Mégse",
|
||||
"centered_modal": "Középre helyezett kizárólagos",
|
||||
"chart": "Diagram",
|
||||
"charts": "Diagramok",
|
||||
"choices": "Választási lehetőségek",
|
||||
"choose_environment": "Környezet kiválasztása",
|
||||
"choose_organization": "Szervezet kiválasztása",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} attribútum} other {{value} attribútum}}",
|
||||
"count_contacts": "{value, plural, one {{value} partner} other {{value} partner}}",
|
||||
"count_responses": "{value, plural, one {{value} válasz} other {{value} válasz}}",
|
||||
"create": "Létrehozás",
|
||||
"create_new_organization": "Új szervezet létrehozása",
|
||||
"create_segment": "Szakasz létrehozása",
|
||||
"create_survey": "Kérdőív létrehozása",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Létrehozta",
|
||||
"customer_success": "Ügyfélsiker",
|
||||
"dark_overlay": "Sötét rávetítés",
|
||||
"dashboard": "Vezérlőpult",
|
||||
"dashboards": "Irányítópultok",
|
||||
"date": "Dátum",
|
||||
"days": "napok",
|
||||
"default": "Alapértelmezett",
|
||||
@@ -231,9 +225,7 @@
|
||||
"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",
|
||||
@@ -245,9 +237,7 @@
|
||||
"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",
|
||||
@@ -265,7 +255,6 @@
|
||||
"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",
|
||||
@@ -315,7 +304,6 @@
|
||||
"on": "Be",
|
||||
"only_one_file_allowed": "Csak egy fájl engedélyezett",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Csak tulajdonosok és kezelők hajthatják végre ezt a műveletet.",
|
||||
"open_options": "Beállítások megnyitása",
|
||||
"option_id": "Választásazonosító",
|
||||
"option_ids": "Választásazonosítók",
|
||||
"optional": "Elhagyható",
|
||||
@@ -458,7 +446,6 @@
|
||||
"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",
|
||||
@@ -622,176 +609,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "A kérdőív ezen az URL-en jelenne meg.",
|
||||
"your_survey_would_not_be_shown": "A kérdőív nem jelenne meg."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"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.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "許可",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "フォームの外側をクリックしてユーザーが終了できるようにする",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "{type}の削除中に不明なエラーが発生しました",
|
||||
"analysis": "分析",
|
||||
"and": "および",
|
||||
"and_response_limit_of": "と回答数の上限",
|
||||
"anonymous": "匿名",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "右下",
|
||||
"cancel": "キャンセル",
|
||||
"centered_modal": "中央モーダル",
|
||||
"chart": "チャート",
|
||||
"charts": "チャート",
|
||||
"choices": "選択肢",
|
||||
"choose_environment": "環境を選択",
|
||||
"choose_organization": "組織を選択",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, other {{value}個の属性}}",
|
||||
"count_contacts": "{count, plural, other {# 件の連絡先}}",
|
||||
"count_responses": "{count, plural, other {# 件の回答}}",
|
||||
"create": "作成",
|
||||
"create_new_organization": "新しい組織を作成",
|
||||
"create_segment": "セグメントを作成",
|
||||
"create_survey": "フォームを作成",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "作成者",
|
||||
"customer_success": "カスタマーサクセス",
|
||||
"dark_overlay": "暗いオーバーレイ",
|
||||
"dashboard": "ダッシュボード",
|
||||
"dashboards": "ダッシュボード",
|
||||
"date": "日付",
|
||||
"days": "日",
|
||||
"default": "デフォルト",
|
||||
@@ -231,9 +225,7 @@
|
||||
"failed_to_copy_to_clipboard": "クリップボードへのコピーに失敗しました",
|
||||
"failed_to_load_organizations": "組織の読み込みに失敗しました",
|
||||
"failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました",
|
||||
"filter": "フィルター",
|
||||
"finish": "完了",
|
||||
"first_name": "名",
|
||||
"follow_these": "こちらの手順に従って",
|
||||
"formbricks_version": "Formbricksバージョン",
|
||||
"full_name": "氏名",
|
||||
@@ -245,9 +237,7 @@
|
||||
"hidden": "非表示",
|
||||
"hidden_field": "非表示フィールド",
|
||||
"hidden_fields": "非表示フィールド",
|
||||
"hide": "非表示",
|
||||
"hide_column": "列を非表示",
|
||||
"id": "ID",
|
||||
"image": "画像",
|
||||
"images": "画像",
|
||||
"import": "インポート",
|
||||
@@ -265,7 +255,6 @@
|
||||
"key": "キー",
|
||||
"label": "ラベル",
|
||||
"language": "言語",
|
||||
"last_name": "姓",
|
||||
"learn_more": "詳細を見る",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "明るいオーバーレイ",
|
||||
@@ -315,7 +304,6 @@
|
||||
"on": "オン",
|
||||
"only_one_file_allowed": "ファイルは1つのみ許可されています",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "このアクションを実行できるのは、オーナーと管理者のみです。",
|
||||
"open_options": "オプションを開く",
|
||||
"option_id": "オプションID",
|
||||
"option_ids": "オプションID",
|
||||
"optional": "任意",
|
||||
@@ -458,7 +446,6 @@
|
||||
"variables": "変数",
|
||||
"verified_email": "認証済みメールアドレス",
|
||||
"video": "動画",
|
||||
"view": "表示",
|
||||
"warning": "警告",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "ライセンスサーバーにアクセスできないため、ライセンスを認証できませんでした。",
|
||||
"webhook": "Webhook",
|
||||
@@ -622,176 +609,6 @@
|
||||
"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": "うまくいきました!接続されました。",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "Toestaan",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Laat gebruikers afsluiten door buiten de enquête te klikken",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Er is een onbekende fout opgetreden bij het verwijderen van {type}s",
|
||||
"analysis": "Analyse",
|
||||
"and": "En",
|
||||
"and_response_limit_of": "en responslimiet van",
|
||||
"anonymous": "Anoniem",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Rechtsonder",
|
||||
"cancel": "Annuleren",
|
||||
"centered_modal": "Gecentreerd modaal",
|
||||
"chart": "Grafiek",
|
||||
"charts": "Grafieken",
|
||||
"choices": "Keuzes",
|
||||
"choose_environment": "Kies omgeving",
|
||||
"choose_organization": "Kies organisatie",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} attribuut} other {{value} attributen}}",
|
||||
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacten}}",
|
||||
"count_responses": "{value, plural, one {{value} reactie} other {{value} reacties}}",
|
||||
"create": "Creëren",
|
||||
"create_new_organization": "Creëer een nieuwe organisatie",
|
||||
"create_segment": "Segment maken",
|
||||
"create_survey": "Enquête maken",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Gemaakt door",
|
||||
"customer_success": "Klant succes",
|
||||
"dark_overlay": "Donkere overlay",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "Dashboards",
|
||||
"date": "Datum",
|
||||
"days": "dagen",
|
||||
"default": "Standaard",
|
||||
@@ -231,9 +225,7 @@
|
||||
"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",
|
||||
@@ -245,9 +237,7 @@
|
||||
"hidden": "Verborgen",
|
||||
"hidden_field": "Verborgen veld",
|
||||
"hidden_fields": "Verborgen velden",
|
||||
"hide": "Verbergen",
|
||||
"hide_column": "Kolom verbergen",
|
||||
"id": "ID",
|
||||
"image": "Afbeelding",
|
||||
"images": "Afbeeldingen",
|
||||
"import": "Importeren",
|
||||
@@ -265,7 +255,6 @@
|
||||
"key": "Sleutel",
|
||||
"label": "Label",
|
||||
"language": "Taal",
|
||||
"last_name": "Achternaam",
|
||||
"learn_more": "Meer informatie",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Lichte overlay",
|
||||
@@ -315,7 +304,6 @@
|
||||
"on": "Op",
|
||||
"only_one_file_allowed": "Er is slechts één bestand toegestaan",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Alleen eigenaren en beheerders kunnen deze actie uitvoeren.",
|
||||
"open_options": "Opties openen",
|
||||
"option_id": "Optie-ID",
|
||||
"option_ids": "Optie-ID's",
|
||||
"optional": "Optioneel",
|
||||
@@ -458,7 +446,6 @@
|
||||
"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",
|
||||
@@ -622,176 +609,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Uw enquête wordt op deze URL weergegeven.",
|
||||
"your_survey_would_not_be_shown": "Uw enquête wordt niet getoond."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"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.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os usuários saiam clicando fora da pesquisa",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao deletar {type}s",
|
||||
"analysis": "Análise",
|
||||
"and": "E",
|
||||
"and_response_limit_of": "e limite de resposta de",
|
||||
"anonymous": "Anônimo",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Canto Inferior Direito",
|
||||
"cancel": "Cancelar",
|
||||
"centered_modal": "Modal Centralizado",
|
||||
"chart": "Gráfico",
|
||||
"charts": "Gráficos",
|
||||
"choices": "Escolhas",
|
||||
"choose_environment": "Escolher ambiente",
|
||||
"choose_organization": "Escolher organização",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
|
||||
"count_contacts": "{value, plural, one {# contato} other {# contatos} }",
|
||||
"count_responses": "{value, plural, other {# respostas}}",
|
||||
"create": "Criar",
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"create_segment": "Criar segmento",
|
||||
"create_survey": "Criar pesquisa",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Criado por",
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"dark_overlay": "sobreposição escura",
|
||||
"dashboard": "Painel",
|
||||
"dashboards": "Painéis",
|
||||
"date": "Encontro",
|
||||
"days": "dias",
|
||||
"default": "Padrão",
|
||||
@@ -231,9 +225,7 @@
|
||||
"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",
|
||||
@@ -245,9 +237,7 @@
|
||||
"hidden": "Escondido",
|
||||
"hidden_field": "Campo oculto",
|
||||
"hidden_fields": "Campos ocultos",
|
||||
"hide": "Ocultar",
|
||||
"hide_column": "Ocultar coluna",
|
||||
"id": "ID",
|
||||
"image": "imagem",
|
||||
"images": "Imagens",
|
||||
"import": "importar",
|
||||
@@ -265,7 +255,6 @@
|
||||
"key": "Chave",
|
||||
"label": "Etiqueta",
|
||||
"language": "Língua",
|
||||
"last_name": "Sobrenome",
|
||||
"learn_more": "Saiba mais",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "sobreposição leve",
|
||||
@@ -315,7 +304,6 @@
|
||||
"on": "ligado",
|
||||
"only_one_file_allowed": "É permitido apenas um arquivo",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
|
||||
"open_options": "Abrir opções",
|
||||
"option_id": "ID da opção",
|
||||
"option_ids": "IDs da Opção",
|
||||
"optional": "Opcional",
|
||||
@@ -458,7 +446,6 @@
|
||||
"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",
|
||||
@@ -622,176 +609,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Sua pesquisa seria exibida neste URL.",
|
||||
"your_survey_would_not_be_shown": "Sua pesquisa não seria exibida."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"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.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "Permitir",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permitir que os utilizadores saiam se clicarem 'sair do questionário'",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ocorreu um erro desconhecido ao eliminar {type}s",
|
||||
"analysis": "Análise",
|
||||
"and": "E",
|
||||
"and_response_limit_of": "e limite de resposta de",
|
||||
"anonymous": "Anónimo",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Inferior Direito",
|
||||
"cancel": "Cancelar",
|
||||
"centered_modal": "Modal Centralizado",
|
||||
"chart": "Gráfico",
|
||||
"charts": "Gráficos",
|
||||
"choices": "Escolhas",
|
||||
"choose_environment": "Escolha o ambiente",
|
||||
"choose_organization": "Escolher organização",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
|
||||
"count_contacts": "{value, plural, one {# contacto} other {# contactos} }",
|
||||
"count_responses": "{value, plural, other {# respostas}}",
|
||||
"create": "Criar",
|
||||
"create_new_organization": "Criar nova organização",
|
||||
"create_segment": "Criar segmento",
|
||||
"create_survey": "Criar inquérito",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Criado por",
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"dark_overlay": "Sobreposição escura",
|
||||
"dashboard": "Painel",
|
||||
"dashboards": "Dashboards",
|
||||
"date": "Data",
|
||||
"days": "dias",
|
||||
"default": "Padrão",
|
||||
@@ -231,9 +225,7 @@
|
||||
"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",
|
||||
@@ -245,9 +237,7 @@
|
||||
"hidden": "Oculto",
|
||||
"hidden_field": "Campo oculto",
|
||||
"hidden_fields": "Campos ocultos",
|
||||
"hide": "Ocultar",
|
||||
"hide_column": "Ocultar coluna",
|
||||
"id": "ID",
|
||||
"image": "Imagem",
|
||||
"images": "Imagens",
|
||||
"import": "Importar",
|
||||
@@ -265,7 +255,6 @@
|
||||
"key": "Chave",
|
||||
"label": "Etiqueta",
|
||||
"language": "Idioma",
|
||||
"last_name": "Apelido",
|
||||
"learn_more": "Saiba mais",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Sobreposição leve",
|
||||
@@ -315,7 +304,6 @@
|
||||
"on": "Ligado",
|
||||
"only_one_file_allowed": "Apenas um ficheiro é permitido",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
|
||||
"open_options": "Abrir opções",
|
||||
"option_id": "ID de Opção",
|
||||
"option_ids": "IDs de Opção",
|
||||
"optional": "Opcional",
|
||||
@@ -458,7 +446,6 @@
|
||||
"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",
|
||||
@@ -622,176 +609,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "O seu inquérito seria mostrado neste URL.",
|
||||
"your_survey_would_not_be_shown": "O seu inquérito não seria mostrado."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"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.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "Permite",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Permite utilizatorilor să iasă făcând clic în afara sondajului",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "A apărut o eroare necunoscută la ștergerea elementelor de tipul {type}",
|
||||
"analysis": "Analiză",
|
||||
"and": "Și",
|
||||
"and_response_limit_of": "și limită răspuns",
|
||||
"anonymous": "Anonim",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Dreapta Jos",
|
||||
"cancel": "Anulare",
|
||||
"centered_modal": "Modală centralizată",
|
||||
"chart": "Grafic",
|
||||
"charts": "Grafice",
|
||||
"choices": "Alegeri",
|
||||
"choose_environment": "Alege mediul",
|
||||
"choose_organization": "Alege organizația",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} atribut} few {{value} atribute} other {{value} de atribute}}",
|
||||
"count_contacts": "{value, plural, one {# contact} other {# contacte} }",
|
||||
"count_responses": "{value, plural, one {# răspuns} other {# răspunsuri} }",
|
||||
"create": "Creează",
|
||||
"create_new_organization": "Creează organizație nouă",
|
||||
"create_segment": "Creați segment",
|
||||
"create_survey": "Creează sondaj",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Creat de",
|
||||
"customer_success": "Succesul Clientului",
|
||||
"dark_overlay": "Suprapunere întunecată",
|
||||
"dashboard": "Tablou de bord",
|
||||
"dashboards": "Tablouri de bord",
|
||||
"date": "Dată",
|
||||
"days": "zile",
|
||||
"default": "Implicit",
|
||||
@@ -231,9 +225,7 @@
|
||||
"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",
|
||||
@@ -245,9 +237,7 @@
|
||||
"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",
|
||||
@@ -265,7 +255,6 @@
|
||||
"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ă",
|
||||
@@ -315,7 +304,6 @@
|
||||
"on": "Pe",
|
||||
"only_one_file_allowed": "Este permis doar un fișier",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Doar proprietarii și managerii pot efectua această acțiune.",
|
||||
"open_options": "Deschide opțiunile",
|
||||
"option_id": "ID opțiune",
|
||||
"option_ids": "ID-uri opțiuni",
|
||||
"optional": "Opțional",
|
||||
@@ -458,7 +446,6 @@
|
||||
"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",
|
||||
@@ -622,176 +609,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Sondajul dumneavoastră ar fi afișat pe acest URL.",
|
||||
"your_survey_would_not_be_shown": "Sondajul dumneavoastră nu va fi afișat."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"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.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "Разрешить",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Разрешить пользователям выходить, кликнув вне опроса",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Произошла неизвестная ошибка при удалении {type}ов",
|
||||
"analysis": "Аналитика",
|
||||
"and": "и",
|
||||
"and_response_limit_of": "и лимит ответов",
|
||||
"anonymous": "Аноним",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Внизу справа",
|
||||
"cancel": "Отмена",
|
||||
"centered_modal": "Центрированное модальное окно",
|
||||
"chart": "График",
|
||||
"charts": "Графики",
|
||||
"choices": "Варианты",
|
||||
"choose_environment": "Выберите среду",
|
||||
"choose_organization": "Выберите организацию",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} атрибут} few {{value} атрибута} many {{value} атрибутов} other {{value} атрибута}}",
|
||||
"count_contacts": "{value, plural, one {{value} контакт} few {{value} контакта} many {{value} контактов} other {{value} контактов}}",
|
||||
"count_responses": "{value, plural, one {{value} ответ} few {{value} ответа} many {{value} ответов} other {{value} ответов}}",
|
||||
"create": "Создать",
|
||||
"create_new_organization": "Создать новую организацию",
|
||||
"create_segment": "Создать сегмент",
|
||||
"create_survey": "Создать опрос",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Создано пользователем",
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Тёмный оверлей",
|
||||
"dashboard": "Панель управления",
|
||||
"dashboards": "Дашборды",
|
||||
"date": "Дата",
|
||||
"days": "дни",
|
||||
"default": "По умолчанию",
|
||||
@@ -231,9 +225,7 @@
|
||||
"failed_to_copy_to_clipboard": "Не удалось скопировать в буфер обмена",
|
||||
"failed_to_load_organizations": "Не удалось загрузить организации",
|
||||
"failed_to_load_workspaces": "Не удалось загрузить рабочие пространства",
|
||||
"filter": "Фильтр",
|
||||
"finish": "Завершить",
|
||||
"first_name": "Имя",
|
||||
"follow_these": "Выполните следующие действия",
|
||||
"formbricks_version": "Версия Formbricks",
|
||||
"full_name": "Полное имя",
|
||||
@@ -245,9 +237,7 @@
|
||||
"hidden": "Скрыто",
|
||||
"hidden_field": "Скрытое поле",
|
||||
"hidden_fields": "Скрытые поля",
|
||||
"hide": "Скрыть",
|
||||
"hide_column": "Скрыть столбец",
|
||||
"id": "ID",
|
||||
"image": "Изображение",
|
||||
"images": "Изображения",
|
||||
"import": "Импорт",
|
||||
@@ -265,7 +255,6 @@
|
||||
"key": "Ключ",
|
||||
"label": "Метка",
|
||||
"language": "Язык",
|
||||
"last_name": "Фамилия",
|
||||
"learn_more": "Подробнее",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Светлый оверлей",
|
||||
@@ -315,7 +304,6 @@
|
||||
"on": "Вкл.",
|
||||
"only_one_file_allowed": "Разрешён только один файл",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Только владельцы и менеджеры могут выполнять это действие.",
|
||||
"open_options": "Открыть параметры",
|
||||
"option_id": "ID опции",
|
||||
"option_ids": "ID опций",
|
||||
"optional": "Необязательно",
|
||||
@@ -458,7 +446,6 @@
|
||||
"variables": "Переменные",
|
||||
"verified_email": "Подтверждённый email",
|
||||
"video": "Видео",
|
||||
"view": "Просмотреть",
|
||||
"warning": "Предупреждение",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "Не удалось проверить вашу лицензию, так как сервер лицензий недоступен.",
|
||||
"webhook": "Webhook",
|
||||
@@ -622,176 +609,6 @@
|
||||
"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": "Отлично! Мы подключены.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "Tillåt",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "Tillåt användare att avsluta genom att klicka utanför enkäten",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "Ett okänt fel uppstod vid borttagning av {type}",
|
||||
"analysis": "Analys",
|
||||
"and": "Och",
|
||||
"and_response_limit_of": "och svarsgräns på",
|
||||
"anonymous": "Anonym",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "Nedre höger",
|
||||
"cancel": "Avbryt",
|
||||
"centered_modal": "Centrerad modal",
|
||||
"chart": "Diagram",
|
||||
"charts": "Diagram",
|
||||
"choices": "Val",
|
||||
"choose_environment": "Välj miljö",
|
||||
"choose_organization": "Välj organisation",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attribut}}",
|
||||
"count_contacts": "{value, plural, one {{value} kontakt} other {{value} kontakter}}",
|
||||
"count_responses": "{value, plural, one {{value} svar} other {{value} svar}}",
|
||||
"create": "Skapa",
|
||||
"create_new_organization": "Skapa ny organisation",
|
||||
"create_segment": "Skapa segment",
|
||||
"create_survey": "Skapa enkät",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "Skapad av",
|
||||
"customer_success": "Kundframgång",
|
||||
"dark_overlay": "Mörkt överlägg",
|
||||
"dashboard": "Instrumentpanel",
|
||||
"dashboards": "Instrumentpaneler",
|
||||
"date": "Datum",
|
||||
"days": "dagar",
|
||||
"default": "Standard",
|
||||
@@ -231,9 +225,7 @@
|
||||
"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",
|
||||
@@ -245,9 +237,7 @@
|
||||
"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",
|
||||
@@ -265,7 +255,6 @@
|
||||
"key": "Nyckel",
|
||||
"label": "Etikett",
|
||||
"language": "Språk",
|
||||
"last_name": "Efternamn",
|
||||
"learn_more": "Läs mer",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Ljust överlägg",
|
||||
@@ -315,7 +304,6 @@
|
||||
"on": "På",
|
||||
"only_one_file_allowed": "Endast en fil är tillåten",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Endast ägare och chefer kan utföra denna åtgärd.",
|
||||
"open_options": "Öppna alternativ",
|
||||
"option_id": "Alternativ-ID",
|
||||
"option_ids": "Alternativ-ID:n",
|
||||
"optional": "Valfritt",
|
||||
@@ -458,7 +446,6 @@
|
||||
"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",
|
||||
@@ -622,176 +609,6 @@
|
||||
"your_survey_would_be_shown_on_this_url": "Din enkät skulle visas på denna URL.",
|
||||
"your_survey_would_not_be_shown": "Din enkät skulle inte visas."
|
||||
},
|
||||
"analysis": {
|
||||
"charts": {
|
||||
"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.",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "允许",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "允许 用户 通过 点击 调查 外部 退出",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "删除 {type} 时发生未知错误",
|
||||
"analysis": "分析",
|
||||
"and": "和",
|
||||
"and_response_limit_of": "和 响应限制",
|
||||
"anonymous": "匿名",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "右下",
|
||||
"cancel": "取消",
|
||||
"centered_modal": "居中 模态",
|
||||
"chart": "图表",
|
||||
"charts": "图表",
|
||||
"choices": "选项",
|
||||
"choose_environment": "选择 环境",
|
||||
"choose_organization": "选择 组织",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} 个属性} other {{value} 个属性}}",
|
||||
"count_contacts": "{value, plural, other {{value} 联系人} }",
|
||||
"count_responses": "{value, plural, other {{value} 回复} }",
|
||||
"create": "创建",
|
||||
"create_new_organization": "创建 新的 组织",
|
||||
"create_segment": "创建 细分",
|
||||
"create_survey": "创建 调查",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "由 创建",
|
||||
"customer_success": "客户成功",
|
||||
"dark_overlay": "深色遮罩层",
|
||||
"dashboard": "Dashboard",
|
||||
"dashboards": "仪表盘",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "默认",
|
||||
@@ -231,9 +225,7 @@
|
||||
"failed_to_copy_to_clipboard": "复制到剪贴板失败",
|
||||
"failed_to_load_organizations": "加载组织失败",
|
||||
"failed_to_load_workspaces": "加载工作区失败",
|
||||
"filter": "筛选",
|
||||
"finish": "完成",
|
||||
"first_name": "名字",
|
||||
"follow_these": "遵循 这些",
|
||||
"formbricks_version": "Formbricks 版本",
|
||||
"full_name": "全名",
|
||||
@@ -245,9 +237,7 @@
|
||||
"hidden": "隐藏",
|
||||
"hidden_field": "隐藏 字段",
|
||||
"hidden_fields": "隐藏 字段",
|
||||
"hide": "隐藏",
|
||||
"hide_column": "隐藏 列",
|
||||
"id": "ID",
|
||||
"image": "图片",
|
||||
"images": "图片",
|
||||
"import": "导入",
|
||||
@@ -265,7 +255,6 @@
|
||||
"key": "键",
|
||||
"label": "标签",
|
||||
"language": "语言",
|
||||
"last_name": "姓",
|
||||
"learn_more": "了解 更多",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "浅色遮罩层",
|
||||
@@ -315,7 +304,6 @@
|
||||
"on": "开启",
|
||||
"only_one_file_allowed": "只 允许 一个 文件",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有 所有者 和 管理者 可以 执行 此 操作。",
|
||||
"open_options": "打开选项",
|
||||
"option_id": "选项 ID",
|
||||
"option_ids": "选项 ID",
|
||||
"optional": "可选",
|
||||
@@ -458,7 +446,6 @@
|
||||
"variables": "变量",
|
||||
"verified_email": "已验证 电子邮件",
|
||||
"video": "视频",
|
||||
"view": "查看",
|
||||
"warning": "警告",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "我们无法验证您的许可证,因为许可证服务器无法访问。",
|
||||
"webhook": "Webhook",
|
||||
@@ -622,176 +609,6 @@
|
||||
"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": "做得好 !我们 已经 连接。",
|
||||
|
||||
@@ -133,7 +133,6 @@
|
||||
"allow": "允許",
|
||||
"allow_users_to_exit_by_clicking_outside_the_survey": "允許使用者點擊問卷外退出",
|
||||
"an_unknown_error_occurred_while_deleting_table_items": "刪除 '{'type'}' 時發生未知錯誤",
|
||||
"analysis": "分析",
|
||||
"and": "且",
|
||||
"and_response_limit_of": "且回應上限為",
|
||||
"anonymous": "匿名",
|
||||
@@ -150,8 +149,6 @@
|
||||
"bottom_right": "右下",
|
||||
"cancel": "取消",
|
||||
"centered_modal": "置中彈窗",
|
||||
"chart": "圖表",
|
||||
"charts": "圖表",
|
||||
"choices": "選項",
|
||||
"choose_environment": "選擇環境",
|
||||
"choose_organization": "選擇 組織",
|
||||
@@ -181,7 +178,6 @@
|
||||
"count_attributes": "{value, plural, one {{value} 個屬性} other {{value} 個屬性}}",
|
||||
"count_contacts": "{value, plural, other {{value} 聯絡人} }",
|
||||
"count_responses": "{value, plural, other {{value} 回應} }",
|
||||
"create": "建立",
|
||||
"create_new_organization": "建立新組織",
|
||||
"create_segment": "建立區隔",
|
||||
"create_survey": "建立問卷",
|
||||
@@ -191,8 +187,6 @@
|
||||
"created_by": "建立者",
|
||||
"customer_success": "客戶成功",
|
||||
"dark_overlay": "深色覆蓋",
|
||||
"dashboard": "儀表板",
|
||||
"dashboards": "儀表板",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "預設",
|
||||
@@ -231,9 +225,7 @@
|
||||
"failed_to_copy_to_clipboard": "無法複製到剪貼簿",
|
||||
"failed_to_load_organizations": "無法載入組織",
|
||||
"failed_to_load_workspaces": "載入工作區失敗",
|
||||
"filter": "篩選",
|
||||
"finish": "完成",
|
||||
"first_name": "名字",
|
||||
"follow_these": "按照這些步驟",
|
||||
"formbricks_version": "Formbricks 版本",
|
||||
"full_name": "全名",
|
||||
@@ -245,9 +237,7 @@
|
||||
"hidden": "隱藏",
|
||||
"hidden_field": "隱藏欄位",
|
||||
"hidden_fields": "隱藏欄位",
|
||||
"hide": "隱藏",
|
||||
"hide_column": "隱藏欄位",
|
||||
"id": "ID",
|
||||
"image": "圖片",
|
||||
"images": "圖片",
|
||||
"import": "匯入",
|
||||
@@ -265,7 +255,6 @@
|
||||
"key": "金鑰",
|
||||
"label": "標籤",
|
||||
"language": "語言",
|
||||
"last_name": "姓氏",
|
||||
"learn_more": "瞭解更多",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "淺色覆蓋",
|
||||
@@ -315,7 +304,6 @@
|
||||
"on": "開啟",
|
||||
"only_one_file_allowed": "僅允許一個檔案",
|
||||
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
|
||||
"open_options": "開啟選項",
|
||||
"option_id": "選項 ID",
|
||||
"option_ids": "選項 IDs",
|
||||
"optional": "選填",
|
||||
@@ -458,7 +446,6 @@
|
||||
"variables": "變數",
|
||||
"verified_email": "已驗證的電子郵件",
|
||||
"video": "影片",
|
||||
"view": "檢視",
|
||||
"warning": "警告",
|
||||
"we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable": "我們無法驗證您的授權,因為授權伺服器無法連線。",
|
||||
"webhook": "Webhook",
|
||||
@@ -622,176 +609,6 @@
|
||||
"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": "做得好!我們已連線。",
|
||||
|
||||
@@ -12,9 +12,7 @@ type HasFindMany =
|
||||
| Prisma.TeamFindManyArgs
|
||||
| Prisma.ProjectTeamFindManyArgs
|
||||
| Prisma.UserFindManyArgs
|
||||
| Prisma.ContactAttributeKeyFindManyArgs
|
||||
| Prisma.ChartFindManyArgs
|
||||
| Prisma.DashboardFindManyArgs;
|
||||
| Prisma.ContactAttributeKeyFindManyArgs;
|
||||
|
||||
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
||||
const { limit, skip, sortBy, order, startDate, endDate, filterDateField = "createdAt" } = params || {};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from "@/modules/ui/components/button";
|
||||
export const BackToLoginButton = async () => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<Button variant="default" className="w-full justify-center">
|
||||
<Button variant="secondary" className="w-full justify-center">
|
||||
<Link href="/auth/login" className="h-full w-full">
|
||||
{t("auth.signup.log_in")}
|
||||
</Link>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mockLoad = vi.fn();
|
||||
const mockTablePivot = vi.fn();
|
||||
|
||||
vi.mock("@cubejs-client/core", () => ({
|
||||
default: vi.fn(() => ({
|
||||
load: mockLoad,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("executeQuery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
const resultSet = { tablePivot: mockTablePivot };
|
||||
mockLoad.mockResolvedValue(resultSet);
|
||||
mockTablePivot.mockReturnValue([{ id: "1", count: 42 }]);
|
||||
});
|
||||
|
||||
test("loads query and returns tablePivot result", async () => {
|
||||
const { executeQuery } = await import("./cube-client");
|
||||
const query = { measures: ["FeedbackRecords.count"] };
|
||||
const result = await executeQuery(query);
|
||||
|
||||
expect(mockLoad).toHaveBeenCalledWith(query);
|
||||
expect(mockTablePivot).toHaveBeenCalled();
|
||||
expect(result).toEqual([{ id: "1", count: 42 }]);
|
||||
});
|
||||
|
||||
test("preserves API URL when it already contains /cubejs-api/v1", async () => {
|
||||
const fullUrl = "https://cube.example.com/cubejs-api/v1";
|
||||
vi.stubEnv("CUBEJS_API_URL", fullUrl);
|
||||
const { executeQuery } = await import("./cube-client");
|
||||
|
||||
await executeQuery({ measures: ["FeedbackRecords.count"] });
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const cubejs = ((await vi.importMock("@cubejs-client/core")) as any).default;
|
||||
expect(cubejs).toHaveBeenCalledWith(expect.any(String), { apiUrl: fullUrl });
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import cubejs, { type CubeApi, type Query } from "@cubejs-client/core";
|
||||
|
||||
const getApiUrl = (): string => {
|
||||
const baseUrl = process.env.CUBEJS_API_URL || "http://localhost:4000";
|
||||
if (baseUrl.includes("/cubejs-api/v1")) {
|
||||
return baseUrl;
|
||||
}
|
||||
return `${baseUrl.replace(/\/$/, "")}/cubejs-api/v1`;
|
||||
};
|
||||
|
||||
let cubeClient: CubeApi | null = null;
|
||||
|
||||
function getCubeClient(): CubeApi {
|
||||
if (!cubeClient) {
|
||||
// TODO: This will fail silently if the token is not set. We need to fix this before going to production.
|
||||
const token = process.env.CUBEJS_API_TOKEN ?? "";
|
||||
cubeClient = cubejs(token, { apiUrl: getApiUrl() });
|
||||
}
|
||||
return cubeClient;
|
||||
}
|
||||
|
||||
export async function executeQuery(query: Query) {
|
||||
const client = getCubeClient();
|
||||
const resultSet = await client.load(query);
|
||||
return resultSet.tablePivot();
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
"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>;
|
||||
}) => {
|
||||
await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
|
||||
const charts = await getCharts(parsedInput.environmentId);
|
||||
return charts;
|
||||
}
|
||||
);
|
||||
|
||||
// ── 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 : [],
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -1,113 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface AddToDashboardDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
chartName: string;
|
||||
onChartNameChange: (name: string) => void;
|
||||
dashboards: Array<{ id: string; name: string }>;
|
||||
selectedDashboardId: string | 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>
|
||||
);
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
"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 { 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 && (
|
||||
<>
|
||||
<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.group_data")}
|
||||
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",
|
||||
dateRange: "last 30 days",
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
htmlId="chart-time-dimension-toggle"
|
||||
title={t("environments.analysis.charts.time_dimension_title")}
|
||||
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.filter_data")}
|
||||
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>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleRunQuery} disabled={isLoading || !hasConfigChanged}>
|
||||
{isLoading ? <LoadingSpinner /> : t("environments.analysis.charts.create_chart")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!hidePreview && (
|
||||
<AdvancedChartPreview
|
||||
error={error}
|
||||
isLoading={isLoading}
|
||||
chartData={chartData}
|
||||
chartType={chartType}
|
||||
query={query}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ActivityIcon, WandSparklesIcon } 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="flex flex-col items-center gap-2 text-center">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<form className="flex flex-col gap-3" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t("environments.analysis.charts.ai_query_placeholder")}
|
||||
value={userQuery}
|
||||
onChange={(e) => setUserQuery(e.target.value)}
|
||||
maxLength={2000}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
disabled={!userQuery.trim() || isGenerating}
|
||||
loading={isGenerating}>
|
||||
<WandSparklesIcon className="h-4 w-4" />
|
||||
{t("environments.analysis.charts.create_chart_with_ai")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon, SaveIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DialogFooter } from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ChartDialogFooterProps {
|
||||
onSaveClick: () => void;
|
||||
onAddToDashboardClick: () => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function ChartDialogFooter({
|
||||
onSaveClick,
|
||||
onAddToDashboardClick,
|
||||
isSaving,
|
||||
}: Readonly<ChartDialogFooterProps>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
<Button onClick={onSaveClick} disabled={isSaving}>
|
||||
<SaveIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.analysis.charts.save_chart")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
|
||||
interface ChartDialogLoadingViewProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChartDialogLoadingView({ open, onClose }: Readonly<ChartDialogLoadingViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent width="wide">
|
||||
<DialogTitle className="sr-only">{t("common.loading")}</DialogTitle>
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteChartAction, duplicateChartAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
|
||||
interface ChartDropdownMenuProps {
|
||||
environmentId: string;
|
||||
chart: TChartWithCreator;
|
||||
onEdit?: () => 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>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
"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;
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
"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
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
"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] ?? 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ChartTypeSelectorProps {
|
||||
selectedChartType: TChartType;
|
||||
onChartTypeSelect: (chartType: TChartType) => void;
|
||||
}
|
||||
|
||||
export function ChartTypeSelector({
|
||||
selectedChartType,
|
||||
onChartTypeSelect,
|
||||
}: Readonly<ChartTypeSelectorProps>) {
|
||||
const { t } = useTranslation();
|
||||
const chartTypes = getChartTypes(t);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-md font-semibold text-gray-900">
|
||||
{t("environments.analysis.charts.chart_builder_choose_chart_type")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{chartTypes.map((chart) => {
|
||||
const isSelected = selectedChartType === chart.id;
|
||||
return (
|
||||
<button
|
||||
key={chart.id}
|
||||
type="button"
|
||||
onClick={() => onChartTypeSelect(chart.id)}
|
||||
className={`rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 ${
|
||||
isSelected
|
||||
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}>
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
|
||||
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Delay } from "@suspensive/react";
|
||||
import { Suspense, use } from "react";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list";
|
||||
import { ChartsListSkeleton } from "@/modules/ee/analysis/charts/components/charts-list-skeleton";
|
||||
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
|
||||
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
|
||||
interface ChartsListContentProps {
|
||||
chartsPromise: Promise<TChartWithCreator[]>;
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
const ChartsListContent = ({
|
||||
chartsPromise,
|
||||
environmentId,
|
||||
isReadOnly,
|
||||
}: Readonly<ChartsListContentProps>) => {
|
||||
const charts = use(chartsPromise);
|
||||
|
||||
return <ChartsList charts={charts} environmentId={environmentId} isReadOnly={isReadOnly} />;
|
||||
};
|
||||
|
||||
interface ChartsListPageProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export async function ChartsListPage({ environmentId }: Readonly<ChartsListPageProps>) {
|
||||
const t = await getTranslate();
|
||||
const { project, isReadOnly } = await getEnvironmentAuth(environmentId);
|
||||
const chartsPromise = getChartsWithCreator(project.id);
|
||||
|
||||
return (
|
||||
<AnalysisPageLayout
|
||||
pageTitle={t("common.analysis")}
|
||||
environmentId={environmentId}
|
||||
cta={isReadOnly ? undefined : <CreateChartButton environmentId={environmentId} />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Delay ms={200}>
|
||||
<ChartsListSkeleton
|
||||
columnHeaders={[
|
||||
t("common.title"),
|
||||
t("common.created_by"),
|
||||
t("common.created_at"),
|
||||
t("common.updated_at"),
|
||||
]}
|
||||
/>
|
||||
</Delay>
|
||||
}>
|
||||
<ChartsListContent
|
||||
chartsPromise={chartsPromise}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</Suspense>
|
||||
</AnalysisPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
const SKELETON_ROWS = 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>
|
||||
);
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface CreateChartButtonProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export function CreateChartButton({ environmentId }: Readonly<CreateChartButtonProps>) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setIsDialogOpen(true)}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.analysis.charts.create_chart")}
|
||||
</Button>
|
||||
<CreateChartDialog open={isDialogOpen} onOpenChange={setIsDialogOpen} environmentId={environmentId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
"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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
|
||||
import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query-section";
|
||||
import { 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" disableCloseOnOutsideClick>
|
||||
<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-white px-2 text-sm text-gray-500">
|
||||
{t("environments.analysis.charts.OR")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ManualChartBuilder
|
||||
selectedChartType={selectedChartType}
|
||||
onChartTypeSelect={handleChartTypeChange}
|
||||
/>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { DatabaseIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatCellValue } from "@/modules/ee/analysis/charts/lib/chart-utils";
|
||||
import { formatCubeColumnHeader } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FEEDBACK_FIELDS, getTranslatedFieldLabel } from "@/modules/ee/analysis/lib/schema-definition";
|
||||
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
|
||||
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")}
|
||||
/>
|
||||
<Alert variant="info" size="small">
|
||||
<AlertTitle>{t("environments.analysis.charts.group_by_description")}</AlertTitle>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
|
||||
import { 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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Plus, TrashIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FilterRow, TFilterFieldType } from "@/modules/ee/analysis/lib/query-builder";
|
||||
import {
|
||||
FEEDBACK_FIELDS,
|
||||
getFieldById,
|
||||
getFilterOperatorsForType,
|
||||
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-start" : "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("environments.analysis.charts.and_filter_logic")}</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>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getChartTypes } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface ManualChartBuilderProps {
|
||||
selectedChartType?: TChartType;
|
||||
onChartTypeSelect: (type: TChartType) => void;
|
||||
}
|
||||
|
||||
export function ManualChartBuilder({
|
||||
selectedChartType,
|
||||
onChartTypeSelect,
|
||||
}: Readonly<ManualChartBuilderProps>) {
|
||||
const { t } = useTranslation();
|
||||
const chartTypes = getChartTypes(t);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{chartTypes.map((chart) => {
|
||||
const isSelected = selectedChartType === chart.id;
|
||||
return (
|
||||
<button
|
||||
key={chart.id}
|
||||
type="button"
|
||||
onClick={() => onChartTypeSelect(chart.id)}
|
||||
className={cn(
|
||||
"focus:ring-brand-dark rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2",
|
||||
isSelected
|
||||
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
)}>
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
|
||||
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">{chart.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface SaveChartDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
chartName: string;
|
||||
onChartNameChange: (name: string) => void;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function SaveChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
chartName,
|
||||
onChartNameChange,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: Readonly<SaveChartDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.analysis.charts.save_chart_dialog_title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.analysis.charts.enter_a_name_for_your_chart")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import Calendar from "react-calendar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TimeDimensionConfig } from "@/modules/ee/analysis/lib/query-builder";
|
||||
import {
|
||||
DATE_PRESETS,
|
||||
FEEDBACK_FIELDS,
|
||||
TIME_GRANULARITIES,
|
||||
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",
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
createChartAction,
|
||||
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,
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
"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 };
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { CHART_TYPE_ICONS, getChartTypes } from "./chart-types";
|
||||
|
||||
describe("chart-types", () => {
|
||||
test("CHART_TYPE_ICONS has all chart types", () => {
|
||||
expect(Object.keys(CHART_TYPE_ICONS)).toEqual(["area", "bar", "line", "pie", "big_number"]);
|
||||
});
|
||||
|
||||
test("getChartTypes returns chart types with translated labels", () => {
|
||||
const t = vi.fn((key: string) => key);
|
||||
const result = getChartTypes(t);
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result.map((r) => r.id)).toEqual(["area", "bar", "line", "pie", "big_number"]);
|
||||
expect(t).toHaveBeenCalledWith("environments.analysis.charts.chart_type_area");
|
||||
expect(result[0].label).toBe("environments.analysis.charts.chart_type_area");
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { ActivityIcon, AreaChartIcon, BarChart3Icon, LineChartIcon, PieChartIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import type { TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export const 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"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { format, isValid, parseISO } from "date-fns";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
import { ZChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export const CHART_BRAND_DARK = "#00C4B8";
|
||||
export const CHART_BRAND_LIGHT = "#00E6CA";
|
||||
|
||||
/** 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(", ")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
var mockTxChart: {
|
||||
// NOSONAR S1135 - var required for vi.mock hoisting
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
vi.mock("@formbricks/database", () => {
|
||||
const tx = {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
mockTxChart = tx;
|
||||
return {
|
||||
prisma: {
|
||||
chart: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn((cb: any) => cb({ chart: tx })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: () => Promise.resolve({ project: { id: "project-abc-123" } }),
|
||||
}));
|
||||
|
||||
const mockChartId = "chart-abc-123";
|
||||
const mockProjectId = "project-abc-123";
|
||||
const mockUserId = "user-abc-123";
|
||||
|
||||
const selectChart = {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
};
|
||||
|
||||
const mockChart = {
|
||||
id: mockChartId,
|
||||
name: "Test Chart",
|
||||
type: "bar",
|
||||
query: { measures: ["Responses.count"] },
|
||||
config: { showLegend: true },
|
||||
createdAt: new Date("2025-01-01"),
|
||||
updatedAt: new Date("2025-01-01"),
|
||||
};
|
||||
|
||||
const makePrismaError = (code: string) =>
|
||||
new Prisma.PrismaClientKnownRequestError("mock error", { code, clientVersion: "5.0.0" });
|
||||
|
||||
describe("Chart Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("createChart", () => {
|
||||
test("creates a chart successfully", async () => {
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue(mockChart as any);
|
||||
const { createChart } = await import("./charts");
|
||||
|
||||
const result = await createChart({
|
||||
projectId: mockProjectId,
|
||||
name: "Test Chart",
|
||||
type: "bar",
|
||||
query: { measures: ["Responses.count"] },
|
||||
config: { showLegend: true },
|
||||
createdBy: mockUserId,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockChart);
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Test Chart",
|
||||
type: "bar",
|
||||
projectId: mockProjectId,
|
||||
query: { measures: ["Responses.count"] },
|
||||
config: { showLegend: true },
|
||||
createdBy: mockUserId,
|
||||
},
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
vi.mocked(prisma.chart.create).mockRejectedValue(
|
||||
makePrismaError(PrismaErrorType.UniqueConstraintViolation)
|
||||
);
|
||||
const { createChart } = await import("./charts");
|
||||
|
||||
await expect(
|
||||
createChart({
|
||||
projectId: mockProjectId,
|
||||
name: "Duplicate",
|
||||
type: "bar",
|
||||
query: {},
|
||||
config: {},
|
||||
createdBy: mockUserId,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on other Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.create).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { createChart } = await import("./charts");
|
||||
|
||||
await expect(
|
||||
createChart({
|
||||
projectId: mockProjectId,
|
||||
name: "Test",
|
||||
type: "bar",
|
||||
query: {},
|
||||
config: {},
|
||||
createdBy: mockUserId,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateChart", () => {
|
||||
test("updates a chart successfully", async () => {
|
||||
const updatedChart = { ...mockChart, name: "Updated Chart" };
|
||||
mockTxChart.findFirst.mockResolvedValue(mockChart);
|
||||
mockTxChart.update.mockResolvedValue(updatedChart);
|
||||
const { updateChart } = await import("./charts");
|
||||
|
||||
const result = await updateChart(mockChartId, mockProjectId, { name: "Updated Chart" });
|
||||
|
||||
expect(result).toEqual({ chart: mockChart, updatedChart });
|
||||
expect(mockTxChart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, projectId: mockProjectId },
|
||||
select: selectChart,
|
||||
});
|
||||
expect(mockTxChart.update).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId },
|
||||
data: { name: "Updated Chart", type: undefined, query: undefined, config: undefined },
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when chart does not exist", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(null);
|
||||
const { updateChart } = await import("./charts");
|
||||
|
||||
await expect(updateChart(mockChartId, mockProjectId, { name: "Updated" })).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
expect(mockTxChart.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError on unique constraint violation", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(mockChart);
|
||||
mockTxChart.update.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart }));
|
||||
const { updateChart } = await import("./charts");
|
||||
|
||||
await expect(updateChart(mockChartId, mockProjectId, { name: "Taken Name" })).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateChart", () => {
|
||||
test("duplicates a chart with '(copy)' suffix", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({ ...mockChart, name: "Test Chart (copy)" } as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockProjectId, mockUserId);
|
||||
|
||||
expect(prisma.chart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, projectId: mockProjectId },
|
||||
select: selectChart,
|
||||
});
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ name: "Test Chart (copy)" }),
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("increments copy number when '(copy)' already exists", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([{ name: "Test Chart (copy)" }] as any);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({
|
||||
...mockChart,
|
||||
name: "Test Chart (copy 2)",
|
||||
} as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockProjectId, mockUserId);
|
||||
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ name: "Test Chart (copy 2)" }),
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("finds next available copy number", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([
|
||||
{ name: "Test Chart (copy)" },
|
||||
{ name: "Test Chart (copy 2)" },
|
||||
] as any);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({
|
||||
...mockChart,
|
||||
name: "Test Chart (copy 3)",
|
||||
} as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockProjectId, mockUserId);
|
||||
|
||||
expect(prisma.chart.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ name: "Test Chart (copy 3)" }),
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("strips existing copy suffix before generating new name", async () => {
|
||||
const chartWithCopy = { ...mockChart, name: "Test Chart (copy)" };
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(chartWithCopy as any);
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([{ name: "Test Chart (copy)" }] as any);
|
||||
vi.mocked(prisma.chart.create).mockResolvedValue({
|
||||
...mockChart,
|
||||
name: "Test Chart (copy 2)",
|
||||
} as any);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await duplicateChart(mockChartId, mockProjectId, mockUserId);
|
||||
|
||||
expect(prisma.chart.findMany).toHaveBeenCalledWith({
|
||||
where: { projectId: mockProjectId, name: { startsWith: "Test Chart (copy" } },
|
||||
select: { name: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when source chart does not exist", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(null);
|
||||
const { duplicateChart } = await import("./charts");
|
||||
|
||||
await expect(duplicateChart(mockChartId, mockProjectId, mockUserId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteChart", () => {
|
||||
test("deletes a chart successfully", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(mockChart);
|
||||
mockTxChart.delete.mockResolvedValue(undefined);
|
||||
const { deleteChart } = await import("./charts");
|
||||
|
||||
const result = await deleteChart(mockChartId, mockProjectId);
|
||||
|
||||
expect(result).toEqual(mockChart);
|
||||
expect(mockTxChart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, projectId: mockProjectId },
|
||||
select: selectChart,
|
||||
});
|
||||
expect(mockTxChart.delete).toHaveBeenCalledWith({ where: { id: mockChartId } });
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when chart does not exist", async () => {
|
||||
mockTxChart.findFirst.mockResolvedValue(null);
|
||||
const { deleteChart } = await import("./charts");
|
||||
|
||||
await expect(deleteChart(mockChartId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
expect(mockTxChart.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
mockTxChart.findFirst.mockRejectedValue(makePrismaError("P9999"));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart }));
|
||||
const { deleteChart } = await import("./charts");
|
||||
|
||||
await expect(deleteChart(mockChartId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChart", () => {
|
||||
test("returns a chart successfully", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
|
||||
const { getChart } = await import("./charts");
|
||||
|
||||
const result = await getChart(mockChartId, mockProjectId);
|
||||
|
||||
expect(result).toEqual(mockChart);
|
||||
expect(prisma.chart.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockChartId, projectId: mockProjectId },
|
||||
select: selectChart,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when chart does not exist", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockResolvedValue(null);
|
||||
const { getChart } = await import("./charts");
|
||||
|
||||
await expect(getChart(mockChartId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "Chart",
|
||||
resourceId: mockChartId,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.findFirst).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getChart } = await import("./charts");
|
||||
|
||||
await expect(getChart(mockChartId, mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCharts", () => {
|
||||
test("returns all charts for a project", async () => {
|
||||
const chartsFromDb = [
|
||||
{ ...mockChart, creator: { name: "User 1" } },
|
||||
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: { name: null } },
|
||||
];
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue(chartsFromDb as any);
|
||||
const { getCharts } = await import("./charts");
|
||||
|
||||
const result = await getCharts(mockProjectId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ ...mockChart, creator: { name: "User 1" } },
|
||||
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: { name: null } },
|
||||
]);
|
||||
expect(prisma.chart.findMany).toHaveBeenCalledWith({
|
||||
where: { projectId: mockProjectId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
creator: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no charts exist", async () => {
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue([]);
|
||||
const { getCharts } = await import("./charts");
|
||||
|
||||
const result = await getCharts(mockProjectId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.findMany).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getCharts } = await import("./charts");
|
||||
|
||||
await expect(getCharts(mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getChartsWithCreator", () => {
|
||||
test("returns charts with creator info", async () => {
|
||||
const chartsWithCreator = [
|
||||
{ ...mockChart, creator: { name: "Alice" } },
|
||||
{ ...mockChart, id: "chart-2", name: "Chart 2", creator: null },
|
||||
];
|
||||
vi.mocked(prisma.chart.findMany).mockResolvedValue(chartsWithCreator as any);
|
||||
const { getChartsWithCreator } = await import("./charts");
|
||||
|
||||
const result = await getChartsWithCreator(mockProjectId);
|
||||
|
||||
expect(result).toEqual(chartsWithCreator);
|
||||
expect(prisma.chart.findMany).toHaveBeenCalledWith({
|
||||
where: { projectId: mockProjectId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: expect.objectContaining({
|
||||
creator: { select: { name: true } },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma errors", async () => {
|
||||
vi.mocked(prisma.chart.findMany).mockRejectedValue(makePrismaError("P9999"));
|
||||
const { getChartsWithCreator } = await import("./charts");
|
||||
|
||||
await expect(getChartsWithCreator(mockProjectId)).rejects.toMatchObject({
|
||||
name: "DatabaseError",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,275 +0,0 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZChartConfig, ZChartQuery } from "@formbricks/types/analysis";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import {
|
||||
TChart,
|
||||
TChartCreateInput,
|
||||
TChartUpdateInput,
|
||||
TChartWithCreator,
|
||||
ZChartCreateInput,
|
||||
ZChartType,
|
||||
ZChartUpdateInput,
|
||||
} from "@/modules/ee/analysis/types/analysis";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
|
||||
export const selectChart = {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
query: true,
|
||||
config: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} as const;
|
||||
|
||||
export const createChart = async (data: TChartCreateInput): Promise<TChart> => {
|
||||
validateInputs([data, ZChartCreateInput]);
|
||||
|
||||
try {
|
||||
return await prisma.chart.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
projectId: data.projectId,
|
||||
query: data.query,
|
||||
config: data.config,
|
||||
createdBy: data.createdBy,
|
||||
},
|
||||
select: selectChart,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("A chart with this name already exists");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const updateChart = async (
|
||||
chartId: string,
|
||||
projectId: string,
|
||||
data: TChartUpdateInput
|
||||
): Promise<{ chart: TChart; updatedChart: TChart }> => {
|
||||
validateInputs([chartId, ZId], [projectId, ZId], [data, ZChartUpdateInput]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const chart = await tx.chart.findFirst({
|
||||
where: { id: chartId, projectId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!chart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
const updatedChart = await tx.chart.update({
|
||||
where: { id: chartId },
|
||||
data: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
query: data.query,
|
||||
config: data.config,
|
||||
},
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
return { chart, updatedChart };
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("A chart with this name already exists");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getUniqueCopyName = async (baseName: string, projectId: string): Promise<string> => {
|
||||
const stripped = baseName.replace(/ \(copy(?: \d+)?\)$/, "");
|
||||
|
||||
try {
|
||||
const existing = await prisma.chart.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
name: { startsWith: `${stripped} (copy` },
|
||||
},
|
||||
select: { name: true },
|
||||
});
|
||||
|
||||
const existingNames = new Set(existing.map((c) => c.name));
|
||||
|
||||
const firstCandidate = `${stripped} (copy)`;
|
||||
if (!existingNames.has(firstCandidate)) {
|
||||
return firstCandidate;
|
||||
}
|
||||
|
||||
let n = 2;
|
||||
while (existingNames.has(`${stripped} (copy ${n})`)) {
|
||||
n++;
|
||||
}
|
||||
return `${stripped} (copy ${n})`;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const duplicateChart = async (
|
||||
chartId: string,
|
||||
projectId: string,
|
||||
createdBy: string
|
||||
): Promise<TChart> => {
|
||||
validateInputs([chartId, ZId], [projectId, ZId], [createdBy, ZId]);
|
||||
|
||||
try {
|
||||
const sourceChart = await prisma.chart.findFirst({
|
||||
where: { id: chartId, projectId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!sourceChart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
const uniqueName = await getUniqueCopyName(sourceChart.name, projectId);
|
||||
|
||||
return await createChart({
|
||||
projectId,
|
||||
name: uniqueName,
|
||||
type: ZChartType.parse(sourceChart.type),
|
||||
query: ZChartQuery.parse(sourceChart.query),
|
||||
config: ZChartConfig.parse(sourceChart.config ?? {}),
|
||||
createdBy,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError || error instanceof InvalidInputError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteChart = async (chartId: string, projectId: string): Promise<TChart> => {
|
||||
validateInputs([chartId, ZId], [projectId, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const chart = await tx.chart.findFirst({
|
||||
where: { id: chartId, projectId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!chart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
await tx.chart.delete({
|
||||
where: { id: chartId },
|
||||
});
|
||||
|
||||
return chart;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getChart = async (chartId: string, projectId: string): Promise<TChart> => {
|
||||
validateInputs([chartId, ZId], [projectId, ZId]);
|
||||
|
||||
try {
|
||||
const chart = await prisma.chart.findFirst({
|
||||
where: { id: chartId, projectId },
|
||||
select: selectChart,
|
||||
});
|
||||
|
||||
if (!chart) {
|
||||
throw new ResourceNotFoundError("Chart", chartId);
|
||||
}
|
||||
|
||||
return chart;
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all charts for the given environment (for list/dashboard UI).
|
||||
* Uses getEnvironmentAuth for access check and enriches with creator names.
|
||||
*/
|
||||
export const getCharts = async (environmentId: string): Promise<TChartWithCreator[]> => {
|
||||
try {
|
||||
const { project } = await getEnvironmentAuth(environmentId);
|
||||
|
||||
const charts = await prisma.chart.findMany({
|
||||
where: { projectId: project.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
...selectChart,
|
||||
creator: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
return charts;
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getChartsWithCreator = async (projectId: string): Promise<TChartWithCreator[]> => {
|
||||
validateInputs([projectId, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.chart.findMany({
|
||||
where: { projectId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
...selectChart,
|
||||
creator: {
|
||||
select: { name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { AnalysisSecondaryNavigation } from "./analysis-secondary-navigation";
|
||||
|
||||
interface AnalysisPageLayoutProps {
|
||||
pageTitle: string;
|
||||
environmentId: string;
|
||||
cta?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AnalysisPageLayout({
|
||||
pageTitle,
|
||||
environmentId,
|
||||
cta,
|
||||
children,
|
||||
}: Readonly<AnalysisPageLayoutProps>) {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={pageTitle} cta={cta}>
|
||||
<AnalysisSecondaryNavigation environmentId={environmentId} />
|
||||
</PageHeader>
|
||||
{children}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface AnalysisSecondaryNavigationProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export function AnalysisSecondaryNavigation({ environmentId }: Readonly<AnalysisSecondaryNavigationProps>) {
|
||||
const { t } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
|
||||
const activeId = pathname?.includes("/charts") ? "charts" : "dashboards";
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
id: "dashboards",
|
||||
label: t("common.dashboards"),
|
||||
href: `/environments/${environmentId}/analysis/dashboards`,
|
||||
},
|
||||
{
|
||||
id: "charts",
|
||||
label: t("common.charts"),
|
||||
href: `/environments/${environmentId}/analysis/charts`,
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} />;
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
"use server";
|
||||
|
||||
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,
|
||||
updateWidgetLayouts,
|
||||
} from "./lib/dashboards";
|
||||
|
||||
const ZCreateDashboardAction = z.object({
|
||||
environmentId: ZId,
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
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,
|
||||
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,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.dashboardId = parsedInput.dashboardId;
|
||||
ctx.auditLoggingCtx.oldObject = dashboard;
|
||||
ctx.auditLoggingCtx.newObject = updatedDashboard;
|
||||
return updatedDashboard;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateWidgetLayoutsAction = z.object({
|
||||
environmentId: ZId,
|
||||
dashboardId: ZId,
|
||||
widgets: z
|
||||
.array(
|
||||
z.object({
|
||||
id: ZId,
|
||||
layout: ZWidgetLayout,
|
||||
order: z.number().int().nonnegative(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
|
||||
export const updateWidgetLayoutsAction = authenticatedActionClient.schema(ZUpdateWidgetLayoutsAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"dashboard",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateWidgetLayoutsAction>;
|
||||
}) => {
|
||||
const { organizationId, projectId } = await checkProjectAccess(
|
||||
ctx.user.id,
|
||||
parsedInput.environmentId,
|
||||
"readWrite"
|
||||
);
|
||||
|
||||
const dashboard = await getDashboard(parsedInput.dashboardId, projectId);
|
||||
|
||||
await updateWidgetLayouts(parsedInput.dashboardId, projectId, parsedInput.widgets);
|
||||
|
||||
const updatedDashboard = await getDashboard(parsedInput.dashboardId, projectId);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.dashboardId = parsedInput.dashboardId;
|
||||
ctx.auditLoggingCtx.oldObject = dashboard;
|
||||
ctx.auditLoggingCtx.newObject = updatedDashboard;
|
||||
return { ok: true };
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
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,
|
||||
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,
|
||||
layout: parsedInput.layout,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = projectId;
|
||||
ctx.auditLoggingCtx.dashboardWidgetId = widget.id;
|
||||
ctx.auditLoggingCtx.newObject = widget;
|
||||
return widget;
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1,162 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { getChartsAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import { addChartToDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
|
||||
interface AddExistingChartsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
environmentId: string;
|
||||
dashboardId: string;
|
||||
existingChartIds: string[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface ChartOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function AddExistingChartsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
environmentId,
|
||||
dashboardId,
|
||||
existingChartIds,
|
||||
onSuccess,
|
||||
}: Readonly<AddExistingChartsDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [chartOptions, setChartOptions] = useState<ChartOption[]>([]);
|
||||
const [selectedChartIds, setSelectedChartIds] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const loadCharts = async () => {
|
||||
setIsLoading(true);
|
||||
setSelectedChartIds([]);
|
||||
try {
|
||||
const result = await getChartsAction({ environmentId });
|
||||
if (result?.data) {
|
||||
const availableCharts = result.data.filter((chart) => !existingChartIds.includes(chart.id));
|
||||
setChartOptions(availableCharts.map((chart) => ({ value: chart.id, label: chart.name })));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("environments.analysis.dashboards.charts_load_failed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCharts();
|
||||
}, [open, environmentId, existingChartIds, t]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (selectedChartIds.length === 0) return;
|
||||
|
||||
setIsAdding(true);
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
selectedChartIds.map((chartId) => addChartToDashboardAction({ environmentId, chartId, dashboardId }))
|
||||
);
|
||||
|
||||
const fulfilled = results.filter(
|
||||
(r): r is PromiseFulfilledResult<Awaited<ReturnType<typeof addChartToDashboardAction>>> =>
|
||||
r.status === "fulfilled"
|
||||
);
|
||||
const rejected = results.filter((r): r is PromiseRejectedResult => r.status === "rejected");
|
||||
const successes = fulfilled.filter((r) => r.value?.data);
|
||||
const failures = fulfilled.filter((r) => !r.value?.data).length + rejected.length;
|
||||
|
||||
if (failures > 0) {
|
||||
if (successes.length > 0) {
|
||||
toast.error(t("environments.analysis.dashboards.charts_add_partial_failure", { count: failures }));
|
||||
} else {
|
||||
toast.error(t("environments.analysis.dashboards.charts_add_failed"));
|
||||
}
|
||||
} else {
|
||||
toast.success(
|
||||
t("environments.analysis.dashboards.charts_added_to_dashboard", {
|
||||
count: selectedChartIds.length,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (successes.length > 0) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("environments.analysis.dashboards.charts_add_failed"));
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("common.add_charts")}</DialogTitle>
|
||||
<DialogDescription>{t("common.add_existing_chart_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center rounded-md border px-3 py-2">
|
||||
<Loader2Icon className="h-5 w-5 animate-spin text-slate-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{chartOptions.length === 0 && (
|
||||
<Alert variant="info" className="mb-4">
|
||||
<AlertTitle>{t("environments.analysis.dashboards.no_charts_to_add_message")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("environments.analysis.dashboards.no_charts_available_description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<MultiSelect
|
||||
options={chartOptions}
|
||||
value={selectedChartIds}
|
||||
onChange={setSelectedChartIds}
|
||||
placeholder={t("common.search_charts")}
|
||||
disabled={chartOptions.length === 0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAdding}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleAdd} loading={isAdding} disabled={selectedChartIds.length === 0 || isAdding}>
|
||||
{selectedChartIds.length > 0
|
||||
? t("environments.analysis.dashboards.add_count_charts", { count: selectedChartIds.length })
|
||||
: t("common.add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import { CreateDashboardDialog } from "@/modules/ee/analysis/dashboards/components/create-dashboard-dialog";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
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 [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsCreateDialogOpen(open);
|
||||
if (!open) {
|
||||
setDashboardName("");
|
||||
}
|
||||
};
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
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 size="sm" 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}
|
||||
onCreate={handleCreate}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface CreateDashboardDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
dashboardName: string;
|
||||
onDashboardNameChange: (name: string) => void;
|
||||
onCreate: () => void;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
export const CreateDashboardDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
dashboardName,
|
||||
onDashboardNameChange,
|
||||
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>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isCreating}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isCreating} disabled={!dashboardName.trim()}>
|
||||
{t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, PencilIcon, PlusIcon, RefreshCwIcon, TrashIcon, XIcon } 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 { deleteDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import { AddExistingChartsDialog } from "@/modules/ee/analysis/dashboards/components/add-existing-charts-dialog";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
|
||||
interface DashboardControlBarProps {
|
||||
environmentId: string;
|
||||
dashboardId: string;
|
||||
existingChartIds: string[];
|
||||
isEditing: boolean;
|
||||
isSaving: boolean;
|
||||
hasChanges: boolean;
|
||||
isReadOnly: boolean;
|
||||
onRefresh: () => void;
|
||||
onEditToggle: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const DashboardControlBar = ({
|
||||
environmentId,
|
||||
dashboardId,
|
||||
existingChartIds,
|
||||
isEditing,
|
||||
isSaving,
|
||||
hasChanges,
|
||||
isReadOnly,
|
||||
onRefresh,
|
||||
onEditToggle,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: Readonly<DashboardControlBarProps>) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isAddExistingDialogOpen, setIsAddExistingDialogOpen] = useState(false);
|
||||
|
||||
const handleDeleteDashboard = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteDashboardAction({ environmentId, dashboardId });
|
||||
if (result?.data) {
|
||||
router.push(`/environments/${environmentId}/analysis/dashboards`);
|
||||
toast.success(t("environments.analysis.dashboards.delete_success"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("environments.analysis.dashboards.delete_failed"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const editModeActions = [
|
||||
{
|
||||
icon: CheckIcon,
|
||||
tooltip: hasChanges ? t("common.save") : t("common.no_changes"),
|
||||
onClick: onSave,
|
||||
isVisible: true,
|
||||
isLoading: isSaving,
|
||||
disabled: isSaving || !hasChanges,
|
||||
},
|
||||
{
|
||||
icon: XIcon,
|
||||
tooltip: t("common.cancel"),
|
||||
onClick: onCancel,
|
||||
isVisible: true,
|
||||
disabled: isSaving,
|
||||
},
|
||||
];
|
||||
|
||||
const viewModeActions = [
|
||||
{
|
||||
icon: PlusIcon,
|
||||
tooltip: t("common.add_charts"),
|
||||
onClick: () => setIsAddExistingDialogOpen(true),
|
||||
isVisible: !isReadOnly,
|
||||
},
|
||||
{
|
||||
icon: RefreshCwIcon,
|
||||
tooltip: t("common.refresh"),
|
||||
onClick: onRefresh,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
icon: PencilIcon,
|
||||
tooltip: t("common.edit"),
|
||||
onClick: onEditToggle,
|
||||
isVisible: !isReadOnly,
|
||||
},
|
||||
{
|
||||
icon: TrashIcon,
|
||||
tooltip: t("common.delete"),
|
||||
onClick: () => setDeleteDialogOpen(true),
|
||||
isVisible: !isReadOnly,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconBar actions={isEditing ? editModeActions : viewModeActions} />
|
||||
<DeleteDialog
|
||||
deleteWhat={t("environments.analysis.dashboards.dashboard")}
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setDeleteDialogOpen}
|
||||
onDelete={handleDeleteDashboard}
|
||||
text={t("environments.analysis.dashboards.dashboard_delete_confirmation")}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
<AddExistingChartsDialog
|
||||
open={isAddExistingDialogOpen}
|
||||
onOpenChange={setIsAddExistingDialogOpen}
|
||||
environmentId={environmentId}
|
||||
dashboardId={dashboardId}
|
||||
existingChartIds={existingChartIds}
|
||||
onSuccess={() => {
|
||||
setIsAddExistingDialogOpen(false);
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,316 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Delay } from "@suspensive/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Suspense, memo, useCallback, useMemo, useState, useTransition } from "react";
|
||||
import { ResponsiveGridLayout, useContainerWidth, verticalCompactor } from "react-grid-layout";
|
||||
import type { Layout, LayoutItem } from "react-grid-layout";
|
||||
import "react-grid-layout/css/styles.css";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "react-resizable/css/styles.css";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { DashboardControlBar } from "@/modules/ee/analysis/dashboards/components/dashboard-control-bar";
|
||||
import { DashboardPageHeader } from "@/modules/ee/analysis/dashboards/components/dashboard-page-header";
|
||||
import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/dashboard-widget";
|
||||
import { DashboardWidgetData } from "@/modules/ee/analysis/dashboards/components/dashboard-widget-data";
|
||||
import { DashboardWidgetSkeleton } from "@/modules/ee/analysis/dashboards/components/dashboard-widget-skeleton";
|
||||
import type { TChartDataRow, TDashboardDetail, TDashboardWidget } from "@/modules/ee/analysis/types/analysis";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { updateDashboardAction, updateWidgetLayoutsAction } from "../actions";
|
||||
|
||||
const ROW_HEIGHT = 80;
|
||||
|
||||
interface DashboardDetailClientProps {
|
||||
environmentId: string;
|
||||
dashboard: TDashboardDetail;
|
||||
widgetDataPromises: Map<string, Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>>;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
const widgetsToLayout = (widgets: TDashboardWidget[]): LayoutItem[] => {
|
||||
return widgets.map((widget) => ({
|
||||
i: widget.id,
|
||||
x: widget.layout.x,
|
||||
y: widget.layout.y,
|
||||
w: widget.layout.w,
|
||||
h: widget.layout.h,
|
||||
minW: 2,
|
||||
minH: 2,
|
||||
maxW: 12,
|
||||
maxH: 8,
|
||||
}));
|
||||
};
|
||||
|
||||
const widgetLayoutsChanged = (current: TDashboardWidget[], original: TDashboardWidget[]): boolean => {
|
||||
if (current.length !== original.length) return true;
|
||||
return current.some((widget, i) => {
|
||||
const orig = original[i];
|
||||
return (
|
||||
widget.id !== orig.id ||
|
||||
widget.layout.x !== orig.layout.x ||
|
||||
widget.layout.y !== orig.layout.y ||
|
||||
widget.layout.w !== orig.layout.w ||
|
||||
widget.layout.h !== orig.layout.h ||
|
||||
widget.order !== orig.order
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const applyLayoutToWidgets = (widgets: TDashboardWidget[], newLayout: Layout): TDashboardWidget[] => {
|
||||
let changed = false;
|
||||
const updated = widgets.map((widget) => {
|
||||
const layoutItem = newLayout.find((l) => l.i === widget.id);
|
||||
if (!layoutItem) return widget;
|
||||
|
||||
if (
|
||||
widget.layout.x === layoutItem.x &&
|
||||
widget.layout.y === layoutItem.y &&
|
||||
widget.layout.w === layoutItem.w &&
|
||||
widget.layout.h === layoutItem.h
|
||||
) {
|
||||
return widget;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
return {
|
||||
...widget,
|
||||
layout: {
|
||||
x: layoutItem.x,
|
||||
y: layoutItem.y,
|
||||
w: layoutItem.w,
|
||||
h: layoutItem.h,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return changed ? updated : widgets;
|
||||
};
|
||||
|
||||
const MemoizedWidgetContent = memo(function WidgetContent({
|
||||
widget,
|
||||
dataPromise,
|
||||
}: Readonly<{
|
||||
widget: TDashboardWidget;
|
||||
dataPromise?: Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>;
|
||||
}>) {
|
||||
if (widget.chart && dataPromise) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Delay ms={200}>
|
||||
<DashboardWidgetSkeleton />
|
||||
</Delay>
|
||||
}>
|
||||
<DashboardWidgetData
|
||||
dataPromise={dataPromise}
|
||||
chartType={widget.chart.type}
|
||||
query={widget.chart.query}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
return <DashboardWidgetSkeleton />;
|
||||
});
|
||||
|
||||
const MemoizedWidgetItem = memo(function WidgetItem({
|
||||
widget,
|
||||
isEditing,
|
||||
dataPromise,
|
||||
onRemove,
|
||||
}: Readonly<{
|
||||
widget: TDashboardWidget;
|
||||
isEditing: boolean;
|
||||
dataPromise?: Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>;
|
||||
onRemove?: () => void;
|
||||
}>) {
|
||||
const title = widget.chart.name;
|
||||
|
||||
return (
|
||||
<DashboardWidget title={title} isEditing={isEditing} onRemove={onRemove}>
|
||||
<MemoizedWidgetContent widget={widget} dataPromise={dataPromise} />
|
||||
</DashboardWidget>
|
||||
);
|
||||
});
|
||||
|
||||
export function DashboardDetailClient({
|
||||
environmentId,
|
||||
dashboard,
|
||||
widgetDataPromises,
|
||||
isReadOnly,
|
||||
}: Readonly<DashboardDetailClientProps>) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { width, containerRef, mounted } = useContainerWidth();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [name, setName] = useState(dashboard.name);
|
||||
const [draftWidgets, setDraftWidgets] = useState<TDashboardWidget[] | null>(null);
|
||||
|
||||
const widgets = draftWidgets ?? dashboard.widgets;
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (name !== dashboard.name) return true;
|
||||
if (!draftWidgets) return false;
|
||||
return widgetLayoutsChanged(draftWidgets, dashboard.widgets);
|
||||
}, [name, draftWidgets, dashboard]);
|
||||
|
||||
const layout = useMemo(() => widgetsToLayout(widgets), [widgets]);
|
||||
|
||||
const handleInteractionEnd = useCallback(
|
||||
(finalLayout: Layout) => {
|
||||
setDraftWidgets((current) => applyLayoutToWidgets(current ?? dashboard.widgets, finalLayout));
|
||||
},
|
||||
[dashboard.widgets]
|
||||
);
|
||||
|
||||
const handleRemoveWidget = useCallback(
|
||||
(widgetId: string) => {
|
||||
setDraftWidgets((current) => (current ?? dashboard.widgets).filter((w) => w.id !== widgetId));
|
||||
},
|
||||
[dashboard.widgets]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setName(dashboard.name);
|
||||
setDraftWidgets(null);
|
||||
setIsEditing(false);
|
||||
}, [dashboard]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("environments.analysis.dashboards.dashboard_name_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
if (name !== dashboard.name) {
|
||||
const dashboardResult = await updateDashboardAction({
|
||||
environmentId,
|
||||
dashboardId: dashboard.id,
|
||||
name: name.trim(),
|
||||
});
|
||||
|
||||
if (!dashboardResult?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(dashboardResult);
|
||||
toast.error(errorMessage);
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (widgetLayoutsChanged(widgets, dashboard.widgets)) {
|
||||
const widgetUpdates = widgets.map((widget, i) => ({
|
||||
id: widget.id,
|
||||
layout: widget.layout,
|
||||
order: i,
|
||||
}));
|
||||
|
||||
const widgetsResult = await updateWidgetLayoutsAction({
|
||||
environmentId,
|
||||
dashboardId: dashboard.id,
|
||||
widgets: widgetUpdates,
|
||||
});
|
||||
|
||||
if (!widgetsResult?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(widgetsResult);
|
||||
toast.error(errorMessage);
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(t("environments.analysis.dashboards.dashboard_saved"));
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
setDraftWidgets(null);
|
||||
setIsEditing(false);
|
||||
});
|
||||
} catch {
|
||||
toast.error(t("environments.analysis.dashboards.dashboard_save_failed"));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [name, widgets, dashboard, environmentId, router, t, startTransition]);
|
||||
|
||||
const isEmpty = widgets.length === 0;
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`/environments/${environmentId}/analysis/dashboards`} />
|
||||
<DashboardPageHeader
|
||||
name={name}
|
||||
isEditing={isEditing}
|
||||
onNameChange={setName}
|
||||
cta={
|
||||
<DashboardControlBar
|
||||
environmentId={environmentId}
|
||||
dashboardId={dashboard.id}
|
||||
existingChartIds={widgets.map((w) => w.chartId)}
|
||||
isEditing={isEditing}
|
||||
isSaving={isSaving}
|
||||
hasChanges={hasChanges}
|
||||
isReadOnly={isReadOnly}
|
||||
onRefresh={() => router.refresh()}
|
||||
onEditToggle={() => {
|
||||
setDraftWidgets(dashboard.widgets);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<div ref={containerRef} className="w-full">
|
||||
{isEmpty ? (
|
||||
<EmptyState text={t("environments.analysis.dashboards.no_data_message")} />
|
||||
) : (
|
||||
mounted && (
|
||||
<ResponsiveGridLayout
|
||||
width={width}
|
||||
layouts={{ lg: layout }}
|
||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
margin={[16, 16]}
|
||||
dragConfig={{
|
||||
enabled: isEditing,
|
||||
handle: ".rgl-drag-handle",
|
||||
bounded: false,
|
||||
threshold: 5,
|
||||
}}
|
||||
resizeConfig={{
|
||||
enabled: isEditing,
|
||||
handles: ["n", "s", "e", "w", "ne", "nw", "se", "sw"],
|
||||
}}
|
||||
compactor={verticalCompactor}
|
||||
onDragStop={(finalLayout) => handleInteractionEnd(finalLayout)}
|
||||
onResizeStop={(finalLayout) => handleInteractionEnd(finalLayout)}>
|
||||
{widgets.map((widget) => (
|
||||
<div key={widget.id}>
|
||||
<MemoizedWidgetItem
|
||||
widget={widget}
|
||||
isEditing={isEditing}
|
||||
dataPromise={widgetDataPromises.get(widget.id)}
|
||||
onRemove={isEditing ? () => handleRemoveWidget(widget.id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { 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 {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} 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 {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} 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" />}
|
||||
onClick={() => {
|
||||
setIsDropDownOpen(false);
|
||||
router.push(`/environments/${environmentId}/analysis/dashboards/${dashboardId}`);
|
||||
}}>
|
||||
{t("common.edit")}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DashboardPageHeaderProps {
|
||||
name: string;
|
||||
isEditing: boolean;
|
||||
onNameChange: (name: string) => void;
|
||||
cta?: ReactNode;
|
||||
}
|
||||
|
||||
export function DashboardPageHeader({
|
||||
name,
|
||||
isEditing,
|
||||
onNameChange,
|
||||
cta,
|
||||
}: Readonly<DashboardPageHeaderProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border-b border-slate-200">
|
||||
<div className="flex items-center justify-between space-x-4 pb-4">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
className="focus:border-brand-dark w-full rounded-md border border-dashed border-slate-300 bg-transparent px-2 py-1 text-3xl font-bold text-slate-800 focus:outline-none focus:ring-0"
|
||||
aria-label={t("environments.analysis.dashboards.dashboard_name_placeholder")}
|
||||
placeholder={t("environments.analysis.dashboards.dashboard_name_placeholder")}
|
||||
/>
|
||||
) : (
|
||||
<h1 className="border border-transparent px-2 py-1 text-3xl font-bold text-slate-800">{name}</h1>
|
||||
)}
|
||||
{cta}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { ChartRenderer } from "@/modules/ee/analysis/charts/components/chart-renderer";
|
||||
import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
interface DashboardWidgetDataProps {
|
||||
dataPromise: Promise<{ data: TChartDataRow[] } | { error: string }>;
|
||||
chartType: TChartType;
|
||||
query: TChartQuery;
|
||||
}
|
||||
|
||||
export function DashboardWidgetData({ dataPromise, chartType, query }: Readonly<DashboardWidgetDataProps>) {
|
||||
const { t } = useTranslation();
|
||||
const result = use(dataPromise);
|
||||
|
||||
if ("error" in result) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-red-500">
|
||||
{t("environments.analysis.dashboards.failed_to_load_chart_data")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ChartRenderer chartType={chartType} data={result.data} query={query} />;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function DashboardWidgetSkeleton() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="border-t-brand-dark h-8 w-8 animate-spin rounded-full border-2 border-gray-200" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MoreVerticalIcon, TrashIcon } from "lucide-react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
|
||||
interface DashboardWidgetProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
isEditing?: boolean;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export function DashboardWidget({ title, children, isEditing, onRemove }: Readonly<DashboardWidgetProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col rounded-lg border border-gray-200 bg-white shadow-sm ring-2 ring-transparent",
|
||||
isEditing && "ring-brand-dark/20 hover:ring-brand-dark/40 transition-shadow"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 items-center justify-between border-b border-gray-100 px-4",
|
||||
isEditing && "rgl-drag-handle cursor-grab active:cursor-grabbing"
|
||||
)}>
|
||||
<h3 className="flex-1 truncate text-sm font-semibold text-gray-800">{title}</h3>
|
||||
{onRemove && (
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.more_options")}
|
||||
className="ml-2 shrink-0 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<MoreVerticalIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setMenuOpen(false);
|
||||
onRemove();
|
||||
}}
|
||||
className="text-red-600 focus:text-red-600">
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.remove")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex-1 overflow-hidden p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
const SKELETON_ROWS = 3;
|
||||
|
||||
const SkeletonRow = () => {
|
||||
return (
|
||||
<div className="grid h-12 w-full animate-pulse grid-cols-8 content-center">
|
||||
<div className="col-span-7 grid grid-cols-7 content-center p-2">
|
||||
<div className="col-span-3 flex items-center gap-4 pl-6">
|
||||
<div className="h-5 w-5 rounded bg-gray-200" />
|
||||
<div className="h-4 w-36 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-6 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-16 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-24 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden sm:flex sm:justify-center">
|
||||
<div className="h-4 w-20 rounded bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DashboardsListSkeletonProps {
|
||||
columnHeaders: string[];
|
||||
}
|
||||
|
||||
export const DashboardsListSkeleton = ({ columnHeaders }: Readonly<DashboardsListSkeletonProps>) => {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-8 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">{columnHeaders[0]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[1]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[2]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[3]}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{columnHeaders[4]}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{Array.from({ length: SKELETON_ROWS }).map((_, i) => (
|
||||
<SkeletonRow key={`skeleton-row-${String(i)}`} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,80 +0,0 @@
|
||||
import { BarChart3Icon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { convertDateString, timeSinceDate } from "@/lib/time";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { TDashboardWithCount } from "../../types/analysis";
|
||||
import { DashboardDropdownMenu } from "./dashboard-dropdown-menu";
|
||||
|
||||
interface DashboardsTableProps {
|
||||
dashboards: TDashboardWithCount[];
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const DashboardsTable = async ({
|
||||
dashboards,
|
||||
environmentId,
|
||||
isReadOnly,
|
||||
}: Readonly<DashboardsTableProps>) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-8 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-3 pl-6">{t("common.title")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.charts")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created_by")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.created")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.updated")}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{dashboards.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-slate-400">
|
||||
{t("environments.analysis.dashboards.no_dashboards_found")}
|
||||
</p>
|
||||
) : (
|
||||
dashboards.map((dashboard) => {
|
||||
return (
|
||||
<div
|
||||
key={dashboard.id}
|
||||
className="grid h-12 w-full grid-cols-8 content-center text-left transition-colors ease-in-out hover:bg-slate-100">
|
||||
<Link
|
||||
href={`/environments/${environmentId}/analysis/dashboards/${dashboard.id}`}
|
||||
className="col-span-7 grid cursor-pointer grid-cols-7 content-center p-2">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-8 flex-shrink-0 text-slate-500">
|
||||
<BarChart3Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="font-medium text-slate-900">{dashboard.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="text-slate-900">{dashboard._count.widgets}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
|
||||
<div className="text-slate-900">{dashboard.creator?.name || "-"}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
|
||||
<div className="text-slate-900">{convertDateString(dashboard.createdAt.toISOString())}</div>
|
||||
</div>
|
||||
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
|
||||
<div className="text-slate-900">{timeSinceDate(dashboard.updatedAt)}</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="col-span-1 my-auto flex items-center justify-end pr-6">
|
||||
{!isReadOnly && (
|
||||
<DashboardDropdownMenu
|
||||
environmentId={environmentId}
|
||||
dashboardId={dashboard.id}
|
||||
dashboardName={dashboard.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,574 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
var mockTxDashboard: {
|
||||
// NOSONAR / test code
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
var mockTxChart: { findFirst: ReturnType<typeof vi.fn> }; // NOSONAR / test code
|
||||
|
||||
var mockTxWidget: {
|
||||
// NOSONAR / test code
|
||||
aggregate: ReturnType<typeof vi.fn>;
|
||||
findMany: 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(), findMany: 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,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
createdBy: true,
|
||||
};
|
||||
|
||||
const mockDashboard = {
|
||||
id: mockDashboardId,
|
||||
name: "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",
|
||||
createdBy: mockUserId,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockDashboard);
|
||||
expect(prisma.dashboard.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Test Dashboard",
|
||||
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" },
|
||||
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,
|
||||
layout: { x: 0, y: 0, w: 4, h: 3 },
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: "widget-2",
|
||||
chartId: "chart-2",
|
||||
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)",
|
||||
projectId: mockProjectId,
|
||||
createdBy: mockUserId,
|
||||
widgets: {
|
||||
create: [
|
||||
{
|
||||
chartId: mockChartId,
|
||||
layout: { x: 0, y: 0, w: 4, h: 3 },
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
chartId: "chart-2",
|
||||
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,
|
||||
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.findMany.mockResolvedValue([]);
|
||||
mockTxWidget.create.mockResolvedValue(mockWidget);
|
||||
const { addChartToDashboard } = await import("./dashboards");
|
||||
|
||||
const result = await addChartToDashboard({
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
projectId: mockProjectId,
|
||||
layout: mockLayout,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockWidget);
|
||||
expect(mockTxWidget.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
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.findMany.mockResolvedValue([{ layout: { y: 0, h: 3 } }]);
|
||||
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.findMany.mockResolvedValue([]);
|
||||
mockTxWidget.create.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
|
||||
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
|
||||
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
|
||||
);
|
||||
const { addChartToDashboard } = await import("./dashboards");
|
||||
|
||||
await expect(
|
||||
addChartToDashboard({
|
||||
dashboardId: mockDashboardId,
|
||||
chartId: mockChartId,
|
||||
projectId: mockProjectId,
|
||||
layout: mockLayout,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,364 +0,0 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TWidgetLayout } 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 { 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,
|
||||
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,
|
||||
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,
|
||||
},
|
||||
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,
|
||||
projectId,
|
||||
createdBy,
|
||||
widgets: {
|
||||
create: source.widgets.map((widget) => ({
|
||||
chartId: widget.chartId,
|
||||
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 updateWidgetLayouts = async (
|
||||
dashboardId: string,
|
||||
projectId: string,
|
||||
widgets: { id: string; layout: TWidgetLayout; order: number }[]
|
||||
): Promise<{ widgetCount: number }> => {
|
||||
validateInputs([dashboardId, ZId], [projectId, ZId]);
|
||||
|
||||
try {
|
||||
return await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const dashboard = await tx.dashboard.findFirst({
|
||||
where: { id: dashboardId, projectId },
|
||||
include: { widgets: { select: { id: true } } },
|
||||
});
|
||||
|
||||
if (!dashboard) {
|
||||
throw new ResourceNotFoundError("Dashboard", dashboardId);
|
||||
}
|
||||
|
||||
const existingWidgetIds = new Set(dashboard.widgets.map((w) => w.id));
|
||||
const updatedWidgetIds = new Set(widgets.map((w) => w.id));
|
||||
const invalidIds = widgets.filter((w) => !existingWidgetIds.has(w.id)).map((w) => w.id);
|
||||
if (invalidIds.length > 0) {
|
||||
throw new InvalidInputError(`Invalid widget IDs: ${invalidIds.join(", ")}`);
|
||||
}
|
||||
|
||||
const removedWidgetIds = dashboard.widgets
|
||||
.filter((w) => !updatedWidgetIds.has(w.id))
|
||||
.map((w) => w.id);
|
||||
|
||||
await Promise.all([
|
||||
...widgets.map((widget) =>
|
||||
tx.dashboardWidget.update({
|
||||
where: { id: widget.id },
|
||||
data: {
|
||||
layout: widget.layout,
|
||||
order: widget.order,
|
||||
},
|
||||
})
|
||||
),
|
||||
...(removedWidgetIds.length > 0
|
||||
? [tx.dashboardWidget.deleteMany({ where: { id: { in: removedWidgetIds } } })]
|
||||
: []),
|
||||
]);
|
||||
|
||||
return { widgetCount: widgets.length };
|
||||
},
|
||||
{ isolationLevel: "Serializable" }
|
||||
);
|
||||
} 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 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, existingWidgets] = await Promise.all([
|
||||
tx.dashboardWidget.aggregate({
|
||||
where: { dashboardId: data.dashboardId },
|
||||
_max: { order: true },
|
||||
}),
|
||||
tx.dashboardWidget.findMany({
|
||||
where: { dashboardId: data.dashboardId },
|
||||
select: { layout: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const bottomY = existingWidgets.reduce((max, w) => {
|
||||
const layout =
|
||||
typeof w.layout === "object" && w.layout !== null
|
||||
? (w.layout as Partial<{ y: number; h: number }>)
|
||||
: {};
|
||||
return Math.max(max, (layout.y ?? 0) + (layout.h ?? 0));
|
||||
}, 0);
|
||||
|
||||
return tx.dashboardWidget.create({
|
||||
data: {
|
||||
dashboardId: data.dashboardId,
|
||||
chartId: data.chartId,
|
||||
layout: { ...data.layout, y: bottomY },
|
||||
order: (maxOrder._max.order ?? -1) + 1,
|
||||
},
|
||||
});
|
||||
},
|
||||
{ isolationLevel: "Serializable" }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
|
||||
throw new InvalidInputError("This chart is already on the dashboard");
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { executeQuery } from "@/modules/ee/analysis/api/lib/cube-client";
|
||||
import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { DashboardDetailClient } from "../components/dashboard-detail-client";
|
||||
import { getDashboard } from "../lib/dashboards";
|
||||
|
||||
interface WidgetQueryResult {
|
||||
data: TChartDataRow[];
|
||||
query: TChartQuery;
|
||||
}
|
||||
|
||||
async function executeWidgetQuery(query: TChartQuery): Promise<WidgetQueryResult | null> {
|
||||
try {
|
||||
const data = await executeQuery(query);
|
||||
return { data: Array.isArray(data) ? data : [], query };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function DashboardDetailPage({
|
||||
params,
|
||||
}: Readonly<{
|
||||
params: Promise<{ environmentId: string; dashboardId: string }>;
|
||||
}>) {
|
||||
const { environmentId, dashboardId } = await params;
|
||||
const { project, isReadOnly } = await getEnvironmentAuth(environmentId);
|
||||
|
||||
let dashboard;
|
||||
try {
|
||||
dashboard = await getDashboard(dashboardId, project.id);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return notFound();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const widgetDataPromises = new Map<string, Promise<WidgetQueryResult>>();
|
||||
const widgetsWithCharts = dashboard.widgets.filter(
|
||||
(w): w is typeof w & { chart: NonNullable<typeof w.chart> } => !!w.chart
|
||||
);
|
||||
const queryPromises = widgetsWithCharts.map((widget) => ({
|
||||
widgetId: widget.id,
|
||||
promise: executeWidgetQuery(widget.chart.query),
|
||||
}));
|
||||
const results = await Promise.all(queryPromises.map((q) => q.promise));
|
||||
queryPromises.forEach(({ widgetId }, i: number) => {
|
||||
const result = results[i];
|
||||
if (result) {
|
||||
widgetDataPromises.set(widgetId, Promise.resolve(result));
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<DashboardDetailClient
|
||||
environmentId={environmentId}
|
||||
dashboard={dashboard}
|
||||
widgetDataPromises={widgetDataPromises}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Delay } from "@suspensive/react";
|
||||
import { Suspense, use } from "react";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TDashboardWithCount } from "../../types/analysis";
|
||||
import { CreateDashboardButton } from "../components/create-dashboard-button";
|
||||
import { DashboardsListSkeleton } from "../components/dashboards-list-skeleton";
|
||||
import { DashboardsTable } from "../components/dashboards-table";
|
||||
import { getDashboards } from "../lib/dashboards";
|
||||
|
||||
interface DashboardsListContentProps {
|
||||
dashboardsPromise: Promise<TDashboardWithCount[]>;
|
||||
environmentId: string;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
const DashboardsListContent = ({
|
||||
dashboardsPromise,
|
||||
environmentId,
|
||||
isReadOnly,
|
||||
}: Readonly<DashboardsListContentProps>) => {
|
||||
const dashboards = use(dashboardsPromise);
|
||||
|
||||
return <DashboardsTable dashboards={dashboards} environmentId={environmentId} isReadOnly={isReadOnly} />;
|
||||
};
|
||||
|
||||
interface DashboardsListPageProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const DashboardsListPage = async ({ environmentId }: Readonly<DashboardsListPageProps>) => {
|
||||
const t = await getTranslate();
|
||||
const { project, isReadOnly } = await getEnvironmentAuth(environmentId);
|
||||
|
||||
const dashboardsPromise = getDashboards(project.id);
|
||||
|
||||
return (
|
||||
<AnalysisPageLayout
|
||||
pageTitle={t("common.analysis")}
|
||||
environmentId={environmentId}
|
||||
cta={isReadOnly ? undefined : <CreateDashboardButton environmentId={environmentId} />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Delay ms={200}>
|
||||
<DashboardsListSkeleton
|
||||
columnHeaders={[
|
||||
t("common.title"),
|
||||
t("common.charts"),
|
||||
t("common.created_by"),
|
||||
t("common.created"),
|
||||
t("common.updated"),
|
||||
]}
|
||||
/>
|
||||
</Delay>
|
||||
}>
|
||||
<DashboardsListContent
|
||||
dashboardsPromise={dashboardsPromise}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</Suspense>
|
||||
</AnalysisPageLayout>
|
||||
);
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const mockGetEnvironment = vi.fn();
|
||||
const mockGetOrganizationIdFromProjectId = vi.fn();
|
||||
const mockCheckAuthorizationUpdated = vi.fn();
|
||||
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getEnvironment: (...args: any[]) => mockGetEnvironment(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getOrganizationIdFromProjectId: (...args: any[]) => mockGetOrganizationIdFromProjectId(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: (...args: any[]) => mockCheckAuthorizationUpdated(...args),
|
||||
}));
|
||||
|
||||
const mockUserId = "user-abc-123";
|
||||
const mockEnvironmentId = "env-abc-123";
|
||||
const mockProjectId = "project-abc-123";
|
||||
const mockOrganizationId = "org-abc-123";
|
||||
|
||||
describe("checkProjectAccess", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns organizationId and projectId on successful access check", async () => {
|
||||
mockGetEnvironment.mockResolvedValue({ projectId: mockProjectId });
|
||||
mockGetOrganizationIdFromProjectId.mockResolvedValue(mockOrganizationId);
|
||||
mockCheckAuthorizationUpdated.mockResolvedValue(undefined);
|
||||
const { checkProjectAccess } = await import("./access");
|
||||
|
||||
const result = await checkProjectAccess(mockUserId, mockEnvironmentId, "readWrite");
|
||||
|
||||
expect(result).toEqual({ organizationId: mockOrganizationId, projectId: mockProjectId });
|
||||
expect(mockGetEnvironment).toHaveBeenCalledWith(mockEnvironmentId);
|
||||
expect(mockGetOrganizationIdFromProjectId).toHaveBeenCalledWith(mockProjectId);
|
||||
expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrganizationId,
|
||||
access: [
|
||||
{ type: "organization", roles: ["owner", "manager"] },
|
||||
{ type: "projectTeam", minPermission: "readWrite", projectId: mockProjectId },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when environment is not found", async () => {
|
||||
mockGetEnvironment.mockResolvedValue(null);
|
||||
const { checkProjectAccess } = await import("./access");
|
||||
|
||||
await expect(checkProjectAccess(mockUserId, mockEnvironmentId, "read")).rejects.toMatchObject({
|
||||
name: "ResourceNotFoundError",
|
||||
resourceType: "environment",
|
||||
resourceId: mockEnvironmentId,
|
||||
});
|
||||
expect(mockGetOrganizationIdFromProjectId).not.toHaveBeenCalled();
|
||||
expect(mockCheckAuthorizationUpdated).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("propagates authorization errors from checkAuthorizationUpdated", async () => {
|
||||
mockGetEnvironment.mockResolvedValue({ projectId: mockProjectId });
|
||||
mockGetOrganizationIdFromProjectId.mockResolvedValue(mockOrganizationId);
|
||||
mockCheckAuthorizationUpdated.mockRejectedValue(new Error("Unauthorized"));
|
||||
const { checkProjectAccess } = await import("./access");
|
||||
|
||||
await expect(checkProjectAccess(mockUserId, mockEnvironmentId, "manage")).rejects.toThrow("Unauthorized");
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import "server-only";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
|
||||
export const checkProjectAccess = async (
|
||||
userId: string,
|
||||
environmentId: string,
|
||||
minPermission: TTeamPermission
|
||||
) => {
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError("environment", environmentId);
|
||||
}
|
||||
|
||||
const projectId = environment.projectId;
|
||||
const organizationId = await getOrganizationIdFromProjectId(projectId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId,
|
||||
organizationId,
|
||||
access: [
|
||||
{ type: "organization", roles: ["owner", "manager"] },
|
||||
{ type: "projectTeam", minPermission, projectId },
|
||||
],
|
||||
});
|
||||
|
||||
return { organizationId, projectId };
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* Generates a system prompt for the AI chart query LLM.
|
||||
* Derived from FEEDBACK_FIELDS to keep schema and prompt in sync.
|
||||
*/
|
||||
import {
|
||||
DATE_PRESETS,
|
||||
FEEDBACK_FIELDS,
|
||||
FILTER_OPERATORS,
|
||||
type FieldDefinition,
|
||||
type MeasureDefinition,
|
||||
} from "./schema-definition";
|
||||
|
||||
const CUBE_NAME = "FeedbackRecords";
|
||||
|
||||
function formatMeasure(m: MeasureDefinition): string {
|
||||
const suffix = m.description ? ` (${m.description})` : "";
|
||||
return `- ${m.id}: ${m.label}${suffix}`;
|
||||
}
|
||||
|
||||
function formatDimension(d: FieldDefinition): string {
|
||||
const suffix = d.description ? ` (${d.description})` : "";
|
||||
return `- ${d.id}: ${d.label}${suffix}`;
|
||||
}
|
||||
|
||||
function formatOperators(): string {
|
||||
const lines = Object.entries(FILTER_OPERATORS).map(([type, ops]) => ` ${type}: ${ops.join(", ")}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function generateSchemaContext(): string {
|
||||
const measuresText = FEEDBACK_FIELDS.measures.map(formatMeasure).join("\n");
|
||||
const dimensionsText = FEEDBACK_FIELDS.dimensions.map(formatDimension).join("\n");
|
||||
const datePresetsText = DATE_PRESETS.map((p) => `"${p.value}"`).join(", ");
|
||||
const operatorsText = formatOperators();
|
||||
|
||||
return `You are an expert at converting natural language questions into Cube.js analytics queries.
|
||||
|
||||
## Available schema
|
||||
|
||||
### Measures (use these measure IDs in the query)
|
||||
${measuresText}
|
||||
|
||||
### Dimensions (use these dimension IDs in the query)
|
||||
${dimensionsText}
|
||||
|
||||
### Time dimension
|
||||
The time field is \`${CUBE_NAME}.collectedAt\`. Supported granularities: hour, day, week, month, quarter, year.
|
||||
Date range presets: ${datePresetsText}
|
||||
|
||||
### Filter operators by field type
|
||||
${operatorsText}
|
||||
|
||||
## Guidelines
|
||||
- Always include at least one measure. If unspecified, default to \`${CUBE_NAME}.count\`.
|
||||
- Use dimension IDs exactly as shown (e.g. \`FeedbackRecords.sentiment\`, \`FeedbackRecords.collectedAt\`).
|
||||
- For time-based filtering (date range only, no time grouping): add a timeDimension with dimension \`${CUBE_NAME}.collectedAt\` and dateRange. Do NOT include granularity (default is None / filter only).
|
||||
- For time-series or trend questions (e.g. "over time", "by day", "weekly", "monthly"): add a timeDimension with dimension, granularity (hour/day/week/month/quarter/year), and dateRange.
|
||||
- Choose the most appropriate chart type: bar, line, area, pie, or big_number (for single-number queries).
|
||||
- Filters must use the exact operator strings from the schema.`;
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
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 without granularity (filter only)", () => {
|
||||
const config: ChartBuilderState = {
|
||||
selectedMeasures: ["FeedbackRecords.count"],
|
||||
selectedDimensions: [],
|
||||
filters: [],
|
||||
filterLogic: "and",
|
||||
timeDimension: {
|
||||
dimension: "FeedbackRecords.collectedAt",
|
||||
dateRange: "last 30 days",
|
||||
},
|
||||
};
|
||||
const query = buildCubeQuery(config);
|
||||
expect(query.timeDimensions).toEqual([
|
||||
{ dimension: "FeedbackRecords.collectedAt", 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",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses time dimension without granularity (filter only)", () => {
|
||||
const query = {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
timeDimensions: [
|
||||
{
|
||||
dimension: "FeedbackRecords.collectedAt",
|
||||
dateRange: "last 30 days",
|
||||
},
|
||||
],
|
||||
};
|
||||
const state = parseQueryToState(query);
|
||||
expect(state.timeDimension).toEqual({
|
||||
dimension: "FeedbackRecords.collectedAt",
|
||||
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 }))
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,259 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { TWidgetLayout, 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: ZChartType,
|
||||
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),
|
||||
createdBy: ZId,
|
||||
});
|
||||
export type TDashboardCreateInput = z.infer<typeof ZDashboardCreateInput>;
|
||||
|
||||
export const ZDashboardUpdateInput = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
});
|
||||
export type TDashboardUpdateInput = z.infer<typeof ZDashboardUpdateInput>;
|
||||
|
||||
// ── Dashboard output type (matches selectDashboard) ─────────────────────────
|
||||
|
||||
export type TDashboard = {
|
||||
id: string;
|
||||
name: string;
|
||||
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,
|
||||
layout: ZWidgetLayout,
|
||||
});
|
||||
export type TAddWidgetInput = z.infer<typeof ZAddWidgetInput>;
|
||||
|
||||
// ── Widget output type (matches getDashboard widget include) ────────────────
|
||||
|
||||
export type TDashboardWidget = {
|
||||
id: string;
|
||||
dashboardId: string;
|
||||
chartId: string;
|
||||
layout: TWidgetLayout;
|
||||
order: number;
|
||||
chart: TChart;
|
||||
};
|
||||
|
||||
export type TDashboardDetail = TDashboard & {
|
||||
widgets: TDashboardWidget[];
|
||||
};
|
||||
// ── Charts UI (query execution, AI response) ─────────────────────────────────
|
||||
|
||||
/** Row from Cube.js tablePivot - keys are measure/dimension names, values are primitives */
|
||||
export type TChartDataRow = Record<string, string | number | null | boolean | undefined>;
|
||||
|
||||
export interface AnalyticsResponse {
|
||||
query: z.infer<typeof ZChartQuery>;
|
||||
chartType: TChartType;
|
||||
data?: TChartDataRow[];
|
||||
error?: string;
|
||||
}
|
||||
@@ -229,49 +229,4 @@ describe("withAuditLogging", () => {
|
||||
// Reset for other tests; clearAllMockHandles will also do this in the next beforeEach
|
||||
if (mutableConstants) mutableConstants.AUDIT_LOG_ENABLED = true;
|
||||
});
|
||||
|
||||
test("resolves targetId for chart target type", async () => {
|
||||
const chartCtx = {
|
||||
...mockCtxBase,
|
||||
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, chartId: "chart-1" },
|
||||
};
|
||||
const handlerImpl = vi.fn().mockResolvedValue("ok");
|
||||
const wrapped = OriginalHandler.withAuditLogging("created", "chart", handlerImpl);
|
||||
await wrapped({ ctx: chartCtx as any, parsedInput: mockParsedInput });
|
||||
await new Promise(setImmediate);
|
||||
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
|
||||
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
|
||||
expect(callArgs.target.type).toBe("chart");
|
||||
expect(callArgs.target.id).toBe("chart-1");
|
||||
});
|
||||
|
||||
test("resolves targetId for dashboard target type", async () => {
|
||||
const dashCtx = {
|
||||
...mockCtxBase,
|
||||
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, dashboardId: "dash-1" },
|
||||
};
|
||||
const handlerImpl = vi.fn().mockResolvedValue("ok");
|
||||
const wrapped = OriginalHandler.withAuditLogging("created", "dashboard", handlerImpl);
|
||||
await wrapped({ ctx: dashCtx as any, parsedInput: mockParsedInput });
|
||||
await new Promise(setImmediate);
|
||||
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
|
||||
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
|
||||
expect(callArgs.target.type).toBe("dashboard");
|
||||
expect(callArgs.target.id).toBe("dash-1");
|
||||
});
|
||||
|
||||
test("resolves targetId for dashboardWidget target type", async () => {
|
||||
const widgetCtx = {
|
||||
...mockCtxBase,
|
||||
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, dashboardWidgetId: "widget-1" },
|
||||
};
|
||||
const handlerImpl = vi.fn().mockResolvedValue("ok");
|
||||
const wrapped = OriginalHandler.withAuditLogging("created", "dashboardWidget", handlerImpl);
|
||||
await wrapped({ ctx: widgetCtx as any, parsedInput: mockParsedInput });
|
||||
await new Promise(setImmediate);
|
||||
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
|
||||
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
|
||||
expect(callArgs.target.type).toBe("dashboardWidget");
|
||||
expect(callArgs.target.id).toBe("widget-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,15 +292,6 @@ export const withAuditLogging = <TParsedInput = Record<string, unknown>, TResult
|
||||
case "quota":
|
||||
targetId = auditLoggingCtx.quotaId;
|
||||
break;
|
||||
case "chart":
|
||||
targetId = auditLoggingCtx.chartId;
|
||||
break;
|
||||
case "dashboard":
|
||||
targetId = auditLoggingCtx.dashboardId;
|
||||
break;
|
||||
case "dashboardWidget":
|
||||
targetId = auditLoggingCtx.dashboardWidgetId;
|
||||
break;
|
||||
default:
|
||||
targetId = UNKNOWN_DATA;
|
||||
break;
|
||||
|
||||
@@ -25,9 +25,6 @@ export const ZAuditTarget = z.enum([
|
||||
"integration",
|
||||
"file",
|
||||
"quota",
|
||||
"chart",
|
||||
"dashboard",
|
||||
"dashboardWidget",
|
||||
]);
|
||||
export const ZAuditAction = z.enum([
|
||||
"created",
|
||||
|
||||
@@ -52,41 +52,54 @@ export const getPersonSegmentIds = async (
|
||||
return [];
|
||||
}
|
||||
|
||||
// Phase 1: Build WHERE clauses sequentially to avoid connection pool contention.
|
||||
// segmentFilterToPrismaQuery can itself hit the DB (e.g. unmigrated-row checks),
|
||||
// so running all builds concurrently would saturate the pool.
|
||||
// Phase 1: Build all Prisma where clauses concurrently.
|
||||
// This converts segment filters into where clauses without per-contact DB queries.
|
||||
const segmentWithClauses = await Promise.all(
|
||||
segments.map(async (segment) => {
|
||||
const filters = segment.filters as TBaseFilters | null;
|
||||
|
||||
if (!filters || filters.length === 0) {
|
||||
return { segmentId: segment.id, whereClause: {} as Prisma.ContactWhereInput };
|
||||
}
|
||||
|
||||
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
|
||||
|
||||
if (!queryResult.ok) {
|
||||
logger.warn(
|
||||
{ segmentId: segment.id, environmentId, error: queryResult.error },
|
||||
"Failed to build Prisma query for segment"
|
||||
);
|
||||
return { segmentId: segment.id, whereClause: null };
|
||||
}
|
||||
|
||||
return { segmentId: segment.id, whereClause: queryResult.data.whereClause };
|
||||
})
|
||||
);
|
||||
|
||||
// Separate segments into: always-match (no filters), needs-DB-check, and failed-to-build
|
||||
const alwaysMatchIds: string[] = [];
|
||||
const dbChecks: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
|
||||
const toCheck: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
const filters = segment.filters as TBaseFilters;
|
||||
|
||||
if (!filters?.length) {
|
||||
alwaysMatchIds.push(segment.id);
|
||||
for (const item of segmentWithClauses) {
|
||||
if (item.whereClause === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
|
||||
|
||||
if (!queryResult.ok) {
|
||||
logger.warn(
|
||||
{ segmentId: segment.id, environmentId, error: queryResult.error },
|
||||
"Failed to build Prisma query for segment, skipping"
|
||||
);
|
||||
continue;
|
||||
if (Object.keys(item.whereClause).length === 0) {
|
||||
alwaysMatchIds.push(item.segmentId);
|
||||
} else {
|
||||
toCheck.push({ segmentId: item.segmentId, whereClause: item.whereClause });
|
||||
}
|
||||
|
||||
dbChecks.push({ segmentId: segment.id, whereClause: queryResult.data.whereClause });
|
||||
}
|
||||
|
||||
if (dbChecks.length === 0) {
|
||||
if (toCheck.length === 0) {
|
||||
return alwaysMatchIds;
|
||||
}
|
||||
|
||||
// Phase 2: Execute all membership checks in a single transaction.
|
||||
// Uses one connection instead of N concurrent ones, eliminating pool contention.
|
||||
const txResults = await prisma.$transaction(
|
||||
dbChecks.map(({ whereClause }) =>
|
||||
// Phase 2: Batch all contact-match checks into a single DB transaction.
|
||||
// Replaces N individual findFirst queries with one batched round-trip.
|
||||
const batchResults = await prisma.$transaction(
|
||||
toCheck.map(({ whereClause }) =>
|
||||
prisma.contact.findFirst({
|
||||
where: { id: contactId, ...whereClause },
|
||||
select: { id: true },
|
||||
@@ -94,12 +107,17 @@ export const getPersonSegmentIds = async (
|
||||
)
|
||||
);
|
||||
|
||||
const matchedIds = dbChecks.filter((_, i) => txResults[i] !== null).map(({ segmentId }) => segmentId);
|
||||
// Phase 3: Collect matching segment IDs
|
||||
const dbMatchIds = toCheck.filter((_, i) => batchResults[i] !== null).map(({ segmentId }) => segmentId);
|
||||
|
||||
return [...alwaysMatchIds, ...matchedIds];
|
||||
return [...alwaysMatchIds, ...dbMatchIds];
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ environmentId, contactId, error },
|
||||
{
|
||||
environmentId,
|
||||
contactId,
|
||||
error,
|
||||
},
|
||||
"Failed to get person segment IDs, returning empty array"
|
||||
);
|
||||
return [];
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
import { getPersonSegmentIds } from "./segments";
|
||||
import { getUserState } from "./user-state";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./segments", () => ({
|
||||
getPersonSegmentIds: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "test-environment-id";
|
||||
const mockUserId = "test-user-id";
|
||||
const mockContactId = "test-contact-id";
|
||||
const mockDevice = "desktop";
|
||||
|
||||
describe("getUserState", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return user state with empty responses and displays", async () => {
|
||||
const mockContactData = {
|
||||
id: mockContactId,
|
||||
responses: [],
|
||||
displays: [],
|
||||
};
|
||||
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
|
||||
vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment1"]);
|
||||
|
||||
const result = await getUserState({
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
contactId: mockContactId,
|
||||
device: mockDevice,
|
||||
});
|
||||
|
||||
expect(prisma.contact.findUniqueOrThrow).toHaveBeenCalledWith({
|
||||
where: { id: mockContactId },
|
||||
select: {
|
||||
id: true,
|
||||
responses: {
|
||||
select: { surveyId: true },
|
||||
},
|
||||
displays: {
|
||||
select: { surveyId: true, createdAt: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getPersonSegmentIds).toHaveBeenCalledWith(
|
||||
mockEnvironmentId,
|
||||
mockContactId,
|
||||
mockUserId,
|
||||
mockDevice
|
||||
);
|
||||
expect(result).toEqual<TJsPersonState["data"]>({
|
||||
contactId: mockContactId,
|
||||
userId: mockUserId,
|
||||
segments: ["segment1"],
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return user state with responses and displays, and sort displays by createdAt", async () => {
|
||||
const mockDate1 = new Date("2023-01-01T00:00:00.000Z");
|
||||
const mockDate2 = new Date("2023-01-02T00:00:00.000Z");
|
||||
|
||||
const mockContactData = {
|
||||
id: mockContactId,
|
||||
responses: [{ surveyId: "survey1" }, { surveyId: "survey2" }],
|
||||
displays: [
|
||||
{ surveyId: "survey4", createdAt: mockDate2 }, // most recent (already sorted by desc)
|
||||
{ surveyId: "survey3", createdAt: mockDate1 },
|
||||
],
|
||||
};
|
||||
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
|
||||
vi.mocked(getPersonSegmentIds).mockResolvedValue(["segment2", "segment3"]);
|
||||
|
||||
const result = await getUserState({
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
contactId: mockContactId,
|
||||
device: mockDevice,
|
||||
});
|
||||
|
||||
expect(result).toEqual<TJsPersonState["data"]>({
|
||||
contactId: mockContactId,
|
||||
userId: mockUserId,
|
||||
segments: ["segment2", "segment3"],
|
||||
displays: [
|
||||
{ surveyId: "survey4", createdAt: mockDate2 },
|
||||
{ surveyId: "survey3", createdAt: mockDate1 },
|
||||
],
|
||||
responses: ["survey1", "survey2"],
|
||||
lastDisplayAt: mockDate2,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle empty arrays from prisma", async () => {
|
||||
// This case tests with proper empty arrays instead of null
|
||||
const mockContactData = {
|
||||
id: mockContactId,
|
||||
responses: [],
|
||||
displays: [],
|
||||
};
|
||||
vi.mocked(prisma.contact.findUniqueOrThrow).mockResolvedValue(mockContactData as any);
|
||||
vi.mocked(getPersonSegmentIds).mockResolvedValue([]);
|
||||
|
||||
const result = await getUserState({
|
||||
environmentId: mockEnvironmentId,
|
||||
userId: mockUserId,
|
||||
contactId: mockContactId,
|
||||
device: mockDevice,
|
||||
});
|
||||
|
||||
expect(result).toEqual<TJsPersonState["data"]>({
|
||||
contactId: mockContactId,
|
||||
userId: mockUserId,
|
||||
segments: [],
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TJsPersonState } from "@formbricks/types/js";
|
||||
import { getPersonSegmentIds } from "./segments";
|
||||
|
||||
/**
|
||||
* Optimized single query to get all user state data
|
||||
* Replaces multiple separate queries with one efficient query
|
||||
*/
|
||||
const getUserStateDataOptimized = async (contactId: string) => {
|
||||
return prisma.contact.findUniqueOrThrow({
|
||||
where: { id: contactId },
|
||||
select: {
|
||||
id: true,
|
||||
responses: {
|
||||
select: { surveyId: true },
|
||||
},
|
||||
displays: {
|
||||
select: {
|
||||
surveyId: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Optimized user state fetcher without caching
|
||||
* Uses single database query and efficient data processing
|
||||
* NO CACHING - user state changes frequently with contact updates
|
||||
*
|
||||
* @param environmentId - The environment id
|
||||
* @param userId - The user id
|
||||
* @param device - The device type
|
||||
* @returns The person state
|
||||
* @throws {ValidationError} - If the input is invalid
|
||||
* @throws {ResourceNotFoundError} - If the environment or organization is not found
|
||||
*/
|
||||
export const getUserState = async ({
|
||||
environmentId,
|
||||
userId,
|
||||
contactId,
|
||||
device,
|
||||
}: {
|
||||
environmentId: string;
|
||||
userId: string;
|
||||
contactId: string;
|
||||
device: "phone" | "desktop";
|
||||
}): Promise<TJsPersonState["data"]> => {
|
||||
// Single optimized query for all contact data
|
||||
const contactData = await getUserStateDataOptimized(contactId);
|
||||
|
||||
// Get segments using Prisma-based evaluation (no attributes needed - fetched from DB)
|
||||
const segments = await getPersonSegmentIds(environmentId, contactId, userId, device);
|
||||
|
||||
// Process displays efficiently
|
||||
const displays = (contactData.displays ?? []).map((display) => ({
|
||||
surveyId: display.surveyId,
|
||||
createdAt: display.createdAt,
|
||||
}));
|
||||
|
||||
// Get latest display date
|
||||
const lastDisplayAt =
|
||||
contactData.displays && contactData.displays.length > 0 ? contactData.displays[0].createdAt : null;
|
||||
|
||||
// Process responses efficiently
|
||||
const responses = (contactData.responses ?? []).map((response) => response.surveyId);
|
||||
|
||||
const userState: TJsPersonState["data"] = {
|
||||
contactId,
|
||||
userId,
|
||||
segments,
|
||||
displays,
|
||||
responses,
|
||||
lastDisplayAt,
|
||||
};
|
||||
|
||||
return userState;
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getLocale } from "@/lingodotdev/language";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { ContactsPageLayout } from "@/modules/ee/contacts/components/contacts-page-layout";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -14,7 +13,7 @@ export const AttributesPage = async ({
|
||||
}) => {
|
||||
const params = await paramsProps;
|
||||
const locale = await getLocale();
|
||||
const t = await getTranslate();
|
||||
|
||||
const [{ isReadOnly }, contactAttributeKeys] = await Promise.all([
|
||||
getEnvironmentAuth(params.environmentId),
|
||||
getContactAttributeKeys(params.environmentId),
|
||||
@@ -24,7 +23,7 @@ export const AttributesPage = async ({
|
||||
|
||||
return (
|
||||
<ContactsPageLayout
|
||||
pageTitle={t("common.contacts")}
|
||||
pageTitle="Contacts"
|
||||
activeId="attributes"
|
||||
environmentId={params.environmentId}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user