diff --git a/.gitignore b/.gitignore index c0218e30e668..34cfd559132d 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ yarn-error.log* /playwright/.cache/ # project specific +.local/ packages/lib/uploads apps/web/public/js packages/database/generated diff --git a/apps/web/app/(app)/account/authorize/components/OAuthConsentActions.tsx b/apps/web/app/(app)/account/authorize/components/OAuthConsentActions.tsx index 629f022dbfd0..2bb0f517677f 100644 --- a/apps/web/app/(app)/account/authorize/components/OAuthConsentActions.tsx +++ b/apps/web/app/(app)/account/authorize/components/OAuthConsentActions.tsx @@ -5,6 +5,7 @@ import { toast } from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { authClient } from "@/modules/auth/lib/auth-client"; import { Button } from "@/modules/ui/components/button"; +import { resolveConsentRedirectUrl } from "../lib/consent-redirect"; export const OAuthConsentActions = () => { const { t } = useTranslation(); @@ -21,21 +22,14 @@ export const OAuthConsentActions = () => { return; } - const redirectUri = - response.data && - typeof response.data === "object" && - "redirect_uri" in response.data && - typeof response.data.redirect_uri === "string" && - response.data.redirect_uri.length > 0 - ? response.data.redirect_uri - : null; - - if (!redirectUri) { + // Better Auth returns { redirect: true, url } (both accept and deny) — not { redirect_uri }. + const redirectUrl = resolveConsentRedirectUrl(response.data); + if (!redirectUrl) { toast.error(t("auth.oauth.consent_failed")); return; } - window.location.href = redirectUri; + window.location.href = redirectUrl; } catch { toast.error(t("auth.oauth.consent_failed")); } finally { diff --git a/apps/web/app/(app)/account/authorize/lib/consent-redirect.test.ts b/apps/web/app/(app)/account/authorize/lib/consent-redirect.test.ts new file mode 100644 index 000000000000..2a3c329de2c9 --- /dev/null +++ b/apps/web/app/(app)/account/authorize/lib/consent-redirect.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "vitest"; +import { resolveConsentRedirectUrl } from "./consent-redirect"; + +describe("resolveConsentRedirectUrl", () => { + test("reads `url` from the real accept shape ({ redirect: true, url })", () => { + expect( + resolveConsentRedirectUrl({ redirect: true, url: "https://client.example/callback?code=abc&state=xyz" }) + ).toBe("https://client.example/callback?code=abc&state=xyz"); + }); + + test("reads the error `url` from the deny shape", () => { + expect( + resolveConsentRedirectUrl({ + redirect: true, + url: "https://client.example/callback?error=access_denied", + }) + ).toBe("https://client.example/callback?error=access_denied"); + }); + + test("allows loopback and custom-scheme redirect targets (native MCP clients)", () => { + expect(resolveConsentRedirectUrl({ url: "http://127.0.0.1:52765/callback?code=abc" })).toBe( + "http://127.0.0.1:52765/callback?code=abc" + ); + expect(resolveConsentRedirectUrl({ url: "cursor://anysphere.cursor-mcp/callback?code=abc" })).toBe( + "cursor://anysphere.cursor-mcp/callback?code=abc" + ); + }); + + test("falls back to `redirect_uri` when only that is present (documented/legacy shape)", () => { + expect(resolveConsentRedirectUrl({ redirect_uri: "https://client.example/cb?code=abc" })).toBe( + "https://client.example/cb?code=abc" + ); + }); + + test("prefers `url` over `redirect_uri` when both are present", () => { + expect( + resolveConsentRedirectUrl({ url: "https://a.example/cb", redirect_uri: "https://b.example/cb" }) + ).toBe("https://a.example/cb"); + }); + + test("returns null for missing/empty/non-object/non-string values (→ toast)", () => { + expect(resolveConsentRedirectUrl({})).toBeNull(); + expect(resolveConsentRedirectUrl({ url: "" })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: " " })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: 123 })).toBeNull(); + expect(resolveConsentRedirectUrl(null)).toBeNull(); + expect(resolveConsentRedirectUrl(undefined)).toBeNull(); + expect(resolveConsentRedirectUrl("https://client.example/cb")).toBeNull(); + }); + + test("rejects dangerous schemes (XSS guard for the location.href sink)", () => { + expect(resolveConsentRedirectUrl({ url: "javascript:alert(1)" })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: " JavaScript:alert(1)" })).toBeNull(); + // URL parsing normalizes control-char-obfuscated schemes, so this is caught too. + expect(resolveConsentRedirectUrl({ url: "java\tscript:alert(1)" })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: "data:text/html," })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: "file:///etc/passwd" })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: "blob:https://x/1" })).toBeNull(); + // falls through to the fallback, which is also dangerous → null + expect(resolveConsentRedirectUrl({ url: "javascript:1", redirect_uri: "data:x" })).toBeNull(); + }); + + test("rejects malformed / protocol-relative values (not a well-formed absolute URL)", () => { + expect(resolveConsentRedirectUrl({ url: "//evil.com/path" })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: "not a url" })).toBeNull(); + expect(resolveConsentRedirectUrl({ url: "/relative/path" })).toBeNull(); + }); +}); diff --git a/apps/web/app/(app)/account/authorize/lib/consent-redirect.ts b/apps/web/app/(app)/account/authorize/lib/consent-redirect.ts new file mode 100644 index 000000000000..2236256ba495 --- /dev/null +++ b/apps/web/app/(app)/account/authorize/lib/consent-redirect.ts @@ -0,0 +1,37 @@ +/** + * Extract the post-consent redirect URL from a Better Auth `oauth2.consent` response. + * + * On accept, `@better-auth/oauth-provider` delegates to its authorize handler, which for a fetch/JSON + * request returns `{ redirect: true, url: "" }` (see `handleRedirect` in the plugin) — + * NOT `{ redirect_uri }`. The plugin's OpenAPI schema documents `redirect_uri`, which is misleading; + * the runtime field is `url`. Deny returns the same shape with an `error=access_denied` URL. We read + * `url` first, and keep `redirect_uri` as a defensive fallback for the documented/legacy shape. + * + * Security: the returned string is fed to `window.location.href`, so we reject XSS-capable schemes. + * We parse with the `URL` constructor and deny the dangerous protocols rather than allowlisting — native + * MCP clients use arbitrary custom/loopback schemes (`cursor://`, `vscode://`, `http://127.0.0.1:PORT`), + * so the safe set can't be enumerated, and the oauth-provider already validated the target `redirect_uri` + * against the registered client at `/authorize`. Parsing (vs a regex) also normalizes obfuscated schemes + * like `java\tscript:` and rejects malformed / protocol-relative values. + */ +const DANGEROUS_PROTOCOLS = new Set(["javascript:", "data:", "vbscript:", "file:", "blob:"]); + +const asRedirectString = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + let protocol: string; + try { + protocol = new URL(trimmed).protocol; + } catch { + return null; // not a well-formed absolute URL + } + if (DANGEROUS_PROTOCOLS.has(protocol.toLowerCase())) return null; + return trimmed; +}; + +export const resolveConsentRedirectUrl = (data: unknown): string | null => { + if (typeof data !== "object" || data === null) return null; + const record = data as Record; + return asRedirectString(record.url) ?? asRedirectString(record.redirect_uri); +}; diff --git a/apps/web/app/api/mcp/route.test.ts b/apps/web/app/api/mcp/route.test.ts index 7cc1c7de3df7..744bd94c2006 100644 --- a/apps/web/app/api/mcp/route.test.ts +++ b/apps/web/app/api/mcp/route.test.ts @@ -212,6 +212,7 @@ describe("POST /api/mcp", () => { "validate_survey", "patch_survey", "delete_survey", + "list_workspaces", ]); const tools = new Map(message.result.tools.map((tool: { name: string }) => [tool.name, tool])); expect(Object.keys((tools.get("create_survey") as any).inputSchema.properties)).toEqual( diff --git a/apps/web/app/api/v3/workspaces/lib/operations.test.ts b/apps/web/app/api/v3/workspaces/lib/operations.test.ts new file mode 100644 index 000000000000..c5e67b682c98 --- /dev/null +++ b/apps/web/app/api/v3/workspaces/lib/operations.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { TV3Authentication } from "@/app/api/v3/lib/types"; +import { getOrganizationsByUserId } from "@/lib/organization/service"; +import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service"; +import { listV3Workspaces } from "./operations"; + +vi.mock("@/lib/organization/service", () => ({ getOrganizationsByUserId: vi.fn() })); +vi.mock("@/lib/workspace/service", () => ({ getUserWorkspaces: vi.fn(), getWorkspace: vi.fn() })); +vi.mock("@formbricks/logger", () => ({ + logger: { withContext: vi.fn(() => ({ error: vi.fn(), warn: vi.fn() })) }, +})); + +const sessionAuth = { + user: { id: "user_1" }, + expires: "2026-07-01T00:00:00.000Z", +} as unknown as TV3Authentication; + +const params = (authentication: TV3Authentication) => ({ + authentication, + requestId: "req_1", + instance: "/api/mcp", +}); + +// A workspace as the services return it: full entity. The operation must serialize it down to the DTO. +const ws = (id: string, name: string, organizationId: string) => + ({ + id, + name, + organizationId, + createdAt: new Date(), + updatedAt: new Date(), + config: { secret: "should-not-leak" }, + styling: {}, + }) as any; + +describe("listV3Workspaces", () => { + beforeEach(() => vi.clearAllMocks()); + + test("session user: aggregates + dedupes across orgs and returns the minimal DTO only", async () => { + vi.mocked(getOrganizationsByUserId).mockResolvedValue([ + { id: "org_1", name: "Org 1" }, + { id: "org_2", name: "Org 2" }, + ] as any); + vi.mocked(getUserWorkspaces).mockImplementation(async (_userId: string, orgId: string) => + orgId === "org_1" + ? [ws("w1", "Alpha", "org_1"), ws("w2", "Beta", "org_1")] + : [ws("w2", "Beta", "org_1"), ws("w3", "Gamma", "org_2")] + ); + + const res = await listV3Workspaces(params(sessionAuth)); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.data).toEqual([ + { id: "w1", name: "Alpha", organizationId: "org_1" }, + { id: "w2", name: "Beta", organizationId: "org_1" }, + { id: "w3", name: "Gamma", organizationId: "org_2" }, + ]); + expect(body.meta.totalCount).toBe(3); + // Only the DTO fields — no config/styling/entity internals leak. + expect(Object.keys(body.data[0])).toEqual(["id", "name", "organizationId"]); + }); + + test("returns workspaces in a deterministic order (name, then id)", async () => { + vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org_1", name: "Org 1" }] as any); + vi.mocked(getUserWorkspaces).mockResolvedValue([ + ws("w3", "Zeta", "org_1"), + ws("w1", "alpha", "org_1"), + ws("w2", "Beta", "org_1"), + ]); + + const res = await listV3Workspaces(params(sessionAuth)); + const body = await res.json(); + + expect(body.data.map((w: { name: string }) => w.name)).toEqual(["alpha", "Beta", "Zeta"]); + }); + + test("session user with no orgs → empty list, no workspace lookups", async () => { + vi.mocked(getOrganizationsByUserId).mockResolvedValue([] as any); + + const res = await listV3Workspaces(params(sessionAuth)); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data).toEqual([]); + expect(getUserWorkspaces).not.toHaveBeenCalled(); + }); + + test("api key: returns only the workspaces in workspacePermissions", async () => { + const keyAuth = { + apiKeyId: "key_1", + workspacePermissions: [ + { workspaceId: "w1", permission: "read" }, + { workspaceId: "w9", permission: "write" }, + ], + } as unknown as TV3Authentication; + vi.mocked(getWorkspace).mockImplementation(async (id: string) => + id === "w1" ? ws("w1", "Alpha", "org_1") : id === "w9" ? ws("w9", "Zeta", "org_2") : null + ); + + const res = await listV3Workspaces(params(keyAuth)); + const body = await res.json(); + + expect(body.data).toEqual([ + { id: "w1", name: "Alpha", organizationId: "org_1" }, + { id: "w9", name: "Zeta", organizationId: "org_2" }, + ]); + // API-key path must never fall back to the user/org aggregation. + expect(getOrganizationsByUserId).not.toHaveBeenCalled(); + }); + + test("no authentication → 401", async () => { + const res = await listV3Workspaces(params(null)); + expect(res.status).toBe(401); + }); + + test("an unexpected service failure is logged and returned as a 500 (never thrown)", async () => { + vi.mocked(getOrganizationsByUserId).mockRejectedValue(new Error("db exploded")); + const res = await listV3Workspaces(params(sessionAuth)); + expect(res.status).toBe(500); + }); +}); diff --git a/apps/web/app/api/v3/workspaces/lib/operations.ts b/apps/web/app/api/v3/workspaces/lib/operations.ts new file mode 100644 index 000000000000..1cb80d6e02a7 --- /dev/null +++ b/apps/web/app/api/v3/workspaces/lib/operations.ts @@ -0,0 +1,109 @@ +import "server-only"; +import { logger } from "@formbricks/logger"; +import type { TAuthenticationApiKey } from "@formbricks/types/auth"; +import { problemInternalError, problemUnauthorized, successListResponse } from "@/app/api/v3/lib/response"; +import type { TV3Authentication } from "@/app/api/v3/lib/types"; +import { getOrganizationsByUserId } from "@/lib/organization/service"; +import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service"; + +type TListV3WorkspacesParams = { + authentication: TV3Authentication; + requestId: string; + instance: string; +}; + +/** Minimal DTO — never return the raw Workspace entity (no config/env/styling internals). */ +type TV3WorkspaceListItem = { + id: string; + name: string; + organizationId: string; +}; + +const serializeV3WorkspaceListItem = (workspace: { + id: string; + name: string; + organizationId: string; +}): TV3WorkspaceListItem => ({ + id: workspace.id, + name: workspace.name, + organizationId: workspace.organizationId, +}); + +/** + * Session user's accessible workspaces: every workspace across the orgs they're a member of. Scoping is + * enforced by the reused services — `getOrganizationsByUserId` limits to the user's own orgs, and + * `getUserWorkspaces` returns all of an org's workspaces for owners/managers but only team-scoped ones + * for `member`-role users. + */ +async function fetchSessionWorkspaces(userId: string): Promise { + const organizations = await getOrganizationsByUserId(userId); + const workspacesPerOrg = await Promise.all( + organizations.map((organization) => getUserWorkspaces(userId, organization.id)) + ); + return workspacesPerOrg.flat().map(serializeV3WorkspaceListItem); +} + +/** API key's accessible workspaces: exactly the ones named in its `workspacePermissions`, nothing else. */ +async function fetchApiKeyWorkspaces(keyAuth: TAuthenticationApiKey): Promise { + const workspaceIds = Array.from(new Set(keyAuth.workspacePermissions.map((p) => p.workspaceId))); + const workspaces = await Promise.all(workspaceIds.map((id) => getWorkspace(id))); + return workspaces + .filter((workspace): workspace is NonNullable => workspace !== null) + .map(serializeV3WorkspaceListItem); +} + +/** + * List the workspaces the authenticated principal can access (session user or API key). There is no + * `workspaceId` input, so there is no IDOR surface — the result is always derived from the caller's own + * live membership / key grants, never from client-supplied ids. + * + * Thin orchestrator: resolve the principal's workspaces → dedupe → order → cap → respond. The per- + * principal access resolution lives in the `fetch*Workspaces` helpers. + */ +export async function listV3Workspaces({ + authentication, + requestId, + instance, +}: TListV3WorkspacesParams): Promise { + if (!authentication) { + return problemUnauthorized(requestId, "Not authenticated", instance); + } + + const log = logger.withContext({ requestId }); + + try { + let items: TV3WorkspaceListItem[]; + + if ("user" in authentication && authentication.user?.id) { + items = await fetchSessionWorkspaces(authentication.user.id); + } else if ( + "apiKeyId" in authentication && + authentication.apiKeyId && + Array.isArray(authentication.workspacePermissions) + ) { + items = await fetchApiKeyWorkspaces(authentication); + } else { + return problemUnauthorized(requestId, "Not authenticated", instance); + } + + // Dedupe by id (defensive) + a stable, deterministic order — the underlying queries have no ORDER BY, + // so without this the output would vary between calls. + const deduped = Array.from(new Map(items.map((item) => [item.id, item])).values()).sort( + (a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id) + ); + + // No pagination: a principal's accessible workspaces are bounded by their memberships (small), so we + // return them all. No arbitrary cap — a truncation here would silently hide workspaces (the tool + // accepts no cursor) without bounding DB work, which was already done above. + return successListResponse( + deduped, + { nextCursor: null, totalCount: deduped.length }, + { requestId, cache: "private, no-store" } + ); + } catch (error) { + // Log every failure with request context (not just DatabaseError) so nothing significant is lost, + // and always return a clean 500 instead of throwing a raw error past this boundary. + log.error({ error, statusCode: 500 }, "Failed to list workspaces"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } +} diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 2835bee39fd9..b4e30ba42ace 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -1650,6 +1650,7 @@ checksums: workspace/analysis/charts/field_label_sentiment: 9ba5719c80c0136c2d0644217619aff6 workspace/analysis/charts/field_label_sentiment_average: 6351961495851db39f8968f2995d0514 workspace/analysis/charts/field_label_sentiment_score: 3bec3c4ec9c18ca77a9fb07963ec79da + workspace/analysis/charts/field_label_source_id: 134a9a7d473508c5623ac724a5ba4be9 workspace/analysis/charts/field_label_source_name: 157675beca12efcd8ec512c5256b1a61 workspace/analysis/charts/field_label_source_type: d1ff69af76c687eb189db72030717570 workspace/analysis/charts/field_label_surprise_count: dc72d86e49a621972c24fc445554a38d @@ -1696,6 +1697,7 @@ checksums: workspace/analysis/charts/no_data_returned: 73949b8732604b9e2d22d046cc4a6ef9 workspace/analysis/charts/no_data_returned_for_chart: b9ff6c85697c683f40b3d0c05eeb2046 workspace/analysis/charts/no_data_source_available: 0b247d065eaf2d0fc1c8ec558eebfba6 + workspace/analysis/charts/no_fields_found: 084d71950d12a793edad8dbede5328ea workspace/analysis/charts/no_grouping: e3a6943e61407600cae057e0833a482d workspace/analysis/charts/no_valid_data_to_display: d1ba2b0686520c0a2c62ee73daa1c9c9 workspace/analysis/charts/no_values_found: c86fa6b3d7f7e255525cd2f9330a9295 @@ -1713,6 +1715,7 @@ checksums: workspace/analysis/charts/save_and_add_to_dashboard: a76ed91c62dae10c5f8a9d48cbacd566 workspace/analysis/charts/save_chart: 2e4505f7bf3d1c35b0b37b1e9d3dc566 workspace/analysis/charts/save_chart_dialog_title: 2e4505f7bf3d1c35b0b37b1e9d3dc566 + workspace/analysis/charts/search_field: 8585baa2984e1d037e2d83815f0df1b6 workspace/analysis/charts/search_value: c92a85848cc34ea1717daee8d9a34c86 workspace/analysis/charts/select_data_source: 983394bc0182b65ec68f713a46b97302 workspace/analysis/charts/select_data_source_first: 82a02846de9d6351595c97a0929f3b9a @@ -3675,8 +3678,6 @@ checksums: workspace/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b workspace/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02 workspace/teams/team_settings_description: 52f91883b9ceb6de83efbf8efd4f11c0 - workspace/unify/add_feedback_record: 19cf2b1fef0ca1400f2400e7ee681ea0 - workspace/unify/add_feedback_record_description: 94bca46246ba7353049b33742554b4c0 workspace/unify/add_feedback_source: 69a643c09bd3a3053797ddcf68c2219a workspace/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e workspace/unify/ai_enrichment: ab208e42a764628a034c350bc5f3a2be @@ -3729,8 +3730,6 @@ checksums: workspace/unify/csv_source_context_hint: eaf09edede7b8105712903992f8964ee workspace/unify/csv_unmapped_columns: b94a8b5c3cf9df8859e5ac484a14969c workspace/unify/csv_unmapped_columns_explainer: 58cdfc3c06c141f156288dc434f8e0ca - workspace/unify/custom_source_type: d931a8a74d3a5becd568e398107979da - workspace/unify/custom_source_type_placeholder: f139e3e5d70dbf426d7c6b5ab2b198cc workspace/unify/data_origin: 9a62ac5378411b0b7735c01dbeeb454f workspace/unify/default_source_name_csv: ef4060fef24c4fec064987b9d2a9fa4b workspace/unify/default_source_name_formbricks: 9088b01e1085b1cf42412597ebeea051 @@ -3738,8 +3737,6 @@ checksums: workspace/unify/delete_feedback_record_confirmation: 389f5a27a76d825d8751cc8fa20589ea workspace/unify/delete_feedback_records_confirmation: 9867caf985bfd77e4df9be2164298dd5 workspace/unify/delete_source_confirmation: 43b048de6338be1757ade6bf5029b010 - workspace/unify/discard_feedback_record_changes_description: 48ccde99858dcbeb4d679749d0f51941 - workspace/unify/discard_feedback_record_changes_title: 52df2800f7b0e8a1d04c47113e019a3e workspace/unify/dismiss: c2f298165cbfc17041b795065bfeb57c workspace/unify/download_sample_csv: 76975598e61d4eaeb04fc14da8b0a837 workspace/unify/edit_csv_mapping: 4f3bad444664d58ffe8ace3dc9e200f9 @@ -3758,13 +3755,10 @@ checksums: workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8 workspace/unify/feedback_data: eb1cf6e9ed7e9e1fdf8d4c950653700a workspace/unify/feedback_directory: b964b69f8f1bec9e5cd8b8e7307568b9 - workspace/unify/feedback_record_created_successfully: 0ff30472085f1313a5ad53837c83e7c1 workspace/unify/feedback_record_deleted_successfully: ea58f53841cc48dc974f1589f4718da6 workspace/unify/feedback_record_details: 823f3353db049a9d263ef31405054cda workspace/unify/feedback_record_details_description: 0b6f908154161241ce6bdeb4a2acaecd workspace/unify/feedback_record_mcp: 8d9aeea97bbb0ad4c4adbf508057f284 - workspace/unify/feedback_record_updated_successfully: cb40ef4b924e21fa627ebe6809d1d826 - workspace/unify/feedback_record_value_required: b54d4d86f82071a93dc979e8eb359cf0 workspace/unify/feedback_records: e24cf48bb6985910f4ffe5e00512d388 workspace/unify/feedback_records_deleted_successfully: 176c27a362d593a62355904c50bb0e9e workspace/unify/feedback_records_partially_deleted: dff8cd8482e8053ce4186e6b42d0aee8 @@ -3794,9 +3788,7 @@ checksums: workspace/unify/manage_directories: 0ad23f38f8185bb987a2caf5f09334dc workspace/unify/manage_feedback_sources: 6aa6a82334ab680b5aa187b7245e8ec8 workspace/unify/metadata: 695d4f7da261ba76e3be4de495491028 - workspace/unify/metadata_key: c478d228673f59fa556208ece60452f6 workspace/unify/metadata_read_only_entries: 1934fee46c0a117f4926b61cc3d2d602 - workspace/unify/metadata_value: 8d69be1f5a20d9473a33c35670dff216 workspace/unify/missing_feedback_source_title: 9ab1b8d54b4da72dd00ce03fe3b698b5 workspace/unify/no_feedback_directory_available: 3b8def4bb369948f17da401bd64e942b workspace/unify/no_feedback_directory_linked_admin_description: 0a7db006ea1a8e3e667da0e9c4483bb2 @@ -3816,7 +3808,6 @@ checksums: workspace/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20 workspace/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862 workspace/unify/select_feedback_directory: 36ea24993aacd0749be6124cbf6861aa - workspace/unify/select_feedback_record_source_type: 10997fcbea2f93e756888cf7a7476fdf workspace/unify/select_questions: 13c79b8c284423eb6140534bf2137e56 workspace/unify/select_questions_for_import: 5f360f6c821ae548120ab1c8f4f4a9ac workspace/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc @@ -3935,8 +3926,7 @@ checksums: workspace/unify/value: 34b0eaa85808b15cbc4be94c64d0146b workspace/unify/value_boolean: bbdcd3f46954b6304b9069e94e1371ab workspace/unify/value_date: c8d705d1975affc01c002324725fec3f - workspace/unify/value_id: ed21d97b8ab035ba89fb3f5f073229bd - workspace/unify/value_id_hint: 9691fae95d3fdd6d9ffa3d2ab5a4bd3c + workspace/unify/value_id: 5e0222661639b5ef6aab7ccff11363a1 workspace/unify/value_number: 1f14da79d14bd7b1c2324141f4470675 workspace/unify/value_text: e097a597cc507c716401ad18255de578 workspace/xm-templates/ces: e2ea309b2f7f13257967b966c2fda1e9 diff --git a/apps/web/lib/feedback-source/transform.test.ts b/apps/web/lib/feedback-source/transform.test.ts index 59a37a114eb8..884566dc0c45 100644 --- a/apps/web/lib/feedback-source/transform.test.ts +++ b/apps/web/lib/feedback-source/transform.test.ts @@ -177,11 +177,17 @@ describe("transformResponseToFeedbackRecords", () => { expect(result[0].value_boolean).toBe(true); }); - test("transforms categorical (multi-select) field to comma-separated text", () => { + test("splits a multi-select answer into one record per selected option", () => { const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings, mockTenantId); - expect(result).toHaveLength(1); - expect(result[0].value_text).toBe("feat-a, feat-b"); + expect(result).toHaveLength(2); + // el-multi has no choices in this fixture, so entries stay unmatched (no value_id) but still + // split, each tied back to the question via field_group_id and to the respondent via submission_id. + expect(result.map((r) => r.field_id)).toEqual(["el-multi__feat-a", "el-multi__feat-b"]); + expect(result.map((r) => r.value_text)).toEqual(["feat-a", "feat-b"]); + expect(result.every((r) => r.field_group_id === "el-multi")).toBe(true); + expect(result.every((r) => r.submission_id === "resp-1")).toBe(true); + expect(result.every((r) => r.value_id === undefined)).toBe(true); }); test("uses customFieldLabel when provided", () => { @@ -238,9 +244,18 @@ describe("transformResponseToFeedbackRecords", () => { test("transforms all mappings in a single call", () => { const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, allMappings, mockTenantId); - expect(result).toHaveLength(6); + // el-multi splits into one record per selected option, so it contributes two rows. + expect(result).toHaveLength(7); const fieldIds = result.map((r) => r.field_id); - expect(fieldIds).toEqual(["el-text", "el-nps", "el-rating", "el-date", "el-bool", "el-multi"]); + expect(fieldIds).toEqual([ + "el-text", + "el-nps", + "el-rating", + "el-date", + "el-bool", + "el-multi__feat-a", + "el-multi__feat-b", + ]); }); test("falls back to 'Untitled' for element with no headline", () => { @@ -329,12 +344,29 @@ describe("transformResponseToFeedbackRecords", () => { }); test("handles single string value for categorical field", () => { + // Provide an element with a choices array so the choice-lookup path does not crash. + // The submitted value does not match any choice, so it passes through unchanged. + const surveyWithChoices = { + ...mockSurvey, + blocks: [ + { + elements: [ + { + id: "el-multi", + type: "multipleChoiceMulti", + headline: { default: "Select features" }, + choices: [{ id: "ch-a", label: { default: "Option A" } }], + }, + ], + }, + ], + } as unknown as TSurvey; const response = { ...mockResponse, data: { "el-multi": "single-choice" }, } as unknown as TResponse; const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })]; - const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId); + const result = transformResponseToFeedbackRecords(response, surveyWithChoices, mappings, mockTenantId); expect(result[0].value_text).toBe("single-choice"); }); @@ -349,24 +381,26 @@ describe("transformResponseToFeedbackRecords", () => { expect(result[0].value_text).not.toBe("[object Object]"); }); - test("creates a record for a ranking response (string array)", () => { + test("joins array values to comma-separated text for non-choice elements", () => { + // An element absent from the survey definition has no type, so the generic path stores the + // raw array joined. The per-option split only applies to multipleChoiceMulti elements. const response = { ...mockResponse, - data: { "el-multi": ["LabelA", "LabelB", "LabelC"] }, + data: { "el-array": ["LabelA", "LabelB", "LabelC"] }, } as unknown as TResponse; - const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })]; + const mappings = [createMapping({ elementId: "el-array", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId); expect(result).toHaveLength(1); - expect(result[0].field_id).toBe("el-multi"); + expect(result[0].field_id).toBe("el-array"); expect(result[0].value_text).toBe("LabelA, LabelB, LabelC"); }); - test("creates a record for an empty ranking response (empty array)", () => { + test("joins an empty array to an empty string for non-choice elements", () => { const response = { ...mockResponse, - data: { "el-multi": [] }, + data: { "el-array": [] }, } as unknown as TResponse; - const mappings = [createMapping({ elementId: "el-multi", hubFieldType: "categorical" })]; + const mappings = [createMapping({ elementId: "el-array", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, mockSurvey, mappings, mockTenantId); expect(result).toHaveLength(1); expect(result[0].value_text).toBe(""); @@ -436,6 +470,7 @@ describe("transformResponseToFeedbackRecords", () => { field_id: "el-matrix__row-1", field_label: "Speed", field_type: "categorical", + // value_text = default-language label (canonical) — for default-language responses this equals the submitted label. value_text: "Good", }); expect(result[1]).toMatchObject({ @@ -597,7 +632,7 @@ describe("transformResponseToFeedbackRecords", () => { }); }); - describe("choice values across languages (labels stored as submitted, identity via value_id)", () => { + describe("choice values across languages (value_text = default-language label (canonical), value_id = stable choice id)", () => { const bilingualSurvey = { id: "survey-1", name: "Bilingual Survey", @@ -636,43 +671,55 @@ describe("transformResponseToFeedbackRecords", () => { language, }) as unknown as TResponse; - test("stores the submitted-language label for a single select, with the choice id as identity", () => { + test("stores the default-language label for a single select answered in another language, with the choice id as identity", () => { const response = buildResponse({ "el-gender": "ذكر" }, "ar"); const mappings = [createMapping({ elementId: "el-gender", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, bilingualSurvey, mappings, mockTenantId); expect(result).toHaveLength(1); - expect(result[0].value_text).toBe("ذكر"); + // value_text = default-language label (canonical); value_id is the stable grouping key. + expect(result[0].value_text).toBe("Male"); expect(result[0].value_id).toBe("c-male"); expect(result[0].language).toBe("ar"); }); - test("stores submitted-language labels for a multi select answered in another language", () => { + test("splits a multi select answered in another language into per-option records with default-language labels", () => { const response = buildResponse({ "el-feats": ["سرعة", "جودة"] }, "ar"); const mappings = [createMapping({ elementId: "el-feats", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, bilingualSurvey, mappings, mockTenantId); - expect(result).toHaveLength(1); - expect(result[0].value_text).toBe("سرعة, جودة"); + // One record per selection; each value_text is the canonical default-language label and each + // carries its stable choice id, regardless of the response language. + expect(result).toHaveLength(2); + expect(result.map((r) => r.value_text)).toEqual(["Speed", "Quality"]); + expect(result.map((r) => r.value_id)).toEqual(["c-speed", "c-quality"]); + expect(result.map((r) => r.field_id)).toEqual(["el-feats__c-speed", "el-feats__c-quality"]); + expect(result.every((r) => r.field_group_id === "el-feats")).toBe(true); }); - test("passes through values that match no choice label (other / free text)", () => { + test("splits a multi select and passes unmatched free text through per option", () => { const response = buildResponse({ "el-feats": ["سرعة", "something else"] }, "ar"); const mappings = [createMapping({ elementId: "el-feats", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, bilingualSurvey, mappings, mockTenantId); - expect(result[0].value_text).toBe("سرعة, something else"); + // Matched entry canonicalizes to its default-language label with its choice id; the unmatched + // free text passes through and carries no value_id (el-feats offers no "other" option). + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ value_text: "Speed", value_id: "c-speed" }); + expect(result[1]).toMatchObject({ value_text: "something else" }); + expect(result[1].value_id).toBeUndefined(); }); - test("leaves default-language answers unchanged", () => { + test("stores the original label for default-language answers (unchanged since submitted=default)", () => { const response = buildResponse({ "el-gender": "Female" }, "default"); const mappings = [createMapping({ elementId: "el-gender", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, bilingualSurvey, mappings, mockTenantId); + // value_text = default-language label (canonical); for default-language answers this equals the submitted label. expect(result[0].value_text).toBe("Female"); }); @@ -684,12 +731,13 @@ describe("transformResponseToFeedbackRecords", () => { const result = transformResponseToFeedbackRecords(response, bilingualSurvey, mappings, mockTenantId); + // value_text = default-language label (canonical); value_id = stable choice id. expect(result[0].value_text).toBe("Female"); expect(result[0].value_id).toBe("c-female"); expect(result[0].language).toBe("en-US"); }); - test("stores the submitted-language column label for a matrix answered in another language", () => { + test("stores the default-language label for a matrix column answered in another language", () => { const matrixSurvey = { id: "survey-1", name: "Matrix Survey", @@ -720,7 +768,8 @@ describe("transformResponseToFeedbackRecords", () => { expect(result[0]).toMatchObject({ field_id: "el-matrix__row-1", field_label: "Speed", - value_text: "جيد", + // value_text = default-language label (canonical); value_id = stable column id. + value_text: "Good", value_id: "col-1", }); }); @@ -770,14 +819,15 @@ describe("transformResponseToFeedbackRecords", () => { language, }) as unknown as TResponse; - test("sets value_id to the matched choice id for a single select", () => { + test("sets value_id to the matched choice id for a single select; value_text stores the default-language label (canonical)", () => { const response = buildResponse({ "el-gender": "ذكر" }, "ar"); const mappings = [createMapping({ elementId: "el-gender", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, survey, mappings, mockTenantId); expect(result[0].value_id).toBe("c-male"); - expect(result[0].value_text).toBe("ذكر"); + // value_text = default-language label (canonical), regardless of the response language. + expect(result[0].value_text).toBe("Male"); }); test("sets value_id for default-language single select answers too", () => { @@ -787,6 +837,8 @@ describe("transformResponseToFeedbackRecords", () => { const result = transformResponseToFeedbackRecords(response, survey, mappings, mockTenantId); expect(result[0].value_id).toBe("c-female"); + // value_text = default-language label (canonical); for default-language answers this equals the submitted label. + expect(result[0].value_text).toBe("Female"); }); test("sets value_id when the response carries the default language's concrete code (ENG-1673 regression)", () => { @@ -796,6 +848,7 @@ describe("transformResponseToFeedbackRecords", () => { const result = transformResponseToFeedbackRecords(response, survey, mappings, mockTenantId); expect(result[0].value_id).toBe("c-female"); + // value_text = default-language label (canonical); same as submitted since answered in default language. expect(result[0].value_text).toBe("Female"); }); @@ -809,16 +862,64 @@ describe("transformResponseToFeedbackRecords", () => { expect(result[0].value_text).toBe("self-described"); }); - // Multi-select answers stay unmatched and split per language (ENG-1702 follow-up): - // the joined record cannot carry one id per selected option. - test("omits value_id for multi select (joined record cannot carry multiple ids)", () => { + // Multi-select splits into one record per selected option (ENG-1702), so each option keeps + // its own stable value_id instead of collapsing into a single joined record. + test("sets a value_id per selected option for multi select", () => { const response = buildResponse({ "el-feats": ["Speed", "Quality"] }); const mappings = [createMapping({ elementId: "el-feats", hubFieldType: "categorical" })]; const result = transformResponseToFeedbackRecords(response, survey, mappings, mockTenantId); - expect(result[0].value_id).toBeUndefined(); - expect(result[0].value_text).toBe("Speed, Quality"); + expect(result).toHaveLength(2); + expect(result.map((r) => r.value_id)).toEqual(["c-speed", "c-quality"]); + expect(result.map((r) => r.value_text)).toEqual(["Speed", "Quality"]); + expect(result.map((r) => r.field_id)).toEqual(["el-feats__c-speed", "el-feats__c-quality"]); + expect(result.every((r) => r.field_group_id === "el-feats")).toBe(true); + }); + + test("emits no records for an empty multi select selection", () => { + const response = buildResponse({ "el-feats": [] }); + const mappings = [createMapping({ elementId: "el-feats", hubFieldType: "categorical" })]; + + const result = transformResponseToFeedbackRecords(response, survey, mappings, mockTenantId); + + expect(result).toEqual([]); + }); + + test("groups unmatched multi-select entries under the stable 'other' id when the element offers one", () => { + const otherSurvey = { + id: "survey-1", + name: "Other Survey", + languages: bilingualLanguages, + blocks: [ + { + elements: [ + { + id: "el-feats-other", + type: "multipleChoiceMulti", + headline: { default: "Select features" }, + choices: [ + { id: "c-speed", label: { default: "Speed" } }, + { id: "other", label: { default: "Other" } }, + ], + }, + ], + }, + ], + } as unknown as TSurvey; + const response = buildResponse({ "el-feats-other": ["Speed", "my own idea"] }); + const mappings = [createMapping({ elementId: "el-feats-other", hubFieldType: "categorical" })]; + + const result = transformResponseToFeedbackRecords(response, otherSurvey, mappings, mockTenantId); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ value_text: "Speed", value_id: "c-speed" }); + // The free-text entry groups under the stable "other" id and keeps the submitted text. + expect(result[1]).toMatchObject({ + field_id: "el-feats-other__other", + value_text: "my own idea", + value_id: "other", + }); }); test("omits value_id for non-choice elements", () => { @@ -830,7 +931,7 @@ describe("transformResponseToFeedbackRecords", () => { expect(result[0].value_id).toBeUndefined(); }); - test("sets value_id to the matched column id for matrix answers", () => { + test("sets value_id to the matched column id for matrix answers; value_text is the default-language label (canonical)", () => { const matrixSurvey = { id: "survey-1", name: "Matrix Survey", @@ -859,7 +960,8 @@ describe("transformResponseToFeedbackRecords", () => { expect(result[0]).toMatchObject({ field_id: "el-matrix__row-1", - value_text: "جيد", + // value_text = default-language label (canonical); value_id = stable column id. + value_text: "Good", value_id: "col-1", }); }); diff --git a/apps/web/lib/feedback-source/transform.ts b/apps/web/lib/feedback-source/transform.ts index 339f26011cbb..e9425740aa09 100644 --- a/apps/web/lib/feedback-source/transform.ts +++ b/apps/web/lib/feedback-source/transform.ts @@ -7,6 +7,7 @@ import type { TSurveyElementChoice, TSurveyMatrixElement, TSurveyMatrixElementChoice, + TSurveyMultipleChoiceElement, TSurveyRankingElement, } from "@formbricks/types/surveys/elements"; import type { TSurvey } from "@formbricks/types/surveys/types"; @@ -36,18 +37,24 @@ const findChoiceByLabel = { + if (!Array.isArray(value) || value.length === 0) return []; + + const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(element); + const choices = element.choices ?? []; + const hasOtherOption = + element.otherOptionPlaceholder !== undefined || choices.some((choice) => choice.id === "other"); + const records: FeedbackRecordCreateParams[] = []; + + value.forEach((entry) => { + if (typeof entry !== "string" || entry === "") return; + + const choice = findChoiceByLabel(choices, entry, lookupLanguage); + // Matched choice → canonical default-language label + stable id. Unmatched entry → the "other" + // free text; group it under the stable "other" id when the element offers that option. + const valueId = choice?.id ?? (hasOtherOption ? "other" : undefined); + const canonicalValue = choice ? getChoiceLabel(choice, "default") : entry; + const valueFields = convertValueToHubFields(canonicalValue, mapping.hubFieldType); + + records.push({ + ...baseFields, + field_id: `${element.id}__${valueId ?? entry}`, + field_type: mapping.hubFieldType, + field_label: fieldLabel, + field_group_id: element.id, + field_group_label: fieldLabel, + metadata: { question_type: "multipleChoiceMulti" }, + ...valueFields, + ...(valueId ? { value_id: valueId } : {}), + }); + }); + + return records; +}; + +/** + * Normalize an element's answer for storage. Choice elements canonicalize labels and resolve + * value_id via normalizeChoiceValue; every other element type stores the value as submitted. + * + * "Other" free-text answers never match a choice label, so they'd otherwise carry no + * value_id and each distinct free-text string would chart as its own bucket. When the + * element offers an "other" option, group them all under the stable "other" choice id + * (the survey convention: the other option's choice id is "other"). + */ +const normalizeElementValue = ( + element: TSurveyElement | undefined, + value: TResponseDataValue, + lookupLanguage: string +): NormalizedChoiceValue => { + const isChoiceElement = + element && + (element.type === TSurveyElementTypeEnum.MultipleChoiceSingle || + element.type === TSurveyElementTypeEnum.MultipleChoiceMulti); + if (!isChoiceElement) return { value }; + + const normalized = normalizeChoiceValue(element.choices, value, lookupLanguage); + + if ( + !normalized.valueId && + typeof value === "string" && + (element.otherOptionPlaceholder !== undefined || element.choices.some((c) => c.id === "other")) + ) { + normalized.valueId = "other"; + } + + return normalized; +}; + /** * Transform a Formbricks survey response into FeedbackRecord payloads. * Called from the pipeline handler when a response is created/finished. * - * Matrix and ranking questions expand into one record per row/item, sharing a - * field_group_id so Hub analytics can aggregate across them. + * Matrix, ranking, and multi-select questions expand into one record per row/item/option, + * sharing a field_group_id so Hub analytics can aggregate across them. */ export function transformResponseToFeedbackRecords( response: TResponse, @@ -285,16 +379,18 @@ export function transformResponseToFeedbackRecords( continue; } - const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(element); + // Multi-select splits into one record per selected option so each keeps its own stable + // value_id (ENG-1702). A single string answer falls through to the generic path below. + if (element?.type === TSurveyElementTypeEnum.MultipleChoiceMulti && Array.isArray(value)) { + feedbackRecords.push( + ...expandMultiChoiceToRecords(element, mapping, value, baseFields, lookupLanguage) + ); + continue; + } - const isChoiceElement = - element && - (element.type === TSurveyElementTypeEnum.MultipleChoiceSingle || - element.type === TSurveyElementTypeEnum.MultipleChoiceMulti); - const normalized = isChoiceElement - ? normalizeChoiceValue(element.choices, value, lookupLanguage) - : { value }; + const fieldLabel = mapping.customFieldLabel || getHeadlineFromElement(element); + const normalized = normalizeElementValue(element, value, lookupLanguage); const valueFields = convertValueToHubFields(normalized.value, mapping.hubFieldType); feedbackRecords.push({ diff --git a/apps/web/lib/workspace/service.test.ts b/apps/web/lib/workspace/service.test.ts index 5fc68fe487a7..7d717d5e60ff 100644 --- a/apps/web/lib/workspace/service.test.ts +++ b/apps/web/lib/workspace/service.test.ts @@ -4,7 +4,12 @@ import { prisma } from "@formbricks/database"; import { OrganizationRole, Prisma, WidgetPlacement, Workspace } from "@formbricks/database/prisma"; import { DatabaseError, ValidationError } from "@formbricks/types/errors"; import { ITEMS_PER_PAGE } from "../constants"; -import { getUserWorkspaces, getWorkspace, getWorkspaces } from "./service"; +import { + getUserWorkspaces, + getUserWorkspacesByOrganizationIds, + getWorkspace, + getWorkspaces, +} from "./service"; vi.mock("@formbricks/database", () => ({ prisma: { @@ -221,6 +226,62 @@ describe("Workspace Service", () => { }); }); + test("getUserWorkspacesByOrganizationIds team-scopes non-owner/manager roles (owner/manager get all)", async () => { + const userId = createId(); + const orgManager = createId(); + const orgBilling = createId(); + + vi.mocked(prisma.membership.findMany).mockResolvedValue([ + { userId, organizationId: orgManager, role: OrganizationRole.manager, accepted: true }, + { userId, organizationId: orgBilling, role: OrganizationRole.billing, accepted: true }, + ]); + vi.mocked(prisma.workspace.findMany).mockResolvedValue([]); + + await getUserWorkspacesByOrganizationIds([orgManager, orgBilling], userId); + + const teamScope = { some: { team: { teamUsers: { some: { userId } } } } }; + expect(prisma.workspace.findMany).toHaveBeenCalledWith({ + where: { + OR: [ + // manager: all of the org's workspaces (no team filter) + { organizationId: orgManager }, + // billing: team-scoped only (regression: previously unscoped → every workspace) + { organizationId: orgBilling, workspaceTeams: teamScope }, + ], + }, + select: { id: true }, + }); + }); + + test("getUserWorkspaces should team-scope a billing user (not return every workspace)", async () => { + const userId = createId(); + const organizationId = createId(); + + vi.mocked(prisma.membership.findFirst).mockResolvedValue({ + userId, + organizationId, + role: OrganizationRole.billing, + accepted: true, + }); + vi.mocked(prisma.workspace.findMany).mockResolvedValue([]); + + await getUserWorkspaces(userId, organizationId); + + // Billing is not owner/manager, so it must be scoped to team-accessible workspaces — never the + // whole org (regression: `role === "member"` previously leaked all workspaces to billing users). + expect(prisma.workspace.findMany).toHaveBeenCalledWith({ + where: { + organizationId, + workspaceTeams: { + some: { team: { teamUsers: { some: { userId } } } }, + }, + }, + select: expect.any(Object), + take: undefined, + skip: undefined, + }); + }); + test("getUserWorkspaces should throw ValidationError when user is not a member of organization", async () => { const userId = createId(); const organizationId = createId(); diff --git a/apps/web/lib/workspace/service.ts b/apps/web/lib/workspace/service.ts index c7cf431829bb..781923031dd6 100644 --- a/apps/web/lib/workspace/service.ts +++ b/apps/web/lib/workspace/service.ts @@ -46,7 +46,11 @@ export const getUserWorkspaces = reactCache( let workspaceWhereClause: Prisma.WorkspaceWhereInput = {}; - if (orgMembership.role === "member") { + // Only org owners/managers get every workspace. Every other role (member, billing, …) is scoped to + // the workspaces whose teams they belong to — mirroring the per-workspace v3 authorization + // (`requireV3WorkspaceAccess`: org owner/manager OR workspace-team membership). Special-casing only + // `member` here previously leaked all workspaces to `billing` members, who cannot access them. + if (orgMembership.role !== "owner" && orgMembership.role !== "manager") { workspaceWhereClause = { workspaceTeams: { some: { @@ -171,7 +175,9 @@ export const getUserWorkspacesByOrganizationIds = reactCache( organizationId: membership.organizationId, }; - if (membership.role === "member") { + // Same scoping as getUserWorkspaces: only owner/manager see all of an org's workspaces; every + // other role (member, billing, …) is limited to their team-scoped workspaces. + if (membership.role !== "owner" && membership.role !== "manager") { workspaceWhereClause = { ...workspaceWhereClause, workspaceTeams: { diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 05d6b64ed864..65db74c3aeb3 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Stimmung", "field_label_sentiment_average": "Stimmung: Durchschnitt", "field_label_sentiment_score": "Stimmungswert", + "field_label_source_id": "Quellen-ID", "field_label_source_name": "Quellenname", "field_label_source_type": "Quellentyp", "field_label_surprise_count": "Emotion: Überraschung", @@ -1759,6 +1760,7 @@ "no_data_returned": "Keine Daten zum Anzeigen vorhanden, bitte ändere deine Einstellungen.", "no_data_returned_for_chart": "Keine Daten für Diagramm zurückgegeben", "no_data_source_available": "Diesem Workspace ist kein Feedback-Datensatz zugewiesen.", + "no_fields_found": "Keine Felder gefunden", "no_grouping": "Keine (nur Filter)", "no_valid_data_to_display": "Keine gültigen Daten zur Anzeige", "no_values_found": "Keine Werte gefunden", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Speichern und zum Dashboard hinzufügen", "save_chart": "Diagramm speichern", "save_chart_dialog_title": "Diagramm speichern", + "search_field": "Feld suchen...", "search_value": "Wert suchen...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Sieh nach, welche Teams auf diesen Workspace zugreifen können." }, "unify": { - "add_feedback_record": "Feedback-Datensatz hinzufügen", - "add_feedback_record_description": "Erstellen Sie manuell einen Feedback-Datensatz.", "add_feedback_source": "Feedback-Quelle hinzufügen", "add_source": "Quelle hinzufügen", "ai_enrichment": "KI-Anreicherung", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Zeigt an, woher dieser Feedback-Stapel stammt.", "csv_unmapped_columns": "Nicht zugeordnete Spalten ({count}): {columns}", "csv_unmapped_columns_explainer": "Diese Spalten werden von keinem Feedback-Datensatz-Feld verwendet. Sie werden beim Import ignoriert.", - "custom_source_type": "Benutzerdefinierter Quelltyp", - "custom_source_type_placeholder": "Geben Sie den benutzerdefinierten Quelltyp ein", "data_origin": "Data origin", "default_source_name_csv": "CSV-Import", "default_source_name_formbricks": "Formbricks-Umfragequelle", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Dadurch wird der Feedback-Eintrag dauerhaft gelöscht und aus dem verknüpften Datensatz entfernt.", "delete_feedback_records_confirmation": "Dadurch werden {count} Feedback-Einträge dauerhaft gelöscht und aus dem verknüpften Datensatz entfernt.", "delete_source_confirmation": "Wenn du diese Quelle löschst, werden zukünftige Importe gestoppt und das gespeicherte Mapping entfernt. Vorhandene Feedback-Datensätze bleiben weiterhin verfügbar.", - "discard_feedback_record_changes_description": "Ihre Änderungen gehen verloren, wenn Sie diese Schublade schließen.", - "discard_feedback_record_changes_title": "Nicht gespeicherte Änderungen verwerfen?", "dismiss": "Dismiss", "download_sample_csv": "Beispiel-CSV herunterladen", "edit_csv_mapping": "CSV-Zuordnung bearbeiten", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Feedback-Einträge konnten nicht geladen werden", "feedback_data": "Feedback Data", "feedback_directory": "Feedback-Datensatz", - "feedback_record_created_successfully": "Feedback-Datensatz erfolgreich erstellt", "feedback_record_deleted_successfully": "Feedback-Eintrag erfolgreich gelöscht", "feedback_record_details": "Details zum Feedback-Datensatz", "feedback_record_details_description": "Überprüfen und aktualisieren Sie die Felder des Feedback-Datensatzes.", "feedback_record_mcp": "MCP-Server verwenden", - "feedback_record_updated_successfully": "Feedback-Datensatz erfolgreich aktualisiert", - "feedback_record_value_required": "Für den ausgewählten Feldtyp ist ein Wert erforderlich", "feedback_records": "Feedback-Einträge", "feedback_records_deleted_successfully": "{count} Feedback-Einträge gelöscht", "feedback_records_partially_deleted": "{succeeded} von {total} Feedback-Einträgen gelöscht", @@ -3954,9 +3948,7 @@ "manage_directories": "Datensätze verwalten", "manage_feedback_sources": "Feedbackquellen verwalten", "metadata": "Metadaten", - "metadata_key": "Metadatenschlüssel", "metadata_read_only_entries": "Schreibgeschützte Metadatenwerte (keine Zeichenfolge)", - "metadata_value": "Metadatenwert", "missing_feedback_source_title": "Feedback-Quelle fehlt?", "no_feedback_directory_available": "Diesem Workspace ist kein Feedback-Datensatz zugeordnet. Erstelle oder weise zuerst einen zu.", "no_feedback_directory_linked_admin_description": "Feedback-Datensätze bringen Feedback-Daten aus mehreren Workspaces zusammen. Verbinde einen Datensatz mit diesem Workspace, um loszulegen.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Wähle eine Umfrage aus, um ihre Fragen zu sehen", "select_a_value": "Wähle einen Wert aus...", "select_feedback_directory": "Datensatz auswählen", - "select_feedback_record_source_type": "Wählen Sie den Quelltyp aus", "select_questions": "Fragen auswählen", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Wähle die Art der Feedback-Quelle aus, die Du verbinden möchtest.", @@ -4095,8 +4086,7 @@ "value": "Wert", "value_boolean": "Wert (Boolescher Wert)", "value_date": "Wert (Datum)", - "value_id": "Options-ID", - "value_id_hint": "Stabile ID der ausgewählten Option in der Quellumfrage. Sie hält diesen Datensatz über Labeländerungen und Sprachen hinweg mit seiner Option verknüpft und wird automatisch verwaltet.", + "value_id": "Wert-ID", "value_number": "Wert (Anzahl)", "value_text": "Wert (Text)" }, diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index a80112eb760e..031b902579bc 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentiment", "field_label_sentiment_average": "Sentiment: Average", "field_label_sentiment_score": "Sentiment Score", + "field_label_source_id": "Source ID", "field_label_source_name": "Source Name", "field_label_source_type": "Source Type", "field_label_surprise_count": "Emotion: Surprise", @@ -1759,6 +1760,7 @@ "no_data_returned": "No data to display, please change your settings.", "no_data_returned_for_chart": "No data returned for chart", "no_data_source_available": "No feedback dataset is assigned to this workspace.", + "no_fields_found": "No fields found", "no_grouping": "None (filter only)", "no_valid_data_to_display": "No valid data to display", "no_values_found": "No values found", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Save & add to dashboard", "save_chart": "Save Chart", "save_chart_dialog_title": "Save Chart", + "search_field": "Search field...", "search_value": "Search value...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "See which teams can access this workspace." }, "unify": { - "add_feedback_record": "Add feedback record", - "add_feedback_record_description": "Create a feedback record manually.", "add_feedback_source": "Add feedback source", "add_source": "Add source", "ai_enrichment": "AI enrichment", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Identifies where this batch of feedback came from.", "csv_unmapped_columns": "Unmapped columns ({count}): {columns}", "csv_unmapped_columns_explainer": "These columns aren't used by any Feedback Record field. They'll be ignored at import.", - "custom_source_type": "Custom source type", - "custom_source_type_placeholder": "Enter custom source type", "data_origin": "Data origin", "default_source_name_csv": "CSV Import", "default_source_name_formbricks": "Formbricks Survey Source", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "This will permanently delete the feedback record and remove it from the connected dataset.", "delete_feedback_records_confirmation": "This will permanently delete {count} feedback records and remove them from the connected dataset.", "delete_source_confirmation": "Deleting this source will stop future imports and remove its saved mapping. Existing feedback records will remain available.", - "discard_feedback_record_changes_description": "Your changes will be lost if you close this drawer.", - "discard_feedback_record_changes_title": "Discard unsaved changes?", "dismiss": "Dismiss", "download_sample_csv": "Download sample CSV", "edit_csv_mapping": "Edit CSV mapping", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Failed to load feedback records", "feedback_data": "Feedback Data", "feedback_directory": "Feedback Dataset", - "feedback_record_created_successfully": "Feedback record created successfully", "feedback_record_deleted_successfully": "Feedback record deleted successfully", "feedback_record_details": "Feedback record details", "feedback_record_details_description": "Review and update feedback record fields.", "feedback_record_mcp": "Use MCP server", - "feedback_record_updated_successfully": "Feedback record updated successfully", - "feedback_record_value_required": "A value is required for the selected field type", "feedback_records": "Feedback Records", "feedback_records_deleted_successfully": "{count} feedback records deleted", "feedback_records_partially_deleted": "{succeeded} of {total} feedback records deleted", @@ -3954,9 +3948,7 @@ "manage_directories": "Manage datasets", "manage_feedback_sources": "Manage feedback sources", "metadata": "Metadata", - "metadata_key": "Metadata key", "metadata_read_only_entries": "Read-only metadata values (non-string)", - "metadata_value": "Metadata value", "missing_feedback_source_title": "Missing feedback source?", "no_feedback_directory_available": "No feedback dataset assigned to this workspace. Create or assign one first.", "no_feedback_directory_linked_admin_description": "Feedback datasets bring feedback data from multiple workspaces together. Connect a dataset to this workspace to get started.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Select a survey to see its questions", "select_a_value": "Select a value...", "select_feedback_directory": "Select a dataset", - "select_feedback_record_source_type": "Select source type", "select_questions": "Select questions", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Select the type of feedback source you want to connect.", @@ -4095,8 +4086,7 @@ "value": "Value", "value_boolean": "Value (Boolean)", "value_date": "Value (Date)", - "value_id": "Option ID", - "value_id_hint": "Stable ID of the selected option in the source survey. It keeps this record linked to its option across label edits and languages, and is managed automatically.", + "value_id": "Value ID", "value_number": "Value (Number)", "value_text": "Value (Text)" }, diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 79be1ce53579..540ea1948bb4 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentimiento", "field_label_sentiment_average": "Sentimiento: Promedio", "field_label_sentiment_score": "Puntuación de sentimiento", + "field_label_source_id": "ID de origen", "field_label_source_name": "Nombre de origen", "field_label_source_type": "Tipo de origen", "field_label_surprise_count": "Emoción: Sorpresa", @@ -1759,6 +1760,7 @@ "no_data_returned": "No hay datos para mostrar, por favor cambia tu configuración.", "no_data_returned_for_chart": "No se han devuelto datos para el gráfico", "no_data_source_available": "No hay ningún conjunto de datos de feedback asignado a este espacio de trabajo.", + "no_fields_found": "No se encontraron campos", "no_grouping": "Ninguno (solo filtro)", "no_valid_data_to_display": "No hay datos válidos para mostrar", "no_values_found": "No se encontraron valores", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Guardar y agregar al panel", "save_chart": "Guardar gráfico", "save_chart_dialog_title": "Guardar gráfico", + "search_field": "Buscar campo...", "search_value": "Buscar valor...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Consulta qué equipos pueden acceder a este espacio de trabajo." }, "unify": { - "add_feedback_record": "Agregar registro de comentarios", - "add_feedback_record_description": "Cree un registro de comentarios manualmente.", "add_feedback_source": "Añadir fuente de feedback", "add_source": "Añadir fuente", "ai_enrichment": "Enriquecimiento de IA", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Identifica de dónde proviene este lote de feedback.", "csv_unmapped_columns": "Columnas sin mapear ({count}): {columns}", "csv_unmapped_columns_explainer": "Estas columnas no están siendo usadas por ningún campo de registro de feedback. Se ignorarán durante la importación.", - "custom_source_type": "Tipo de fuente personalizado", - "custom_source_type_placeholder": "Ingrese el tipo de fuente personalizado", "data_origin": "Data origin", "default_source_name_csv": "Importación CSV", "default_source_name_formbricks": "Fuente de encuestas Formbricks", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Esto eliminará permanentemente el registro de feedback y lo quitará del conjunto de datos conectado.", "delete_feedback_records_confirmation": "Esto eliminará permanentemente {count} registros de feedback y los quitará del conjunto de datos conectado.", "delete_source_confirmation": "Eliminar esta fuente detendrá futuras importaciones y eliminará su mapeo guardado. Los registros de comentarios existentes seguirán estando disponibles.", - "discard_feedback_record_changes_description": "Sus cambios se perderán si cierra este cajón.", - "discard_feedback_record_changes_title": "¿Descartar los cambios no guardados?", "dismiss": "Dismiss", "download_sample_csv": "Descargar CSV de muestra", "edit_csv_mapping": "Editar mapeo de CSV", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Error al cargar los registros de comentarios", "feedback_data": "Feedback Data", "feedback_directory": "Conjunto de Datos de Feedback", - "feedback_record_created_successfully": "Registro de comentarios creado correctamente", "feedback_record_deleted_successfully": "Registro de comentarios eliminado correctamente", "feedback_record_details": "Detalles del registro de comentarios", "feedback_record_details_description": "Revise y actualice los campos del registro de comentarios.", "feedback_record_mcp": "Usar servidor MCP", - "feedback_record_updated_successfully": "Registro de comentarios actualizado correctamente", - "feedback_record_value_required": "Se requiere un valor para el tipo de campo seleccionado", "feedback_records": "Registros de comentarios", "feedback_records_deleted_successfully": "{count} registros de comentarios eliminados", "feedback_records_partially_deleted": "{succeeded} de {total} registros de comentarios eliminados", @@ -3954,9 +3948,7 @@ "manage_directories": "Gestionar conjuntos de datos", "manage_feedback_sources": "Administrar fuentes de comentarios", "metadata": "Metadatos", - "metadata_key": "Clave de metadatos", "metadata_read_only_entries": "Valores de metadatos de solo lectura (no cadenas)", - "metadata_value": "Valor de metadatos", "missing_feedback_source_title": "¿Falta alguna fuente de feedback?", "no_feedback_directory_available": "No hay ningún conjunto de datos de feedback asignado a este espacio de trabajo. Crea o asigna uno primero.", "no_feedback_directory_linked_admin_description": "Los conjuntos de datos de comentarios reúnen datos de comentarios de múltiples espacios de trabajo. Conecta un conjunto de datos a este espacio de trabajo para comenzar.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Selecciona una encuesta para ver sus preguntas", "select_a_value": "Selecciona un valor...", "select_feedback_directory": "Selecciona un conjunto de datos", - "select_feedback_record_source_type": "Seleccionar tipo de fuente", "select_questions": "Seleccionar preguntas", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Selecciona el tipo de fuente de feedback que quieres conectar.", @@ -4095,8 +4086,7 @@ "value": "Valor", "value_boolean": "Valor (booleano)", "value_date": "Valor (Fecha)", - "value_id": "ID de opción", - "value_id_hint": "ID estable de la opción seleccionada en la encuesta de origen. Mantiene este registro vinculado a su opción a través de ediciones de etiquetas e idiomas, y se gestiona automáticamente.", + "value_id": "ID de valor", "value_number": "Valor (Número)", "value_text": "Valor (Texto)" }, diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 05292ec2aade..58e07d7f60f7 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentiment", "field_label_sentiment_average": "Sentiment : Moyenne", "field_label_sentiment_score": "Score de sentiment", + "field_label_source_id": "ID source", "field_label_source_name": "Nom de la source", "field_label_source_type": "Type de source", "field_label_surprise_count": "Émotion : Surprise", @@ -1759,6 +1760,7 @@ "no_data_returned": "Aucune donnée à afficher, merci de modifier tes paramètres.", "no_data_returned_for_chart": "Aucune donnée retournée pour le graphique", "no_data_source_available": "Aucun ensemble de données de feedback n'est attribué à cet espace de travail.", + "no_fields_found": "Aucun champ trouvé", "no_grouping": "Aucun (filtre uniquement)", "no_valid_data_to_display": "Aucune donnée valide à afficher", "no_values_found": "Aucune valeur trouvée", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Enregistrer et ajouter au tableau de bord", "save_chart": "Enregistrer le graphique", "save_chart_dialog_title": "Enregistrer le graphique", + "search_field": "Rechercher un champ...", "search_value": "Rechercher une valeur...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Voir quelles équipes peuvent accéder à cet espace de travail." }, "unify": { - "add_feedback_record": "Ajouter un enregistrement de commentaires", - "add_feedback_record_description": "Créez manuellement un enregistrement de commentaires.", "add_feedback_source": "Ajouter une source de feedback", "add_source": "Ajouter une source", "ai_enrichment": "Enrichissement IA", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Identifie d'où provient ce lot de retours.", "csv_unmapped_columns": "Colonnes non mappées ({count}) : {columns}", "csv_unmapped_columns_explainer": "Ces colonnes ne sont utilisées par aucun champ d'enregistrement de retour. Elles seront ignorées lors de l'importation.", - "custom_source_type": "Type de source personnalisé", - "custom_source_type_placeholder": "Entrez le type de source personnalisé", "data_origin": "Data origin", "default_source_name_csv": "Importation CSV", "default_source_name_formbricks": "Source de sondage Formbricks", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Cette action supprimera définitivement le retour et le retirera de l'ensemble de données connecté.", "delete_feedback_records_confirmation": "Cette action supprimera définitivement {count} retours et les retirera de l'ensemble de données connecté.", "delete_source_confirmation": "La suppression de cette source arrêtera les futures importations et supprimera sa configuration de mapping enregistrée. Les enregistrements de feedback existants resteront disponibles.", - "discard_feedback_record_changes_description": "Vos modifications seront perdues si vous fermez ce tiroir.", - "discard_feedback_record_changes_title": "Supprimer les modifications non enregistrées ?", "dismiss": "Dismiss", "download_sample_csv": "Télécharger un exemple de CSV", "edit_csv_mapping": "Modifier le mappage CSV", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback", "feedback_data": "Feedback Data", "feedback_directory": "Ensemble de données de feedback", - "feedback_record_created_successfully": "Enregistrement de commentaires créé avec succès", "feedback_record_deleted_successfully": "Enregistrement de commentaire supprimé avec succès", "feedback_record_details": "Détails de l'enregistrement des commentaires", "feedback_record_details_description": "Examiner et mettre à jour les champs d’enregistrement des commentaires.", "feedback_record_mcp": "Utiliser le serveur MCP", - "feedback_record_updated_successfully": "L'enregistrement des commentaires a été mis à jour avec succès", - "feedback_record_value_required": "Une valeur est requise pour le type de champ sélectionné", "feedback_records": "Enregistrements de feedback", "feedback_records_deleted_successfully": "{count} enregistrements de commentaires supprimés", "feedback_records_partially_deleted": "{succeeded} enregistrements de commentaires supprimés sur {total}", @@ -3954,9 +3948,7 @@ "manage_directories": "Gérer les ensembles de données", "manage_feedback_sources": "Gérer les sources de commentaires", "metadata": "Métadonnées", - "metadata_key": "Clé de métadonnées", "metadata_read_only_entries": "Valeurs de métadonnées en lecture seule (non-chaîne)", - "metadata_value": "Valeur des métadonnées", "missing_feedback_source_title": "Il manque une source de feedback ?", "no_feedback_directory_available": "Aucun ensemble de données de feedback n'est assigné à cet espace de travail. Crée ou assigne-en un d'abord.", "no_feedback_directory_linked_admin_description": "Les jeux de données de retours rassemblent les données de retours de plusieurs espaces de travail. Connecte un jeu de données à cet espace de travail pour commencer.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Sélectionnez une enquête pour voir ses questions", "select_a_value": "Sélectionnez une valeur...", "select_feedback_directory": "Sélectionner un ensemble de données", - "select_feedback_record_source_type": "Sélectionnez le type de source", "select_questions": "Sélectionner les questions", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Sélectionnez le type de source de feedback que vous souhaitez connecter.", @@ -4095,8 +4086,7 @@ "value": "Valeur", "value_boolean": "Valeur (booléenne)", "value_date": "Valeur (Date)", - "value_id": "ID de l'option", - "value_id_hint": "ID stable de l'option sélectionnée dans le questionnaire source. Il maintient ce dossier lié à son option lors de modifications de libellé et de changements de langue, et est géré automatiquement.", + "value_id": "ID de valeur", "value_number": "Valeur (Nombre)", "value_text": "Valeur (texte)" }, diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 78b0bb3262e1..bb850fba9d82 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Hangulat", "field_label_sentiment_average": "Vélemény: Átlag", "field_label_sentiment_score": "Hangulatpontszám", + "field_label_source_id": "Forrásazonosító", "field_label_source_name": "Forrás neve", "field_label_source_type": "Forrás típusa", "field_label_surprise_count": "Érzelem: Meglepetés", @@ -1759,6 +1760,7 @@ "no_data_returned": "Nincs megjeleníthető adat, kérem, módosítsa a beállításait.", "no_data_returned_for_chart": "Nem lett adat visszaadva a diagramhoz", "no_data_source_available": "Ehhez a munkaterülethez nincs hozzárendelve visszajelzési adatkészlet.", + "no_fields_found": "Nem található mező", "no_grouping": "Nincs (csak szűrő)", "no_valid_data_to_display": "Nincsenek érvényes adatok a megjelenítéshez", "no_values_found": "Nem található érték", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Mentés és hozzáadás a vezérlőpulthoz", "save_chart": "Diagram mentése", "save_chart_dialog_title": "Diagram mentése", + "search_field": "Mező keresése...", "search_value": "Érték keresése...", "select_data_source": "Adatforrás kiválasztása", "select_data_source_first": "Először válasszon egy adatforrást", @@ -3835,8 +3838,6 @@ "team_settings_description": "Annak megtekintése, hogy mely csapatok férhetnek hozzá ehhez a munkaterülethez." }, "unify": { - "add_feedback_record": "Visszajelzési rekord hozzáadása", - "add_feedback_record_description": "Visszajelzési rekord létrehozása kézzel.", "add_feedback_source": "Visszajelzési forrás hozzáadása", "add_source": "Forrás hozzáadása", "ai_enrichment": "Mesterséges intelligencia alapú dúsítás", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Azonosítja, hogy honnan származik ez a visszajelzésköteg.", "csv_unmapped_columns": "Leképezetlen oszlopok ({count}): {columns}", "csv_unmapped_columns_explainer": "Ezeket az oszlopokat nem használja egyetlen visszajelzési rekord sem. Mellőzve lesznek az importáláskor.", - "custom_source_type": "Egyéni forrástípus", - "custom_source_type_placeholder": "Egyéni forrástípus megadása", "data_origin": "Data origin", "default_source_name_csv": "CSV-importálás", "default_source_name_formbricks": "Formbricks-kérdőív forrása", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Ez véglegesen törli a visszajelzési rekordot, és eltávolítja a csatlakoztatott adatkészletből.", "delete_feedback_records_confirmation": "Ez véglegesen töröl {count} visszajelzési rekordot, és eltávolítja azokat a csatlakoztatott adatkészletből.", "delete_source_confirmation": "A forrás törlése le fogja állítani a jövőbeli importálásokat, és eltávolítja a mentett leképezését. A meglévő visszajelzési rekordok elérhetők maradnak.", - "discard_feedback_record_changes_description": "A változtatásai el fognak veszni, ha bezárja ezt a fiókot.", - "discard_feedback_record_changes_title": "Elveti a mentetlen változtatásokat?", "dismiss": "Dismiss", "download_sample_csv": "Minta CSV letöltése", "edit_csv_mapping": "CSV-leképezés szerkesztése", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Nem sikerült betölteni a visszajelzési rekordokat", "feedback_data": "Feedback Data", "feedback_directory": "Visszajelzési Adatkészlet", - "feedback_record_created_successfully": "A visszajelzési rekord sikeresen létrehozva", "feedback_record_deleted_successfully": "A visszajelzési rekord sikeresen törölve", "feedback_record_details": "Visszajelzési rekord részletei", "feedback_record_details_description": "Visszajelzési rekord mezőinek értékelése és frissítése.", "feedback_record_mcp": "MCP szerver használata", - "feedback_record_updated_successfully": "A visszajelzési rekord sikeresen frissítve", - "feedback_record_value_required": "Érték szükséges a kiválasztott mezőtípushoz", "feedback_records": "Visszajelzési rekordok", "feedback_records_deleted_successfully": "{count} visszajelzési rekord törölve", "feedback_records_partially_deleted": "{succeeded} / {total} visszajelzési rekord törölve", @@ -3954,9 +3948,7 @@ "manage_directories": "Adatkészletek kezelése", "manage_feedback_sources": "Visszajelzési források kezelése", "metadata": "Metaadatok", - "metadata_key": "Metaadatkulcs", "metadata_read_only_entries": "Csak olvasható metaadatértékek (nem karakterlánc)", - "metadata_value": "Metaadatérték", "missing_feedback_source_title": "Hiányzik a visszajelzési forrás?", "no_feedback_directory_available": "Nincs visszajelzési adatkészlet hozzárendelve ehhez a munkaterülethez. Először hozzon létre vagy rendeljen hozzá egyet.", "no_feedback_directory_linked_admin_description": "A visszajelzési adatkészletek több munkaterületről származó visszajelzési adatokat egyesítenek. Csatlakoztasson egy adatkészletet ehhez a munkaterülethez a kezdéshez.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Kérdőív kiválasztása a kérdései megtekintéséhez", "select_a_value": "Érték kiválasztása…", "select_feedback_directory": "Válasszon egy adatkészletet", - "select_feedback_record_source_type": "Forrástípus kiválasztása", "select_questions": "Kérdések kiválasztása", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Válassza ki a csatlakoztatni kívánt visszajelzési forrás típusát.", @@ -4095,8 +4086,7 @@ "value": "Érték", "value_boolean": "Érték (logikai)", "value_date": "Érték (dátum)", - "value_id": "Opció-azonosító", - "value_id_hint": "A forrásfelmérésben kiválasztott opció stabil azonosítója. Ez tartja a rekordot kapcsolatban az opciójával a címkeszerkesztések és nyelvek között, és automatikusan kerül kezelésre.", + "value_id": "Érték azonosító", "value_number": "Érték (szám)", "value_text": "Érték (szöveg)" }, diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 3e46b6d85f86..365ec5b1d642 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "センチメント", "field_label_sentiment_average": "センチメント:平均", "field_label_sentiment_score": "センチメントスコア", + "field_label_source_id": "ソースID", "field_label_source_name": "ソース名", "field_label_source_type": "ソースタイプ", "field_label_surprise_count": "感情:驚き", @@ -1759,6 +1760,7 @@ "no_data_returned": "表示するデータがありません。設定を変更してください。", "no_data_returned_for_chart": "チャートのデータが返されませんでした", "no_data_source_available": "このワークスペースにはフィードバックデータセットが割り当てられていません。", + "no_fields_found": "フィールドが見つかりません", "no_grouping": "なし(フィルターのみ)", "no_valid_data_to_display": "表示する有効なデータがありません", "no_values_found": "値が見つかりません", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "保存してダッシュボードに追加", "save_chart": "チャートを保存", "save_chart_dialog_title": "チャートを保存", + "search_field": "フィールドを検索...", "search_value": "値を検索...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "このワークスペースにアクセスできるチームを確認します。" }, "unify": { - "add_feedback_record": "フィードバックレコードを追加する", - "add_feedback_record_description": "フィードバック記録を手動で作成します。", "add_feedback_source": "フィードバックソースを追加", "add_source": "ソースを追加", "ai_enrichment": "AIエンリッチメント", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "このフィードバックのバッチがどこから来たかを識別します。", "csv_unmapped_columns": "未マッピングの列({count}件): {columns}", "csv_unmapped_columns_explainer": "これらの列はフィードバックレコードのどのフィールドにも使用されていません。インポート時に無視されます。", - "custom_source_type": "カスタムソースタイプ", - "custom_source_type_placeholder": "カスタムソースタイプを入力してください", "data_origin": "Data origin", "default_source_name_csv": "CSVインポート", "default_source_name_formbricks": "Formbricksアンケートソース", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "フィードバック記録が完全に削除され、接続されたデータセットから削除されます。", "delete_feedback_records_confirmation": "{count}件のフィードバック記録が完全に削除され、接続されたデータセットから削除されます。", "delete_source_confirmation": "このソースを削除すると、今後のインポートが停止され、保存されたマッピングが削除されます。既存のフィードバック記録は引き続き利用可能です。", - "discard_feedback_record_changes_description": "このドロワーを閉じると、変更内容は失われます。", - "discard_feedback_record_changes_title": "保存されていない変更を破棄しますか?", "dismiss": "Dismiss", "download_sample_csv": "サンプルCSVをダウンロード", "edit_csv_mapping": "CSVマッピングを編集", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "フィードバックレコードの読み込みに失敗しました", "feedback_data": "Feedback Data", "feedback_directory": "フィードバックデータセット", - "feedback_record_created_successfully": "フィードバックレコードが正常に作成されました", "feedback_record_deleted_successfully": "フィードバック記録を削除しました", "feedback_record_details": "フィードバック記録の詳細", "feedback_record_details_description": "フィードバック レコード フィールドを確認して更新します。", "feedback_record_mcp": "MCPサーバーを使用", - "feedback_record_updated_successfully": "フィードバックレコードが正常に更新されました", - "feedback_record_value_required": "選択したフィールド タイプには値が必要です", "feedback_records": "フィードバックレコード", "feedback_records_deleted_successfully": "{count}件のフィードバック記録を削除しました", "feedback_records_partially_deleted": "{total}件中{succeeded}件のフィードバックレコードを削除しました", @@ -3954,9 +3948,7 @@ "manage_directories": "データセットを管理", "manage_feedback_sources": "フィードバックソースを管理する", "metadata": "メタデータ", - "metadata_key": "メタデータキー", "metadata_read_only_entries": "読み取り専用メタデータ値 (非文字列)", - "metadata_value": "メタデータ値", "missing_feedback_source_title": "フィードバックソースが見つかりませんか?", "no_feedback_directory_available": "このワークスペースにフィードバックデータセットが割り当てられていません。まず作成または割り当ててください。", "no_feedback_directory_linked_admin_description": "フィードバックデータセットは、複数のワークスペースからのフィードバックデータをまとめます。開始するには、このワークスペースにデータセットを接続してください。", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "フォームを選択して質問を表示", "select_a_value": "値を選択...", "select_feedback_directory": "データセットを選択", - "select_feedback_record_source_type": "ソースタイプを選択してください", "select_questions": "質問を選択", "select_questions_for_import": "Select questions for import", "select_source_type_description": "接続するフィードバックソースの種類を選択してください。", @@ -4095,8 +4086,7 @@ "value": "値", "value_boolean": "値 (ブール値)", "value_date": "値 (日付)", - "value_id": "オプションID", - "value_id_hint": "ソース調査で選択されたオプションの固定ID。ラベルの編集や言語の変更に関わらず、このレコードをオプションにリンクし続けます。自動的に管理されます。", + "value_id": "値ID", "value_number": "値(数値)", "value_text": "値 (テキスト)" }, diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 712f23b7d486..ee3ab0d32ea3 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentiment", "field_label_sentiment_average": "Sentiment: Gemiddelde", "field_label_sentiment_score": "Sentimentscore", + "field_label_source_id": "Bron-ID", "field_label_source_name": "Bronnaam", "field_label_source_type": "Brontype", "field_label_surprise_count": "Emotie: Verrassing", @@ -1759,6 +1760,7 @@ "no_data_returned": "Geen gegevens om weer te geven, pas je instellingen aan.", "no_data_returned_for_chart": "Geen gegevens geretourneerd voor diagram", "no_data_source_available": "Er is geen feedbackdataset toegewezen aan deze werkruimte.", + "no_fields_found": "Geen velden gevonden", "no_grouping": "Geen (alleen filteren)", "no_valid_data_to_display": "Geen geldige gegevens om weer te geven", "no_values_found": "Geen waarden gevonden", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Opslaan en toevoegen aan dashboard", "save_chart": "Diagram opslaan", "save_chart_dialog_title": "Diagram opslaan", + "search_field": "Zoek veld...", "search_value": "Waarde zoeken...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Bekijk welke teams toegang hebben tot deze workspace." }, "unify": { - "add_feedback_record": "Feedbackrecord toevoegen", - "add_feedback_record_description": "Maak handmatig een feedbackrecord.", "add_feedback_source": "Feedbackbron toevoegen", "add_source": "Bron toevoegen", "ai_enrichment": "AI-verrijking", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Geeft aan waar deze batch feedback vandaan komt.", "csv_unmapped_columns": "Niet-gekoppelde kolommen ({count}): {columns}", "csv_unmapped_columns_explainer": "Deze kolommen worden niet gebruikt door een Feedback Record-veld. Ze worden genegeerd bij het importeren.", - "custom_source_type": "Aangepast brontype", - "custom_source_type_placeholder": "Voer een aangepast brontype in", "data_origin": "Data origin", "default_source_name_csv": "CSV import", "default_source_name_formbricks": "Formbricks Enquêtebron", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Dit verwijdert het feedbackgegeven permanent en haalt het uit de gekoppelde dataset.", "delete_feedback_records_confirmation": "Dit verwijdert {count} feedbackgegevens permanent en haalt ze uit de gekoppelde dataset.", "delete_source_confirmation": "Het verwijderen van deze bron stopt toekomstige imports en verwijdert de opgeslagen mapping. Bestaande feedbackgegevens blijven beschikbaar.", - "discard_feedback_record_changes_description": "Als u deze lade sluit, gaan uw wijzigingen verloren.", - "discard_feedback_record_changes_title": "Niet-opgeslagen wijzigingen verwijderen?", "dismiss": "Dismiss", "download_sample_csv": "Voorbeeld-CSV downloaden", "edit_csv_mapping": "CSV-mapping bewerken", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Kan feedbackrecords niet laden", "feedback_data": "Feedback Data", "feedback_directory": "Feedback-dataset", - "feedback_record_created_successfully": "Feedbackrecord is succesvol aangemaakt", "feedback_record_deleted_successfully": "Feedbackrecord succesvol verwijderd", "feedback_record_details": "Details van feedbackrecord", "feedback_record_details_description": "Controleer en update de feedbackrecordvelden.", "feedback_record_mcp": "Gebruik MCP-server", - "feedback_record_updated_successfully": "Feedbackrecord is succesvol bijgewerkt", - "feedback_record_value_required": "Er is een waarde vereist voor het geselecteerde veldtype", "feedback_records": "Feedbackrecords", "feedback_records_deleted_successfully": "{count} feedbackrecords verwijderd", "feedback_records_partially_deleted": "{succeeded} van {total} feedbackgegevens verwijderd", @@ -3954,9 +3948,7 @@ "manage_directories": "Datasets beheren", "manage_feedback_sources": "Beheer feedbackbronnen", "metadata": "Metagegevens", - "metadata_key": "Metagegevenssleutel", "metadata_read_only_entries": "Alleen-lezen metadatawaarden (niet-tekenreeks)", - "metadata_value": "Metagegevenswaarde", "missing_feedback_source_title": "Mis je een feedbackbron?", "no_feedback_directory_available": "Geen feedback-dataset toegewezen aan deze workspace. Maak of wijs er eerst een toe.", "no_feedback_directory_linked_admin_description": "Feedback-datasets brengen feedbackgegevens uit meerdere workspaces samen. Koppel een dataset aan deze workspace om aan de slag te gaan.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Selecteer een enquête om de vragen te zien", "select_a_value": "Selecteer een waarde...", "select_feedback_directory": "Selecteer een dataset", - "select_feedback_record_source_type": "Selecteer brontype", "select_questions": "Selecteer vragen", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Selecteer het type feedbackbron dat je wilt verbinden.", @@ -4095,8 +4086,7 @@ "value": "Waarde", "value_boolean": "Waarde (Booleaans)", "value_date": "Waarde (datum)", - "value_id": "Optie-ID", - "value_id_hint": "Stabiele ID van de geselecteerde optie in de bronnenquête. Het houdt dit record gekoppeld aan zijn optie over labelbewerkingen en talen heen, en wordt automatisch beheerd.", + "value_id": "Waarde-ID", "value_number": "Waarde (getal)", "value_text": "Waarde (tekst)" }, diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index c5a76530b7aa..3b27844ac4f6 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentimento", "field_label_sentiment_average": "Sentimento: Média", "field_label_sentiment_score": "Pontuação de sentimento", + "field_label_source_id": "ID de Origem", "field_label_source_name": "Nome da fonte", "field_label_source_type": "Tipo de fonte", "field_label_surprise_count": "Emoção: Surpresa", @@ -1759,6 +1760,7 @@ "no_data_returned": "Nenhum dado para exibir, por favor altere suas configurações.", "no_data_returned_for_chart": "Nenhum dado retornado para o gráfico", "no_data_source_available": "Nenhum conjunto de dados de feedback está atribuído a este workspace.", + "no_fields_found": "Nenhum campo encontrado", "no_grouping": "Nenhum (apenas filtro)", "no_valid_data_to_display": "Nenhum dado válido para exibir", "no_values_found": "Nenhum valor encontrado", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Salvar e adicionar ao painel", "save_chart": "Salvar gráfico", "save_chart_dialog_title": "Salvar gráfico", + "search_field": "Pesquisar campo...", "search_value": "Buscar valor...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Veja quais equipes podem acessar este workspace." }, "unify": { - "add_feedback_record": "Adicionar registro de feedback", - "add_feedback_record_description": "Crie um registro de feedback manualmente.", "add_feedback_source": "Adicionar fonte de feedback", "add_source": "Adicionar fonte", "ai_enrichment": "Enriquecimento de IA", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Identifica de onde esse lote de feedback veio.", "csv_unmapped_columns": "Colunas não mapeadas ({count}): {columns}", "csv_unmapped_columns_explainer": "Essas colunas não são usadas por nenhum campo de Registro de Feedback. Elas serão ignoradas na importação.", - "custom_source_type": "Tipo de origem personalizado", - "custom_source_type_placeholder": "Insira o tipo de fonte personalizado", "data_origin": "Data origin", "default_source_name_csv": "Importação CSV", "default_source_name_formbricks": "Fonte de Pesquisa Formbricks", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Isso excluirá permanentemente o registro de feedback e o removerá do conjunto de dados conectado.", "delete_feedback_records_confirmation": "Isso excluirá permanentemente {count} registros de feedback e os removerá do conjunto de dados conectado.", "delete_source_confirmation": "Excluir esta fonte interromperá futuras importações e removerá seu mapeamento salvo. Os registros de feedback existentes permanecerão disponíveis.", - "discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.", - "discard_feedback_record_changes_title": "Descartar alterações não salvas?", "dismiss": "Dismiss", "download_sample_csv": "Baixar CSV de exemplo", "edit_csv_mapping": "Editar mapeamento CSV", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Falha ao carregar registros de feedback", "feedback_data": "Feedback Data", "feedback_directory": "Conjunto de Dados de Feedback", - "feedback_record_created_successfully": "Registro de feedback criado com sucesso", "feedback_record_deleted_successfully": "Registro de feedback excluído com sucesso", "feedback_record_details": "Detalhes do registro de feedback", "feedback_record_details_description": "Revise e atualize os campos de registro de feedback.", "feedback_record_mcp": "Usar servidor MCP", - "feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso", - "feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado", "feedback_records": "Registros de feedback", "feedback_records_deleted_successfully": "{count} registros de feedback excluídos", "feedback_records_partially_deleted": "{succeeded} de {total} registros de feedback excluídos", @@ -3954,9 +3948,7 @@ "manage_directories": "Gerenciar conjuntos de dados", "manage_feedback_sources": "Gerenciar fontes de feedback", "metadata": "Metadados", - "metadata_key": "Chave de metadados", "metadata_read_only_entries": "Valores de metadados somente leitura (sem string)", - "metadata_value": "Valor dos metadados", "missing_feedback_source_title": "Faltando alguma fonte de feedback?", "no_feedback_directory_available": "Nenhum conjunto de dados de feedback atribuído a este workspace. Crie ou atribua um primeiro.", "no_feedback_directory_linked_admin_description": "Conjuntos de dados de feedback reúnem dados de feedback de vários workspaces. Conecte um conjunto de dados a este workspace para começar.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Selecione uma pesquisa para ver suas perguntas", "select_a_value": "Selecione um valor...", "select_feedback_directory": "Selecionar um conjunto de dados", - "select_feedback_record_source_type": "Selecione o tipo de fonte", "select_questions": "Selecionar perguntas", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Selecione o tipo de fonte de feedback que você deseja conectar.", @@ -4095,8 +4086,7 @@ "value": "Valor", "value_boolean": "Valor (Booleano)", "value_date": "Valor (Data)", - "value_id": "ID da Opção", - "value_id_hint": "ID estável da opção selecionada na pesquisa de origem. Mantém este registro vinculado à sua opção mesmo com edições de rótulo e mudanças de idioma, sendo gerenciado automaticamente.", + "value_id": "ID do Valor", "value_number": "Valor (Número)", "value_text": "Valor (Texto)" }, diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 57300283838d..8c0a77e34f8c 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentimento", "field_label_sentiment_average": "Sentimento: Média", "field_label_sentiment_score": "Pontuação de sentimento", + "field_label_source_id": "ID de Origem", "field_label_source_name": "Nome da origem", "field_label_source_type": "Tipo de origem", "field_label_surprise_count": "Emoção: Surpresa", @@ -1759,6 +1760,7 @@ "no_data_returned": "Não há dados para apresentar, por favor altera as tuas definições.", "no_data_returned_for_chart": "Nenhum dado devolvido para o gráfico", "no_data_source_available": "Nenhum conjunto de dados de feedback está atribuído a este espaço de trabalho.", + "no_fields_found": "Nenhum campo encontrado", "no_grouping": "Nenhum (apenas filtro)", "no_valid_data_to_display": "Nenhum dado válido para exibir", "no_values_found": "Nenhum valor encontrado", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Salvar e adicionar ao painel", "save_chart": "Guardar gráfico", "save_chart_dialog_title": "Guardar gráfico", + "search_field": "Pesquisar campo...", "search_value": "Pesquisar valor...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Veja quais as equipas que podem aceder a este espaço de trabalho." }, "unify": { - "add_feedback_record": "Adicionar registro de feedback", - "add_feedback_record_description": "Crie um registro de feedback manualmente.", "add_feedback_source": "Adicionar fonte de feedback", "add_source": "Adicionar fonte", "ai_enrichment": "Enriquecimento de IA", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Identifica de onde veio este lote de feedback.", "csv_unmapped_columns": "Colunas não mapeadas ({count}): {columns}", "csv_unmapped_columns_explainer": "Estas colunas não são usadas por nenhum campo de Registo de Feedback. Serão ignoradas na importação.", - "custom_source_type": "Tipo de origem personalizado", - "custom_source_type_placeholder": "Insira o tipo de fonte personalizado", "data_origin": "Data origin", "default_source_name_csv": "Importação CSV", "default_source_name_formbricks": "Fonte de Inquéritos Formbricks", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Isto irá eliminar permanentemente o registo de feedback e removê-lo do conjunto de dados associado.", "delete_feedback_records_confirmation": "Isto irá eliminar permanentemente {count} registos de feedback e removê-los do conjunto de dados associado.", "delete_source_confirmation": "Eliminar esta origem irá parar importações futuras e remover o seu mapeamento guardado. Os registos de feedback existentes permanecerão disponíveis.", - "discard_feedback_record_changes_description": "Suas alterações serão perdidas se você fechar esta gaveta.", - "discard_feedback_record_changes_title": "Descartar alterações não salvas?", "dismiss": "Dismiss", "download_sample_csv": "Transferir CSV de exemplo", "edit_csv_mapping": "Editar mapeamento CSV", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Falha ao carregar registos de feedback", "feedback_data": "Feedback Data", "feedback_directory": "Conjunto de Dados de Feedback", - "feedback_record_created_successfully": "Registro de feedback criado com sucesso", "feedback_record_deleted_successfully": "Registo de feedback eliminado com sucesso", "feedback_record_details": "Detalhes do registro de feedback", "feedback_record_details_description": "Revise e atualize os campos de registro de feedback.", "feedback_record_mcp": "Usar servidor MCP", - "feedback_record_updated_successfully": "Registro de feedback atualizado com sucesso", - "feedback_record_value_required": "Um valor é obrigatório para o tipo de campo selecionado", "feedback_records": "Registos de feedback", "feedback_records_deleted_successfully": "{count} registos de feedback eliminados", "feedback_records_partially_deleted": "{succeeded} de {total} registos de feedback eliminados", @@ -3954,9 +3948,7 @@ "manage_directories": "Gerir conjuntos de dados", "manage_feedback_sources": "Gerenciar fontes de feedback", "metadata": "Metadados", - "metadata_key": "Chave de metadados", "metadata_read_only_entries": "Valores de metadados somente leitura (sem string)", - "metadata_value": "Valor dos metadados", "missing_feedback_source_title": "Falta alguma fonte de feedback?", "no_feedback_directory_available": "Nenhum conjunto de dados de feedback atribuído a este espaço de trabalho. Cria ou atribui um primeiro.", "no_feedback_directory_linked_admin_description": "Os conjuntos de dados de feedback reúnem dados de feedback de vários espaços de trabalho. Liga um conjunto de dados a este espaço de trabalho para começares.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Selecione um inquérito para ver as suas perguntas", "select_a_value": "Selecione um valor...", "select_feedback_directory": "Selecionar um conjunto de dados", - "select_feedback_record_source_type": "Selecione o tipo de fonte", "select_questions": "Selecionar perguntas", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Selecione o tipo de fonte de feedback que pretende conectar.", @@ -4095,8 +4086,7 @@ "value": "Valor", "value_boolean": "Valor (Booleano)", "value_date": "Valor (Data)", - "value_id": "ID da opção", - "value_id_hint": "ID estável da opção selecionada no inquérito de origem. Mantém este registo ligado à sua opção através de edições de etiquetas e idiomas, sendo gerido automaticamente.", + "value_id": "ID de Valor", "value_number": "Valor (Número)", "value_text": "Valor (Texto)" }, diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 9befb981a090..40ab48aee398 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentiment", "field_label_sentiment_average": "Sentiment: Medie", "field_label_sentiment_score": "Scor de sentiment", + "field_label_source_id": "ID sursă", "field_label_source_name": "Nume sursă", "field_label_source_type": "Tip sursă", "field_label_surprise_count": "Emoție: Surpriză", @@ -1759,6 +1760,7 @@ "no_data_returned": "Nu există date de afișat, te rugăm să modifici setările.", "no_data_returned_for_chart": "Nu au fost returnate date pentru grafic", "no_data_source_available": "Niciun set de date cu feedback nu este atribuit acestui spațiu de lucru.", + "no_fields_found": "Nu s-au găsit câmpuri", "no_grouping": "Niciuna (doar filtru)", "no_valid_data_to_display": "Nu există date valide de afișat", "no_values_found": "Nu s-au găsit valori", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Salvați și adăugați în tabloul de bord", "save_chart": "Salvează graficul", "save_chart_dialog_title": "Salvează graficul", + "search_field": "Caută câmp...", "search_value": "Caută valoare...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Vedeți ce echipe pot accesa acest spațiu de lucru." }, "unify": { - "add_feedback_record": "Adăugați înregistrarea de feedback", - "add_feedback_record_description": "Creați manual o înregistrare de feedback.", "add_feedback_source": "Adaugă sursă de feedback", "add_source": "Adaugă sursă", "ai_enrichment": "Îmbogățire AI", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Identifică de unde provine acest lot de feedback.", "csv_unmapped_columns": "Coloane nemapate ({count}): {columns}", "csv_unmapped_columns_explainer": "Aceste coloane nu sunt folosite de niciun câmp din Înregistrarea de Feedback. Vor fi ignorate la import.", - "custom_source_type": "Tip sursă personalizat", - "custom_source_type_placeholder": "Introduceți tipul de sursă personalizat", "data_origin": "Data origin", "default_source_name_csv": "Import CSV", "default_source_name_formbricks": "Sursă de sondaje Formbricks", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Aceasta va șterge definitiv înregistrarea de feedback și o va elimina din setul de date conectat.", "delete_feedback_records_confirmation": "Aceasta va șterge definitiv {count} înregistrări de feedback și le va elimina din setul de date conectat.", "delete_source_confirmation": "Ștergerea acestei surse va opri importurile viitoare și va elimina maparea salvată. Înregistrările de feedback existente vor rămâne disponibile.", - "discard_feedback_record_changes_description": "Modificările dvs. se vor pierde dacă închideți acest sertar.", - "discard_feedback_record_changes_title": "Renunțați la modificările nesalvate?", "dismiss": "Dismiss", "download_sample_csv": "Descarcă un CSV de exemplu", "edit_csv_mapping": "Editează maparea CSV", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Nu s-au putut încărca înregistrările de feedback", "feedback_data": "Feedback Data", "feedback_directory": "Set de date de feedback", - "feedback_record_created_successfully": "Înregistrare de feedback creată cu succes", "feedback_record_deleted_successfully": "Înregistrarea de feedback a fost ștearsă cu succes", "feedback_record_details": "Detaliile înregistrării feedback-ului", "feedback_record_details_description": "Examinați și actualizați câmpurile pentru înregistrarea de feedback.", "feedback_record_mcp": "Folosește serverul MCP", - "feedback_record_updated_successfully": "Înregistrarea feedback-ului a fost actualizată cu succes", - "feedback_record_value_required": "Este necesară o valoare pentru tipul de câmp selectat", "feedback_records": "Înregistrări de feedback", "feedback_records_deleted_successfully": "{count} înregistrări de feedback șterse", "feedback_records_partially_deleted": "{succeeded} din {total} înregistrări de feedback șterse", @@ -3954,9 +3948,7 @@ "manage_directories": "Gestionează seturile de date", "manage_feedback_sources": "Gestionați sursele de feedback", "metadata": "Metadate", - "metadata_key": "Cheia de metadate", "metadata_read_only_entries": "Valori de metadate numai pentru citire (fără șir)", - "metadata_value": "Valoarea metadatelor", "missing_feedback_source_title": "Lipsește o sursă de feedback?", "no_feedback_directory_available": "Niciun set de date de feedback atribuit acestui workspace. Creează sau atribuie unul mai întâi.", "no_feedback_directory_linked_admin_description": "Seturile de date cu feedback reunesc datele de feedback din mai multe spații de lucru. Conectează un set de date la acest spațiu de lucru pentru a începe.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Selectează un chestionar pentru a vedea întrebările", "select_a_value": "Selectează o valoare...", "select_feedback_directory": "Selectează un set de date", - "select_feedback_record_source_type": "Selectați tipul sursei", "select_questions": "Selectează întrebări", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Selectează tipul sursei de feedback pe care vrei să o conectezi.", @@ -4095,8 +4086,7 @@ "value": "Valoare", "value_boolean": "Valoare (booleană)", "value_date": "Valoare (data)", - "value_id": "ID opțiune", - "value_id_hint": "ID-ul stabil al opțiunii selectate în chestionarul sursă. Menține această înregistrare conectată la opțiunea sa prin editări de etichete și limbi, fiind gestionat automat.", + "value_id": "ID valoare", "value_number": "Valoare (număr)", "value_text": "Valoare (Text)" }, diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 0b644d6d364f..483aca3b05bd 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Тональность", "field_label_sentiment_average": "Настроение: Среднее", "field_label_sentiment_score": "Оценка тональности", + "field_label_source_id": "ID источника", "field_label_source_name": "Название источника", "field_label_source_type": "Тип источника", "field_label_surprise_count": "Эмоция: Удивление", @@ -1759,6 +1760,7 @@ "no_data_returned": "Нет данных для отображения, измени настройки.", "no_data_returned_for_chart": "Для графика не получено данных", "no_data_source_available": "К этому рабочему пространству не привязан набор данных отзывов.", + "no_fields_found": "Поля не найдены", "no_grouping": "Нет (только фильтр)", "no_valid_data_to_display": "Нет корректных данных для отображения", "no_values_found": "Значения не найдены", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Сохранить и добавить на панель управления", "save_chart": "Сохранить график", "save_chart_dialog_title": "Сохранить график", + "search_field": "Поиск поля...", "search_value": "Поиск значения...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Посмотрите, какие команды имеют доступ к этому рабочему пространству." }, "unify": { - "add_feedback_record": "Добавить запись отзыва", - "add_feedback_record_description": "Создайте запись обратной связи вручную.", "add_feedback_source": "Добавить источник обратной связи", "add_source": "Добавить источник", "ai_enrichment": "Обогащение с помощью ИИ", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Указывает, откуда пришёл этот набор отзывов.", "csv_unmapped_columns": "Несопоставленные столбцы ({count}): {columns}", "csv_unmapped_columns_explainer": "Эти столбцы не используются ни одним полем записи отзывов. Они будут проигнорированы при импорте.", - "custom_source_type": "Пользовательский тип источника", - "custom_source_type_placeholder": "Введите собственный тип источника", "data_origin": "Data origin", "default_source_name_csv": "Импорт CSV", "default_source_name_formbricks": "Источник опросов Formbricks", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Это действие безвозвратно удалит запись обратной связи и удалит её из подключенного датасета.", "delete_feedback_records_confirmation": "Это действие безвозвратно удалит {count} записей обратной связи и удалит их из подключенного датасета.", "delete_source_confirmation": "Удаление этого источника остановит будущие импорты и удалит сохранённое сопоставление. Существующие записи обратной связи останутся доступны.", - "discard_feedback_record_changes_description": "Ваши изменения будут потеряны, если вы закроете этот ящик.", - "discard_feedback_record_changes_title": "Отменить несохраненные изменения?", "dismiss": "Dismiss", "download_sample_csv": "Скачать пример CSV", "edit_csv_mapping": "Редактировать сопоставление CSV", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Не удалось загрузить отзывы", "feedback_data": "Feedback Data", "feedback_directory": "Датасет обратной связи", - "feedback_record_created_successfully": "Запись отзыва успешно создана", "feedback_record_deleted_successfully": "Запись обратной связи успешно удалена", "feedback_record_details": "Детали записи обратной связи", "feedback_record_details_description": "Просмотрите и обновите поля записи отзыва.", "feedback_record_mcp": "Использовать MCP-сервер", - "feedback_record_updated_successfully": "Запись отзыва успешно обновлена.", - "feedback_record_value_required": "Требуется значение для выбранного типа поля.", "feedback_records": "Записи отзывов", "feedback_records_deleted_successfully": "Удалено {count} {count, plural, one {запись обратной связи} few {записи обратной связи} many {записей обратной связи} other {записей обратной связи}}", "feedback_records_partially_deleted": "Удалено {succeeded} из {total} записей обратной связи", @@ -3954,9 +3948,7 @@ "manage_directories": "Управление датасетами", "manage_feedback_sources": "Управление источниками обратной связи", "metadata": "Метаданные", - "metadata_key": "Ключ метаданных", "metadata_read_only_entries": "Значения метаданных только для чтения (нестроковые)", - "metadata_value": "Значение метаданных", "missing_feedback_source_title": "Не нашли нужный источник обратной связи?", "no_feedback_directory_available": "К этому рабочему пространству не привязан датасет обратной связи. Сначала создайте или назначьте его.", "no_feedback_directory_linked_admin_description": "Наборы данных обратной связи объединяют данные из нескольких рабочих пространств. Подключите набор данных к этому рабочему пространству, чтобы начать работу.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Выберите опрос, чтобы увидеть его вопросы", "select_a_value": "Выберите значение...", "select_feedback_directory": "Выбрать датасет", - "select_feedback_record_source_type": "Выберите тип источника", "select_questions": "Выберите вопросы", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Выберите тип источника отзывов, который хотите подключить.", @@ -4095,8 +4086,7 @@ "value": "Значение", "value_boolean": "Значение (логическое)", "value_date": "Значение (Дата)", - "value_id": "ID варианта", - "value_id_hint": "Стабильный ID выбранного варианта в исходном опросе. Он сохраняет связь этой записи с вариантом при изменении текста и языков и управляется автоматически.", + "value_id": "ID значения", "value_number": "Значение (число)", "value_text": "Значение (текст)" }, diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 7b267428cdcb..72a241e83ba8 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Sentiment", "field_label_sentiment_average": "Sentiment: Medelvärde", "field_label_sentiment_score": "Sentimentpoäng", + "field_label_source_id": "Käll-ID", "field_label_source_name": "Källnamn", "field_label_source_type": "Källtyp", "field_label_surprise_count": "Känsla: Överraskning", @@ -1759,6 +1760,7 @@ "no_data_returned": "Ingen data att visa, ändra dina inställningar.", "no_data_returned_for_chart": "Ingen data returnerades för diagrammet", "no_data_source_available": "Inget feedbackdataset är tilldelat den här arbetsytan.", + "no_fields_found": "Inga fält hittades", "no_grouping": "Ingen (endast filter)", "no_valid_data_to_display": "Ingen giltig data att visa", "no_values_found": "Inga värden hittades", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Spara och lägg till i instrumentpanelen", "save_chart": "Spara diagram", "save_chart_dialog_title": "Spara diagram", + "search_field": "Sök fält...", "search_value": "Sök värde...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "Se vilka team som har tillgång till denna arbetsyta." }, "unify": { - "add_feedback_record": "Lägg till feedbackpost", - "add_feedback_record_description": "Skapa en feedbackpost manuellt.", "add_feedback_source": "Lägg till feedbackkälla", "add_source": "Lägg till källa", "ai_enrichment": "AI-berikning", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Identifierar var denna batch av feedback kom ifrån.", "csv_unmapped_columns": "Omappade kolumner ({count}): {columns}", "csv_unmapped_columns_explainer": "Dessa kolumner används inte av något fält i feedbackposten. De kommer att ignoreras vid import.", - "custom_source_type": "Anpassad källtyp", - "custom_source_type_placeholder": "Ange anpassad källtyp", "data_origin": "Data origin", "default_source_name_csv": "CSV-import", "default_source_name_formbricks": "Formbricks enkätkälla", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Detta kommer permanent radera feedbackposten och ta bort den från det anslutna datasetet.", "delete_feedback_records_confirmation": "Detta kommer permanent radera {count} feedbackposter och ta bort dem från det anslutna datasetet.", "delete_source_confirmation": "Att ta bort den här källan kommer att stoppa framtida importer och ta bort dess sparade mappning. Befintliga feedbackposter kommer att förbli tillgängliga.", - "discard_feedback_record_changes_description": "Dina ändringar kommer att gå förlorade om du stänger den här lådan.", - "discard_feedback_record_changes_title": "Vill du ignorera osparade ändringar?", "dismiss": "Dismiss", "download_sample_csv": "Ladda ner exempel-CSV", "edit_csv_mapping": "Redigera CSV-mappning", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Det gick inte att ladda feedbackposter", "feedback_data": "Feedback Data", "feedback_directory": "Feedbackdataset", - "feedback_record_created_successfully": "Feedbackposten har skapats", "feedback_record_deleted_successfully": "Feedbackposten har tagits bort", "feedback_record_details": "Feedbackpostdetaljer", "feedback_record_details_description": "Granska och uppdatera fält för feedbackposter.", "feedback_record_mcp": "Använd MCP-server", - "feedback_record_updated_successfully": "Feedbackposten har uppdaterats", - "feedback_record_value_required": "Ett värde krävs för den valda fälttypen", "feedback_records": "Feedbackposter", "feedback_records_deleted_successfully": "{count} feedbackposter har tagits bort", "feedback_records_partially_deleted": "{succeeded} av {total} feedbackposter raderade", @@ -3954,9 +3948,7 @@ "manage_directories": "Hantera dataset", "manage_feedback_sources": "Hantera feedbackkällor", "metadata": "Metadata", - "metadata_key": "Metadatanyckel", "metadata_read_only_entries": "Skrivskyddade metadatavärden (icke-sträng)", - "metadata_value": "Metadatavärde", "missing_feedback_source_title": "Missing feedback source?", "no_feedback_directory_available": "Inget feedbackdataset är tilldelat denna arbetsyta. Skapa eller tilldela ett först.", "no_feedback_directory_linked_admin_description": "Feedbackdataset samlar feedbackdata från flera arbetsytor. Anslut ett dataset till den här arbetsytan för att komma igång.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Välj en enkät för att se dess frågor", "select_a_value": "Välj ett värde...", "select_feedback_directory": "Välj ett dataset", - "select_feedback_record_source_type": "Välj källtyp", "select_questions": "Välj frågor", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Välj vilken typ av feedbackkälla du vill ansluta.", @@ -4095,8 +4086,7 @@ "value": "Värde", "value_boolean": "Värde (booleskt)", "value_date": "Värde (datum)", - "value_id": "Alternativ-ID", - "value_id_hint": "Stabilt ID för det valda alternativet i källenkäten. Det håller denna post länkad till sitt alternativ över etikettändringar och språk, och hanteras automatiskt.", + "value_id": "Värde-ID", "value_number": "Värde (antal)", "value_text": "Värde (text)" }, diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index f6ca1abe409f..5a430534db24 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "Duygu Durumu", "field_label_sentiment_average": "Duyarlılık: Ortalama", "field_label_sentiment_score": "Duygu Durumu Puanı", + "field_label_source_id": "Kaynak Kimliği", "field_label_source_name": "Kaynak Adı", "field_label_source_type": "Kaynak Türü", "field_label_surprise_count": "Duygu: Şaşkınlık", @@ -1759,6 +1760,7 @@ "no_data_returned": "Görüntülenecek veri yok, lütfen ayarlarını değiştir.", "no_data_returned_for_chart": "Grafik için veri döndürülmedi", "no_data_source_available": "Bu çalışma alanına atanmış bir geri bildirim veri kümesi yok.", + "no_fields_found": "Alan bulunamadı", "no_grouping": "Yok (sadece filtre)", "no_valid_data_to_display": "Görüntülenecek geçerli veri yok", "no_values_found": "Değer bulunamadı", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "Kaydet ve kontrol paneline ekle", "save_chart": "Grafiği Kaydet", "save_chart_dialog_title": "Grafiği Kaydet", + "search_field": "Alan ara...", "search_value": "Değer ara...", "select_data_source": "Bir veri kaynağı seç", "select_data_source_first": "Lütfen önce bir veri kaynağı seç", @@ -3835,8 +3838,6 @@ "team_settings_description": "Bu çalışma alanına hangi takımların erişebildiğini görün." }, "unify": { - "add_feedback_record": "Geri bildirim kaydı ekle", - "add_feedback_record_description": "Manuel olarak bir geri bildirim kaydı oluşturun.", "add_feedback_source": "Geri bildirim kaynağı ekle", "add_source": "Kaynak ekle", "ai_enrichment": "AI zenginleştirme", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "Bu geri bildirim grubunun nereden geldiğini tanımlar.", "csv_unmapped_columns": "Eşlenmemiş sütunlar ({count}): {columns}", "csv_unmapped_columns_explainer": "Bu sütunlar herhangi bir Geri Bildirim Kaydı alanı tarafından kullanılmıyor. İçe aktarma sırasında göz ardı edilecekler.", - "custom_source_type": "Özel kaynak türü", - "custom_source_type_placeholder": "Özel kaynak türünü girin", "data_origin": "Data origin", "default_source_name_csv": "CSV İçe Aktarma", "default_source_name_formbricks": "Formbricks Anket Kaynağı", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "Bu işlem geri bildirim kaydını kalıcı olarak silecek ve bağlı veri kümesinden kaldıracak.", "delete_feedback_records_confirmation": "Bu işlem {count} geri bildirim kaydını kalıcı olarak silecek ve bunları bağlı veri kümesinden kaldıracak.", "delete_source_confirmation": "Bu kaynağı silmek, gelecekteki içe aktarmaları durduracak ve kayıtlı eşleştirmesini kaldıracak. Mevcut geri bildirim kayıtları erişilebilir durumda kalacak.", - "discard_feedback_record_changes_description": "Bu çekmeceyi kapatırsanız değişiklikleriniz kaybolacak.", - "discard_feedback_record_changes_title": "Kaydedilmemiş değişiklikler silinsin mi?", "dismiss": "Dismiss", "download_sample_csv": "Örnek CSV indir", "edit_csv_mapping": "CSV eşlemesini düzenle", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "Geri bildirim kayıtları yüklenemedi", "feedback_data": "Feedback Data", "feedback_directory": "Geri Bildirim Veri Kümesi", - "feedback_record_created_successfully": "Geri bildirim kaydı başarıyla oluşturuldu", "feedback_record_deleted_successfully": "Geri bildirim kaydı başarıyla silindi", "feedback_record_details": "Geri bildirim kaydı ayrıntıları", "feedback_record_details_description": "Geri bildirim kayıt alanlarını inceleyin ve güncelleyin.", "feedback_record_mcp": "MCP sunucusunu kullan", - "feedback_record_updated_successfully": "Geri bildirim kaydı başarıyla güncellendi", - "feedback_record_value_required": "Seçilen alan türü için bir değer gerekli", "feedback_records": "Geri Bildirim Kayıtları", "feedback_records_deleted_successfully": "{count} geri bildirim kaydı silindi", "feedback_records_partially_deleted": "{total} geri bildirim kaydından {succeeded} tanesi silindi", @@ -3954,9 +3948,7 @@ "manage_directories": "Veri kümelerini yönet", "manage_feedback_sources": "Geri bildirim kaynaklarını yönetin", "metadata": "Meta veriler", - "metadata_key": "Meta veri anahtarı", "metadata_read_only_entries": "Salt okunur meta veri değerleri (dize dışı)", - "metadata_value": "Meta veri değeri", "missing_feedback_source_title": "Missing feedback source?", "no_feedback_directory_available": "Bu çalışma alanına atanmış geri bildirim veri kümesi yok. Önce bir tane oluştur veya ata.", "no_feedback_directory_linked_admin_description": "Geri bildirim veri kümeleri, birden fazla çalışma alanından gelen geri bildirim verilerini bir araya getirir. Başlamak için bu çalışma alanına bir veri kümesi bağla.", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "Sorularını görmek için bir anket seç", "select_a_value": "Bir değer seç...", "select_feedback_directory": "Bir veri kümesi seç", - "select_feedback_record_source_type": "Kaynak türünü seçin", "select_questions": "Soru seç", "select_questions_for_import": "Select questions for import", "select_source_type_description": "Bağlamak istediğin geri bildirim kaynağının türünü seç.", @@ -4095,8 +4086,7 @@ "value": "Değer", "value_boolean": "Değer (Boolean)", "value_date": "Değer (Tarih)", - "value_id": "Seçenek ID", - "value_id_hint": "Kaynak anketteki seçilen seçeneğin sabit ID'si. Bu kayıt, etiket düzenlemeleri ve diller genelinde seçeneğiyle bağlantılı kalmasını sağlar ve otomatik olarak yönetilir.", + "value_id": "Değer Kimliği", "value_number": "Değer (Sayı)", "value_text": "Değer (Metin)" }, diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index e69bd76bf035..e665b1d8cc9a 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "情感", "field_label_sentiment_average": "情感:平均值", "field_label_sentiment_score": "情感得分", + "field_label_source_id": "来源 ID", "field_label_source_name": "来源名称", "field_label_source_type": "来源类型", "field_label_surprise_count": "情绪:惊讶", @@ -1759,6 +1760,7 @@ "no_data_returned": "没有可显示的数据,请更改你的设置。", "no_data_returned_for_chart": "该图表未返回数据", "no_data_source_available": "此工作区尚未分配反馈数据集。", + "no_fields_found": "未找到字段", "no_grouping": "无(仅筛选)", "no_valid_data_to_display": "无有效数据可显示", "no_values_found": "未找到任何值", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "保存并添加到仪表板", "save_chart": "保存图表", "save_chart_dialog_title": "保存图表", + "search_field": "搜索字段...", "search_value": "搜索值...", "select_data_source": "Select a data source", "select_data_source_first": "Please select a data source first", @@ -3835,8 +3838,6 @@ "team_settings_description": "查看哪些团队可以访问此工作区。" }, "unify": { - "add_feedback_record": "添加反馈记录", - "add_feedback_record_description": "手动创建反馈记录。", "add_feedback_source": "添加反馈来源", "add_source": "添加来源", "ai_enrichment": "AI 丰富", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "标识这批反馈的来源。", "csv_unmapped_columns": "未映射列({count}):{columns}", "csv_unmapped_columns_explainer": "这些列未被任何反馈记录字段使用,导入时将被忽略。", - "custom_source_type": "自定义源类型", - "custom_source_type_placeholder": "输入自定义来源类型", "data_origin": "Data origin", "default_source_name_csv": "CSV 导入", "default_source_name_formbricks": "Formbricks 调研来源", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "此操作将永久删除该反馈记录,并将其从关联的数据集中移除。", "delete_feedback_records_confirmation": "此操作将永久删除 {count} 条反馈记录,并将它们从关联的数据集中移除。", "delete_source_confirmation": "删除此数据源将停止未来的导入并移除其保存的映射。现有的反馈记录将保持可用。", - "discard_feedback_record_changes_description": "如果关闭此抽屉,您的更改将会丢失。", - "discard_feedback_record_changes_title": "放弃未保存的更改?", "dismiss": "Dismiss", "download_sample_csv": "下载示例 CSV", "edit_csv_mapping": "编辑 CSV 映射", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "加载反馈记录失败", "feedback_data": "Feedback Data", "feedback_directory": "反馈数据集", - "feedback_record_created_successfully": "反馈记录创建成功", "feedback_record_deleted_successfully": "反馈记录已成功删除", "feedback_record_details": "反馈记录详情", "feedback_record_details_description": "查看并更新反馈记录字段。", "feedback_record_mcp": "使用 MCP 服务器", - "feedback_record_updated_successfully": "反馈记录更新成功", - "feedback_record_value_required": "所选字段类型需要一个值", "feedback_records": "反馈记录", "feedback_records_deleted_successfully": "已删除 {count} 条反馈记录", "feedback_records_partially_deleted": "已删除 {succeeded} 条(共 {total} 条)反馈记录", @@ -3954,9 +3948,7 @@ "manage_directories": "管理数据集", "manage_feedback_sources": "管理反馈来源", "metadata": "元数据", - "metadata_key": "元数据键", "metadata_read_only_entries": "只读元数据值(非字符串)", - "metadata_value": "元数据值", "missing_feedback_source_title": "Missing feedback source?", "no_feedback_directory_available": "此工作区未分配反馈数据集。请先创建或分配一个。", "no_feedback_directory_linked_admin_description": "反馈数据集将来自多个工作区的反馈数据汇集在一起。将数据集连接到此工作区即可开始使用。", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "请选择一个调查以查看其问题", "select_a_value": "选择一个值...", "select_feedback_directory": "选择数据集", - "select_feedback_record_source_type": "选择来源类型", "select_questions": "选择问题", "select_questions_for_import": "Select questions for import", "select_source_type_description": "请选择你想要连接的反馈来源类型。", @@ -4095,8 +4086,7 @@ "value": "值", "value_boolean": "值(布尔值)", "value_date": "值(日期)", - "value_id": "选项 ID", - "value_id_hint": "源调查问卷中所选选项的稳定 ID。它使此记录在标签编辑和语言变更时保持与其选项的关联,并且是自动管理的。", + "value_id": "值 ID", "value_number": "值(数量)", "value_text": "值(文本)" }, diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index f254e4363703..6a27005bb83d 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -1713,6 +1713,7 @@ "field_label_sentiment": "情感", "field_label_sentiment_average": "情感:平均值", "field_label_sentiment_score": "情感分數", + "field_label_source_id": "來源 ID", "field_label_source_name": "來源名稱", "field_label_source_type": "來源類型", "field_label_surprise_count": "情緒:驚訝", @@ -1759,6 +1760,7 @@ "no_data_returned": "沒有資料可顯示,請更改你的設定。", "no_data_returned_for_chart": "此圖表沒有回傳資料", "no_data_source_available": "此工作區未指派任何意見回饋資料集。", + "no_fields_found": "找不到欄位", "no_grouping": "無(僅篩選)", "no_valid_data_to_display": "沒有可顯示的有效資料", "no_values_found": "找不到值", @@ -1776,6 +1778,7 @@ "save_and_add_to_dashboard": "儲存並新增到儀表板", "save_chart": "儲存圖表", "save_chart_dialog_title": "儲存圖表", + "search_field": "搜尋欄位...", "search_value": "搜尋值...", "select_data_source": "選擇資料來源", "select_data_source_first": "請先選擇資料來源", @@ -3835,8 +3838,6 @@ "team_settings_description": "查看哪些團隊可以存取此工作區。" }, "unify": { - "add_feedback_record": "新增回饋記錄", - "add_feedback_record_description": "手動建立回饋記錄。", "add_feedback_source": "新增意見回饋來源", "add_source": "新增來源", "ai_enrichment": "AI 擴充", @@ -3889,8 +3890,6 @@ "csv_source_context_hint": "識別這批意見回饋的來源。", "csv_unmapped_columns": "未對應的欄位({count} 個):{columns}", "csv_unmapped_columns_explainer": "這些欄位沒有被任何意見回饋記錄欄位使用。匯入時會被忽略。", - "custom_source_type": "自訂來源類型", - "custom_source_type_placeholder": "輸入自訂來源類型", "data_origin": "Data origin", "default_source_name_csv": "CSV 匯入", "default_source_name_formbricks": "Formbricks 問卷來源", @@ -3898,8 +3897,6 @@ "delete_feedback_record_confirmation": "這將永久刪除該意見回饋記錄,並從已連結的資料集中移除。", "delete_feedback_records_confirmation": "這將永久刪除 {count} 筆意見回饋記錄,並從已連結的資料集中移除。", "delete_source_confirmation": "刪除此來源將停止未來的匯入並移除其已儲存的對應設定。現有的意見回饋記錄將保持可用。", - "discard_feedback_record_changes_description": "如果關閉此抽屜,您的變更將會遺失。", - "discard_feedback_record_changes_title": "放棄未儲存的變更?", "dismiss": "Dismiss", "download_sample_csv": "下載範例 CSV", "edit_csv_mapping": "編輯 CSV 對應", @@ -3918,13 +3915,10 @@ "failed_to_load_feedback_records": "載入回饋紀錄失敗", "feedback_data": "Feedback Data", "feedback_directory": "意見回饋資料集", - "feedback_record_created_successfully": "回饋記錄創建成功", "feedback_record_deleted_successfully": "意見回饋記錄已成功刪除", "feedback_record_details": "反饋記錄詳情", "feedback_record_details_description": "查看並更新回饋記錄欄位。", "feedback_record_mcp": "使用 MCP 伺服器", - "feedback_record_updated_successfully": "回饋記錄更新成功", - "feedback_record_value_required": "所選欄位類型需要一個值", "feedback_records": "回饋紀錄", "feedback_records_deleted_successfully": "已刪除 {count} 筆意見回饋記錄", "feedback_records_partially_deleted": "已刪除 {succeeded} 筆意見回饋記錄,共 {total} 筆", @@ -3954,9 +3948,7 @@ "manage_directories": "管理資料集", "manage_feedback_sources": "管理回饋來源", "metadata": "中繼資料", - "metadata_key": "元資料鍵", "metadata_read_only_entries": "唯讀元資料值(非字串)", - "metadata_value": "元資料值", "missing_feedback_source_title": "缺少意見回饋來源?", "no_feedback_directory_available": "此工作區尚未指派意見回饋資料集。請先建立或指派一個。", "no_feedback_directory_linked_admin_description": "意見回饋資料集將來自多個工作區的意見回饋資料整合在一起。將資料集連結到此工作區即可開始使用。", @@ -3976,7 +3968,6 @@ "select_a_survey_to_see_questions": "請選擇問卷以查看其問題", "select_a_value": "請選擇一個值...", "select_feedback_directory": "選擇資料集", - "select_feedback_record_source_type": "選擇來源類型", "select_questions": "選擇問題", "select_questions_for_import": "Select questions for import", "select_source_type_description": "請選擇你想要連接的回饋來源類型。", @@ -4095,8 +4086,7 @@ "value": "值", "value_boolean": "值(布林值)", "value_date": "值(日期)", - "value_id": "選項 ID", - "value_id_hint": "來源問卷中所選選項的穩定 ID。它讓此記錄在標籤編輯和語言變更時保持與選項的連結,並會自動管理。", + "value_id": "值 ID", "value_number": "值(數量)", "value_text": "值(文字)" }, diff --git a/apps/web/modules/ee/analysis/charts/actions.test.ts b/apps/web/modules/ee/analysis/charts/actions.test.ts index 6bd56c93ed46..46af5658ff00 100644 --- a/apps/web/modules/ee/analysis/charts/actions.test.ts +++ b/apps/web/modules/ee/analysis/charts/actions.test.ts @@ -14,6 +14,9 @@ const mocks = vi.hoisted(() => { executeTenantScopedQuery: vi.fn(), generateAIChartQuery: vi.fn(), updateChart: vi.fn(), + getFeedbackSourcesWithMappings: vi.fn(), + getSurvey: vi.fn(), + getElementsFromBlocks: vi.fn(), }; }); @@ -59,6 +62,18 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getIsDashboardsEnabled: mocks.getIsDashboardsEnabled, })); +vi.mock("@/lib/feedback-source/service", () => ({ + getFeedbackSourcesWithMappings: mocks.getFeedbackSourcesWithMappings, +})); + +// stub server-only modules pulled in by resolveOptionGrouping helpers +vi.mock("@/lib/survey/service", () => ({ getSurvey: mocks.getSurvey })); +vi.mock("@/lib/survey/utils", () => ({ getElementsFromBlocks: mocks.getElementsFromBlocks })); +vi.mock("@formbricks/types/surveys/validation", () => ({ getTextContent: (s: string) => s })); +vi.mock("@/lib/i18n/utils", () => ({ + getLocalizedValue: (obj: Record, lang: string) => obj[lang] ?? obj["default"] ?? "", +})); + vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn), })); @@ -89,6 +104,9 @@ describe("chart Cube actions", () => { config: {}, }); mocks.executeTenantScopedQuery.mockResolvedValue([{ "FeedbackRecords.count": 1 }]); + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([]); + mocks.getSurvey.mockResolvedValue(null); + mocks.getElementsFromBlocks.mockReturnValue([]); mocks.updateChart.mockResolvedValue({ chart: { id: "chart-1", query: { measures: ["FeedbackRecords.count"] } }, updatedChart: { id: "chart-1", query: { measures: ["FeedbackRecords.count"] } }, @@ -107,7 +125,8 @@ describe("chart Cube actions", () => { parsedInput: { workspaceId: "workspace-1", query, feedbackDirectoryId: "frd-1" }, } as any); - expect(result).toEqual([{ "FeedbackRecords.count": 1 }]); + // executeQueryAction now returns { rows, optionLabels?, effectiveQuery } instead of raw array. + expect(result).toMatchObject({ rows: [{ "FeedbackRecords.count": 1 }] }); expect(mocks.checkWorkspaceAccess).toHaveBeenCalledWith("user-1", "workspace-1", "read"); expect(mocks.checkFeedbackDirectoryAccess).toHaveBeenCalledWith({ feedbackDirectoryId: "frd-1", @@ -223,4 +242,414 @@ describe("chart Cube actions", () => { expect(mocks.executeTenantScopedQuery).not.toHaveBeenCalled(); }); + + // ── Multi-select (MultipleChoiceMulti) detection and splitting ────────────── + + test("executeQueryAction keeps the user's dimension and returns optionLabels for a MultipleChoiceMulti element (no rewrite, no split)", async () => { + // Wire up feedbackSources -> survey -> MultipleChoiceMulti element. + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [{ elementId: "field-multi", surveyId: "survey-multi" }], + }, + ]); + mocks.getSurvey.mockResolvedValue({ id: "survey-multi", blocks: [] }); + mocks.getElementsFromBlocks.mockReturnValue([ + { + id: "field-multi", + type: "multipleChoiceMulti", + choices: [ + { id: "c1", label: { default: "One" } }, + { id: "c2", label: { default: "Two" } }, + ], + }, + ]); + + const query = { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueText"], + filters: [{ member: "FeedbackRecords.fieldId", operator: "equals", values: ["field-multi"] }], + }; + + // Multi-select now stores one record per option (see transform.ts), so rows arrive already + // per-option — no joined string to split. + mocks.executeTenantScopedQuery.mockResolvedValue([ + { "FeedbackRecords.valueText": "One", "FeedbackRecords.count": 8 }, + { "FeedbackRecords.valueText": "Two", "FeedbackRecords.count": 5 }, + ]); + + const result = await executeQueryAction({ + ctx, + parsedInput: { workspaceId: "workspace-1", query, feedbackDirectoryId: "frd-1" }, + } as any); + + // The query sent to Cube keeps the user's chosen dimension — no swap. + expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ dimensions: ["FeedbackRecords.valueText"] }), + }) + ); + + // Rows pass through unchanged (no server-side splitting). + expect(result?.rows).toHaveLength(2); + // optionLabels is returned so a valueId grouping of the same question reads nicely. + expect(result?.optionLabels).toEqual({ c1: "One", c2: "Two" }); + }); + + test("executeQueryAction passes rows through unchanged and adds no labels for a non-choice element", async () => { + // A non-choice element type — OpenText — must not be touched. + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [{ elementId: "field-open", surveyId: "survey-open" }], + }, + ]); + mocks.getSurvey.mockResolvedValue({ id: "survey-open", blocks: [] }); + mocks.getElementsFromBlocks.mockReturnValue([{ id: "field-open", type: "openText" }]); + + const rawRows = [{ "FeedbackRecords.valueText": "Hello, World", "FeedbackRecords.count": 2 }]; + mocks.executeTenantScopedQuery.mockResolvedValue(rawRows); + + const result = await executeQueryAction({ + ctx, + parsedInput: { + workspaceId: "workspace-1", + query: { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueText"], + filters: [{ member: "FeedbackRecords.fieldId", operator: "equals", values: ["field-open"] }], + }, + feedbackDirectoryId: "frd-1", + }, + } as any); + + expect(result?.rows).toHaveLength(1); + expect(result?.rows?.[0]?.["FeedbackRecords.valueText"]).toBe("Hello, World"); + expect(result).not.toHaveProperty("optionLabels"); + }); + + // ── fieldLabel filter fallback (ENG-1673) ──────────────────────────────────── + + test("executeQueryAction keeps the user's dimension (no valueText → valueId swap) and still returns optionLabels for a single-select matched by fieldLabel", async () => { + // The mapping has no customFieldLabel, so the effective label comes from the element headline. + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [{ elementId: "field-sc", surveyId: "survey-sc", customFieldLabel: null }], + }, + ]); + mocks.getSurvey.mockResolvedValue({ id: "survey-sc", blocks: [] }); + mocks.getElementsFromBlocks.mockReturnValue([ + { + id: "field-sc", + type: "multipleChoiceSingle", + headline: { default: "Favourite colour?" }, + choices: [ + { id: "c1", label: { default: "Red" } }, + { id: "c2", label: { default: "Blue" } }, + ], + }, + ]); + + mocks.executeTenantScopedQuery.mockResolvedValue([ + { "FeedbackRecords.valueText": "Red", "FeedbackRecords.count": 10 }, + { "FeedbackRecords.valueText": "Blue", "FeedbackRecords.count": 5 }, + ]); + + const result = await executeQueryAction({ + ctx, + parsedInput: { + workspaceId: "workspace-1", + query: { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueText"], + // User filtered by "Question" (fieldLabel), not by fieldId. + filters: [ + { member: "FeedbackRecords.fieldLabel", operator: "equals", values: ["Favourite colour?"] }, + ], + }, + feedbackDirectoryId: "frd-1", + }, + } as any); + + // The query sent to Cube must preserve the user's chosen dimension — no silent swap to valueId. + expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ dimensions: ["FeedbackRecords.valueText"] }), + }) + ); + + // optionLabels is still returned so a valueId grouping can map ids to human labels. + expect(result?.optionLabels).toEqual({ c1: "Red", c2: "Blue" }); + }); + + test("executeQueryAction returns optionLabels for a multi-select element matched by fieldLabel (no rewrite/split)", async () => { + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [{ elementId: "field-mc", surveyId: "survey-mc", customFieldLabel: null }], + }, + ]); + mocks.getSurvey.mockResolvedValue({ id: "survey-mc", blocks: [] }); + mocks.getElementsFromBlocks.mockReturnValue([ + { + id: "field-mc", + type: "multipleChoiceMulti", + headline: { default: "Pick all that apply" }, + choices: [ + { id: "opt1", label: { default: "A" } }, + { id: "opt2", label: { default: "B" } }, + ], + }, + ]); + + mocks.executeTenantScopedQuery.mockResolvedValue([ + { "FeedbackRecords.valueText": "A", "FeedbackRecords.count": 5 }, + { "FeedbackRecords.valueText": "B", "FeedbackRecords.count": 3 }, + ]); + + const result = await executeQueryAction({ + ctx, + parsedInput: { + workspaceId: "workspace-1", + query: { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueText"], + filters: [ + { member: "FeedbackRecords.fieldLabel", operator: "equals", values: ["Pick all that apply"] }, + ], + }, + feedbackDirectoryId: "frd-1", + }, + } as any); + + // Dimension preserved (no swap), rows pass through unchanged. + expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ dimensions: ["FeedbackRecords.valueText"] }), + }) + ); + expect(result?.rows).toHaveLength(2); + expect(result?.optionLabels).toEqual({ opt1: "A", opt2: "B" }); + }); + + test("executeQueryAction does NOT rewrite when fieldLabel matches multiple mappings (ambiguous)", async () => { + // Two different mappings share the same effective label — must not guess. + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [ + { elementId: "field-a", surveyId: "survey-a", customFieldLabel: null }, + { elementId: "field-b", surveyId: "survey-b", customFieldLabel: null }, + ], + }, + ]); + // Both surveys return an element with the same headline. + mocks.getSurvey.mockImplementation((id: string) => Promise.resolve({ id, blocks: [] })); + mocks.getElementsFromBlocks.mockImplementation((blocks: unknown[]) => { + // Return an element whose headline matches; the elementId will vary per survey. + // We use a stable element id here because getElementsFromBlocks is mocked globally, + // but the key point is that both mappings resolve to the same label. + return [ + { + id: "field-a", + type: "multipleChoiceSingle", + headline: { default: "Duplicate label" }, + choices: [], + }, + { + id: "field-b", + type: "multipleChoiceSingle", + headline: { default: "Duplicate label" }, + choices: [], + }, + ]; + }); + + const rawRows = [{ "FeedbackRecords.valueText": "X", "FeedbackRecords.count": 1 }]; + mocks.executeTenantScopedQuery.mockResolvedValue(rawRows); + + const result = await executeQueryAction({ + ctx, + parsedInput: { + workspaceId: "workspace-1", + query: { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueText"], + filters: [ + { member: "FeedbackRecords.fieldLabel", operator: "equals", values: ["Duplicate label"] }, + ], + }, + feedbackDirectoryId: "frd-1", + }, + } as any); + + // Cube must be called with the ORIGINAL (unrewritten) query. + expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ dimensions: ["FeedbackRecords.valueText"] }), + }) + ); + + // No optionLabels returned. + expect(result).not.toHaveProperty("optionLabels"); + }); + + // ── New: dimension=valueId paths (FIX A + FIX B) ──────────────────────────── + + test("executeQueryAction returns effectiveQuery equal to original for a plain count query (no value dimension)", async () => { + const query = { measures: ["FeedbackRecords.count"] }; + mocks.executeTenantScopedQuery.mockResolvedValue([{ "FeedbackRecords.count": 42 }]); + + const result = await executeQueryAction({ + ctx, + parsedInput: { workspaceId: "workspace-1", query, feedbackDirectoryId: "frd-1" }, + } as any); + + expect(result?.effectiveQuery).toEqual(query); + expect(result).not.toHaveProperty("optionLabels"); + }); + + test("executeQueryAction keeps valueId dimension and returns optionLabels for a single-select queried directly by valueId + fieldId filter", async () => { + // User selected "Value (Option)" directly from the picker — dimension is already valueId. + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [{ elementId: "field-sc3", surveyId: "survey-sc3" }], + }, + ]); + mocks.getSurvey.mockResolvedValue({ id: "survey-sc3", blocks: [] }); + mocks.getElementsFromBlocks.mockReturnValue([ + { + id: "field-sc3", + type: "multipleChoiceSingle", + headline: { default: "Size?" }, + choices: [ + { id: "s", label: { default: "Small" } }, + { id: "l", label: { default: "Large" } }, + ], + }, + ]); + + mocks.executeTenantScopedQuery.mockResolvedValue([ + { "FeedbackRecords.valueId": "s", "FeedbackRecords.count": 6 }, + { "FeedbackRecords.valueId": "l", "FeedbackRecords.count": 4 }, + ]); + + const query = { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueId"], + filters: [{ member: "FeedbackRecords.fieldId", operator: "equals", values: ["field-sc3"] }], + }; + + const result = await executeQueryAction({ + ctx, + parsedInput: { workspaceId: "workspace-1", query, feedbackDirectoryId: "frd-1" }, + } as any); + + // Cube must receive valueId (no rewrite needed — already the right dimension). + expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ dimensions: ["FeedbackRecords.valueId"] }), + }) + ); + + // effectiveQuery matches the original (no dimension swap required). + expect(result?.effectiveQuery).toMatchObject({ dimensions: ["FeedbackRecords.valueId"] }); + + // optionLabels must be present so the renderer can map ids to labels. + expect(result?.optionLabels).toEqual({ s: "Small", l: "Large" }); + }); + + test("executeQueryAction keeps valueId (no swap) and returns optionLabels for a multi-select queried by valueId + fieldId filter", async () => { + // User selected "Value (Option)" for a multi-select field — it must stay valueId, not pop back + // to Value (Text). Multi-select stores one record per option with its own value_id now. + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [{ elementId: "field-mc2", surveyId: "survey-mc2" }], + }, + ]); + mocks.getSurvey.mockResolvedValue({ id: "survey-mc2", blocks: [] }); + mocks.getElementsFromBlocks.mockReturnValue([ + { + id: "field-mc2", + type: "multipleChoiceMulti", + headline: { default: "Interests?" }, + choices: [ + { id: "opt-a", label: { default: "Music" } }, + { id: "opt-b", label: { default: "Sport" } }, + ], + }, + ]); + + mocks.executeTenantScopedQuery.mockResolvedValue([ + { "FeedbackRecords.valueId": "opt-a", "FeedbackRecords.count": 10 }, + { "FeedbackRecords.valueId": "opt-b", "FeedbackRecords.count": 7 }, + ]); + + const query = { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueId"], + filters: [{ member: "FeedbackRecords.fieldId", operator: "equals", values: ["field-mc2"] }], + }; + + const result = await executeQueryAction({ + ctx, + parsedInput: { workspaceId: "workspace-1", query, feedbackDirectoryId: "frd-1" }, + } as any); + + // Cube must have been called with valueId — no swap back to valueText. + expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ dimensions: ["FeedbackRecords.valueId"] }), + }) + ); + + // effectiveQuery stays on valueId (this is what the builder reflects back — no auto-pop). + expect(result?.effectiveQuery).toMatchObject({ dimensions: ["FeedbackRecords.valueId"] }); + + // optionLabels map the ids to human labels for the renderer. + expect(result?.optionLabels).toEqual({ "opt-a": "Music", "opt-b": "Sport" }); + }); + + test("executeQueryAction uses fieldId filter when both fieldId and fieldLabel filters are present", async () => { + // fieldId should take precedence; fieldLabel is ignored. + mocks.getFeedbackSourcesWithMappings.mockResolvedValue([ + { + formbricksMappings: [{ elementId: "field-sc2", surveyId: "survey-sc2", customFieldLabel: null }], + }, + ]); + mocks.getSurvey.mockResolvedValue({ id: "survey-sc2", blocks: [] }); + mocks.getElementsFromBlocks.mockReturnValue([ + { + id: "field-sc2", + type: "multipleChoiceSingle", + headline: { default: "Colour?" }, + choices: [{ id: "cx1", label: { default: "Green" } }], + }, + ]); + + mocks.executeTenantScopedQuery.mockResolvedValue([ + { "FeedbackRecords.valueText": "Green", "FeedbackRecords.count": 7 }, + ]); + + const result = await executeQueryAction({ + ctx, + parsedInput: { + workspaceId: "workspace-1", + query: { + measures: ["FeedbackRecords.count"], + dimensions: ["FeedbackRecords.valueText"], + filters: [ + { member: "FeedbackRecords.fieldId", operator: "equals", values: ["field-sc2"] }, + { member: "FeedbackRecords.fieldLabel", operator: "equals", values: ["Colour?"] }, + ], + }, + feedbackDirectoryId: "frd-1", + }, + } as any); + + // Dimension is preserved (no swap); the returned optionLabels confirm the element was + // resolved via the fieldId filter (its mapping is the only source that was mocked). + expect(mocks.executeTenantScopedQuery).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ dimensions: ["FeedbackRecords.valueText"] }), + }) + ); + expect(result?.optionLabels).toEqual({ cx1: "Green" }); + }); }); diff --git a/apps/web/modules/ee/analysis/charts/actions.ts b/apps/web/modules/ee/analysis/charts/actions.ts index 5940381e6db4..d7146ead2c69 100644 --- a/apps/web/modules/ee/analysis/charts/actions.ts +++ b/apps/web/modules/ee/analysis/charts/actions.ts @@ -17,6 +17,7 @@ import { getCharts, updateChart, } from "@/modules/ee/analysis/charts/lib/charts"; +import { resolveOptionGrouping } from "@/modules/ee/analysis/charts/lib/option-grouping"; import { checkFeedbackDirectoryAccess, checkWorkspaceAccess } from "@/modules/ee/analysis/lib/access"; import { isSelectableValueDimension } from "@/modules/ee/analysis/lib/schema-definition"; import { ZChartCreateInput, ZChartUpdateInput } from "@/modules/ee/analysis/types/analysis"; @@ -278,14 +279,20 @@ export const executeQueryAction = authenticatedActionClient source: "charts.executeQueryAction", }); - return executeTenantScopedQuery({ - query: parsedInput.query, + const { rewrittenQuery, optionLabels } = await resolveOptionGrouping(parsedInput.query, workspaceId); + + const rawRows = await executeTenantScopedQuery({ + query: rewrittenQuery, feedbackDirectoryId, workspaceId, organizationId, userId: ctx.user.id, source: "charts.executeQueryAction", }); + + const rows = Array.isArray(rawRows) ? rawRows : []; + + return { rows, ...(optionLabels ? { optionLabels } : {}), effectiveQuery: rewrittenQuery }; } ); @@ -363,7 +370,8 @@ const ZGetDimensionValuesAction = z.object({ /** * Returns the distinct stored values for a low-cardinality string dimension, so the * filter UI can offer a pick-list instead of free-text entry. Picking a real value - * guarantees an exact match for the `equals` / `notEquals` operators. + * guarantees an exact match for the `equals` / `notEquals` operators. Search narrows + * results server-side so dimensions with more than the lookup cap stay usable. */ export const getDimensionValuesAction = authenticatedActionClient .inputSchema(ZGetDimensionValuesAction) @@ -374,7 +382,7 @@ export const getDimensionValuesAction = authenticatedActionClient }: { ctx: AuthenticatedActionClientCtx; parsedInput: z.infer; - }) => { + }): Promise => { const { organizationId, workspaceId } = await checkWorkspaceAccess( ctx.user.id, parsedInput.workspaceId, diff --git a/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx b/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx index 7c5eb71c4152..0bda2c60e236 100644 --- a/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx +++ b/apps/web/modules/ee/analysis/charts/components/advanced-chart-builder.tsx @@ -183,6 +183,43 @@ export function AdvancedChartBuilder({ /> + { + 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("workspace.analysis.charts.filter_data")} + description={t("workspace.analysis.charts.filters_toggle_description")} + customContainerClass="mt-2 px-0" + childrenContainerClass="flex-col gap-3 p-4" + childBorder> + dispatch({ type: ACTION.SET_FILTERS, payload: filters })} + onFilterLogicChange={(logic) => dispatch({ type: ACTION.SET_FILTER_LOGIC, payload: logic })} + /> + + { @@ -228,43 +265,6 @@ export function AdvancedChartBuilder({ onTimeDimensionChange={(config) => dispatch({ type: ACTION.SET_TIME_DIMENSION, payload: config })} /> - - { - 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("workspace.analysis.charts.filter_data")} - description={t("workspace.analysis.charts.filters_toggle_description")} - customContainerClass="mt-2 px-0" - childrenContainerClass="flex-col gap-3 p-4" - childBorder> - dispatch({ type: ACTION.SET_FILTERS, payload: filters })} - onFilterLogicChange={(logic) => dispatch({ type: ACTION.SET_FILTER_LOGIC, payload: logic })} - /> - ); } diff --git a/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx b/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx index 2613ee804f35..eaa9139076e9 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-preview.tsx @@ -82,12 +82,17 @@ export function ChartPreview({ - + - + ); diff --git a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx index 137983655e85..88c673851746 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx @@ -9,6 +9,8 @@ import { PolishedChartTooltip } from "@/modules/ee/analysis/charts/components/po import { CHART_BRAND_DARK, CHART_MEASURE_COLORS, + PIE_MEASURE_NAME_KEY, + PIE_MEASURE_VALUE_KEY, PIVOTED_MEASURE_KEY, PIVOTED_VALUE_KEY, formatCellValue, @@ -16,6 +18,7 @@ import { getSemanticDimensionColor, getSentimentMeasureColor, pivotMeasuresToCategories, + prepareMeasureSliceData, preparePieData, } from "@/modules/ee/analysis/charts/lib/chart-utils"; import { @@ -120,13 +123,205 @@ const PieCenterLabel = ({ ); }; +interface BarChartViewProps { + sortedData: TChartDataRow[]; + dataKeys: string[]; + isMultiMeasure: boolean; + hasCategoryAxis: boolean; + xAxisKey: string; + chartConfig: ChartConfig; + formatDimensionValue: (value: unknown) => string; +} + +const BarChartView = ({ + sortedData, + dataKeys, + isMultiMeasure, + hasCategoryAxis, + xAxisKey, + chartConfig, + formatDimensionValue, +}: Readonly) => { + const { t } = useTranslation(); + + // Measure-only queries (no dimension or time grouping) return a single row with one + // column per measure. Rendered as N bar series that row forms a single category band + // that recharts centers in the plot, leaving a wide empty gap before the first bar + // (ENG-1796). Pivot the measures into categories so the bars fill the axis from the + // left like any other category bar chart, labelled by measure on the x-axis. + if (!hasCategoryAxis) { + // Sentiment measures pivot into the scale order the sentiment *dimension* axis uses, + // so this chart and a dimension-grouped one read in the same direction. + const axisKeys = sortMeasureIdsForCategoryAxis(dataKeys); + const measureData = pivotMeasuresToCategories(sortedData, axisKeys, (key) => + formatCubeColumnHeader(key, t) + ); + // Ticks use the short value label ("Very positive") — the full measure label is too + // wide, so recharts would thin the ticks and bars would lose their name. The tooltip + // keeps the full label via tooltipLabel. + const formatMeasureLabel = (value: unknown) => + getMeasureAxisLabel(typeof value === "string" ? value : "", t); + return ( + + + formatCellValue(value)} + /> + + + ); + } + + // Setting `fill` on the data row (not via ) is what propagates + // the per-bar colour into the tooltip payload as well as the SVG. + const barData = isMultiMeasure + ? sortedData + : sortedData.map((row, index) => ({ + ...row, + fill: + getSemanticDimensionColor(xAxisKey, row[xAxisKey]) ?? + CHART_MEASURE_COLORS[index % CHART_MEASURE_COLORS.length], + })); + + return ( + + {dataKeys.map((key) => ( + + {!isMultiMeasure && ( + formatCellValue(value)} + /> + )} + + ))} + + ); +}; + +interface PieChartViewProps { + sortedData: TChartDataRow[]; + dataKeys: string[]; + dataKey: string; + isMultiMeasure: boolean; + query: TChartQuery; + timeDimKey: string | undefined; + xAxisKey: string; + chartConfig: ChartConfig; + formatDimensionValue: (value: unknown) => string; +} + +const PieChartView = ({ + sortedData, + dataKeys, + dataKey, + isMultiMeasure, + query, + timeDimKey, + xAxisKey, + chartConfig, + formatDimensionValue, +}: Readonly) => { + const { t } = useTranslation(); + + // With several measures and no dimension (e.g. the six Emotion counts), each row column is a + // measure, not a slice — pivot the measures into one slice per measure so the pie shows them + // all instead of only the first. A dimension-based pie keeps its existing (rows = slices) shape. + const useMeasureSlices = isMultiMeasure && (query.dimensions?.length ?? 0) === 0 && !timeDimKey; + const pieDataKey = useMeasureSlices ? PIE_MEASURE_VALUE_KEY : dataKey; + const pieNameKey = useMeasureSlices ? PIE_MEASURE_NAME_KEY : xAxisKey; + const pieSource = useMeasureSlices + ? prepareMeasureSliceData(sortedData, dataKeys, (key) => formatCubeColumnHeader(key, t)) + : sortedData; + const pieResult = preparePieData(pieSource, pieDataKey, pieNameKey); + if (!pieResult) { + return ( +
+ {t("workspace.analysis.charts.no_valid_data_to_display")} +
+ ); + } + const { processedData, colors } = pieResult; + const total = processedData.reduce((sum, row) => sum + (Number(row[pieDataKey]) || 0), 0); + // Multi-measure pies have no single measure to name the center; show just the total. + const centerLabel = useMeasureSlices ? "" : formatCubeColumnHeader(dataKey, t); + + return ( +
+ + + ReactElement`, + // but returning `null` from the function is supported at runtime and is + // how we hide the leader line for sub-2% slices. + labelLine={renderPieLabelLine as never} + label={renderPieLabel}> + {processedData.map((row, index) => { + const rowKey = row[pieNameKey] ?? `row-${index}`; + const uniqueKey = `${pieNameKey}-${String(rowKey)}-${index}`; + return ; + })} + + } /> + formatDimensionValue(value)} + wrapperStyle={{ fontSize: 12 }} + /> + + +
+ ); +}; + interface ChartRendererProps { chartType: TChartType; data: TChartDataRow[]; query: TChartQuery; + /** value_id → default-language label map, present when the query groups by valueId. */ + optionLabels?: Record; } -export function ChartRenderer({ chartType, data, query }: Readonly) { +export function ChartRenderer({ chartType, data, query, optionLabels }: Readonly) { const { t } = useTranslation(); // Unique across charts on the same page so SVG ids don't collide. const gradientIdPrefix = useId(); @@ -154,8 +349,13 @@ export function ChartRenderer({ chartType, data, query }: Readonly - getTranslatedDimensionValueLabel(xAxisKey, value, t) ?? formatXAxisTick(value); + const formatDimensionValue = (value: unknown): string => { + // If the x-axis is a valueId dimension, resolve via the option-label map first. + if (xAxisKey === "FeedbackRecords.valueId" && optionLabels && typeof value === "string") { + return optionLabels[value] ?? value; + } + return getTranslatedDimensionValueLabel(xAxisKey, value, t) ?? formatXAxisTick(value); + }; const measureIds = query.measures?.filter((m) => rowKeys.includes(m)) ?? []; const rawDataKeys = measureIds.length > 0 ? measureIds : rowKeys.filter((k) => k !== xAxisKey); @@ -191,88 +391,18 @@ export function ChartRenderer({ chartType, data, query }: Readonly 1; switch (chartType) { - case "bar": { - // Measure-only queries (no dimension or time grouping) return a single row with one - // column per measure. Rendered as N bar series that row forms a single category band - // that recharts centers in the plot, leaving a wide empty gap before the first bar - // (ENG-1796). Pivot the measures into categories so the bars fill the axis from the - // left like any other category bar chart, labelled by measure on the x-axis. - if (!hasCategoryAxis) { - // Sentiment measures pivot into the scale order the sentiment *dimension* axis uses, - // so this chart and a dimension-grouped one read in the same direction. - const axisKeys = sortMeasureIdsForCategoryAxis(dataKeys); - const measureData = pivotMeasuresToCategories(sortedData, axisKeys, (key) => - formatCubeColumnHeader(key, t) - ); - // Ticks use the short value label ("Very positive") — the full measure label is too - // wide, so recharts would thin the ticks and bars would lose their name. The tooltip - // keeps the full label via tooltipLabel. - const formatMeasureLabel = (value: unknown) => - getMeasureAxisLabel(typeof value === "string" ? value : "", t); - return ( - - - formatCellValue(value)} - /> - - - ); - } - - // Setting `fill` on the data row (not via ) is what propagates - // the per-bar colour into the tooltip payload as well as the SVG. - const barData = isMultiMeasure - ? sortedData - : sortedData.map((row, index) => ({ - ...row, - fill: - getSemanticDimensionColor(xAxisKey, row[xAxisKey]) ?? - CHART_MEASURE_COLORS[index % CHART_MEASURE_COLORS.length], - })); - + case "bar": return ( - - {dataKeys.map((key) => ( - - {!isMultiMeasure && ( - formatCellValue(value)} - /> - )} - - ))} - + xAxisKey={xAxisKey} + chartConfig={chartConfig} + formatDimensionValue={formatDimensionValue} + /> ); - } case "line": // AreaChart with a thin stroke + gradient fade reads as a line with a soft tint. return ( @@ -340,58 +470,20 @@ export function ChartRenderer({ chartType, data, query }: Readonly ); - case "pie": { - const pieResult = preparePieData(sortedData, dataKey, xAxisKey); - if (!pieResult) { - return ( -
- {t("workspace.analysis.charts.no_valid_data_to_display")} -
- ); - } - const { processedData, colors } = pieResult; - const total = processedData.reduce((sum, row) => sum + (Number(row[dataKey]) || 0), 0); - const centerLabel = formatCubeColumnHeader(dataKey, t); - + case "pie": return ( -
- - - ReactElement`, - // but returning `null` from the function is supported at runtime and is - // how we hide the leader line for sub-2% slices. - labelLine={renderPieLabelLine as never} - label={renderPieLabel}> - {processedData.map((row, index) => { - const rowKey = row[xAxisKey] ?? `row-${index}`; - const uniqueKey = `${xAxisKey}-${String(rowKey)}-${index}`; - return ; - })} - - } /> - formatDimensionValue(value)} - wrapperStyle={{ fontSize: 12 }} - /> - - -
+ ); - } case "big_number": { const total = data.length === 1 diff --git a/apps/web/modules/ee/analysis/charts/components/data-viewer.tsx b/apps/web/modules/ee/analysis/charts/components/data-viewer.tsx index 4360122f4386..7002efcd545a 100644 --- a/apps/web/modules/ee/analysis/charts/components/data-viewer.tsx +++ b/apps/web/modules/ee/analysis/charts/components/data-viewer.tsx @@ -12,9 +12,11 @@ import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis"; const MAX_DISPLAY_ROWS = 50; interface DataViewerProps { data: TChartDataRow[]; + /** value_id → default-language label map, present when the query groups by valueId. */ + optionLabels?: Record; } -export function DataViewer({ data }: Readonly) { +export function DataViewer({ data, optionLabels }: Readonly) { const { t } = useTranslation(); if (!data || data.length === 0 || Object.keys(data[0]).length === 0) { return ( @@ -27,6 +29,13 @@ export function DataViewer({ data }: Readonly) { const columns = Object.keys(data[0]); const displayData = data.slice(0, MAX_DISPLAY_ROWS); + const renderCellValue = (key: string, value: unknown): string => { + if (key === "FeedbackRecords.valueId" && optionLabels && typeof value === "string") { + return optionLabels[value] ?? value; + } + return (getTranslatedDimensionValueLabel(key, value, t) ?? formatCellValue(value)) as string; + }; + return (
@@ -55,7 +64,7 @@ export function DataViewer({ data }: Readonly) { {Object.entries(row).map(([key, value]) => ( - {getTranslatedDimensionValueLabel(key, value, t) ?? formatCellValue(value)} + {renderCellValue(key, value)} ))} diff --git a/apps/web/modules/ee/analysis/charts/components/filter-field-combobox.tsx b/apps/web/modules/ee/analysis/charts/components/filter-field-combobox.tsx new file mode 100644 index 000000000000..460bc53d651c --- /dev/null +++ b/apps/web/modules/ee/analysis/charts/components/filter-field-combobox.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { CheckIcon, ChevronDownIcon, SparklesIcon } from "lucide-react"; +import { useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, +} from "@/modules/ui/components/command"; +import { Input } from "@/modules/ui/components/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover"; +import { cn } from "@/modules/ui/lib/utils"; + +interface FilterFieldOption { + value: string; + label: string; + isGenerated?: boolean; +} + +interface FilterFieldComboboxProps { + options: FilterFieldOption[]; + value: string; + onChange: (value: string) => void; +} + +/** + * Searchable pick-list for the left (field) operand of a filter row. + * Mirrors the structure of FilterValueCombobox but filters purely client-side + * since the field list is static and small. + */ +export function FilterFieldCombobox({ options, value, onChange }: Readonly) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const listRef = useRef(null); + const listboxId = useId(); + + const trimmedSearch = search.trim().toLowerCase(); + const filtered = trimmedSearch + ? options.filter((opt) => opt.label.toLowerCase().includes(trimmedSearch)) + : options; + + const selectedLabel = options.find((opt) => opt.value === value)?.label; + + return ( + { + setOpen(nextOpen); + // Clear the search term when the popover closes. + if (!nextOpen) setSearch(""); + }}> + + + + event.preventDefault()} + onWheel={(event) => { + const list = listRef.current; + if (!list?.contains(event.target as Node)) return; + + const { scrollTop, scrollHeight, clientHeight } = list; + const canScrollUp = scrollTop > 0; + const canScrollDown = scrollTop + clientHeight < scrollHeight; + + if ((event.deltaY > 0 && canScrollDown) || (event.deltaY < 0 && canScrollUp)) { + list.scrollTop += event.deltaY; + event.preventDefault(); + } + }}> + +
+ setSearch(event.target.value)} + className="h-8 border-none bg-transparent p-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" + /> +
+
+ + {filtered.length === 0 && ( + {t("workspace.analysis.charts.no_fields_found")} + )} + {filtered.length > 0 && ( + + {filtered.map((option) => ( + { + onChange(option.value); + setOpen(false); + }}> + + + {option.isGenerated && ( + + + ))} + + )} + +
+
+
+
+ ); +} diff --git a/apps/web/modules/ee/analysis/charts/components/filters-panel.tsx b/apps/web/modules/ee/analysis/charts/components/filters-panel.tsx index ef069c1c9135..cd11d1e22ed9 100644 --- a/apps/web/modules/ee/analysis/charts/components/filters-panel.tsx +++ b/apps/web/modules/ee/analysis/charts/components/filters-panel.tsx @@ -1,14 +1,18 @@ "use client"; -import { Plus, SparklesIcon, TrashIcon } from "lucide-react"; +import { Plus, TrashIcon } from "lucide-react"; import { useTranslation } from "react-i18next"; import { FilterDateInput } from "@/modules/ee/analysis/charts/components/filter-date-input"; +import { FilterFieldCombobox } from "@/modules/ee/analysis/charts/components/filter-field-combobox"; import { FilterValueCombobox } from "@/modules/ee/analysis/charts/components/filter-value-combobox"; import type { FilterRow, TFilterFieldType } from "@/modules/ee/analysis/lib/query-builder"; import { + EMOTIONS_DIMENSION_ID, + EMOTION_VALUES, FEEDBACK_FIELDS, getFieldById, getFilterOperatorsForType, + getTranslatedDimensionValueLabel, getTranslatedFieldLabel, isSelectableValueDimension, } from "@/modules/ee/analysis/lib/schema-definition"; @@ -52,12 +56,18 @@ export function FiltersPanel({ type: d.type, isGenerated: d.isGenerated ?? false, })), - ...FEEDBACK_FIELDS.measures.map((m) => ({ - value: m.id, - label: getTranslatedFieldLabel(m.id, t), - type: "number" as TFilterFieldType, - isGenerated: false, - })), + // Only continuous aggregate measures (scores + averages) make sense as filters — you + // threshold them (e.g. NPS score > 50, average sentiment > 0.5). Count measures are + // excluded: filtering by a count is either a no-op here or redundant with a dimension + // filter (e.g. "Sentiment: Positive" count vs. the Sentiment dimension = "positive"). + ...FEEDBACK_FIELDS.measures + .filter((m) => m.group === "score" || m.group === "average") + .map((m) => ({ + value: m.id, + label: getTranslatedFieldLabel(m.id, t), + type: "number" as TFilterFieldType, + isGenerated: false, + })), ]; const handleAddFilter = () => { @@ -107,6 +117,28 @@ export function FiltersPanel({ ); } + // Emotions is a multi-label comma-set, so its values can't come from a Cube distinct + // lookup (that returns joined combinations) and free text is error-prone. Offer the + // fixed emotion vocabulary as a pick-list; pair with `contains` to match one emotion. + if (filter.field === EMOTIONS_DIMENSION_ID) { + return ( + + ); + } + // Exact-match operators on a low-cardinality string dimension get a pick-list of // real stored values, so the chosen value matches exactly (no casing/whitespace drift). const canSelectValues = @@ -179,34 +211,25 @@ export function FiltersPanel({ return (
- + }} + /> - )} /> @@ -451,9 +209,8 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.submission_id")} - + - )} /> @@ -464,9 +221,8 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.collected_at")} - + - )} /> @@ -499,59 +255,18 @@ export const FeedbackRecordFormDrawer = ({ />
- {isEditMode ? ( - ( - - {t("workspace.unify.source_type")} - - - - - )} - /> - ) : ( -
- {t("workspace.unify.source_type")} - - {sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE && ( - { - setCustomSourceType(event.target.value); - form.setValue("source_type", event.target.value, { shouldDirty: true }); - }} - placeholder={t("workspace.unify.custom_source_type_placeholder")} - disabled={!canWrite} - /> - )} - {form.formState.errors.source_type?.message} -
- )} + ( + + {t("workspace.unify.source_type")} + + + + + )} + />
{t("workspace.unify.source_id")} - + )} @@ -573,7 +288,7 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.source_name")} - + )} @@ -588,9 +303,8 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.field_id")} - + - )} /> @@ -601,7 +315,7 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.field_label")} - + )} @@ -621,7 +335,7 @@ export const FeedbackRecordFormDrawer = ({ onValueChange={(value) => field.onChange(value as TFeedbackRecordFormValues["field_type"]) } - disabled={isEditMode || !canWrite}> + disabled> @@ -644,7 +358,7 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.field_group_id")} - + )} @@ -658,28 +372,36 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.field_group_label")} - + )} /> - ( - - {t("workspace.unify.value_text")} - - - - - )} - /> +
+ ( + + {t("workspace.unify.value_text")} + + + + + )} + /> + + {/* ENG-1673: read-only — the stable option identity is managed by ingestion/backfill; + hand-editing it would desync the record from its source option. Always shown so the + canonical Value ID is visible even when the record has none. */} +
+ + +
+
{translatedText && (
@@ -697,22 +419,7 @@ export const FeedbackRecordFormDrawer = ({
)} - {/* ENG-1673: read-only — the stable option identity is managed by ingestion/backfill; - hand-editing it would desync the record from its source option. */} - {record?.value_id && ( -
-
-
-

{record.value_id}

-

{t("workspace.unify.value_id_hint")}

-
- )} - - {record && selectedValueField === "value_text" && ( + {selectedValueField === "value_text" && (
@@ -803,7 +499,6 @@ export const FeedbackRecordFormDrawer = ({ )} /> - {form.formState.errors[selectedValueField]?.message}
{t("common.language")} - + )} @@ -825,71 +520,16 @@ export const FeedbackRecordFormDrawer = ({ {t("workspace.unify.user_identifier")} - + )} />
-
-
- {t("workspace.unify.metadata")} - {canWrite && !isReadOnly && ( - - )} -
- + {readOnlyMetadataEntries.length > 0 && (
- {fields.map((field, index) => ( -
- ( - - - - - - )} - /> - ( - - - - - - )} - /> - {canWrite && !isReadOnly && ( - - )} -
- ))} -
- - {readOnlyMetadataEntries.length > 0 && ( + {t("workspace.unify.metadata")}

{t("workspace.unify.metadata_read_only_entries")} @@ -905,52 +545,30 @@ export const FeedbackRecordFormDrawer = ({

))}
- )} -
- +
+ )} +
)} - {isEditMode && canWrite && recordId ? ( + {canWrite && recordId ? ( ) : ( )} -
- - {canWrite && ( - - )} -
+
- setIsDiscardDialogOpen(false)} - onConfirm={handleDiscardChanges} - /> - void; onClick: () => void; @@ -107,7 +107,6 @@ export const FeedbackRecordsTable = ({ const [isRefreshing, setIsRefreshing] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [error, setError] = useState(null); - const [drawerMode, setDrawerMode] = useState<"create" | "edit">("edit"); const [drawerRecordId, setDrawerRecordId] = useState(); const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [csvImportSource, setCsvImportSource] = useState<{ @@ -337,18 +336,11 @@ export const FeedbackRecordsTable = ({ } }; - const openEditDrawer = (recordId: string) => { - setDrawerMode("edit"); + const openViewDrawer = (recordId: string) => { setDrawerRecordId(recordId); setIsDrawerOpen(true); }; - const openCreateDrawer = () => { - setDrawerMode("create"); - setDrawerRecordId(undefined); - setIsDrawerOpen(true); - }; - const hasCsvSources = csvSources.length > 0; let headerCheckboxChecked: boolean | "indeterminate" = false; @@ -370,38 +362,28 @@ export const FeedbackRecordsTable = ({ onBulkDelete={() => setIsBulkDeleteDialogOpen(true)} />
- {canWrite && - (hasCsvSources ? ( - - - - - - - {t("workspace.unify.add_feedback_record")} + {canWrite && hasCsvSources && ( + + + + + + {csvSources.map((source) => ( + { + setCsvImportSource(source); + }}> + {t("workspace.unify.import_via_source_name", { sourceName: source.name })} - - {csvSources.map((source) => ( - { - setCsvImportSource(source); - }}> - {t("workspace.unify.import_via_source_name", { sourceName: source.name })} - - ))} - - - ) : ( - - ))} + ))} + + + )}