Compare commits

..

42 Commits

Author SHA1 Message Date
Dhruwang 4aabdc674d feat(unify): add delete option for feedback records (ENG-938)
Adds the missing delete capability to the feedback records UI: a
destructive action in the record drawer for single-row delete plus a
multi-select bulk delete from the table, mirroring the UX pattern used
on the Contacts page.
2026-05-13 12:40:12 +05:30
Dhruwang Jariwala ae9c1e499a fix: add missing title in feedback directory (#7983) 2026-05-13 12:04:15 +05:30
Dhruwang Jariwala 5b70c99eb3 fix(dashboards): chart removal flow + add-charts dialog focus ring (ENG-901, ENG-903) (#7981) 2026-05-12 16:55:48 +05:30
Dhruwang 10c09f00a8 refactor(dashboards): address review on removeWidgetFromDashboard
- Drop the prisma.$transaction wrapper; find + delete is two sequential
  steps, doesn't need a transaction.
- Drop the redundant ResourceNotFoundError catch branch; the trailing
  `throw error` already lets it bubble.
- Let action-client infer ctx / parsedInput types.

Tests: cover the two catch branches (Prisma -> DatabaseError, unknown
rethrow) so the new function is fully line-covered.
2026-05-12 16:11:45 +05:30
Javi Aguilar 5f4f133dcb fix: add missing title in feedback directory 2026-05-12 11:07:02 +02:00
Dhruwang Jariwala 037b005d48 fix(charts): pie tooltip spacing + scroll to chart-name on empty save (ENG-914, ENG-916) (#7970) 2026-05-12 13:22:00 +05:30
Dhruwang ddd2d5e983 fix(dashboards): chart removal flow + add-charts dialog focus ring (ENG-901, ENG-903)
ENG-901: clicking Remove on a chart widget no longer enters dashboard edit mode
(which surfaced the rename input) and saving with the last widget removed no longer
surfaces a raw "widgets: Too small" zod error. Out of edit mode, Remove now goes
through a DeleteDialog -> dedicated removeWidgetFromDashboardAction. The batched
update path also allows an empty widgets array now that the lib already supports
deleting all widgets correctly.

ENG-903: add p-1 to AddExistingChartsDialog DialogBody so the focus ring on the
inner search input is no longer clipped by the body's overflow boundary.
2026-05-12 12:51:46 +05:30
Dhruwang Jariwala 6777b284b3 fix(a11y): large selects are not scrollable (#7963) 2026-05-12 12:08:38 +05:30
Anshuman Pandey c6282632e0 fix: fixes read only issues in feedback sources UI (#7974) 2026-05-12 10:20:30 +04:00
Dhruwang Jariwala f84c409bc4 feat: add beta badge to unify feedback navigation section (#7968) 2026-05-12 11:38:09 +05:30
Dhruwang Jariwala 98b475a2a4 fix(charts): time-range crash + sentiment/emotion/response-id cube column mismatches (ENG-907, ENG-906, ENG-915) (#7973) 2026-05-12 10:02:50 +04:00
Dhruwang c48474b943 chore(charts): drop sonar S6478 JSDoc on PieTooltipRow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 11:31:38 +05:30
Anshuman Pandey 3c0d1e3fd7 fix: fixes connector modal UI errors (#7971) 2026-05-11 16:40:35 +04:00
Bhagya Amarasinghe 1f7a496967 fix(helm): stop forcing TEI float16 dtype (#7967) 2026-05-11 17:24:57 +05:30
Dhruwang 99e378ae2e chore: drop verbose doc comments on ChartDialogFooter props
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:44:15 +05:30
Dhruwang c6e39c3103 refactor(charts): hoist pie tooltip formatter to module scope (sonar S6478)
Same pattern as CartesianChart's ChartTooltipRow — a module-level
component that calls useTranslation internally, plus a thin formatter
function passed to ChartTooltipContent. Resolves the nested-component
warning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:41:18 +05:30
Dhruwang 19472ca9d3 chore: inline form onSubmit to avoid deprecated FormEvent type alias
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:37:20 +05:30
Dhruwang 43feff0009 fix(charts): inline error styling for empty chart-name on submit
Switch from the native browser tooltip to the in-app inline error pattern:
red Label, isInvalid Input border, and helper text below the field. The
onInvalid handler suppresses the default tooltip and scrolls + focuses the
field via event.currentTarget — no ref needed. Typing clears the error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:36:45 +05:30
Dhruwang 507cec6958 feat(charts): show translated message on empty chart-name validation
Browsers default to "Please fill in this field"; use the existing
workspace.analysis.charts.please_enter_chart_name key so the message
matches the toast we surface elsewhere. Clear custom validity on change
so the form unblocks once the user types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:27:52 +05:30
Dhruwang d874b65be9 refactor(charts): use native form validation for chart-name field (ENG-916)
Replace the imperative scrollIntoView + focus on Save click with a tiny
<form> wrapping the required Input. The Save button becomes type=submit
with form="create-chart-form", so the browser handles scroll, focus, and
the "Please fill in this field" tooltip natively when the name is empty.

ChartDialogFooter now accepts either onSaveClick (button mode) or formId
(submit-button mode). All sibling buttons in the footer are explicitly
type="button" so they don't accidentally submit the form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:25:17 +05:30
Dhruwang Jariwala c159af1a26 fix: format toast (ENG-893) (#7966) 2026-05-11 16:22:24 +05:30
Dhruwang 3ce2ef6cf4 fix(charts): scroll/focus chart-name input when save is blocked by empty name (ENG-916)
Clicking Save Chart with the name field empty fired a toast but left the
modal scrolled wherever the user was (e.g. on the chart preview at the
bottom), so they couldn't see what was wrong. Scroll the name field into
view and focus it before delegating to the existing save handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:20:57 +05:30
Dhruwang Jariwala baae6335c9 fix: validate workspace assignment on feedback directory create (ENG-900) (#7965)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-11 14:50:05 +04:00
Dhruwang 8e443db1f6 revert: drop YAxis integer-tick handling
The fractional-tick bar/line/area scenario isn't the bug we're shipping in
this PR. Keep only the pie tooltip spacing/formatting change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:13:40 +05:30
Dhruwang 18cd9afc2c fix(charts): space between value and name in pie tooltip
Returning [value, name] from the tooltip formatter rendered as two adjacent text nodes ("3Nps"). Return a fragment with an explicit space so it reads "3 Nps".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:08:22 +05:30
Dhruwang 680e1e1593 fix(charts): show integer y-axis ticks for COUNT measures and format pie tooltips
ENG-914

When a chart's measures are all whole numbers (e.g. COUNT grouped by a
dimension), Recharts was auto-generating fractional y-axis ticks like
0.5, 1.5, 2 — nonsensical for a discrete count. The pie tooltip also
rendered values via raw String() with no thousand separators.

- Add allValuesAreIntegers() + formatYAxisTick() to chart-utils.
- CartesianChart now passes allowDecimals={false} and a number-aware
  tickFormatter to YAxis when every measure value is an integer.
- ChartRenderer's pie tooltip routes the value through formatCellValue,
  matching the Cartesian tooltip behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:49:57 +05:30
Dhruwang 04bfdeef69 removed stale comment 2026-05-11 15:09:51 +05:30
Dhruwang 8a1db7b6aa reverted errorcode transform change 2026-05-11 15:08:58 +05:30
Dhruwang 1d22fe2da6 fix: surface translated inline form errors and prefer .at() over slice
- FormError ignored its children when an error was set, rendering the raw
  zod message instead of the translated text passed in. Prefer explicit
  children, fall back to the field error message.
- Use .at(-1) in getTranslatedFeedbackDirectoryError per SonarQube S7755.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:03:00 +05:30
Dhruwang d8a119712c revert: drop zero-workspace requirement on feedback directory create
Per Johannes on ENG-893: zero-workspace creation is acceptable — admins
move workspaces between directories and forcing at least one is an
arbitrary limitation. The unformatted-error fix
(getTranslatedFeedbackDirectoryError prefix stripping) is retained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:54:54 +05:30
Cursor Agent 50089eeca4 feat: add beta badge to unify feedback section
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-05-11 08:20:06 +00:00
Bhagya Amarasinghe d8fe832f5f fix(helm): stop forcing TEI float16 dtype 2026-05-11 13:24:49 +05:30
Dhruwang 1c904e2243 fix: require at least one workspace when creating a feedback directory
ENG-893

Why: A feedback directory with zero workspaces is useless — it groups
nothing. The create path allowed it, and any validation error that did
fire surfaced as a raw "field: CODE" string in the toast.

How:
- createFeedbackDirectory now rejects empty/missing workspaceIds with
  DIRECTORY_WORKSPACES_REQUIRED before touching the database.
- getTranslatedFeedbackDirectoryError strips the "<field>: " prefix that
  next-safe-action prepends to validation errors, so machine codes always
  map to a translated message.
- Add the new translation key across all 15 locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:04:47 +05:30
Javi Aguilar 4dbecc2d58 fix/a11y-select-scroll 2026-05-11 05:52:55 +02:00
Dhruwang Jariwala 72f4e93432 fix: support Redis Cluster for BullMQ jobs (#7960) 2026-05-08 15:28:10 +05:30
Dhruwang Jariwala 9007502804 feat(feedback-sources): add a Create Survey CTA if there are none (#7943) 2026-05-08 15:10:28 +05:30
Tiago Farto d84589452c Merge branch 'epic/v5' of https://github.com/formbricks/formbricks into fix/investigate-bullmq-issue
# Conflicts:
#	docs/development/technical-handbook/background-job-processing.mdx
2026-05-08 09:38:53 +00:00
Tiago Farto 43aaed3923 fix: support Redis Cluster for BullMQ jobs 2026-05-08 09:19:49 +00:00
Bhagya Amarasinghe 550bfc6a6c fix: update Hub runtime defaults for v5 staging (#7959) 2026-05-07 19:25:20 +02:00
Javi Aguilar d192fbf839 add CR changes 2026-05-07 10:12:06 +02:00
Javi Aguilar c5d52df9b7 use i18n interpolation properly 2026-05-07 10:12:06 +02:00
Javi Aguilar 550e859a2d feat(unify): add CTA to create a survey before using it as feedback source if there are none 2026-05-07 10:12:06 +02:00
54 changed files with 1195 additions and 186 deletions
@@ -43,6 +43,7 @@ import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
@@ -151,7 +152,17 @@ export const MainNavigation = ({
},
{
id: "unify-feedback",
name: t("workspace.unify.unify_feedback"),
name: (
<span className="inline-flex items-center gap-2">
<span>{t("workspace.unify.unify_feedback")}</span>
<Badge
text="Beta"
type="gray"
size="tiny"
className="normal-case text-[10px] font-semibold tracking-normal"
/>
</span>
),
items: [
{
name: t("workspace.unify.feedback_records"),
+13
View File
@@ -1743,6 +1743,7 @@ checksums:
workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190
workspace/analysis/charts/update_chart: 7d9223335d9f0c5938ec30356d7034a9
workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa
workspace/analysis/dashboards/chart_removed: 1ce20b8ee0b56bcd7d6fea2b5c1ae9fd
workspace/analysis/dashboards/charts_add_failed: c4fda79ede798ab6747a989f083a0947
workspace/analysis/dashboards/charts_add_partial_failure: b1a9fc6fe18ab20fe16c16e91a05c195
workspace/analysis/dashboards/charts_added_to_dashboard: 917abab80231adf5af51e812352fc77b
@@ -3520,6 +3521,9 @@ checksums:
workspace/unify/custom_source_type_placeholder: f139e3e5d70dbf426d7c6b5ab2b198cc
workspace/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
workspace/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
workspace/unify/delete_feedback_record: 86d7262c000cfb1f91ea373036cd3616
workspace/unify/delete_feedback_record_confirmation: dd2b12e75cb52a73f92c997c347a8f36
workspace/unify/delete_feedback_records_confirmation: cd8a4ba828963fb6dab5c96606565079
workspace/unify/discard_feedback_record_changes_description: 48ccde99858dcbeb4d679749d0f51941
workspace/unify/discard_feedback_record_changes_title: 52df2800f7b0e8a1d04c47113e019a3e
workspace/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
@@ -3529,10 +3533,17 @@ checksums:
workspace/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
workspace/unify/enter_value: 4f068bb59617975c1e546218373122cd
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
workspace/unify/error_connector_field_mapping_duplicate: 4e507ae4a99f53dfa75d849d32566bf2
workspace/unify/error_connector_formbricks_mapping_duplicate: 48524e22fa33bd0b2829f7aea49c711b
workspace/unify/error_connector_name_duplicate: 56a1a05212e87dff21261d1db90fdb11
workspace/unify/error_connector_name_required: b2d5f79f6126e23128d7bef0c1736ff2
workspace/unify/error_connector_questions_required: d7d8e388959ab83a9195ba63bebf6516
workspace/unify/error_connector_survey_required: 1f49086dfb874307aae1136e88c3d514
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
workspace/unify/feedback_directory: 156fa9957e1ee5eadf1f44226a2365e4
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_fields: 88c0f13afeb88fe751f85e79b0f73064
@@ -3540,6 +3551,7 @@ checksums:
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_refreshed: 4b27a8e2a8dbe8afa945d9f874aa7ef1
workspace/unify/feedback_sources: e58ec9be19db8789e7096a756d24f2b2
workspace/unify/feedback_sources_directory_access_multiple: 11d613bc1e9825aa6faa3db17ae678eb
@@ -3576,6 +3588,7 @@ checksums:
workspace/unify/no_feedback_directory_linked_member_description: e799b17da03899cabd125c3cf672c10d
workspace/unify/no_feedback_directory_linked_title: b01a53d508aeeb1c8ad080ee07efcd04
workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
workspace/unify/no_formbricks_surveys_available_description: 2218334806910168bdfb6f283f31b963
workspace/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
workspace/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
workspace/unify/optional: 396fb9a0472daf401c392bdc3e248943
+81 -2
View File
@@ -386,11 +386,12 @@ describe("createConnectorWithMappings", () => {
);
});
test("throws InvalidInputError on unique constraint violation", async () => {
test("throws CONNECTOR_NAME_DUPLICATE on Connector name unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "name"] },
})
);
@@ -400,7 +401,43 @@ describe("createConnectorWithMappings", () => {
type: "formbricks_survey",
feedbackDirectoryId: FRD_ID,
})
).rejects.toThrow(InvalidInputError);
).rejects.toThrow(new InvalidInputError("CONNECTOR_NAME_DUPLICATE"));
});
test("throws CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE on mapping unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "connectorId", "surveyId", "elementId"] },
})
);
await expect(
createConnectorWithMappings(ENV_ID, {
name: "Dup mapping",
type: "formbricks_survey",
feedbackDirectoryId: FRD_ID,
})
).rejects.toThrow(new InvalidInputError("CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE"));
});
test("throws CONNECTOR_FIELD_MAPPING_DUPLICATE on field mapping unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "connectorId", "sourceFieldId", "targetFieldId"] },
})
);
await expect(
createConnectorWithMappings(ENV_ID, {
name: "Dup field mapping",
type: "csv",
feedbackDirectoryId: FRD_ID,
})
).rejects.toThrow(new InvalidInputError("CONNECTOR_FIELD_MAPPING_DUPLICATE"));
});
test("throws DatabaseError on generic Prisma error", async () => {
@@ -526,6 +563,48 @@ describe("updateConnectorWithMappings", () => {
);
});
test("throws CONNECTOR_NAME_DUPLICATE on Connector name unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "name"] },
})
);
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "Dup" })).rejects.toThrow(
new InvalidInputError("CONNECTOR_NAME_DUPLICATE")
);
});
test("throws CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE on formbricks mapping unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "connectorId", "surveyId", "elementId"] },
})
);
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
new InvalidInputError("CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE")
);
});
test("throws CONNECTOR_FIELD_MAPPING_DUPLICATE on field mapping unique violation", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
clientVersion: "5.0.0",
meta: { target: ["workspaceId", "connectorId", "sourceFieldId", "targetFieldId"] },
})
);
await expect(updateConnectorWithMappings(CONNECTOR_ID, ENV_ID, { name: "x" })).rejects.toThrow(
new InvalidInputError("CONNECTOR_FIELD_MAPPING_DUPLICATE")
);
});
test("throws DatabaseError on generic Prisma error", async () => {
vi.mocked(prisma.$transaction).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("DB error", {
+16 -1
View File
@@ -212,6 +212,18 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
// -- Composite functions --
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
const target = error.meta?.target;
const targetFields = Array.isArray(target) ? (target as string[]) : [];
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
return new InvalidInputError("CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE");
}
if (targetFields.includes("sourceFieldId") || targetFields.includes("targetFieldId")) {
return new InvalidInputError("CONNECTOR_FIELD_MAPPING_DUPLICATE");
}
return new InvalidInputError("CONNECTOR_NAME_DUPLICATE");
};
export type TFormbricksMappingsInput = {
type: "formbricks_survey";
mappings: TConnectorFormbricksMappingCreateInput[];
@@ -284,7 +296,7 @@ export const createConnectorWithMappings = async (
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError(`Connector with name ${data.name} already exists`);
throw mapUniqueConstraintError(error);
}
throw new DatabaseError(error.message);
}
@@ -359,6 +371,9 @@ export const updateConnectorWithMappings = async (
return mapConnectorWithMappings(result);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw mapUniqueConstraintError(error);
}
if (error.code === PrismaErrorType.RecordDoesNotExist) {
throw new ResourceNotFoundError("Connector", connectorId);
}
+13
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "{count} Diagramm(e) hinzufügen",
"chart_removed": "Diagramm vom Dashboard entfernt",
"charts_add_failed": "Diagramme konnten nicht zum Dashboard hinzugefügt werden",
"charts_add_partial_failure": "{count} Diagramm(e) konnten nicht hinzugefügt werden",
"charts_added_to_dashboard": "Diagramme zum Dashboard hinzugefügt",
@@ -3680,6 +3681,9 @@
"custom_source_type_placeholder": "Geben Sie den benutzerdefinierten Quelltyp ein",
"default_connector_name_csv": "CSV-Import",
"default_connector_name_formbricks": "Formbricks-Umfrage-Verbindung",
"delete_feedback_record": "Feedback-Eintrag löschen",
"delete_feedback_record_confirmation": "Dadurch wird der Feedback-Eintrag dauerhaft gelöscht und aus dem verbundenen Verzeichnis entfernt.",
"delete_feedback_records_confirmation": "Dadurch werden {count} Feedback-Einträge dauerhaft gelöscht und aus dem verbundenen Verzeichnis entfernt.",
"discard_feedback_record_changes_description": "Ihre Änderungen gehen verloren, wenn Sie diese Schublade schließen.",
"discard_feedback_record_changes_title": "Nicht gespeicherte Änderungen verwerfen?",
"drop_a_field_here": "Ziehe ein Feld hierher",
@@ -3689,10 +3693,17 @@
"enter_name_for_source": "Gib einen Namen für diese Quelle ein",
"enter_value": "Wert eingeben...",
"enum": "Aufzählung",
"error_connector_field_mapping_duplicate": "Doppelte Feldzuordnung für diese Quelle",
"error_connector_formbricks_mapping_duplicate": "Doppelte Fragenzuordnung für diese Quelle",
"error_connector_name_duplicate": "Eine Quelle mit diesem Namen existiert bereits",
"error_connector_name_required": "Quellenname ist erforderlich",
"error_connector_questions_required": "Wähle mindestens eine Frage aus",
"error_connector_survey_required": "Wähle eine Umfrage aus",
"failed_to_load_feedback_records": "Feedback-Einträge konnten nicht geladen werden",
"feedback_date": "Aktuelles Datum",
"feedback_directory": "Feedback-Verzeichnis",
"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_fields": "Feedback-Eintragsfelder",
@@ -3700,6 +3711,7 @@
"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_refreshed": "Feedback-Einträge aktualisiert",
"feedback_sources": "Feedback-Quellen",
"feedback_sources_directory_access_multiple": "Neue Datensätze aus diesen Quellen werden gespeichert in: {directoryNames}",
@@ -3736,6 +3748,7 @@
"no_feedback_directory_linked_member_description": "Für diesen Workspace muss ein Feedback-Verzeichnis eingerichtet werden, bevor diese Funktion verfügbar ist. Bitte einen Organisationsinhaber oder Manager, eins zuzuweisen.",
"no_feedback_directory_linked_title": "Kein Feedback-Verzeichnis verknüpft",
"no_feedback_records": "Noch keine Feedback-Einträge vorhanden. Einträge erscheinen hier, sobald deine Konnektoren Daten senden.",
"no_formbricks_surveys_available_description": "In diesem Workspace gibt es noch keine Umfragen. <surveyLink>Erstelle eine neue Umfrage</surveyLink>, um eine als Feedback-Quelle zu verwenden.",
"no_source_fields_loaded": "Noch keine Quellfelder geladen",
"no_sources_connected": "Noch keine Quellen verbunden. Füge eine Quelle hinzu, um loszulegen.",
"optional": "Optional",
+13
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Add {count} chart(s)",
"chart_removed": "Chart removed from dashboard",
"charts_add_failed": "Failed to add charts to dashboard",
"charts_add_partial_failure": "Failed to add {count} chart(s)",
"charts_added_to_dashboard": "Charts added to dashboard",
@@ -3680,6 +3681,9 @@
"custom_source_type_placeholder": "Enter custom source type",
"default_connector_name_csv": "CSV Import",
"default_connector_name_formbricks": "Formbricks Survey Connection",
"delete_feedback_record": "Delete feedback record",
"delete_feedback_record_confirmation": "This will permanently delete the feedback record and remove it from the connected directory.",
"delete_feedback_records_confirmation": "This will permanently delete {count} feedback records and remove them from the connected directory.",
"discard_feedback_record_changes_description": "Your changes will be lost if you close this drawer.",
"discard_feedback_record_changes_title": "Discard unsaved changes?",
"drop_a_field_here": "Drop a field here",
@@ -3689,10 +3693,17 @@
"enter_name_for_source": "Enter a name for this source",
"enter_value": "Enter value...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Duplicate field mapping for this source",
"error_connector_formbricks_mapping_duplicate": "Duplicate question mapping for this source",
"error_connector_name_duplicate": "A source with this name already exists",
"error_connector_name_required": "Source name is required",
"error_connector_questions_required": "Select at least one question",
"error_connector_survey_required": "Select a survey",
"failed_to_load_feedback_records": "Failed to load feedback records",
"feedback_date": "Current date",
"feedback_directory": "Feedback Directory",
"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_fields": "Feedback Record Fields",
@@ -3700,6 +3711,7 @@
"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_refreshed": "Feedback records refreshed",
"feedback_sources": "Feedback Sources",
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
@@ -3736,6 +3748,7 @@
"no_feedback_directory_linked_member_description": "A feedback directory needs to be set up for this workspace before this functionality is available. Ask an organization owner or manager to assign one.",
"no_feedback_directory_linked_title": "No feedback directory linked",
"no_feedback_records": "No feedback records yet. Records will appear here once your connectors start sending data.",
"no_formbricks_surveys_available_description": "There are no surveys in this workspace yet. <surveyLink>Create a new survey</surveyLink> to use it as a feedback source.",
"no_source_fields_loaded": "No source fields loaded yet",
"no_sources_connected": "No sources connected yet. Add a source to get started.",
"optional": "Optional",
+13
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Añadir {count} gráfico(s)",
"chart_removed": "Gráfico eliminado del panel",
"charts_add_failed": "Error al añadir gráficos al panel",
"charts_add_partial_failure": "Error al añadir {count} gráfico(s)",
"charts_added_to_dashboard": "Gráficos añadidos al panel",
@@ -3680,6 +3681,9 @@
"custom_source_type_placeholder": "Ingrese el tipo de fuente personalizado",
"default_connector_name_csv": "Importación CSV",
"default_connector_name_formbricks": "Conexión de encuesta de Formbricks",
"delete_feedback_record": "Eliminar registro de comentarios",
"delete_feedback_record_confirmation": "Esto eliminará permanentemente el registro de comentarios y lo quitará del directorio conectado.",
"delete_feedback_records_confirmation": "Esto eliminará permanentemente {count} registros de comentarios y los quitará del directorio conectado.",
"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?",
"drop_a_field_here": "Suelta un campo aquí",
@@ -3689,10 +3693,17 @@
"enter_name_for_source": "Introduce un nombre para este origen",
"enter_value": "Introduce un valor...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Mapeo de campo duplicado para esta fuente",
"error_connector_formbricks_mapping_duplicate": "Mapeo de pregunta duplicado para esta fuente",
"error_connector_name_duplicate": "Ya existe una fuente con este nombre",
"error_connector_name_required": "El nombre de origen es obligatorio",
"error_connector_questions_required": "Selecciona al menos una pregunta",
"error_connector_survey_required": "Selecciona una encuesta",
"failed_to_load_feedback_records": "Error al cargar los registros de comentarios",
"feedback_date": "Fecha actual",
"feedback_directory": "Directorio 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_fields": "Campos de registro de comentarios",
@@ -3700,6 +3711,7 @@
"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_refreshed": "Registros de comentarios actualizados",
"feedback_sources": "Fuentes de feedback",
"feedback_sources_directory_access_multiple": "Los nuevos registros de estas fuentes se almacenarán en: {directoryNames}",
@@ -3736,6 +3748,7 @@
"no_feedback_directory_linked_member_description": "Es necesario configurar un directorio de feedback para este espacio de trabajo antes de que esta funcionalidad esté disponible. Solicita a un propietario o gestor de la organización que asigne uno.",
"no_feedback_directory_linked_title": "No hay ningún directorio de feedback vinculado",
"no_feedback_records": "Aún no hay registros de comentarios. Los registros aparecerán aquí una vez que tus conectores empiecen a enviar datos.",
"no_formbricks_surveys_available_description": "Todavía no hay encuestas en este espacio de trabajo. <surveyLink>Crea una nueva encuesta</surveyLink> para usar una como fuente de feedback.",
"no_source_fields_loaded": "Aún no se han cargado campos de origen",
"no_sources_connected": "Aún no hay fuentes conectadas. Añade una fuente para empezar.",
"optional": "Opcional",
+13
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Ajouter {count} graphique(s)",
"chart_removed": "Graphique retiré du tableau de bord",
"charts_add_failed": "Échec de l'ajout des graphiques au tableau de bord",
"charts_add_partial_failure": "Échec de l'ajout de {count} graphique(s)",
"charts_added_to_dashboard": "Graphiques ajoutés au tableau de bord",
@@ -3680,6 +3681,9 @@
"custom_source_type_placeholder": "Entrez le type de source personnalisé",
"default_connector_name_csv": "Importation CSV",
"default_connector_name_formbricks": "Connexion de sondage Formbricks",
"delete_feedback_record": "Supprimer l'enregistrement de commentaire",
"delete_feedback_record_confirmation": "Cela supprimera définitivement l'enregistrement de commentaire et le retirera du répertoire connecté.",
"delete_feedback_records_confirmation": "Cela supprimera définitivement {count} enregistrements de commentaires et les retirera du répertoire connecté.",
"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 ?",
"drop_a_field_here": "Déposez un champ ici",
@@ -3689,10 +3693,17 @@
"enter_name_for_source": "Entrez un nom pour cette source",
"enter_value": "Saisir une valeur...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Mappage de champ en double pour cette source",
"error_connector_formbricks_mapping_duplicate": "Mappage de question en double pour cette source",
"error_connector_name_duplicate": "Une source avec ce nom existe déjà",
"error_connector_name_required": "Le nom de la source est requis",
"error_connector_questions_required": "Sélectionnez au moins une question",
"error_connector_survey_required": "Sélectionnez une enquête",
"failed_to_load_feedback_records": "Échec du chargement des enregistrements de feedback",
"feedback_date": "Date actuelle",
"feedback_directory": "Répertoire de retours",
"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 denregistrement des commentaires.",
"feedback_record_fields": "Champs d'enregistrement de feedback",
@@ -3700,6 +3711,7 @@
"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_refreshed": "Enregistrements de feedback actualisés",
"feedback_sources": "Sources de feedback",
"feedback_sources_directory_access_multiple": "Les nouveaux enregistrements de ces sources seront stockés dans : {directoryNames}",
@@ -3736,6 +3748,7 @@
"no_feedback_directory_linked_member_description": "Un répertoire de feedback doit être configuré pour cet espace de travail avant que cette fonctionnalité ne soit disponible. Demande à un propriétaire ou un gestionnaire de l'organisation d'en attribuer un.",
"no_feedback_directory_linked_title": "Aucun répertoire de feedback lié",
"no_feedback_records": "Aucun enregistrement de feedback pour le moment. Les enregistrements apparaîtront ici une fois que vos connecteurs commenceront à envoyer des données.",
"no_formbricks_surveys_available_description": "Il ny a pas encore de sondages dans cet espace de travail. <surveyLink>Créez une nouvelle enquête</surveyLink> pour en utiliser une comme source de feedback.",
"no_source_fields_loaded": "Aucun champ source chargé pour le moment",
"no_sources_connected": "Aucune source connectée pour le moment. Ajoutez une source pour commencer.",
"optional": "Facultatif",
+13
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "{count} diagram hozzáadása",
"chart_removed": "A diagram eltávolítva a műszerfalról",
"charts_add_failed": "A diagramok műszerfalhoz való hozzáadása sikertelen",
"charts_add_partial_failure": "{count} diagram hozzáadása sikertelen",
"charts_added_to_dashboard": "Diagramok hozzáadva a műszerfalhoz",
@@ -3680,6 +3681,9 @@
"custom_source_type_placeholder": "Adja meg az egyéni forrástípust",
"default_connector_name_csv": "CSV importálás",
"default_connector_name_formbricks": "Formbricks kérdőív kapcsolat",
"delete_feedback_record": "Visszajelzési bejegyzés törlése",
"delete_feedback_record_confirmation": "Ez véglegesen törli a visszajelzési bejegyzést, és eltávolítja a csatlakoztatott könyvtárból.",
"delete_feedback_records_confirmation": "Ez véglegesen töröl {count} visszajelzési bejegyzést, és eltávolítja őket a csatlakoztatott könyvtárból.",
"discard_feedback_record_changes_description": "A módosítások elvesznek, ha bezárja ezt a fiókot.",
"discard_feedback_record_changes_title": "Elveti a nem mentett módosításokat?",
"drop_a_field_here": "Húzz ide egy mezőt",
@@ -3689,10 +3693,17 @@
"enter_name_for_source": "Adj nevet ennek a forrásnak",
"enter_value": "Érték megadása...",
"enum": "felsorolás",
"error_connector_field_mapping_duplicate": "Duplikált mezőleképezés ehhez a forráshoz",
"error_connector_formbricks_mapping_duplicate": "Duplikált kérdésleképezés ehhez a forráshoz",
"error_connector_name_duplicate": "Már létezik forrás ezzel a névvel",
"error_connector_name_required": "A forrás neve kötelező",
"error_connector_questions_required": "Válasszon ki legalább egy kérdést",
"error_connector_survey_required": "Válasszon ki egy felmérést",
"failed_to_load_feedback_records": "Nem sikerült betölteni a visszajelzési rekordokat",
"feedback_date": "Aktuális dátum",
"feedback_directory": "Visszajelzési könyvtár",
"feedback_record_created_successfully": "A visszajelzési rekord sikeresen létrehozva",
"feedback_record_deleted_successfully": "A visszajelzési bejegyzés sikeresen törölve",
"feedback_record_details": "A visszajelzési rekord részletei",
"feedback_record_details_description": "Tekintse át és frissítse a visszajelzési rekordmezőket.",
"feedback_record_fields": "Visszajelzési rekord mezők",
@@ -3700,6 +3711,7 @@
"feedback_record_updated_successfully": "A visszajelzési rekord sikeresen frissítve",
"feedback_record_value_required": "A kiválasztott mezőtípushoz értéket kell megadni",
"feedback_records": "Visszajelzési rekordok",
"feedback_records_deleted_successfully": "{count} visszajelzési bejegyzés törölve",
"feedback_records_refreshed": "Visszajelzési rekordok frissítve",
"feedback_sources": "Visszajelzési források",
"feedback_sources_directory_access_multiple": "Az ezekből a forrásokból származó új rekordok a következő helyen lesznek tárolva: {directoryNames}",
@@ -3736,6 +3748,7 @@
"no_feedback_directory_linked_member_description": "Ehhez a munkaterülethez be kell állítani egy visszajelzési könyvtárat, mielőtt ez a funkció elérhetővé válna. Kérje meg a szervezet tulajdonosát vagy vezetőjét, hogy rendeljen hozzá egyet.",
"no_feedback_directory_linked_title": "Nincs visszajelzési könyvtár kapcsolva",
"no_feedback_records": "Még nincsenek visszajelzési rekordok. A rekordok itt fognak megjelenni, amint a csatlakozók elkezdik küldeni az adatokat.",
"no_formbricks_surveys_available_description": "Ebben a munkaterületen még nincsenek kérdőívek. <surveyLink>Hozz létre egy új kérdőívet</surveyLink>, hogy visszajelzési forrásként használhass egyet.",
"no_source_fields_loaded": "Még nincsenek forrás mezők betöltve",
"no_sources_connected": "Még nincsenek források csatlakoztatva. Adj hozzá egy forrást a kezdéshez.",
"optional": "Elhagyható",
+13
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "{count}個のグラフを追加",
"chart_removed": "チャートがダッシュボードから削除されました",
"charts_add_failed": "ダッシュボードへのグラフの追加に失敗しました",
"charts_add_partial_failure": "{count}個のグラフの追加に失敗しました",
"charts_added_to_dashboard": "グラフをダッシュボードに追加しました",
@@ -3680,6 +3681,9 @@
"custom_source_type_placeholder": "カスタムソースタイプを入力してください",
"default_connector_name_csv": "CSVインポート",
"default_connector_name_formbricks": "Formbricks フォーム接続",
"delete_feedback_record": "フィードバック記録を削除",
"delete_feedback_record_confirmation": "この操作により、フィードバック記録が完全に削除され、接続されているディレクトリから削除されます。",
"delete_feedback_records_confirmation": "この操作により、{count}件のフィードバック記録が完全に削除され、接続されているディレクトリから削除されます。",
"discard_feedback_record_changes_description": "このドロワーを閉じると、変更内容は失われます。",
"discard_feedback_record_changes_title": "保存されていない変更を破棄しますか?",
"drop_a_field_here": "ここにフィールドをドロップ",
@@ -3689,10 +3693,17 @@
"enter_name_for_source": "このソースの名前を入力",
"enter_value": "値を入力...",
"enum": "列挙型",
"error_connector_field_mapping_duplicate": "このソースのフィールドマッピングが重複しています",
"error_connector_formbricks_mapping_duplicate": "このソースの質問マッピングが重複しています",
"error_connector_name_duplicate": "この名前のソースは既に存在します",
"error_connector_name_required": "ソース名は必須です",
"error_connector_questions_required": "少なくとも1つの質問を選択してください",
"error_connector_survey_required": "アンケートを選択してください",
"failed_to_load_feedback_records": "フィードバックレコードの読み込みに失敗しました",
"feedback_date": "現在の日付",
"feedback_directory": "フィードバックディレクトリ",
"feedback_record_created_successfully": "フィードバックレコードが正常に作成されました",
"feedback_record_deleted_successfully": "フィードバック記録を削除しました",
"feedback_record_details": "フィードバック記録の詳細",
"feedback_record_details_description": "フィードバック レコード フィールドを確認して更新します。",
"feedback_record_fields": "フィードバックレコードフィールド",
@@ -3700,6 +3711,7 @@
"feedback_record_updated_successfully": "フィードバックレコードが正常に更新されました",
"feedback_record_value_required": "選択したフィールド タイプには値が必要です",
"feedback_records": "フィードバックレコード",
"feedback_records_deleted_successfully": "{count}件のフィードバック記録を削除しました",
"feedback_records_refreshed": "フィードバックレコードを更新しました",
"feedback_sources": "フィードバックソース",
"feedback_sources_directory_access_multiple": "これらのソースからの新しいレコードは次の場所に保存されます:{directoryNames}",
@@ -3736,6 +3748,7 @@
"no_feedback_directory_linked_member_description": "この機能を利用するには、このワークスペースにフィードバックディレクトリを設定する必要があります。組織のオーナーまたはマネージャーに割り当てを依頼してください。",
"no_feedback_directory_linked_title": "フィードバックディレクトリが未リンク",
"no_feedback_records": "フィードバックレコードはまだありません。コネクタがデータの送信を開始すると、ここにレコードが表示されます。",
"no_formbricks_surveys_available_description": "このワークスペースにはまだフォームがありません。フィードバックソースとして使用するには<surveyLink>新しいフォームを作成</surveyLink>してください。",
"no_source_fields_loaded": "ソースフィールドがまだ読み込まれていません",
"no_sources_connected": "ソースがまだ接続されていません。開始するにはソースを追加してください。",
"optional": "任意",
+13
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "{count} grafiek(en) toevoegen",
"chart_removed": "Grafiek verwijderd van dashboard",
"charts_add_failed": "Grafieken toevoegen aan dashboard mislukt",
"charts_add_partial_failure": "{count} grafiek(en) toevoegen mislukt",
"charts_added_to_dashboard": "Grafieken toegevoegd aan dashboard",
@@ -3680,6 +3681,9 @@
"custom_source_type_placeholder": "Voer een aangepast brontype in",
"default_connector_name_csv": "CSV import",
"default_connector_name_formbricks": "Formbricks Survey verbinding",
"delete_feedback_record": "Feedbackrecord verwijderen",
"delete_feedback_record_confirmation": "Hiermee wordt het feedbackrecord permanent verwijderd en uit de gekoppelde map gehaald.",
"delete_feedback_records_confirmation": "Hiermee worden {count} feedbackrecords permanent verwijderd en uit de gekoppelde map gehaald.",
"discard_feedback_record_changes_description": "Als u deze lade sluit, gaan uw wijzigingen verloren.",
"discard_feedback_record_changes_title": "Niet-opgeslagen wijzigingen verwijderen?",
"drop_a_field_here": "Zet hier een veld neer",
@@ -3689,10 +3693,17 @@
"enter_name_for_source": "Voer een naam in voor deze bron",
"enter_value": "Voer waarde in...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Dubbele veldkoppeling voor deze bron",
"error_connector_formbricks_mapping_duplicate": "Dubbele vraagkoppeling voor deze bron",
"error_connector_name_duplicate": "Er bestaat al een bron met deze naam",
"error_connector_name_required": "Bronnaam is verplicht",
"error_connector_questions_required": "Selecteer minimaal één vraag",
"error_connector_survey_required": "Selecteer een enquête",
"failed_to_load_feedback_records": "Kan feedbackrecords niet laden",
"feedback_date": "Huidige datum",
"feedback_directory": "Feedbackmap",
"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_fields": "Feedbackrecordvelden",
@@ -3700,6 +3711,7 @@
"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_refreshed": "Feedbackrecords vernieuwd",
"feedback_sources": "Feedbackbronnen",
"feedback_sources_directory_access_multiple": "Nieuwe records van deze bronnen worden opgeslagen in: {directoryNames}",
@@ -3736,6 +3748,7 @@
"no_feedback_directory_linked_member_description": "Er moet eerst een feedbackmap worden ingesteld voor deze werkruimte voordat deze functionaliteit beschikbaar is. Vraag een organisatie-eigenaar of manager om er een toe te wijzen.",
"no_feedback_directory_linked_title": "Geen feedbackmap gekoppeld",
"no_feedback_records": "Nog geen feedbackrecords. Records verschijnen hier zodra je connectoren gegevens beginnen te verzenden.",
"no_formbricks_surveys_available_description": "Er zijn nog geen enquêtes in deze werkruimte. <surveyLink>Maak een nieuwe enquête</surveyLink> om er een als feedbackbron te gebruiken.",
"no_source_fields_loaded": "Nog geen bronvelden geladen",
"no_sources_connected": "Nog geen bronnen verbonden. Voeg een bron toe om te beginnen.",
"optional": "Optioneel",
+13
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Adicionar {count} gráfico(s)",
"chart_removed": "Gráfico removido do painel",
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
@@ -3680,6 +3681,9 @@
"custom_source_type_placeholder": "Insira o tipo de fonte personalizado",
"default_connector_name_csv": "Importação CSV",
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
"delete_feedback_record": "Excluir registro de feedback",
"delete_feedback_record_confirmation": "Isso excluirá permanentemente o registro de feedback e o removerá do diretório conectado.",
"delete_feedback_records_confirmation": "Isso excluirá permanentemente {count} registros de feedback e os removerá do diretório conectado.",
"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?",
"drop_a_field_here": "Solte um campo aqui",
@@ -3689,10 +3693,17 @@
"enter_name_for_source": "Digite um nome para esta origem",
"enter_value": "Digite o valor...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Mapeamento de campo duplicado para esta origem",
"error_connector_formbricks_mapping_duplicate": "Mapeamento de pergunta duplicado para esta origem",
"error_connector_name_duplicate": "Uma fonte com este nome já existe",
"error_connector_name_required": "O nome da fonte é obrigatório",
"error_connector_questions_required": "Selecione pelo menos uma pergunta",
"error_connector_survey_required": "Selecione uma pesquisa",
"failed_to_load_feedback_records": "Falha ao carregar registros de feedback",
"feedback_date": "Data atual",
"feedback_directory": "Diretório 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_fields": "Campos do registro de feedback",
@@ -3700,6 +3711,7 @@
"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_refreshed": "Registros de feedback atualizados",
"feedback_sources": "Fontes de Feedback",
"feedback_sources_directory_access_multiple": "Novos registros dessas fontes serão armazenados em: {directoryNames}",
@@ -3736,6 +3748,7 @@
"no_feedback_directory_linked_member_description": "Um diretório de feedback precisa ser configurado para este workspace antes que esta funcionalidade esteja disponível. Peça a um proprietário ou gerente da organização para configurar um.",
"no_feedback_directory_linked_title": "Nenhum diretório de feedback vinculado",
"no_feedback_records": "Nenhum registro de feedback ainda. Os registros aparecerão aqui assim que seus conectores começarem a enviar dados.",
"no_formbricks_surveys_available_description": "Ainda não há pesquisas neste workspace. <surveyLink>Crie uma nova pesquisa</surveyLink> para usar uma como fonte de feedback.",
"no_source_fields_loaded": "Nenhum campo de origem carregado ainda",
"no_sources_connected": "Nenhuma origem conectada ainda. Adicione uma origem para começar.",
"optional": "Opcional",
+13
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Adicionar {count} gráfico(s)",
"chart_removed": "Gráfico removido do painel",
"charts_add_failed": "Falha ao adicionar gráficos ao painel",
"charts_add_partial_failure": "Falha ao adicionar {count} gráfico(s)",
"charts_added_to_dashboard": "Gráficos adicionados ao painel",
@@ -3680,6 +3681,9 @@
"custom_source_type_placeholder": "Insira o tipo de fonte personalizado",
"default_connector_name_csv": "Importação CSV",
"default_connector_name_formbricks": "Conexão de pesquisa Formbricks",
"delete_feedback_record": "Eliminar registo de feedback",
"delete_feedback_record_confirmation": "Esta ação irá eliminar permanentemente o registo de feedback e removê-lo do diretório associado.",
"delete_feedback_records_confirmation": "Esta ação irá eliminar permanentemente {count} registos de feedback e removê-los do diretório associado.",
"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?",
"drop_a_field_here": "Solte um campo aqui",
@@ -3689,10 +3693,17 @@
"enter_name_for_source": "Introduz um nome para esta origem",
"enter_value": "Introduzir valor...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Mapeamento de campo duplicado para esta origem",
"error_connector_formbricks_mapping_duplicate": "Mapeamento de pergunta duplicado para esta origem",
"error_connector_name_duplicate": "Já existe uma origem com este nome",
"error_connector_name_required": "O nome da origem é obrigatório",
"error_connector_questions_required": "Seleciona pelo menos uma pergunta",
"error_connector_survey_required": "Seleciona um inquérito",
"failed_to_load_feedback_records": "Falha ao carregar registos de feedback",
"feedback_date": "Data atual",
"feedback_directory": "Diretório 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_fields": "Campos de registo de feedback",
@@ -3700,6 +3711,7 @@
"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_refreshed": "Registos de feedback atualizados",
"feedback_sources": "Fontes de Feedback",
"feedback_sources_directory_access_multiple": "Novos registos destas fontes serão armazenados em: {directoryNames}",
@@ -3736,6 +3748,7 @@
"no_feedback_directory_linked_member_description": "É necessário configurar um diretório de feedback para este workspace antes de esta funcionalidade estar disponível. Pede a um proprietário ou gestor da organização para atribuir um.",
"no_feedback_directory_linked_title": "Nenhum diretório de feedback vinculado",
"no_feedback_records": "Ainda não há registos de feedback. Os registos aparecerão aqui assim que os teus conectores começarem a enviar dados.",
"no_formbricks_surveys_available_description": "Ainda não há inquéritos neste workspace. <surveyLink>Cria um novo inquérito</surveyLink> para usar um como fonte de feedback.",
"no_source_fields_loaded": "Ainda não foram carregados campos de origem",
"no_sources_connected": "Ainda não há origens ligadas. Adicione uma origem para começar.",
"optional": "Opcional",
+13
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Adaugă {count} grafic(e)",
"chart_removed": "Graficul a fost eliminat din tabloul de bord",
"charts_add_failed": "Nu s-au putut adăuga graficele la panoul de control",
"charts_add_partial_failure": "Nu s-au putut adăuga {count} grafic(e)",
"charts_added_to_dashboard": "Grafice adăugate la panoul de control",
@@ -3680,6 +3681,9 @@
"custom_source_type_placeholder": "Introduceți tipul de sursă personalizat",
"default_connector_name_csv": "Import CSV",
"default_connector_name_formbricks": "Conexiune chestionar Formbricks",
"delete_feedback_record": "Șterge înregistrarea de feedback",
"delete_feedback_record_confirmation": "Aceasta va șterge definitiv înregistrarea de feedback și o va elimina din directorul conectat.",
"delete_feedback_records_confirmation": "Aceasta va șterge definitiv {count} înregistrări de feedback și le va elimina din directorul conectat.",
"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?",
"drop_a_field_here": "Trage un câmp aici",
@@ -3689,10 +3693,17 @@
"enter_name_for_source": "Introdu un nume pentru această sursă",
"enter_value": "Introdu valoarea...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Mapare duplicată a câmpului pentru această sursă",
"error_connector_formbricks_mapping_duplicate": "Mapare duplicată a întrebării pentru această sursă",
"error_connector_name_duplicate": "Există deja o sursă cu acest nume",
"error_connector_name_required": "Numele sursei este obligatoriu",
"error_connector_questions_required": "Selectează cel puțin o întrebare",
"error_connector_survey_required": "Selectează un sondaj",
"failed_to_load_feedback_records": "Nu s-au putut încărca înregistrările de feedback",
"feedback_date": "Data curentă",
"feedback_directory": "Director 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_fields": "Câmpuri înregistrare feedback",
@@ -3700,6 +3711,7 @@
"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_refreshed": "Înregistrările de feedback au fost actualizate",
"feedback_sources": "Surse de feedback",
"feedback_sources_directory_access_multiple": "Înregistrările noi din aceste surse vor fi stocate în: {directoryNames}",
@@ -3736,6 +3748,7 @@
"no_feedback_directory_linked_member_description": "Trebuie configurat un director de feedback pentru acest spațiu de lucru înainte ca această funcționalitate să fie disponibilă. Solicită unui proprietar sau manager al organizației să atribuie unul.",
"no_feedback_directory_linked_title": "Niciun director de feedback conectat",
"no_feedback_records": "Nu există încă înregistrări de feedback. Înregistrările vor apărea aici după ce conectorii tăi vor începe să trimită date.",
"no_formbricks_surveys_available_description": "Nu există încă chestionare în acest spațiu de lucru. <surveyLink>Creează un chestionar nou</surveyLink> pentru a folosi unul ca sursă de feedback.",
"no_source_fields_loaded": "Nu au fost încă încărcate câmpuri sursă",
"no_sources_connected": "Nicio sursă conectată încă. Adaugă o sursă pentru a începe.",
"optional": "Opțional",
+13
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Добавить {count} график(ов)",
"chart_removed": "График удалён с панели",
"charts_add_failed": "Не удалось добавить графики на дашборд",
"charts_add_partial_failure": "Не удалось добавить {count} график(ов)",
"charts_added_to_dashboard": "Графики добавлены на дашборд",
@@ -3680,6 +3681,9 @@
"custom_source_type_placeholder": "Введите собственный тип источника",
"default_connector_name_csv": "Импорт CSV",
"default_connector_name_formbricks": "Подключение опроса Formbricks",
"delete_feedback_record": "Удалить запись обратной связи",
"delete_feedback_record_confirmation": "Это навсегда удалит запись обратной связи и уберёт её из подключённого каталога.",
"delete_feedback_records_confirmation": "Это навсегда удалит {count} {count, plural, one {запись обратной связи} few {записи обратной связи} many {записей обратной связи} other {записей обратной связи}} и уберёт их из подключённого каталога.",
"discard_feedback_record_changes_description": "Ваши изменения будут потеряны, если вы закроете этот ящик.",
"discard_feedback_record_changes_title": "Отменить несохраненные изменения?",
"drop_a_field_here": "Перетащи сюда поле",
@@ -3689,10 +3693,17 @@
"enter_name_for_source": "Введи имя для этого источника",
"enter_value": "Введите значение...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Дублирующееся сопоставление полей для этого источника",
"error_connector_formbricks_mapping_duplicate": "Дублирующееся сопоставление вопросов для этого источника",
"error_connector_name_duplicate": "Источник с таким именем уже существует",
"error_connector_name_required": "Необходимо указать название источника",
"error_connector_questions_required": "Выберите хотя бы один вопрос",
"error_connector_survey_required": "Выберите опрос",
"failed_to_load_feedback_records": "Не удалось загрузить отзывы",
"feedback_date": "Текущая дата",
"feedback_directory": "Директория обратной связи",
"feedback_record_created_successfully": "Запись отзыва успешно создана",
"feedback_record_deleted_successfully": "Запись обратной связи успешно удалена",
"feedback_record_details": "Детали записи обратной связи",
"feedback_record_details_description": "Просмотрите и обновите поля записи отзыва.",
"feedback_record_fields": "Поля записи отзыва",
@@ -3700,6 +3711,7 @@
"feedback_record_updated_successfully": "Запись отзыва успешно обновлена.",
"feedback_record_value_required": "Требуется значение для выбранного типа поля.",
"feedback_records": "Записи отзывов",
"feedback_records_deleted_successfully": "Удалено {count} {count, plural, one {запись обратной связи} few {записи обратной связи} many {записей обратной связи} other {записей обратной связи}}",
"feedback_records_refreshed": "Записи отзывов обновлены",
"feedback_sources": "Источники обратной связи",
"feedback_sources_directory_access_multiple": "Новые записи из этих источников будут сохранены в: {directoryNames}",
@@ -3736,6 +3748,7 @@
"no_feedback_directory_linked_member_description": "Для этого рабочего пространства нужно настроить директорию обратной связи, прежде чем эта функция станет доступна. Попроси владельца или менеджера организации назначить её.",
"no_feedback_directory_linked_title": "Директория обратной связи не привязана",
"no_feedback_records": "Пока нет записей отзывов. Они появятся здесь, когда коннекторы начнут отправлять данные.",
"no_formbricks_surveys_available_description": "В этом рабочем пространстве пока нет опросов. <surveyLink>Создайте новый опрос</surveyLink>, чтобы использовать один как источник обратной связи.",
"no_source_fields_loaded": "Поля источника ещё не загружены",
"no_sources_connected": "Нет подключённых источников. Добавьте источник, чтобы начать.",
"optional": "Необязательно",
+13
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "Lägg till {count} diagram",
"chart_removed": "Diagram borttaget från instrumentpanelen",
"charts_add_failed": "Misslyckades med att lägga till diagram i instrumentpanelen",
"charts_add_partial_failure": "Misslyckades med att lägga till {count} diagram",
"charts_added_to_dashboard": "Diagram tillagda i instrumentpanelen",
@@ -3680,6 +3681,9 @@
"custom_source_type_placeholder": "Ange anpassad källtyp",
"default_connector_name_csv": "CSV-import",
"default_connector_name_formbricks": "Formbricks Survey-anslutning",
"delete_feedback_record": "Ta bort feedbackpost",
"delete_feedback_record_confirmation": "Detta kommer permanent ta bort feedbackposten och radera den från den anslutna katalogen.",
"delete_feedback_records_confirmation": "Detta kommer permanent ta bort {count} feedbackposter och radera dem från den anslutna katalogen.",
"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?",
"drop_a_field_here": "Släpp ett fält här",
@@ -3689,10 +3693,17 @@
"enter_name_for_source": "Ange ett namn för denna källa",
"enter_value": "Ange värde...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Duplicerad fältmappning för denna källa",
"error_connector_formbricks_mapping_duplicate": "Duplicerad frågemappning för denna källa",
"error_connector_name_duplicate": "En källa med det här namnet finns redan",
"error_connector_name_required": "Källnamn krävs",
"error_connector_questions_required": "Välj minst en fråga",
"error_connector_survey_required": "Välj en undersökning",
"failed_to_load_feedback_records": "Det gick inte att ladda feedbackposter",
"feedback_date": "Aktuellt datum",
"feedback_directory": "Feedback-katalog",
"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_fields": "Fält för feedbackpost",
@@ -3700,6 +3711,7 @@
"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_refreshed": "Feedbackposter har uppdaterats",
"feedback_sources": "Feedback Sources",
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
@@ -3736,6 +3748,7 @@
"no_feedback_directory_linked_member_description": "En feedbackkatalog måste konfigureras för denna arbetsyta innan den här funktionen blir tillgänglig. Be en organisationsägare eller chef att tilldela en.",
"no_feedback_directory_linked_title": "Ingen feedbackkatalog länkad",
"no_feedback_records": "Inga feedbackposter ännu. Poster visas här när dina connectors börjar skicka data.",
"no_formbricks_surveys_available_description": "Det finns inga enkäter i denna arbetsyta ännu. <surveyLink>Skapa en ny enkät</surveyLink> för att använda en som feedbackkälla.",
"no_source_fields_loaded": "Inga källfält har laddats än",
"no_sources_connected": "Inga källor är anslutna än. Lägg till en källa för att komma igång.",
"optional": "Valfritt",
+13
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "{count} grafik ekle",
"chart_removed": "Grafik gösterge panosundan kaldırıldı",
"charts_add_failed": "Grafikler panoya eklenemedi",
"charts_add_partial_failure": "{count} grafik eklenemedi",
"charts_added_to_dashboard": "Grafikler panoya eklendi",
@@ -3680,6 +3681,9 @@
"custom_source_type_placeholder": "Özel kaynak türünü girin",
"default_connector_name_csv": "CSV İçe Aktarma",
"default_connector_name_formbricks": "Formbricks Anket Bağlantısı",
"delete_feedback_record": "Geri bildirim kaydını sil",
"delete_feedback_record_confirmation": "Bu işlem geri bildirim kaydını kalıcı olarak silecek ve bağlı dizinden kaldıracaktır.",
"delete_feedback_records_confirmation": "Bu işlem {count} geri bildirim kaydını kalıcı olarak silecek ve bağlı dizinden kaldıracaktır.",
"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?",
"drop_a_field_here": "Buraya bir alan bırakın",
@@ -3689,10 +3693,17 @@
"enter_name_for_source": "Bu kaynak için bir ad girin",
"enter_value": "Değer girin...",
"enum": "enum",
"error_connector_field_mapping_duplicate": "Bu kaynak için yinelenen alan eşlemesi",
"error_connector_formbricks_mapping_duplicate": "Bu kaynak için yinelenen soru eşlemesi",
"error_connector_name_duplicate": "Bu isimde bir kaynak zaten mevcut",
"error_connector_name_required": "Kaynak adı gereklidir",
"error_connector_questions_required": "En az bir soru seçin",
"error_connector_survey_required": "Bir anket seçin",
"failed_to_load_feedback_records": "Geri bildirim kayıtları yüklenemedi",
"feedback_date": "Geçerli tarih",
"feedback_directory": "Geri Bildirim Dizini",
"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_fields": "Geri Bildirim Kayıt Alanları",
@@ -3700,6 +3711,7 @@
"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_refreshed": "Geri bildirim kayıtları yenilendi",
"feedback_sources": "Feedback Sources",
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
@@ -3736,6 +3748,7 @@
"no_feedback_directory_linked_member_description": "Bu işlevin kullanılabilmesi için önce bu çalışma alanı için bir geri bildirim dizini kurulması gerekiyor. Bir organizasyon sahibinden veya yöneticisinden bir tane atamasını iste.",
"no_feedback_directory_linked_title": "Bağlı geri bildirim dizini yok",
"no_feedback_records": "Henüz geri bildirim kaydı yok. Bağlayıcıların veri göndermeye başlamasıyla kayıtlar burada görünecek.",
"no_formbricks_surveys_available_description": "Bu çalışma alanında henüz anket yok. Geri bildirim kaynağı olarak kullanmak için <surveyLink>Yeni bir anket oluştur</surveyLink>.",
"no_source_fields_loaded": "Henüz kaynak alan yüklenmedi",
"no_sources_connected": "Henüz bağlı kaynak yok. Başlamak için bir kaynak ekle.",
"optional": "İsteğe bağlı",
+13
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "添加 {count} 个图表",
"chart_removed": "图表已从仪表板中移除",
"charts_add_failed": "添加图表到仪表板失败",
"charts_add_partial_failure": "添加 {count} 个图表失败",
"charts_added_to_dashboard": "图表已添加到仪表板",
@@ -3680,6 +3681,9 @@
"custom_source_type_placeholder": "输入自定义来源类型",
"default_connector_name_csv": "CSV 导入",
"default_connector_name_formbricks": "Formbricks 调查连接",
"delete_feedback_record": "删除反馈记录",
"delete_feedback_record_confirmation": "这将永久删除该反馈记录并从关联目录中移除。",
"delete_feedback_records_confirmation": "这将永久删除 {count} 条反馈记录并从关联目录中移除。",
"discard_feedback_record_changes_description": "如果关闭此抽屉,您的更改将会丢失。",
"discard_feedback_record_changes_title": "放弃未保存的更改?",
"drop_a_field_here": "将字段拖到这里",
@@ -3689,10 +3693,17 @@
"enter_name_for_source": "为此来源输入名称",
"enter_value": "请输入值...",
"enum": "枚举",
"error_connector_field_mapping_duplicate": "此来源存在重复的字段映射",
"error_connector_formbricks_mapping_duplicate": "此来源存在重复的问题映射",
"error_connector_name_duplicate": "该名称的数据源已存在",
"error_connector_name_required": "数据源名称为必填项",
"error_connector_questions_required": "请至少选择一个问题",
"error_connector_survey_required": "请选择一个调查问卷",
"failed_to_load_feedback_records": "加载反馈记录失败",
"feedback_date": "当前日期",
"feedback_directory": "反馈目录",
"feedback_record_created_successfully": "反馈记录创建成功",
"feedback_record_deleted_successfully": "反馈记录已成功删除",
"feedback_record_details": "反馈记录详情",
"feedback_record_details_description": "查看并更新反馈记录字段。",
"feedback_record_fields": "反馈记录字段",
@@ -3700,6 +3711,7 @@
"feedback_record_updated_successfully": "反馈记录更新成功",
"feedback_record_value_required": "所选字段类型需要一个值",
"feedback_records": "反馈记录",
"feedback_records_deleted_successfully": "已删除 {count} 条反馈记录",
"feedback_records_refreshed": "反馈记录已刷新",
"feedback_sources": "Feedback Sources",
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
@@ -3736,6 +3748,7 @@
"no_feedback_directory_linked_member_description": "在该功能可用前,需要先为此工作区设置反馈目录。请联系组织所有者或管理员进行分配。",
"no_feedback_directory_linked_title": "未关联反馈目录",
"no_feedback_records": "暂无反馈记录。当你的连接器开始发送数据后,记录会显示在这里。",
"no_formbricks_surveys_available_description": "此工作区还没有调查。<surveyLink>创建新调查</surveyLink>,以将其用作反馈来源。",
"no_source_fields_loaded": "尚未加载源字段",
"no_sources_connected": "还没有连接数据源。添加一个数据源开始吧。",
"optional": "可选",
+13
View File
@@ -1810,6 +1810,7 @@
},
"dashboards": {
"add_count_charts": "新增 {count} 個圖表",
"chart_removed": "圖表已從儀表板移除",
"charts_add_failed": "無法將圖表新增至儀表板",
"charts_add_partial_failure": "無法新增 {count} 個圖表",
"charts_added_to_dashboard": "圖表已新增至儀表板",
@@ -3680,6 +3681,9 @@
"custom_source_type_placeholder": "輸入自訂來源類型",
"default_connector_name_csv": "CSV 匯入",
"default_connector_name_formbricks": "Formbricks 問卷連線",
"delete_feedback_record": "刪除意見回饋記錄",
"delete_feedback_record_confirmation": "這將永久刪除此意見回饋記錄,並從已連結的目錄中移除。",
"delete_feedback_records_confirmation": "這將永久刪除 {count} 筆意見回饋記錄,並從已連結的目錄中移除。",
"discard_feedback_record_changes_description": "如果關閉此抽屜,您的變更將會遺失。",
"discard_feedback_record_changes_title": "放棄未儲存的變更?",
"drop_a_field_here": "請將欄位拖曳到這裡",
@@ -3689,10 +3693,17 @@
"enter_name_for_source": "請輸入此來源的名稱",
"enter_value": "請輸入值……",
"enum": "enum",
"error_connector_field_mapping_duplicate": "此來源的欄位對應重複",
"error_connector_formbricks_mapping_duplicate": "此來源的問題對應重複",
"error_connector_name_duplicate": "已存在使用此名稱的來源",
"error_connector_name_required": "來源名稱為必填項目",
"error_connector_questions_required": "請至少選擇一個問題",
"error_connector_survey_required": "請選擇一個調查問卷",
"failed_to_load_feedback_records": "載入回饋紀錄失敗",
"feedback_date": "目前日期",
"feedback_directory": "意見回饋目錄",
"feedback_record_created_successfully": "回饋記錄創建成功",
"feedback_record_deleted_successfully": "意見回饋記錄已成功刪除",
"feedback_record_details": "反饋記錄詳情",
"feedback_record_details_description": "查看並更新回饋記錄欄位。",
"feedback_record_fields": "回饋紀錄欄位",
@@ -3700,6 +3711,7 @@
"feedback_record_updated_successfully": "回饋記錄更新成功",
"feedback_record_value_required": "所選欄位類型需要一個值",
"feedback_records": "回饋紀錄",
"feedback_records_deleted_successfully": "已刪除 {count} 筆意見回饋記錄",
"feedback_records_refreshed": "回饋紀錄已更新",
"feedback_sources": "Feedback Sources",
"feedback_sources_directory_access_multiple": "New records from these sources will be stored in: {directoryNames}",
@@ -3736,6 +3748,7 @@
"no_feedback_directory_linked_member_description": "此工作區需要先設定意見回饋目錄,才能使用此功能。請請組織擁有者或管理員指定一個目錄。",
"no_feedback_directory_linked_title": "未連結意見回饋目錄",
"no_feedback_records": "目前尚無回饋紀錄。當你的連接器開始傳送資料時,紀錄會顯示在這裡。",
"no_formbricks_surveys_available_description": "此工作區尚無問卷。<surveyLink>建立新問卷</surveyLink>,以將其用作回饋來源。",
"no_source_fields_loaded": "尚未載入來源欄位",
"no_sources_connected": "尚未連接任何來源。請新增來源以開始使用。",
"optional": "選填",
@@ -6,7 +6,8 @@ import { Button } from "@/modules/ui/components/button";
import { DialogFooter } from "@/modules/ui/components/dialog";
interface ChartDialogFooterProps {
onSaveClick: () => void;
onSaveClick?: () => void;
formId?: string;
onAddToDashboardClick?: () => void;
isSaving: boolean;
saveLabel?: string;
@@ -15,6 +16,7 @@ interface ChartDialogFooterProps {
export function ChartDialogFooter({
onSaveClick,
formId,
onAddToDashboardClick,
isSaving,
saveLabel,
@@ -24,12 +26,16 @@ export function ChartDialogFooter({
return (
<DialogFooter>
{showAddToDashboard && onAddToDashboardClick && (
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
<Button variant="outline" type="button" onClick={onAddToDashboardClick} disabled={isSaving}>
<PlusIcon className="mr-2 h-4 w-4" />
{t("workspace.analysis.charts.add_to_dashboard")}
</Button>
)}
<Button onClick={onSaveClick} disabled={isSaving}>
<Button
type={formId ? "submit" : "button"}
form={formId}
onClick={formId ? undefined : onSaveClick}
disabled={isSaving}>
<SaveIcon className="mr-2 h-4 w-4" />
{saveLabel ?? t("workspace.analysis.charts.save_chart")}
</Button>
@@ -7,6 +7,7 @@ import { CartesianChart } from "@/modules/ee/analysis/charts/components/cartesia
import {
CHART_BRAND_DARK,
CHART_MEASURE_COLORS,
formatCellValue,
formatXAxisTick,
preparePieData,
} from "@/modules/ee/analysis/charts/lib/chart-utils";
@@ -20,6 +21,19 @@ const formatPieLabel = ({ name, percent }: { name: string; percent?: number }):
return `${formatXAxisTick(name)}: ${(percent * 100).toFixed(0)}%`;
};
const PieTooltipRow = ({ value, name }: Readonly<{ value: unknown; name: string }>) => {
const { t } = useTranslation();
return (
<>
{formatCellValue(value)} {formatCubeColumnHeader(name, t)}
</>
);
};
const pieTooltipFormatter = (value: unknown, name: string | number) => (
<PieTooltipRow value={value} name={String(name)} />
);
interface ChartRendererProps {
chartType: TChartType;
data: TChartDataRow[];
@@ -165,13 +179,7 @@ export function ChartRenderer({ chartType, data, query }: Readonly<ChartRenderer
return <Cell key={uniqueKey} fill={colors[index] || CHART_BRAND_DARK} />;
})}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) => [String(value), formatCubeColumnHeader(String(name), t)]}
/>
}
/>
<ChartTooltip content={<ChartTooltipContent formatter={pieTooltipFormatter} />} />
</PieChart>
</ChartContainer>
</div>
@@ -1,8 +1,9 @@
"use client";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query-section";
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
@@ -79,6 +80,8 @@ export function CreateChartView({
});
const chartPreviewRef = useRef<HTMLDivElement>(null);
const CREATE_CHART_FORM_ID = "create-chart-form";
const [chartNameError, setChartNameError] = useState<string | null>(null);
useEffect(() => {
if (chartData) {
@@ -136,17 +139,38 @@ export function CreateChartView({
<div className="grid gap-4">
{hasSelectedDirectory ? (
<>
<div className="space-y-2">
<Label htmlFor="create-chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
<form
id={CREATE_CHART_FORM_ID}
onSubmit={(event) => {
event.preventDefault();
setChartNameError(null);
return handleSaveChart();
}}
className="space-y-2">
<Label htmlFor="create-chart-name" className={cn(chartNameError && "text-red-500")}>
{t("workspace.analysis.charts.chart_name")}
</Label>
<Input
id="create-chart-name"
value={chartName}
onChange={(event) => setChartName(event.target.value)}
onChange={(event) => {
if (chartNameError) setChartNameError(null);
setChartName(event.target.value);
}}
onInvalid={(event) => {
// Suppress the browser tooltip and render our inline message instead.
event.preventDefault();
event.currentTarget.scrollIntoView({ behavior: "smooth", block: "center" });
event.currentTarget.focus();
setChartNameError(t("workspace.analysis.charts.please_enter_chart_name"));
}}
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
maxLength={255}
required
isInvalid={!!chartNameError}
/>
</div>
{chartNameError && <p className="text-sm text-red-500">{chartNameError}</p>}
</form>
{!isEditing && (
<>
@@ -212,7 +236,7 @@ export function CreateChartView({
{chartData && (
<ChartDialogFooter
onSaveClick={handleSaveChart}
formId={CREATE_CHART_FORM_ID}
isSaving={isSaving}
showAddToDashboard={false}
saveLabel={
@@ -18,6 +18,7 @@ import {
duplicateDashboard,
getDashboard,
getDashboards,
removeWidgetFromDashboard,
updateDashboard,
updateWidgetLayouts,
} from "./lib/dashboards";
@@ -111,15 +112,13 @@ export const updateDashboardAction = authenticatedActionClient.inputSchema(ZUpda
const ZUpdateWidgetLayoutsAction = z.object({
workspaceId: ZId,
dashboardId: ZId,
widgets: z
.array(
z.object({
id: ZId,
layout: ZWidgetLayout,
order: z.number().int().nonnegative(),
})
)
.min(1),
widgets: z.array(
z.object({
id: ZId,
layout: ZWidgetLayout,
order: z.number().int().nonnegative(),
})
),
});
export const updateWidgetLayoutsAction = authenticatedActionClient
@@ -325,3 +324,34 @@ export const addChartToDashboardAction = authenticatedActionClient
}
)
);
const ZRemoveWidgetFromDashboardAction = z.object({
workspaceId: ZId,
dashboardId: ZId,
widgetId: ZId,
});
export const removeWidgetFromDashboardAction = authenticatedActionClient
.inputSchema(ZRemoveWidgetFromDashboardAction)
.action(
withAuditLogging("deleted", "dashboardWidget", async ({ ctx, parsedInput }) => {
const { organizationId, workspaceId } = await checkWorkspaceAccess(
ctx.user.id,
parsedInput.workspaceId,
"readWrite"
);
await checkDashboardsEnabled(organizationId);
const widget = await removeWidgetFromDashboard(
parsedInput.dashboardId,
workspaceId,
parsedInput.widgetId
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.workspaceId = workspaceId;
ctx.auditLoggingCtx.dashboardWidgetId = widget.id;
ctx.auditLoggingCtx.oldObject = widget;
return { success: true };
})
);
@@ -134,7 +134,7 @@ export function AddExistingChartsDialog({
<DialogTitle>{t("common.add_charts")}</DialogTitle>
<DialogDescription>{t("common.add_existing_chart_description")}</DialogDescription>
</DialogHeader>
<DialogBody>
<DialogBody className="p-1">
{isLoading ? (
<div className="flex items-center justify-center rounded-md border px-3 py-2">
<Loader2Icon className="h-5 w-5 animate-spin text-slate-400" />
@@ -17,10 +17,15 @@ import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/das
import { DashboardWidgetData } from "@/modules/ee/analysis/dashboards/components/dashboard-widget-data";
import { DashboardWidgetSkeleton } from "@/modules/ee/analysis/dashboards/components/dashboard-widget-skeleton";
import type { TChartDataRow, TDashboardDetail, TDashboardWidget } from "@/modules/ee/analysis/types/analysis";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { updateDashboardAction, updateWidgetLayoutsAction } from "../actions";
import {
removeWidgetFromDashboardAction,
updateDashboardAction,
updateWidgetLayoutsAction,
} from "../actions";
import type { TDashboardWidgetError } from "../lib/widget-errors";
const ROW_HEIGHT = 80;
@@ -163,6 +168,8 @@ export function DashboardDetailClient({
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [editingChartId, setEditingChartId] = useState<string | null>(null);
const [widgetIdToRemove, setWidgetIdToRemove] = useState<string | null>(null);
const [isRemovingWidget, setIsRemovingWidget] = useState(false);
const [, startTransition] = useTransition();
const [name, setName] = useState(dashboard.name);
@@ -207,17 +214,36 @@ export function DashboardDetailClient({
const handleRemoveWidgetFromMenu = useCallback(
(widgetId: string) => {
if (!isEditing) {
setDraftWidgets((current) => (current ?? dashboard.widgets).filter((w) => w.id !== widgetId));
setIsEditing(true);
if (isEditing) {
handleRemoveWidget(widgetId);
return;
}
handleRemoveWidget(widgetId);
setWidgetIdToRemove(widgetId);
},
[dashboard.widgets, handleRemoveWidget, isEditing]
[handleRemoveWidget, isEditing]
);
const handleConfirmRemoveWidget = useCallback(async () => {
if (!widgetIdToRemove) return;
setIsRemovingWidget(true);
try {
const result = await removeWidgetFromDashboardAction({
workspaceId,
dashboardId: dashboard.id,
widgetId: widgetIdToRemove,
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("workspace.analysis.dashboards.chart_removed"));
setWidgetIdToRemove(null);
startTransition(() => router.refresh());
} finally {
setIsRemovingWidget(false);
}
}, [widgetIdToRemove, workspaceId, dashboard.id, router, t, startTransition]);
const handleCancel = useCallback(() => {
setName(dashboard.name);
setDraftWidgets(null);
@@ -373,6 +399,17 @@ export function DashboardDetailClient({
aiUnavailableReason={aiUnavailableReason}
/>
)}
{!isReadOnly && (
<DeleteDialog
open={widgetIdToRemove !== null}
setOpen={(open) => {
if (!open) setWidgetIdToRemove(null);
}}
deleteWhat={t("common.chart")}
onDelete={handleConfirmRemoveWidget}
isDeleting={isRemovingWidget}
/>
)}
</PageContentWrapper>
);
}
@@ -18,9 +18,11 @@ var mockTxChart: { findFirst: ReturnType<typeof vi.fn> }; // NOSONAR / test code
var mockTxWidget: {
// NOSONAR / test code
aggregate: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
deleteMany: ReturnType<typeof vi.fn>;
};
@@ -29,9 +31,11 @@ vi.mock("@formbricks/database", () => {
const txChart = { findFirst: vi.fn() };
const txWidget = {
aggregate: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
deleteMany: vi.fn(),
};
mockTxDashboard = txDash;
@@ -44,6 +48,7 @@ vi.mock("@formbricks/database", () => {
findFirst: vi.fn(),
findMany: vi.fn(),
},
dashboardWidget: txWidget,
$transaction: vi.fn((cb: any) => cb({ dashboard: txDash, chart: txChart, dashboardWidget: txWidget })),
},
};
@@ -672,4 +677,52 @@ describe("Dashboard Service", () => {
});
});
});
describe("removeWidgetFromDashboard", () => {
const mockWidgetId = "widget-abc-123";
test("deletes a widget that belongs to the dashboard", async () => {
const mockWidget = { id: mockWidgetId, dashboardId: mockDashboardId, chartId: mockChartId };
mockTxWidget.findFirst.mockResolvedValue(mockWidget);
mockTxWidget.delete.mockResolvedValue(mockWidget);
const { removeWidgetFromDashboard } = await import("./dashboards");
const result = await removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId);
expect(result).toEqual(mockWidget);
expect(mockTxWidget.findFirst).toHaveBeenCalledWith({
where: { id: mockWidgetId, dashboard: { id: mockDashboardId, workspaceId: mockWorkspaceId } },
});
expect(mockTxWidget.delete).toHaveBeenCalledWith({ where: { id: mockWidgetId } });
});
test("throws ResourceNotFoundError when the widget is not on the dashboard", async () => {
mockTxWidget.findFirst.mockResolvedValue(null);
const { removeWidgetFromDashboard } = await import("./dashboards");
await expect(
removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)
).rejects.toMatchObject({ name: "ResourceNotFoundError", resourceType: "DashboardWidget" });
expect(mockTxWidget.delete).not.toHaveBeenCalled();
});
test("wraps Prisma errors in DatabaseError", async () => {
mockTxWidget.findFirst.mockRejectedValue(makePrismaError("P9999"));
const { removeWidgetFromDashboard } = await import("./dashboards");
await expect(
removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)
).rejects.toMatchObject({ name: "DatabaseError" });
});
test("rethrows unknown errors", async () => {
const error = new Error("boom");
mockTxWidget.findFirst.mockRejectedValue(error);
const { removeWidgetFromDashboard } = await import("./dashboards");
await expect(removeWidgetFromDashboard(mockDashboardId, mockWorkspaceId, mockWidgetId)).rejects.toBe(
error
);
});
});
});
@@ -301,6 +301,31 @@ export const updateWidgetLayouts = async (
}
};
export const removeWidgetFromDashboard = async (
dashboardId: string,
workspaceId: string,
widgetId: string
) => {
validateInputs([dashboardId, ZId], [workspaceId, ZId], [widgetId, ZId]);
try {
const widget = await prisma.dashboardWidget.findFirst({
where: { id: widgetId, dashboard: { id: dashboardId, workspaceId } },
});
if (!widget) {
throw new ResourceNotFoundError("DashboardWidget", widgetId);
}
return await prisma.dashboardWidget.delete({ where: { id: widgetId } });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const addChartToDashboard = async (data: TAddWidgetInput) => {
validateInputs([data, ZAddWidgetInput]);
@@ -64,10 +64,13 @@ export function buildCubeQuery(config: ChartBuilderState): TChartQuery {
timeDim.dateRange = config.timeDimension.dateRange;
} else if (Array.isArray(config.timeDimension.dateRange)) {
const [startDate, endDate] = config.timeDimension.dateRange;
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const formatDate = (date: Date | string) => {
// dateRange round-trips through JSON (saved chart → parseQueryToState), so the array
// elements may already be ISO strings — coerce before formatting.
const d = date instanceof Date ? date : new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
timeDim.dateRange = [formatDate(startDate), formatDate(endDate)];
@@ -137,7 +140,12 @@ export function parseQueryToState(query: TChartQuery): Partial<ChartBuilderState
config.granularity = timeDim.granularity;
}
if (timeDim.dateRange) {
config.dateRange = timeDim.dateRange as TimeDimensionConfig["dateRange"];
if (typeof timeDim.dateRange === "string") {
config.dateRange = timeDim.dateRange;
} else if (Array.isArray(timeDim.dateRange) && timeDim.dateRange.length === 2) {
// Stored as [isoString, isoString]; lift back into Date objects for the date-picker UI.
config.dateRange = [new Date(timeDim.dateRange[0]), new Date(timeDim.dateRange[1])];
}
}
state.timeDimension = config;
}
@@ -289,6 +289,47 @@ describe("FeedbackDirectory Service", () => {
).rejects.toThrow(new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG"));
});
test("throws InvalidInputError when a workspace is already assigned to another active directory", async () => {
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce({
workspaceId: mockWorkspaceId1,
} as any);
await expect(
createFeedbackDirectory(mockOrganizationId, "Conflicting", [mockWorkspaceId1])
).rejects.toThrow(new InvalidInputError("WORKSPACE_ALREADY_ASSIGNED_TO_DIFFERENT_DIRECTORY"));
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
where: {
workspaceId: { in: [mockWorkspaceId1] },
feedbackDirectory: { isArchived: false },
},
select: { workspaceId: true },
});
expect(prisma.feedbackDirectory.create).not.toHaveBeenCalled();
});
test("allows creation when workspace is only assigned to archived directory", async () => {
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce(null);
vi.mocked(prisma.feedbackDirectory.create).mockResolvedValueOnce({
id: mockDirectoryId,
} as any);
const result = await createFeedbackDirectory(mockOrganizationId, "ArchivedOnly", [mockWorkspaceId1]);
expect(prisma.feedbackDirectoryWorkspace.findFirst).toHaveBeenCalledWith({
where: {
workspaceId: { in: [mockWorkspaceId1] },
feedbackDirectory: { isArchived: false },
},
select: { workspaceId: true },
});
expect(prisma.feedbackDirectory.create).toHaveBeenCalled();
expect(result).toBe(mockDirectoryId);
});
test("throws InvalidInputError on duplicate name (unique constraint violation)", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint", {
code: "P2002",
@@ -279,6 +279,7 @@ export const createFeedbackDirectory = async (
if (count !== workspaceIds.length) {
throw new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG");
}
await assertWorkspacesNotAssignedElsewhere(undefined, workspaceIds);
}
const directory = await prisma.feedbackDirectory.create({
@@ -440,9 +441,12 @@ const pauseConnectorsInWorkspaces = async (
* Enforces the single-active-FRD-per-workspace invariant. The client UI prevents
* assigning a workspace to multiple active directories, but the server must also
* reject such payloads to keep this guarantee under direct API access.
*
* Pass `directoryId` when updating an existing directory to exclude it from the
* conflict check. Omit it on create every active directory is a conflict.
*/
const assertWorkspacesNotAssignedElsewhere = async (
directoryId: string,
directoryId: string | undefined,
workspaceIds: string[]
): Promise<void> => {
if (workspaceIds.length === 0) return;
@@ -450,7 +454,7 @@ const assertWorkspacesNotAssignedElsewhere = async (
const conflicting = await prisma.feedbackDirectoryWorkspace.findFirst({
where: {
workspaceId: { in: workspaceIds },
feedbackDirectoryId: { not: directoryId },
...(directoryId === undefined ? {} : { feedbackDirectoryId: { not: directoryId } }),
feedbackDirectory: { isArchived: false },
},
select: { workspaceId: true },
@@ -4,6 +4,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { FeedbackDirectoryView } from "@/modules/ee/feedback-directory/components/feedback-directory-view";
import { getIsFeedbackDirectoriesEnabled } from "@/modules/ee/license-check/lib/utils";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
@@ -16,9 +17,12 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
const { isOwner, isManager } = getAccessFlags(currentUserMembership.role);
const isFeedbackDirectoriesAllowed = await getIsFeedbackDirectoriesEnabled(organization.id);
const pageTitle = t("workspace.settings.feedback_directories.title");
if (!isFeedbackDirectoriesAllowed) {
return (
<PageContentWrapper>
<PageHeader pageTitle={pageTitle} />
<div className="flex items-center justify-center">
<UpgradePrompt
title={t("workspace.settings.feedback_directories.upgrade_prompt_title")}
@@ -47,6 +51,7 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
if (!isOwner && !isManager) {
return (
<PageContentWrapper>
<PageHeader pageTitle={pageTitle} />
<p className="text-sm text-slate-500">{t("workspace.settings.feedback_directories.no_access")}</p>
</PageContentWrapper>
);
@@ -54,6 +59,7 @@ export const FeedbackDirectoriesPage = async (props: { params: Promise<{ workspa
return (
<PageContentWrapper>
<PageHeader pageTitle={pageTitle} />
<FeedbackDirectoryView organizationId={organization.id} membershipRole={currentUserMembership.role} />
</PageContentWrapper>
);
+31 -2
View File
@@ -1,19 +1,25 @@
"use server";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
import { getFeedbackDirectoriesByWorkspaceId } from "@/modules/ee/feedback-directory/lib/feedback-directory";
import { getIsUnifyFeedbackEnabled } from "@/modules/ee/license-check/lib/utils";
import { createFeedbackRecord, retrieveFeedbackRecord, updateFeedbackRecord } from "@/modules/hub/service";
import {
createFeedbackRecord,
deleteFeedbackRecord,
retrieveFeedbackRecord,
updateFeedbackRecord,
} from "@/modules/hub/service";
import type { FeedbackRecordCreateParams, FeedbackRecordUpdateParams } from "@/modules/hub/types";
import {
TCreateFeedbackRecordAction,
TRetrieveFeedbackRecordAction,
TUpdateFeedbackRecordAction,
ZCreateFeedbackRecordAction,
ZDeleteFeedbackRecordAction,
ZRetrieveFeedbackRecordAction,
ZUpdateFeedbackRecordAction,
} from "./types";
@@ -176,3 +182,26 @@ export const updateFeedbackRecordAction = authenticatedActionClient
return updateResult.data;
}
);
export const deleteFeedbackRecordAction = authenticatedActionClient
.inputSchema(ZDeleteFeedbackRecordAction)
.action(async ({ ctx, parsedInput }) => {
const [, workspaceDirectoryIds] = await Promise.all([
ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite"),
getWorkspaceDirectoryIds(parsedInput.workspaceId),
]);
const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId);
if (!currentRecordResult.data || currentRecordResult.error) {
throw new ResourceNotFoundError("Feedback record", parsedInput.recordId);
}
assertRecordBelongsToWorkspace(workspaceDirectoryIds, currentRecordResult.data.tenant_id);
const deleteResult = await deleteFeedbackRecord(parsedInput.recordId);
if (!deleteResult.ok || deleteResult.error) {
throw new Error(deleteResult.error?.message || "Failed to delete feedback record");
}
return { recordId: parsedInput.recordId };
});
@@ -10,6 +10,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import type { FeedbackRecordData } from "@/modules/hub/types";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
FormControl,
FormError,
@@ -37,6 +38,7 @@ import {
import { Switch } from "@/modules/ui/components/switch";
import {
createFeedbackRecordAction,
deleteFeedbackRecordAction,
retrieveFeedbackRecordAction,
updateFeedbackRecordAction,
} from "../actions";
@@ -87,6 +89,8 @@ export const FeedbackRecordFormDrawer = ({
const [isLoadingRecord, setIsLoadingRecord] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const defaultValues = useMemo(() => getCreateDefaults(directories), [directories]);
@@ -283,6 +287,24 @@ export const FeedbackRecordFormDrawer = ({
return true;
};
const handleDelete = async () => {
if (!recordId) return;
setIsDeleting(true);
try {
const result = await deleteFeedbackRecordAction({ workspaceId, recordId });
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("workspace.unify.feedback_record_deleted_successfully"));
setIsDeleteDialogOpen(false);
await onSuccess();
onOpenChange(false);
} finally {
setIsDeleting(false);
}
};
const handleSubmit = form.handleSubmit(async (values) => {
form.clearErrors();
@@ -785,15 +807,27 @@ export const FeedbackRecordFormDrawer = ({
</FormProvider>
)}
<SheetFooter className="mt-2">
<Button variant="outline" onClick={requestClose} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
{canWrite && (
<Button onClick={handleSubmit} loading={isSubmitting} disabled={isLoadingRecord}>
{mode === "create" ? t("workspace.unify.add_feedback_record") : t("common.save")}
<SheetFooter className="mt-2 sm:justify-between">
{isEditMode && canWrite && recordId ? (
<Button
variant="destructive"
onClick={() => setIsDeleteDialogOpen(true)}
disabled={isSubmitting || isLoadingRecord || isDeleting}>
{t("common.delete")}
</Button>
) : (
<span />
)}
<div className="flex gap-2">
<Button variant="outline" onClick={requestClose} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
{canWrite && (
<Button onClick={handleSubmit} loading={isSubmitting} disabled={isLoadingRecord}>
{mode === "create" ? t("workspace.unify.add_feedback_record") : t("common.save")}
</Button>
)}
</div>
</SheetFooter>
</SheetContent>
</Sheet>
@@ -809,6 +843,15 @@ export const FeedbackRecordFormDrawer = ({
onDecline={() => setIsDiscardDialogOpen(false)}
onConfirm={handleDiscardChanges}
/>
<DeleteDialog
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
deleteWhat={t("workspace.unify.delete_feedback_record")}
text={t("workspace.unify.delete_feedback_record_confirmation")}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</>
);
};
@@ -9,6 +9,7 @@ import {
PlusIcon,
RefreshCwIcon,
ToggleLeftIcon,
Trash2Icon,
TypeIcon,
} from "lucide-react";
import Link from "next/link";
@@ -21,6 +22,8 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import type { FeedbackRecordData } from "@/modules/hub/types";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -29,6 +32,7 @@ import {
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { deleteFeedbackRecordAction } from "../actions";
import { formatSourceType } from "../lib/utils";
import { CsvImportModal } from "../sources/components/csv-import-modal";
import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer";
@@ -87,8 +91,40 @@ export const FeedbackRecordsTable = ({
const [drawerRecordId, setDrawerRecordId] = useState<string | undefined>();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const hasMore = Object.keys(cursors).length > 0;
const selectedCount = selectedIds.size;
const allOnPageSelected = records.length > 0 && records.every((record) => selectedIds.has(record.id));
const someOnPageSelected = records.some((record) => selectedIds.has(record.id));
const toggleAllOnPage = (checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) {
records.forEach((record) => next.add(record.id));
} else {
records.forEach((record) => next.delete(record.id));
}
return next;
});
};
const toggleOne = (recordId: string, checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) {
next.add(recordId);
} else {
next.delete(recordId);
}
return next;
});
};
const clearSelection = () => setSelectedIds(new Set());
const directories = useMemo(
() =>
@@ -160,6 +196,7 @@ export const FeedbackRecordsTable = ({
const mergedRecords = result.records.toSorted((a, b) => (a.collected_at < b.collected_at ? 1 : -1));
setRecords(mergedRecords);
setCursors(result.newCursors);
setSelectedIds(new Set());
setIsRefreshing(false);
toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId });
};
@@ -199,6 +236,51 @@ export const FeedbackRecordsTable = ({
const isEmpty = records.length === 0 && !isRefreshing;
const handleBulkDelete = async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) return;
setIsDeleting(true);
const CHUNK_SIZE = 5;
const failedIds: string[] = [];
try {
for (let i = 0; i < ids.length; i += CHUNK_SIZE) {
const chunk = ids.slice(i, i + CHUNK_SIZE);
const results = await Promise.all(
chunk.map(async (recordId) => ({
recordId,
result: await deleteFeedbackRecordAction({ workspaceId, recordId }),
}))
);
results.forEach(({ recordId, result }) => {
if (!result?.data) failedIds.push(recordId);
});
}
const succeeded = ids.filter((id) => !failedIds.includes(id));
if (succeeded.length > 0) {
setRecords((prev) => prev.filter((record) => !succeeded.includes(record.id)));
setSelectedIds((prev) => {
const next = new Set(prev);
succeeded.forEach((id) => next.delete(id));
return next;
});
}
if (failedIds.length === 0) {
toast.success(
t("workspace.unify.feedback_records_deleted_successfully", { count: succeeded.length })
);
} else if (succeeded.length === 0) {
toast.error(t("workspace.unify.failed_to_load_feedback_records"));
} else {
toast.error(`${succeeded.length}/${ids.length} deleted`);
}
} finally {
setIsDeleting(false);
setIsBulkDeleteDialogOpen(false);
}
};
const openEditDrawer = (recordId: string) => {
setDrawerMode("edit");
setDrawerRecordId(recordId);
@@ -217,7 +299,26 @@ export const FeedbackRecordsTable = ({
<>
<div className="space-y-3">
<div className="flex items-center justify-between">
{isEmpty ? (
{selectedCount > 0 ? (
<div className="flex items-center gap-x-2 rounded-md bg-primary p-1 px-2 text-xs text-white">
<span className="lowercase">
{`${selectedCount} ${t("workspace.unify.feedback_records").toLowerCase()} ${t("common.selected")}`}
</span>
<span>|</span>
<Button variant="outline" size="sm" className="h-6 border-none px-2" onClick={clearSelection}>
{t("common.clear_selection")}
</Button>
<span>|</span>
<Button
variant="secondary"
size="sm"
className="h-6 gap-1 px-2"
onClick={() => setIsBulkDeleteDialogOpen(true)}>
{t("common.delete")}
<Trash2Icon />
</Button>
</div>
) : isEmpty ? (
<span />
) : (
<p className="text-sm text-slate-500">
@@ -280,6 +381,13 @@ export const FeedbackRecordsTable = ({
<table className="w-full min-w-[900px]">
<thead>
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
<th className="w-10 px-4 py-3">
<Checkbox
aria-label={t("common.select_all")}
checked={allOnPageSelected ? true : someOnPageSelected ? "indeterminate" : false}
onCheckedChange={(checked) => toggleAllOnPage(checked === true)}
/>
</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
@@ -292,7 +400,7 @@ export const FeedbackRecordsTable = ({
{isEmpty ? (
<tbody>
<tr>
<td colSpan={7}>
<td colSpan={8}>
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
</div>
@@ -308,6 +416,8 @@ export const FeedbackRecordsTable = ({
workspaceId={workspaceId}
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
t={t}
isSelected={selectedIds.has(record.id)}
onSelectChange={(checked) => toggleOne(record.id, checked)}
onClick={() => openEditDrawer(record.id)}
/>
))}
@@ -342,6 +452,15 @@ export const FeedbackRecordsTable = ({
onSuccess={handleRefresh}
/>
<DeleteDialog
open={isBulkDeleteDialogOpen}
setOpen={setIsBulkDeleteDialogOpen}
deleteWhat={t("workspace.unify.feedback_records")}
text={t("workspace.unify.delete_feedback_records_confirmation", { count: selectedCount })}
onDelete={handleBulkDelete}
isDeleting={isDeleting}
/>
{csvImportSource && (
<CsvImportModal
open={csvImportSource !== null}
@@ -363,12 +482,16 @@ const FeedbackRecordRow = ({
workspaceId,
locale,
t,
isSelected,
onSelectChange,
onClick,
}: {
record: FeedbackRecordData;
workspaceId: string;
locale: string;
t: TFunction;
isSelected: boolean;
onSelectChange: (checked: boolean) => void;
onClick: () => void;
}) => {
const value = formatValue(record, t, locale);
@@ -379,10 +502,11 @@ const FeedbackRecordRow = ({
return (
<tr
className="cursor-pointer text-sm text-slate-700 transition-colors focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
className={`cursor-pointer text-sm text-slate-700 transition-colors focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 ${isSelected ? "bg-slate-50" : ""}`}
tabIndex={0}
role="button"
aria-label={record.field_label ?? record.field_id}
aria-selected={isSelected}
onClick={onClick}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
@@ -390,6 +514,16 @@ const FeedbackRecordRow = ({
onClick();
}
}}>
<td
className="w-10 px-4 py-3"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}>
<Checkbox
aria-label={record.field_label ?? record.field_id}
checked={isSelected}
onCheckedChange={(checked) => onSelectChange(checked === true)}
/>
</td>
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
</td>
@@ -1,14 +1,16 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Trans, useTranslation } from "react-i18next";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Badge } from "@/modules/ui/components/badge";
import { TConnectorOptionId, getConnectorOptions } from "../utils";
interface ConnectorTypeSelectorProps {
selectedType: TConnectorOptionId | null;
onSelectType: (type: TConnectorOptionId) => void;
workspaceId: string;
surveyCount: number;
}
const getOptionClassName = (
@@ -27,43 +29,54 @@ const getOptionClassName = (
return "border-slate-200 hover:border-slate-300 hover:bg-slate-50";
};
export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<ConnectorTypeSelectorProps>) {
export function ConnectorTypeSelector({
selectedType,
onSelectType,
workspaceId,
surveyCount,
}: Readonly<ConnectorTypeSelectorProps>) {
const { t } = useTranslation();
const connectorOptions = getConnectorOptions(t);
return (
<div className="space-y-3">
<div className="space-y-2">
{connectorOptions.map((option) => (
<button
key={option.id}
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id)}
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
selectedType,
option.id,
option.disabled
)}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
</div>
<div
className={`ml-3 h-4 w-4 rounded-full border-2 ${
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
}`}>
{selectedType === option.id && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-white" />
{connectorOptions.map((option) => {
const showNoSurveysAlert =
surveyCount === 0 && option.id === "formbricks_survey" && selectedType === "formbricks_survey";
return (
<div key={option.id} className="space-y-2">
<button
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id)}
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
selectedType,
option.id,
option.disabled
)}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
</div>
)}
<div
className={`ml-3 h-4 w-4 rounded-full border-2 ${
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
}`}>
{selectedType === option.id && (
<div className="flex h-full w-full items-center justify-center">
<div className="h-1.5 w-1.5 rounded-full bg-white" />
</div>
)}
</div>
</button>
{showNoSurveysAlert && <NoFormbricksSurveysAlert workspaceId={workspaceId} />}
</div>
</button>
))}
);
})}
</div>
<Alert variant="outbound" size="small">
<AlertTitle>{t("workspace.unify.missing_feedback_source_title")}</AlertTitle>
@@ -80,3 +93,20 @@ export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<C
</div>
);
}
const NoFormbricksSurveysAlert = ({ workspaceId }: Readonly<{ workspaceId: string }>) => {
return (
<Alert variant="info" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
<Trans
i18nKey="workspace.unify.no_formbricks_surveys_available_description"
components={{
surveyLink: (
<Link href={`/workspaces/${workspaceId}/surveys/templates`} className="font-medium underline" />
),
}}
/>
</AlertDescription>
</Alert>
);
};
@@ -17,7 +17,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { TFieldMapping, TUnifySurvey } from "../types";
import { TFieldMapping, TUnifySurvey, getTranslatedConnectorError } from "../types";
import { ConnectorsTable } from "./connectors-table";
import { CreateConnectorModal } from "./create-connector-modal";
import { CsvImportModal } from "./csv-import-modal";
@@ -28,6 +28,7 @@ interface ConnectorsSectionProps {
initialConnectors: TConnectorWithMappings[];
initialSurveys: TUnifySurvey[];
directories: { id: string; name: string }[];
isReadOnly: boolean;
}
export function ConnectorsSection({
@@ -35,6 +36,7 @@ export function ConnectorsSection({
initialConnectors,
initialSurveys,
directories,
isReadOnly,
}: Readonly<ConnectorsSectionProps>) {
const { t } = useTranslation();
const router = useRouter();
@@ -78,7 +80,7 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return undefined;
}
@@ -93,7 +95,7 @@ export function ConnectorsSection({
name: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => {
}): Promise<boolean> => {
const result = await updateConnectorWithMappingsAction({
connectorId: data.connectorId,
workspaceId: workspaceId,
@@ -111,19 +113,20 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return false;
}
toast.success(t("workspace.unify.connector_updated_successfully"));
router.refresh();
return true;
};
const handleDeleteConnector = async (connectorId: string): Promise<void> => {
const result = await deleteConnectorAction({ connectorId, workspaceId: workspaceId });
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return;
}
@@ -138,7 +141,7 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return;
}
@@ -155,7 +158,7 @@ export function ConnectorsSection({
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
toast.error(getTranslatedConnectorError(getFormattedErrorMessage(result), t));
return;
}
@@ -170,11 +173,15 @@ export function ConnectorsSection({
<SettingsCard
title={t("workspace.unify.feedback_sources")}
description={t("workspace.unify.feedback_sources_settings_description")}
buttonInfo={{
text: t("workspace.unify.add_source"),
onClick: () => setIsCreateModalOpen(true),
variant: "default",
}}>
buttonInfo={
isReadOnly
? undefined
: {
text: t("workspace.unify.add_source"),
onClick: () => setIsCreateModalOpen(true),
variant: "default",
}
}>
<ConnectorsTable
connectors={initialConnectors}
onConnectorClick={setEditingConnector}
@@ -183,15 +190,18 @@ export function ConnectorsSection({
onToggleStatus={handleToggleStatus}
onDelete={handleDeleteConnector}
isLoading={false}
isReadOnly={isReadOnly}
/>
{directories.length > 0 && (
<Alert size="small" className="mt-4">
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
<AlertButton asChild>
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
{t("workspace.unify.manage_directories")}
</Link>
</AlertButton>
{!isReadOnly && (
<AlertButton asChild>
<Link href={`/workspaces/${workspaceId}/settings/organization/feedback-directories`}>
{t("workspace.unify.manage_directories")}
</Link>
</AlertButton>
)}
</Alert>
)}
</SettingsCard>
@@ -208,6 +218,7 @@ export function ConnectorsSection({
<EditConnectorModal
connector={editingConnector}
isReadOnly={isReadOnly}
open={editingConnector !== null}
onOpenChange={(open) => !open && setEditingConnector(null)}
onUpdateConnector={handleUpdateConnector}
@@ -37,6 +37,7 @@ interface ConnectorsTableDataRowProps {
onDuplicate: () => Promise<void>;
onToggleStatus: () => Promise<void>;
onDelete: () => Promise<void>;
isReadOnly?: boolean;
}
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
@@ -52,10 +53,11 @@ export function ConnectorsTableDataRow({
onDuplicate,
onToggleStatus,
onDelete,
isReadOnly = false,
}: Readonly<ConnectorsTableDataRowProps>) {
const { t, i18n } = useTranslation();
const handleRowClick = () => {
if (connector.type === "csv" && onCsvImport) {
if (!isReadOnly && connector.type === "csv" && onCsvImport) {
onCsvImport();
return;
}
@@ -93,10 +95,10 @@ export function ConnectorsTableDataRow({
title={t(getConnectorTypeLabelKey(connector.type))}>
{getConnectorIcon(connector.type, "h-4 w-4 text-slate-500")}
</div>
<div className="col-span-5 flex items-center">
<div className="col-span-4 flex items-center">
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
</div>
<div className="col-span-1 hidden items-center justify-center sm:flex">
<div className="col-span-2 hidden items-center justify-center sm:flex">
<Badge
text={getStatusLabel(connector.status, connector.type)}
type={STATUS_BADGE_TYPE[connector.status]}
@@ -110,14 +112,16 @@ export function ConnectorsTableDataRow({
<span className="truncate">{connector.creatorName ?? "—"}</span>
</div>
<div className="col-span-1 flex items-center justify-end pr-2">
<ConnectorRowDropdown
connector={connector}
onEdit={onEdit}
onCsvImport={onCsvImport}
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
/>
{!isReadOnly && (
<ConnectorRowDropdown
connector={connector}
onEdit={onEdit}
onCsvImport={onCsvImport}
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
/>
)}
</div>
</div>
);
@@ -9,6 +9,7 @@ interface ConnectorsTableRowsContainerProps {
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
onDelete: (connectorId: string) => Promise<void>;
isReadOnly?: boolean;
}
export const ConnectorsTableRowsContainer = ({
@@ -18,6 +19,7 @@ export const ConnectorsTableRowsContainer = ({
onDuplicate,
onToggleStatus,
onDelete,
isReadOnly = false,
}: ConnectorsTableRowsContainerProps) => {
const { t } = useTranslation();
@@ -40,6 +42,7 @@ export const ConnectorsTableRowsContainer = ({
onDuplicate={() => onDuplicate(connector)}
onToggleStatus={() => onToggleStatus(connector)}
onDelete={() => onDelete(connector.id)}
isReadOnly={isReadOnly}
/>
))}
</div>
@@ -13,6 +13,7 @@ interface ConnectorsTableProps {
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
onDelete: (connectorId: string) => Promise<void>;
isLoading?: boolean;
isReadOnly?: boolean;
}
export function ConnectorsTable({
@@ -23,6 +24,7 @@ export function ConnectorsTable({
onToggleStatus,
onDelete,
isLoading = false,
isReadOnly = false,
}: Readonly<ConnectorsTableProps>) {
const { t } = useTranslation();
@@ -30,8 +32,8 @@ export function ConnectorsTable({
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">{t("common.type")}</div>
<div className="col-span-5">{t("common.name")}</div>
<div className="col-span-1 hidden text-center sm:block">{t("common.status")}</div>
<div className="col-span-4">{t("common.name")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.status")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.updated_at")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.created_by")}</div>
<div className="col-span-1" />
@@ -48,6 +50,7 @@ export function ConnectorsTable({
onDuplicate={onDuplicate}
onToggleStatus={onToggleStatus}
onDelete={onDelete}
isReadOnly={isReadOnly}
/>
)}
</div>
@@ -49,6 +49,7 @@ import {
TSourceField,
TUnifySurvey,
ZFormbricksConnectorForm,
getTranslatedConnectorError,
} from "../types";
import {
TConnectorOptionId,
@@ -339,7 +340,12 @@ export const CreateConnectorModal = ({
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
});
if (connectorId && values.importHistorical) {
if (!connectorId) {
setIsCreating(false);
return;
}
if (values.importHistorical) {
await handleHistoricalImport(connectorId, values.surveyId);
}
@@ -368,7 +374,12 @@ export const CreateConnectorModal = ({
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
if (connectorId && csvParsedData.length > 0) {
if (!connectorId) {
setIsCreating(false);
return;
}
if (csvParsedData.length > 0) {
await handleCsvImport(connectorId);
}
@@ -416,16 +427,23 @@ export const CreateConnectorModal = ({
<div className="py-4">
{currentStep === "selectType" && (
<ConnectorTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
<ConnectorTypeSelector
selectedType={selectedType}
onSelectType={setSelectedType}
surveyCount={surveys.length}
workspaceId={workspaceId}
/>
)}
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
<FormProvider {...formbricksForm}>
<form className="space-y-4">
<form
className="space-y-4"
onSubmit={formbricksForm.handleSubmit(handleCreateFormbricksConnector)}>
<FormField
control={formbricksForm.control}
name="sourceName"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
@@ -435,7 +453,9 @@ export const CreateConnectorModal = ({
placeholder={t("workspace.unify.enter_name_for_source")}
/>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -445,7 +465,7 @@ export const CreateConnectorModal = ({
<FormField
control={formbricksForm.control}
name="surveyId"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
<FormControl>
@@ -462,7 +482,9 @@ export const CreateConnectorModal = ({
</SelectContent>
</Select>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -470,7 +492,7 @@ export const CreateConnectorModal = ({
<FormField
control={formbricksForm.control}
name="selectedQuestionIds"
render={() => (
render={({ fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
<FormControl>
@@ -482,7 +504,9 @@ export const CreateConnectorModal = ({
/>
</div>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -578,7 +602,9 @@ export const CreateConnectorModal = ({
</Button>
)}
{currentStep === "selectType" ? (
<Button onClick={handleNextStep} disabled={!selectedType}>
<Button
onClick={handleNextStep}
disabled={!selectedType || (selectedType === "formbricks_survey" && surveys.length === 0)}>
{getNextStepButtonLabel(selectedType, t)}
</Button>
) : (
@@ -38,6 +38,7 @@ import {
TSourceField,
TUnifySurvey,
ZFormbricksConnectorForm,
getTranslatedConnectorError,
} from "../types";
import {
areAllRequiredFieldsMapped,
@@ -59,9 +60,10 @@ interface EditConnectorModalProps {
name: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => Promise<void>;
}) => Promise<boolean>;
surveys: TUnifySurvey[];
onOpenCsvImport?: () => void;
isReadOnly?: boolean;
}
export const EditConnectorModal = ({
@@ -71,6 +73,7 @@ export const EditConnectorModal = ({
onUpdateConnector,
surveys,
onOpenCsvImport,
isReadOnly = false,
}: EditConnectorModalProps) => {
const { t } = useTranslation();
const [csvConnectorName, setCsvConnectorName] = useState("");
@@ -174,7 +177,7 @@ export const EditConnectorModal = ({
const handleUpdateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
if (connector?.type !== "formbricks_survey") return;
setIsUpdating(true);
await onUpdateConnector({
const success = await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: values.sourceName.trim(),
@@ -182,13 +185,15 @@ export const EditConnectorModal = ({
fieldMappings: undefined,
});
setIsUpdating(false);
handleOpenChange(false);
if (success) {
handleOpenChange(false);
}
};
const handleUpdateCsvConnector = async () => {
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
setIsUpdating(true);
await onUpdateConnector({
const success = await onUpdateConnector({
connectorId: connector.id,
workspaceId: connector.workspaceId,
name: csvConnectorName.trim(),
@@ -196,7 +201,9 @@ export const EditConnectorModal = ({
fieldMappings: mappings.length > 0 ? mappings : undefined,
});
setIsUpdating(false);
handleOpenChange(false);
if (success) {
handleOpenChange(false);
}
};
const handleFormbricksQuestionToggle = (questionId: string) => {
@@ -239,11 +246,13 @@ export const EditConnectorModal = ({
<div className="space-y-4 py-4">
{connector.type === "formbricks_survey" ? (
<FormProvider {...formbricksForm}>
<form className="space-y-4">
<form
className="space-y-4"
onSubmit={formbricksForm.handleSubmit(handleUpdateFormbricksConnector)}>
<FormField
control={formbricksForm.control}
name="sourceName"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
<FormControl>
@@ -251,9 +260,12 @@ export const EditConnectorModal = ({
value={field.value}
onChange={field.onChange}
placeholder={t("workspace.unify.enter_name_for_source")}
disabled={isReadOnly}
/>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -261,7 +273,7 @@ export const EditConnectorModal = ({
<FormField
control={formbricksForm.control}
name="surveyId"
render={({ field }) => (
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
<FormControl>
@@ -281,7 +293,9 @@ export const EditConnectorModal = ({
</SelectContent>
</Select>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -289,19 +303,21 @@ export const EditConnectorModal = ({
<FormField
control={formbricksForm.control}
name="selectedQuestionIds"
render={() => (
render={({ fieldState: { error } }) => (
<FormItem>
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
<FormControl>
<div>
<fieldset className={isReadOnly ? "opacity-70" : undefined} disabled={isReadOnly}>
<FormbricksQuestionList
survey={selectedSurvey}
selectedQuestionIds={selectedQuestionIds}
onQuestionToggle={handleFormbricksQuestionToggle}
/>
</div>
</fieldset>
</FormControl>
<FormError />
{error?.message && (
<FormError>{getTranslatedConnectorError(error.message, t)}</FormError>
)}
</FormItem>
)}
/>
@@ -328,41 +344,54 @@ export const EditConnectorModal = ({
value={csvConnectorName}
onChange={(event) => setCsvConnectorName(event.target.value)}
placeholder={t("workspace.unify.enter_name_for_source")}
disabled={isReadOnly}
/>
</div>
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
<fieldset
disabled={isReadOnly}
className={`max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4 ${
isReadOnly ? "opacity-70" : ""
}`}>
<MappingUI
sourceFields={sourceFields}
mappings={mappings}
onMappingsChange={setMappings}
connectorType={connector.type}
/>
</div>
</fieldset>
</>
)}
</div>
<DialogFooter>
{connector.type === "csv" && (
<Button
variant="secondary"
onClick={() => {
handleOpenChange(false);
onOpenCsvImport?.();
}}>
{t("workspace.unify.import_feedback")}
{isReadOnly ? (
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
{t("common.close")}
</Button>
) : (
<>
{connector.type === "csv" && (
<Button
variant="secondary"
onClick={() => {
handleOpenChange(false);
onOpenCsvImport?.();
}}>
{t("workspace.unify.import_feedback")}
</Button>
)}
<Button
onClick={
connector.type === "formbricks_survey"
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
: handleUpdateCsvConnector
}
disabled={saveChangesDisabled}>
{t("workspace.unify.save_changes")}
</Button>
</>
)}
<Button
onClick={
connector.type === "formbricks_survey"
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
: handleUpdateCsvConnector
}
disabled={saveChangesDisabled}>
{t("workspace.unify.save_changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -17,8 +17,16 @@ export const WorkspaceFeedbackSourcesPage = async (
const t = await getTranslate();
const params = await props.params;
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session, organization } =
await getWorkspaceAuth(params.workspaceId);
const {
isOwner,
isManager,
hasReadAccess,
hasReadWriteAccess,
hasManageAccess,
isReadOnly,
session,
organization,
} = await getWorkspaceAuth(params.workspaceId);
if (!session) {
throw new Error(t("common.session_not_found"));
@@ -72,6 +80,7 @@ export const WorkspaceFeedbackSourcesPage = async (
initialConnectors={connectors}
initialSurveys={unifySurveys}
directories={directories}
isReadOnly={isReadOnly}
/>
);
};
@@ -220,10 +220,29 @@ export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSc
export type TCreateConnectorStep = "selectType" | "mapping";
export const ZFormbricksConnectorForm = z.object({
sourceName: z.string().trim().min(1),
surveyId: z.string().min(1),
selectedQuestionIds: z.array(z.string()).min(1),
sourceName: z.string().trim().min(1, "CONNECTOR_NAME_REQUIRED"),
surveyId: z.string().min(1, "CONNECTOR_SURVEY_REQUIRED"),
selectedQuestionIds: z.array(z.string()).min(1, "CONNECTOR_QUESTIONS_REQUIRED"),
importHistorical: z.boolean(),
});
export type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
export const getTranslatedConnectorError = (errorCode: string, t: TFunction): string => {
switch (errorCode) {
case "CONNECTOR_NAME_DUPLICATE":
return t("workspace.unify.error_connector_name_duplicate");
case "CONNECTOR_FORMBRICKS_MAPPING_DUPLICATE":
return t("workspace.unify.error_connector_formbricks_mapping_duplicate");
case "CONNECTOR_FIELD_MAPPING_DUPLICATE":
return t("workspace.unify.error_connector_field_mapping_duplicate");
case "CONNECTOR_NAME_REQUIRED":
return t("workspace.unify.error_connector_name_required");
case "CONNECTOR_SURVEY_REQUIRED":
return t("workspace.unify.error_connector_survey_required");
case "CONNECTOR_QUESTIONS_REQUIRED":
return t("workspace.unify.error_connector_questions_required");
default:
return errorCode;
}
};
@@ -78,3 +78,10 @@ export const ZUpdateFeedbackRecordAction = z.object({
});
export type TUpdateFeedbackRecordAction = z.infer<typeof ZUpdateFeedbackRecordAction>;
export const ZDeleteFeedbackRecordAction = z.object({
workspaceId: ZId,
recordId: ZFeedbackRecordId,
});
export type TDeleteFeedbackRecordAction = z.infer<typeof ZDeleteFeedbackRecordAction>;
+48
View File
@@ -4,6 +4,7 @@ import FormbricksHub from "@formbricks/hub";
import {
createFeedbackRecord,
createFeedbackRecordsBatch,
deleteFeedbackRecord,
getFeedbackRecordTenant,
listFeedbackRecords,
retrieveFeedbackRecord,
@@ -278,6 +279,53 @@ describe("hub service", () => {
});
});
describe("deleteFeedbackRecord", () => {
test("returns config error when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
const result = await deleteFeedbackRecord("rec-1");
expect(result.ok).toBe(false);
expect(result.error?.message).toContain("HUB_API_KEY");
});
test("returns ok when client.delete resolves", async () => {
const deleteSpy = vi.fn().mockResolvedValue(undefined);
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { delete: deleteSpy },
} as any);
const result = await deleteFeedbackRecord("rec-1");
expect(deleteSpy).toHaveBeenCalledWith("rec-1");
expect(result.ok).toBe(true);
expect(result.error).toBeNull();
});
test("returns error when client.delete throws APIError", async () => {
const apiError = new (FormbricksHub as any).APIError("Forbidden", 403);
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { delete: vi.fn().mockRejectedValue(apiError) },
} as any);
const result = await deleteFeedbackRecord("rec-1");
expect(result.ok).toBe(false);
expect(result.error).toMatchObject({ status: 403, message: "Forbidden" });
});
test("returns error when client.delete throws non-API error", async () => {
vi.mocked(getHubClient).mockReturnValue({
feedbackRecords: { delete: vi.fn().mockRejectedValue(new Error("network")) },
} as any);
const result = await deleteFeedbackRecord("rec-1");
expect(result.ok).toBe(false);
expect(result.error).toMatchObject({ status: 0, message: "network" });
});
});
describe("createFeedbackRecordsBatch", () => {
test("returns all errors when getHubClient returns null", async () => {
vi.mocked(getHubClient).mockReturnValue(null);
+25
View File
@@ -98,6 +98,31 @@ export const updateFeedbackRecord = async (
}
};
export type HubFeedbackRecordDeleteResult = {
ok: boolean;
error: HubError | null;
};
/**
* Delete a single feedback record in the Hub by id.
*/
export const deleteFeedbackRecord = async (id: string): Promise<HubFeedbackRecordDeleteResult> => {
const client = getHubClient();
if (!client) {
return { ok: false, error: { ...NO_CONFIG_ERROR } };
}
try {
await client.feedbackRecords.delete(id);
return { ok: true, error: null };
} catch (err) {
logger.warn({ err, id }, "Hub: deleteFeedbackRecord failed");
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
const message = getErrorMessage(err);
return { ok: false, error: { status, message, detail: message } };
}
};
export type ListFeedbackRecordsResult = {
data: FeedbackRecordListResponse | null;
error: HubError | null;
@@ -126,7 +126,8 @@ const FormError = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const errorMessage = error?.message || error?.root?.message;
const body = error ? String(errorMessage) : children;
// Explicit children win — they're typically a translated/formatted version of the raw error.
const body = children ?? (error ? String(errorMessage) : null);
if (!body) {
return null;
@@ -1,7 +1,7 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { ChevronDown } from "lucide-react";
import { ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/cn";
@@ -28,6 +28,32 @@ const SelectTrigger = React.forwardRef<
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1 text-slate-500", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
@@ -39,18 +65,16 @@ const SelectContent: React.ComponentType<SelectPrimitive.SelectContentProps> = R
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-slate-100 bg-white text-slate-700 shadow-md animate-in fade-in-80 dark:bg-slate-700 dark:text-slate-300",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
"max-h-[var(--radix-select-content-available-height)] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}>
className={cn("p-1", position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
@@ -98,6 +122,8 @@ export {
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
@@ -39,6 +39,7 @@ spec:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
command:
- sh
- -c
@@ -55,6 +56,7 @@ spec:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
ports:
- name: http
containerPort: 8080
@@ -54,6 +54,7 @@ spec:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
command:
- sh
- -c
@@ -79,6 +80,7 @@ spec:
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
runAsUser: 1000
envFrom:
- secretRef:
name: {{ include "formbricks.hubSecretName" . }}
+5 -6
View File
@@ -594,6 +594,7 @@ cube:
containerSecurityContext:
readOnlyRootFilesystem: false
runAsNonRoot: true
runAsUser: 1000
livenessProbe:
httpGet:
@@ -639,10 +640,10 @@ hub:
# Pinned by digest for immutable, reproducible deployments. When digest is set it takes
# precedence over tag, and deployment, init container, and migration job all resolve to the
# same immutable image. Update on each Hub release.
# Current digest corresponds to ghcr.io/formbricks/hub:0.2.0.
digest: "sha256:14db7b3d285b6e9165b55693f9b83d08beff840a255fd77dd12882ee0a62f5cb"
# Current digest corresponds to ghcr.io/formbricks/hub:0.3.0.
digest: "sha256:6c39b1143527137e881df785a5b668625a1fe3edb05485bb5ded19f813c8de88"
# Tag is a fallback for dev/non-prod when digest is cleared; keep aligned with the digest above.
tag: "0.2.0"
tag: "0.3.0"
pullPolicy: IfNotPresent
# Optional override for the secret Hub reads from.
@@ -781,9 +782,7 @@ hub:
# When empty, the chart renders TEI args from model, servedModelName, port,
# revision, and persistence.mountPath. Set this to fully override args.
args: []
extraArgs:
- --dtype
- float16
extraArgs: []
env: {}
port: 8080
+4 -4
View File
@@ -61,7 +61,7 @@ cube(`FeedbackRecords`, {
},
sentiment: {
sql: `sentiment`,
sql: `${CUBE}.metadata->>'sentiment'`,
type: `string`,
description: `Sentiment extracted from metadata JSONB field`,
},
@@ -97,9 +97,9 @@ cube(`FeedbackRecords`, {
},
responseId: {
sql: `response_id`,
sql: `submission_id`,
type: `string`,
description: `Unique identifier linking related feedback records`,
description: `Unique identifier linking related feedback records (submission_id in Hub)`,
},
userId: {
@@ -109,7 +109,7 @@ cube(`FeedbackRecords`, {
},
emotion: {
sql: `emotion`,
sql: `${CUBE}.metadata->>'emotion'`,
type: `string`,
description: `Emotion extracted from metadata JSONB field`,
},
+1 -1
View File
@@ -1,7 +1,7 @@
import type { JobSchedulerTemplateOptions, JobsOptions } from "bullmq";
export const JOBS_QUEUE_NAME = "background-jobs";
export const JOBS_PREFIX = "formbricks:jobs";
export const JOBS_PREFIX = "{formbricks:jobs}";
export const JOB_NAMES = {
testLog: "system.test-log",
+4
View File
@@ -132,6 +132,10 @@ describe("@formbricks/jobs queue helpers", () => {
);
});
test("uses a Redis Cluster hash-tagged prefix for BullMQ keys", () => {
expect(JOBS_PREFIX).toBe("{formbricks:jobs}");
});
test("memoizes the producer queue", async () => {
const first = await getJobsQueue();
const second = await getJobsQueue();