mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-09 18:58:46 -06:00
Compare commits
1 Commits
feat/user-
...
typeerror-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
587b4a8924 |
19
.github/workflows/translation-check.yml
vendored
19
.github/workflows/translation-check.yml
vendored
@@ -32,20 +32,21 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
node-version: 18
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||
with:
|
||||
version: 9.15.9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Validate translation keys
|
||||
run: |
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/legacy-next.js"],
|
||||
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["locales/*.json"],
|
||||
plugins: ["i18n-json"],
|
||||
rules: {
|
||||
"i18n-json/identical-keys": [
|
||||
"error",
|
||||
{
|
||||
filePath: require("path").join(__dirname, "locales", "en-US.json"),
|
||||
checkExtraKeys: false,
|
||||
checkMissingKeys: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24-alpine3.23 AS base
|
||||
FROM node:22-alpine3.22 AS base
|
||||
|
||||
#
|
||||
## step 1: Prune monorepo
|
||||
@@ -20,7 +20,7 @@ FROM base AS installer
|
||||
# Enable corepack and prepare pnpm
|
||||
RUN npm install --ignore-scripts -g corepack@latest
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@10.28.2 --activate
|
||||
RUN corepack prepare pnpm@9.15.9 --activate
|
||||
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||
@@ -69,14 +69,20 @@ RUN --mount=type=secret,id=database_url \
|
||||
--mount=type=secret,id=sentry_auth_token \
|
||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||
|
||||
# Extract Prisma version
|
||||
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
|
||||
|
||||
#
|
||||
## step 3: setup production runner
|
||||
#
|
||||
FROM base AS runner
|
||||
|
||||
# Update npm to latest, then create user
|
||||
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
|
||||
RUN npm install --ignore-scripts -g npm@latest \
|
||||
RUN npm install --ignore-scripts -g corepack@latest && \
|
||||
corepack enable
|
||||
|
||||
RUN apk add --no-cache curl \
|
||||
&& apk add --no-cache supercronic \
|
||||
# && addgroup --system --gid 1001 nodejs \
|
||||
&& addgroup -S nextjs \
|
||||
&& adduser -S -u 1001 -G nextjs nextjs
|
||||
|
||||
@@ -107,13 +113,15 @@ RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./package
|
||||
COPY --from=installer /app/packages/database/dist ./packages/database/dist
|
||||
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
|
||||
|
||||
# Copy prisma client packages
|
||||
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
|
||||
|
||||
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
|
||||
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
|
||||
|
||||
COPY --from=installer /prisma_version.txt .
|
||||
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
|
||||
|
||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||
|
||||
@@ -126,9 +134,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||
RUN chmod -R 755 ./node_modules/zod
|
||||
|
||||
# Install prisma CLI globally for database migrations and fix permissions for nextjs user
|
||||
RUN npm install --ignore-scripts -g prisma@6 \
|
||||
&& chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma
|
||||
RUN npm install -g prisma@6
|
||||
|
||||
# Create a startup script to handle the conditional logic
|
||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
||||
@@ -138,8 +144,10 @@ EXPOSE 3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
USER nextjs
|
||||
|
||||
# Prepare pnpm as the nextjs user to ensure it's available at runtime
|
||||
# Prepare volumes for uploads and SAML connections
|
||||
RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
|
||||
RUN corepack prepare pnpm@9.15.9 --activate && \
|
||||
mkdir -p /home/nextjs/apps/web/uploads/ && \
|
||||
mkdir -p /home/nextjs/apps/web/saml-connection
|
||||
|
||||
VOLUME /home/nextjs/apps/web/uploads/
|
||||
|
||||
@@ -316,14 +316,6 @@ export const generateResponseTableColumns = (
|
||||
},
|
||||
};
|
||||
|
||||
const responseIdColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "responseId",
|
||||
header: () => <div className="gap-x-1.5">{t("common.response_id")}</div>,
|
||||
cell: ({ row }) => {
|
||||
return <IdBadge id={row.original.responseId} />;
|
||||
},
|
||||
};
|
||||
|
||||
const quotasColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "quota",
|
||||
header: t("common.quota"),
|
||||
@@ -384,24 +376,24 @@ export const generateResponseTableColumns = (
|
||||
|
||||
const hiddenFieldColumns: ColumnDef<TResponseTableData>[] = survey.hiddenFields.fieldIds
|
||||
? survey.hiddenFields.fieldIds.map((hiddenFieldId) => {
|
||||
return {
|
||||
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
|
||||
header: () => (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="truncate">{hiddenFieldId}</span>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const hiddenFieldResponse = row.original.responseData[hiddenFieldId];
|
||||
if (typeof hiddenFieldResponse === "string") {
|
||||
return <div className="text-slate-900">{hiddenFieldResponse}</div>;
|
||||
}
|
||||
},
|
||||
};
|
||||
})
|
||||
return {
|
||||
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
|
||||
header: () => (
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<span className="h-4 w-4">
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="truncate">{hiddenFieldId}</span>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const hiddenFieldResponse = row.original.responseData[hiddenFieldId];
|
||||
if (typeof hiddenFieldResponse === "string") {
|
||||
return <div className="text-slate-900">{hiddenFieldResponse}</div>;
|
||||
}
|
||||
},
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const metadataColumns = getMetadataColumnsData(t);
|
||||
@@ -422,7 +414,6 @@ export const generateResponseTableColumns = (
|
||||
const baseColumns = [
|
||||
personColumn,
|
||||
singleUseIdColumn,
|
||||
responseIdColumn,
|
||||
dateColumn,
|
||||
...(showQuotasColumn ? [quotasColumn] : []),
|
||||
statusColumn,
|
||||
|
||||
@@ -71,6 +71,12 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Response", responseId, true),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.finished) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Response is already finished", undefined, true),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { ImageResponse } from "@vercel/og";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const GET = async (req: NextRequest) => {
|
||||
|
||||
@@ -323,7 +323,6 @@ checksums:
|
||||
common/request_trial_license: 560df1240ef621f7c60d3f7d65422ccd
|
||||
common/reset_to_default: 68ee98b46677392f44b505b268053b26
|
||||
common/response: c7a9d88269d8ff117abcbc0d97f88b2c
|
||||
common/response_id: 73375099cc976dc7203b8e27f5f709e0
|
||||
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
|
||||
common/restart: bab6232e89f24e3129f8e48268739d5b
|
||||
common/role: 53743bbb6ca938f5b893552e839d067f
|
||||
@@ -929,7 +928,7 @@ checksums:
|
||||
environments/settings/general/from_your_organization: 4b7970431edb3d0f13c394dbd755a055
|
||||
environments/settings/general/invitation_sent_once_more: e6e5ea066810f9dcb65788aa4f05d6e2
|
||||
environments/settings/general/invite_deleted_successfully: 1c7dca6d0f6870d945288e38cfd2f943
|
||||
environments/settings/general/invite_expires_on: 6fd2356ad91a5f189070c43855904bb4
|
||||
environments/settings/general/invited_on: 83476ce4bcdfc3ccf524d1cd91b758a8
|
||||
environments/settings/general/invites_failed: 180ffb8db417050227cc2b2ea74b7aae
|
||||
environments/settings/general/leave_organization: e74132cb4a0dc98c41e61ea3b2dd268b
|
||||
environments/settings/general/leave_organization_description: 2d0cd65e4e78a9b2835cf88c4de407fb
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"request_trial_license": "Testlizenz anfordern",
|
||||
"reset_to_default": "Auf Standard zurücksetzen",
|
||||
"response": "Antwort",
|
||||
"response_id": "Antwort-ID",
|
||||
"responses": "Antworten",
|
||||
"restart": "Neustart",
|
||||
"role": "Rolle",
|
||||
@@ -990,7 +989,7 @@
|
||||
"from_your_organization": "von deiner Organisation",
|
||||
"invitation_sent_once_more": "Einladung nochmal gesendet.",
|
||||
"invite_deleted_successfully": "Einladung erfolgreich gelöscht",
|
||||
"invite_expires_on": "Einladung läuft ab am {date}",
|
||||
"invited_on": "Eingeladen am {date}",
|
||||
"invites_failed": "Einladungen fehlgeschlagen",
|
||||
"leave_organization": "Organisation verlassen",
|
||||
"leave_organization_description": "Du wirst diese Organisation verlassen und den Zugriff auf alle Umfragen und Antworten verlieren. Du kannst nur wieder beitreten, wenn Du erneut eingeladen wirst.",
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"request_trial_license": "Request trial license",
|
||||
"reset_to_default": "Reset to default",
|
||||
"response": "Response",
|
||||
"response_id": "Response ID",
|
||||
"responses": "Responses",
|
||||
"restart": "Restart",
|
||||
"role": "Role",
|
||||
@@ -990,7 +989,7 @@
|
||||
"from_your_organization": "from your organization",
|
||||
"invitation_sent_once_more": "Invitation sent once more.",
|
||||
"invite_deleted_successfully": "Invite deleted successfully",
|
||||
"invite_expires_on": "Invite expires on {date}",
|
||||
"invited_on": "Invited on {date}",
|
||||
"invites_failed": "Invites failed",
|
||||
"leave_organization": "Leave organization",
|
||||
"leave_organization_description": "You wil leave this organization and loose access to all surveys and responses. You can only rejoin if you are invited again.",
|
||||
@@ -1268,13 +1267,14 @@
|
||||
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
|
||||
"date_format": "Date format",
|
||||
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
|
||||
"display_type": "Display type",
|
||||
"dropdown": "Dropdown",
|
||||
"delete_anyways": "Delete anyways",
|
||||
"delete_block": "Delete block",
|
||||
"delete_choice": "Delete choice",
|
||||
"disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey",
|
||||
"display_number_of_responses_for_survey": "Display number of responses for survey",
|
||||
"display_type": "Display type",
|
||||
"divide": "Divide /",
|
||||
"does_not_contain": "Does not contain",
|
||||
"does_not_end_with": "Does not end with",
|
||||
@@ -1282,7 +1282,6 @@
|
||||
"does_not_include_all_of": "Does not include all of",
|
||||
"does_not_include_one_of": "Does not include one of",
|
||||
"does_not_start_with": "Does not start with",
|
||||
"dropdown": "Dropdown",
|
||||
"duplicate_block": "Duplicate block",
|
||||
"duplicate_question": "Duplicate question",
|
||||
"edit_link": "Edit link",
|
||||
@@ -1415,11 +1414,11 @@
|
||||
"limit_the_maximum_file_size": "Limit the maximum file size for uploads.",
|
||||
"limit_upload_file_size_to": "Limit upload file size to",
|
||||
"link_survey_description": "Share a link to a survey page or embed it in a web page or email.",
|
||||
"list": "List",
|
||||
"load_segment": "Load segment",
|
||||
"logic_error_warning": "Changing will cause logic errors",
|
||||
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
|
||||
"logo_settings": "Logo settings",
|
||||
"list": "List",
|
||||
"long_answer": "Long answer",
|
||||
"long_answer_toggle_description": "Allow respondents to write longer, multi-line answers.",
|
||||
"lower_label": "Lower Label",
|
||||
@@ -3095,4 +3094,4 @@
|
||||
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
|
||||
"usability_score_name": "System Usability Score (SUS)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,7 +350,6 @@
|
||||
"request_trial_license": "Solicitar licencia de prueba",
|
||||
"reset_to_default": "Restablecer a valores predeterminados",
|
||||
"response": "Respuesta",
|
||||
"response_id": "ID de respuesta",
|
||||
"responses": "Respuestas",
|
||||
"restart": "Reiniciar",
|
||||
"role": "Rol",
|
||||
@@ -990,7 +989,7 @@
|
||||
"from_your_organization": "de tu organización",
|
||||
"invitation_sent_once_more": "Invitación enviada una vez más.",
|
||||
"invite_deleted_successfully": "Invitación eliminada correctamente",
|
||||
"invite_expires_on": "La invitación expira el {date}",
|
||||
"invited_on": "Invitado el {date}",
|
||||
"invites_failed": "Las invitaciones fallaron",
|
||||
"leave_organization": "Abandonar organización",
|
||||
"leave_organization_description": "Abandonarás esta organización y perderás acceso a todas las encuestas y respuestas. Solo podrás volver a unirte si te invitan de nuevo.",
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"request_trial_license": "Demander une licence d'essai",
|
||||
"reset_to_default": "Réinitialiser par défaut",
|
||||
"response": "Réponse",
|
||||
"response_id": "ID de réponse",
|
||||
"responses": "Réponses",
|
||||
"restart": "Recommencer",
|
||||
"role": "Rôle",
|
||||
@@ -990,7 +989,7 @@
|
||||
"from_your_organization": "de votre organisation",
|
||||
"invitation_sent_once_more": "Invitation envoyée une fois de plus.",
|
||||
"invite_deleted_successfully": "Invitation supprimée avec succès",
|
||||
"invite_expires_on": "L'invitation expire le {date}",
|
||||
"invited_on": "Invité le {date}",
|
||||
"invites_failed": "Invitations échouées",
|
||||
"leave_organization": "Quitter l'organisation",
|
||||
"leave_organization_description": "Vous quitterez cette organisation et perdrez l'accès à toutes les enquêtes et réponses. Vous ne pourrez revenir que si vous êtes de nouveau invité.",
|
||||
|
||||
@@ -254,7 +254,7 @@
|
||||
"label": "Címke",
|
||||
"language": "Nyelv",
|
||||
"learn_more": "Tudjon meg többet",
|
||||
"license_expired": "A licenc lejárt",
|
||||
"license_expired": "Licenc lejárt",
|
||||
"light_overlay": "Világos rávetítés",
|
||||
"limits_reached": "Korlátok elérve",
|
||||
"link": "Összekapcsolás",
|
||||
@@ -350,7 +350,6 @@
|
||||
"request_trial_license": "Próbalicenc kérése",
|
||||
"reset_to_default": "Visszaállítás az alapértelmezettre",
|
||||
"response": "Válasz",
|
||||
"response_id": "Válasz azonosító",
|
||||
"responses": "Válaszok",
|
||||
"restart": "Újraindítás",
|
||||
"role": "Szerep",
|
||||
@@ -463,7 +462,7 @@
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Elérte a havi MIU-korlátját ennek:",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Elérte a havi válaszkorlátját ennek:",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Vissza lesz állítva a közösségi kiadásra ekkor: {date}.",
|
||||
"your_license_has_expired_please_renew": "A vállalati licence lejárt. Újítsa meg, hogy továbbra is használhassa a vállalati funkciókat."
|
||||
"your_license_has_expired_please_renew": "A vállalati licenced lejárt. Kérjük, újítsd meg, hogy továbbra is használhasd a vállalati funkciókat."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Elfogadás",
|
||||
@@ -812,7 +811,7 @@
|
||||
"webhook_deleted_successfully": "A webhorog sikeresen törölve",
|
||||
"webhook_name_placeholder": "Választható: címkézze meg a webhorgot az egyszerű azonosításért",
|
||||
"webhook_test_failed_due_to": "A webhorog tesztelése sikertelen a következő miatt:",
|
||||
"webhook_updated_successfully": "A webhorog sikeresen frissítve",
|
||||
"webhook_updated_successfully": "A webhorog sikeresen frissítve.",
|
||||
"webhook_url_placeholder": "Illessze be azt az URL-t, amelyen az eseményt aktiválni szeretné"
|
||||
},
|
||||
"website_or_app_integration_description": "A Formbricks integrálása a webhelyébe vagy alkalmazásába",
|
||||
@@ -821,7 +820,7 @@
|
||||
"segments": {
|
||||
"add_filter_below": "Szűrő hozzáadása lent",
|
||||
"add_your_first_filter_to_get_started": "Adja hozzá az első szűrőt a kezdéshez",
|
||||
"cannot_delete_segment_used_in_surveys": "Nem tudja törölni ezt a szakaszt, mert még mindig használatban van ezekben a kérdőívekben:",
|
||||
"cannot_delete_segment_used_in_surveys": "Nem tudja eltávolítani ezt a szakaszt, mert még mindig használatban van ezekben a kérdőívekben:",
|
||||
"clone_and_edit_segment": "Szakasz klónozása és szerkesztése",
|
||||
"create_group": "Csoport létrehozása",
|
||||
"create_your_first_segment": "Hozza létre az első szakaszt a kezdéshez",
|
||||
@@ -852,11 +851,11 @@
|
||||
"reset_all_filters": "Összes szűrő visszaállítása",
|
||||
"save_as_new_segment": "Mentés új szakaszként",
|
||||
"save_your_filters_as_a_segment_to_use_it_in_other_surveys": "A szűrők mentése szakaszként más kérdőívekben való használathoz",
|
||||
"segment_created_successfully": "A szakasz sikeresen létrehozva",
|
||||
"segment_deleted_successfully": "A szakasz sikeresen törölve",
|
||||
"segment_created_successfully": "A szakasz sikeresen létrehozva!",
|
||||
"segment_deleted_successfully": "A szakasz sikeresen törölve!",
|
||||
"segment_id": "Szakaszazonosító",
|
||||
"segment_saved_successfully": "A szakasz sikeresen elmentve",
|
||||
"segment_updated_successfully": "A szakasz sikeresen frissítve",
|
||||
"segment_updated_successfully": "A szakasz sikeresen frissítve!",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "A szakaszok segítik a hasonló jellemzőkkel rendelkező felhasználók könnyű megcélzását",
|
||||
"target_audience": "Célközönség",
|
||||
"this_action_resets_all_filters_in_this_survey": "Ez a művelet visszaállítja az összes szűrőt ebben a kérdőívben.",
|
||||
@@ -990,7 +989,7 @@
|
||||
"from_your_organization": "a szervezetétől",
|
||||
"invitation_sent_once_more": "A meghívó még egyszer elküldve.",
|
||||
"invite_deleted_successfully": "A meghívó sikeresen törölve",
|
||||
"invite_expires_on": "A meghívó lejár: {date}",
|
||||
"invited_on": "Meghívva ekkor: {date}",
|
||||
"invites_failed": "A meghívás sikertelen",
|
||||
"leave_organization": "Szervezet elhagyása",
|
||||
"leave_organization_description": "Elhagyja ezt a szervezetet, és elveszíti az összes kérdőívhez és válaszhoz való hozzáférését. Csak akkor tud ismét csatlakozni, ha újra meghívják.",
|
||||
@@ -1005,8 +1004,8 @@
|
||||
"member_invited_successfully": "A tag sikeresen meghívva",
|
||||
"once_its_gone_its_gone": "Ha egyszer eltűnt, akkor eltűnt.",
|
||||
"only_org_owner_can_perform_action": "Csak a szervezet tulajdonosai férhetnek hozzá ehhez a beállításhoz.",
|
||||
"organization_created_successfully": "A szervezet sikeresen létrehozva",
|
||||
"organization_deleted_successfully": "A szervezet sikeresen törölve",
|
||||
"organization_created_successfully": "A szervezet sikeresen létrehozva!",
|
||||
"organization_deleted_successfully": "A szervezet sikeresen törölve.",
|
||||
"organization_invite_link_ready": "A szervezete meghívási hivatkozása készen áll!",
|
||||
"organization_name": "Szervezet neve",
|
||||
"organization_name_description": "Adjon a szervezetének egy leíró nevet.",
|
||||
@@ -1106,8 +1105,8 @@
|
||||
"select_member": "Tag kiválasztása",
|
||||
"select_workspace": "Munkaterület kiválasztása",
|
||||
"team_admin": "Csapatadminisztrátor",
|
||||
"team_created_successfully": "A csapat sikeresen létrehozva",
|
||||
"team_deleted_successfully": "A csapat sikeresen törölve",
|
||||
"team_created_successfully": "A csapat sikeresen létrehozva.",
|
||||
"team_deleted_successfully": "A csapat sikeresen törölve.",
|
||||
"team_deletion_not_allowed": "Önnek nem engedélyezett ennek a csapatnak a törlése.",
|
||||
"team_name": "Csapat neve",
|
||||
"team_name_settings_title": "{teamName} beállításai",
|
||||
@@ -1130,7 +1129,7 @@
|
||||
"copy_survey_error": "Nem sikerült másolni a kérdőívet",
|
||||
"copy_survey_link_to_clipboard": "Kérdőív hivatkozásának másolása a vágólapra",
|
||||
"copy_survey_partially_success": "{success} kérdőív sikeresen másolva, {error} sikertelen.",
|
||||
"copy_survey_success": "A kérdőív sikeresen másolva",
|
||||
"copy_survey_success": "A kérdőív sikeresen másolva!",
|
||||
"delete_survey_and_responses_warning": "Biztosan törölni szeretné ezt a kérdőívet és az összes válaszát?",
|
||||
"edit": {
|
||||
"1_choose_the_default_language_for_this_survey": "1. Válassza ki a kérdőív alapértelmezett nyelvét:",
|
||||
@@ -1274,7 +1273,7 @@
|
||||
"disable_the_visibility_of_survey_progress": "A kérdőív előrehaladási folyamata láthatóságának letiltása.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "A kérdőív becsült kitöltési idejének megjelenítése",
|
||||
"display_number_of_responses_for_survey": "A kérdőív válaszai számának megjelenítése",
|
||||
"display_type": "Megjelenített típus",
|
||||
"display_type": "Megjelenítési típus",
|
||||
"divide": "Osztás /",
|
||||
"does_not_contain": "Nem tartalmazza",
|
||||
"does_not_end_with": "Nem ezzel végződik",
|
||||
@@ -1282,7 +1281,7 @@
|
||||
"does_not_include_all_of": "Nem tartalmazza ezekből az összeset",
|
||||
"does_not_include_one_of": "Nem tartalmazza ezek egyikét",
|
||||
"does_not_start_with": "Nem ezzel kezdődik",
|
||||
"dropdown": "Legördülő",
|
||||
"dropdown": "Legördülő menü",
|
||||
"duplicate_block": "Blokk kettőzése",
|
||||
"duplicate_question": "Kérdés kettőzése",
|
||||
"edit_link": "Hivatkozás szerkesztése",
|
||||
@@ -1546,7 +1545,7 @@
|
||||
"send_survey_to_audience_who_match": "Kérdőív küldése az erre illeszkedő közönségnek…",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "A válaszadók küldése a választási lehetőség oldalára.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "A globális elhelyezés beállítása a megjelenítési beállításokban.",
|
||||
"settings_saved_successfully": "A beállítások sikeresen elmentve",
|
||||
"settings_saved_successfully": "A beállítások sikeresen elmentve.",
|
||||
"seven_points": "7 pont",
|
||||
"show_block_settings": "Blokkbeállítások megjelenítése",
|
||||
"show_button": "Gomb megjelenítése",
|
||||
@@ -1713,7 +1712,7 @@
|
||||
"person_attributes": "A személy jellemzői a beküldés időpontjában",
|
||||
"phone": "Telefon",
|
||||
"respondent_skipped_questions": "A válaszadó kihagyta ezeket a kérdéseket.",
|
||||
"response_deleted_successfully": "A válasz sikeresen törölve",
|
||||
"response_deleted_successfully": "A válasz sikeresen törölve.",
|
||||
"single_use_id": "Egyszer használatos azonosító",
|
||||
"source": "Forrás",
|
||||
"state_region": "Állam vagy régió",
|
||||
@@ -1725,7 +1724,7 @@
|
||||
"search_by_survey_name": "Keresés kérőívnév alapján",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Ha nem titkosítja az egyszer használatos azonosítókat, akkor a „suid=…” bármilyen értéke működik egy válasznál.",
|
||||
"custom_single_use_id_description": "Ha nem titkosítja az egyszer használatos azonosítót, akkor a „suid=…” bármilyen értéke működik egy válasznál.",
|
||||
"custom_single_use_id_title": "Bármilyen értéket beállíthat egyszer használatos azonosítóként az URL-ben.",
|
||||
"custom_start_point": "Egyéni kezdési pont",
|
||||
"data_prefilling": "Adatok előre kitöltése",
|
||||
@@ -1950,8 +1949,8 @@
|
||||
"your_survey_is_public": "A kérdőíve nyilvános",
|
||||
"youre_not_plugged_in_yet": "Még nincs csatlakoztatva!"
|
||||
},
|
||||
"survey_deleted_successfully": "A kérdőív sikeresen törölve",
|
||||
"survey_duplicated_successfully": "A kérdőív sikeresen megkettőzve",
|
||||
"survey_deleted_successfully": "A kérdőív sikeresen törölve!",
|
||||
"survey_duplicated_successfully": "A kérdőív sikeresen megkettőzve.",
|
||||
"survey_duplication_error": "Nem sikerült megkettőzni a kérdőívet.",
|
||||
"templates": {
|
||||
"all_channels": "Összes csatorna",
|
||||
@@ -2014,8 +2013,8 @@
|
||||
"custom_scripts_updated_successfully": "Az egyéni parancsfájlok sikeres frissítve",
|
||||
"custom_scripts_warning": "A parancsfájlok teljes böngésző-hozzáféréssel kerülnek végrehajtásra. Csak megbízható forrásokból származó parancsfájlokat adjon hozzá.",
|
||||
"delete_workspace": "Munkaterület törlése",
|
||||
"delete_workspace_confirmation": "Biztosan törölni szeretné a(z) {projectName} munkaterületet? Ezt a műveletet nem lehet visszavonni.",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "A(z) {projectName} munkaterület törlése, beleértve az összes kérdőívet, választ, személyt, műveletet és attribútumot is.",
|
||||
"delete_workspace_confirmation": "Biztosan törölni szeretné a(z) {projectName} projektet? Ezt a műveletet nem lehet visszavonni.",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "A(z) {projectName} projekt törlése, beleértve az összes kérdőívet, választ, személyt, műveletet és attribútumot is.",
|
||||
"delete_workspace_settings_description": "A munkaterület törlése az összes kérdőívvel, válasszal, személlyel, művelettel és attribútummal együtt. Ezt nem lehet visszavonni.",
|
||||
"error_saving_workspace_information": "Hiba a munkaterület-információk mentésekor",
|
||||
"only_owners_or_managers_can_delete_workspaces": "Csak tulajdonosok vagy kezelők törölhetnek munkaterületeket",
|
||||
@@ -2098,9 +2097,9 @@
|
||||
"search_tags": "Címkék keresése…",
|
||||
"tag": "Címke",
|
||||
"tag_already_exists": "A címke már létezik",
|
||||
"tag_deleted": "A címke sikeresen törölve",
|
||||
"tag_updated": "A címke sikeresen frissítve",
|
||||
"tags_merged": "A címkék sikeresen egyesítve"
|
||||
"tag_deleted": "Címke törölve",
|
||||
"tag_updated": "Címke frissítve",
|
||||
"tags_merged": "Címkék egyesítve"
|
||||
},
|
||||
"teams": {
|
||||
"manage_teams": "Csapatok kezelése",
|
||||
@@ -2295,7 +2294,7 @@
|
||||
"career_development_survey_question_5_choice_5": "Üzemeltetés",
|
||||
"career_development_survey_question_5_choice_6": "Egyéb",
|
||||
"career_development_survey_question_5_headline": "Milyen funkcióban dolgozik?",
|
||||
"career_development_survey_question_5_subheader": "Válassza ki a következő lehetőségek egyikét:",
|
||||
"career_development_survey_question_5_subheader": "Válassza a következők egyikét",
|
||||
"career_development_survey_question_6_choice_1": "Egyéni közreműködő",
|
||||
"career_development_survey_question_6_choice_2": "Igazgató",
|
||||
"career_development_survey_question_6_choice_3": "Vezető igazgató",
|
||||
@@ -2303,7 +2302,7 @@
|
||||
"career_development_survey_question_6_choice_5": "Igazgató",
|
||||
"career_development_survey_question_6_choice_6": "Egyéb",
|
||||
"career_development_survey_question_6_headline": "Az alábbiak közül melyik írja le legjobban a jelenlegi munkája szintjét?",
|
||||
"career_development_survey_question_6_subheader": "Válassza ki a következő lehetőségek egyikét:",
|
||||
"career_development_survey_question_6_subheader": "Válassza a következők egyikét",
|
||||
"cess_survey_name": "Ügyfél-erőfeszítési pontszám kérdőív",
|
||||
"cess_survey_question_1_headline": "A(z) $[projectName] megkönnyíti számomra a [CÉL HOZZÁADÁSA] tevékenységet",
|
||||
"cess_survey_question_1_lower_label": "Egyáltalán nem értek egyet",
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"request_trial_license": "トライアルライセンスをリクエスト",
|
||||
"reset_to_default": "デフォルトにリセット",
|
||||
"response": "回答",
|
||||
"response_id": "回答ID",
|
||||
"responses": "回答",
|
||||
"restart": "再開",
|
||||
"role": "役割",
|
||||
@@ -990,7 +989,7 @@
|
||||
"from_your_organization": "あなたの組織から",
|
||||
"invitation_sent_once_more": "招待状を再度送信しました。",
|
||||
"invite_deleted_successfully": "招待を正常に削除しました",
|
||||
"invite_expires_on": "招待は{date}に期限切れ",
|
||||
"invited_on": "{date}に招待",
|
||||
"invites_failed": "招待に失敗しました",
|
||||
"leave_organization": "組織を離れる",
|
||||
"leave_organization_description": "この組織を離れ、すべてのフォームと回答へのアクセス権を失います。再度招待された場合にのみ再参加できます。",
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"request_trial_license": "Proeflicentie aanvragen",
|
||||
"reset_to_default": "Resetten naar standaard",
|
||||
"response": "Antwoord",
|
||||
"response_id": "Antwoord-ID",
|
||||
"responses": "Reacties",
|
||||
"restart": "Opnieuw opstarten",
|
||||
"role": "Rol",
|
||||
@@ -990,7 +989,7 @@
|
||||
"from_your_organization": "vanuit uw organisatie",
|
||||
"invitation_sent_once_more": "Uitnodiging nogmaals verzonden.",
|
||||
"invite_deleted_successfully": "Uitnodiging succesvol verwijderd",
|
||||
"invite_expires_on": "Uitnodiging verloopt op {date}",
|
||||
"invited_on": "Uitgenodigd op {date}",
|
||||
"invites_failed": "Uitnodigingen zijn mislukt",
|
||||
"leave_organization": "Verlaat de organisatie",
|
||||
"leave_organization_description": "U verlaat deze organisatie en verliest de toegang tot alle enquêtes en reacties. Je kunt alleen weer meedoen als je opnieuw wordt uitgenodigd.",
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"request_trial_license": "Pedir licença de teste",
|
||||
"reset_to_default": "Restaurar para o padrão",
|
||||
"response": "Resposta",
|
||||
"response_id": "ID da resposta",
|
||||
"responses": "Respostas",
|
||||
"restart": "Reiniciar",
|
||||
"role": "Rolê",
|
||||
@@ -990,7 +989,7 @@
|
||||
"from_your_organization": "da sua organização",
|
||||
"invitation_sent_once_more": "Convite enviado de novo.",
|
||||
"invite_deleted_successfully": "Convite deletado com sucesso",
|
||||
"invite_expires_on": "O convite expira em {date}",
|
||||
"invited_on": "Convidado em {date}",
|
||||
"invites_failed": "Convites falharam",
|
||||
"leave_organization": "Sair da organização",
|
||||
"leave_organization_description": "Você vai sair dessa organização e perder acesso a todas as pesquisas e respostas. Você só pode voltar se for convidado de novo.",
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"request_trial_license": "Solicitar licença de teste",
|
||||
"reset_to_default": "Repor para o padrão",
|
||||
"response": "Resposta",
|
||||
"response_id": "ID de resposta",
|
||||
"responses": "Respostas",
|
||||
"restart": "Reiniciar",
|
||||
"role": "Função",
|
||||
@@ -990,7 +989,7 @@
|
||||
"from_your_organization": "da sua organização",
|
||||
"invitation_sent_once_more": "Convite enviado mais uma vez.",
|
||||
"invite_deleted_successfully": "Convite eliminado com sucesso",
|
||||
"invite_expires_on": "O convite expira em {date}",
|
||||
"invited_on": "Convidado em {date}",
|
||||
"invites_failed": "Convites falharam",
|
||||
"leave_organization": "Sair da organização",
|
||||
"leave_organization_description": "Vai sair desta organização e perder o acesso a todos os inquéritos e respostas. Só pode voltar a juntar-se se for convidado novamente.",
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"request_trial_license": "Solicitați o licență de încercare",
|
||||
"reset_to_default": "Revino la implicit",
|
||||
"response": "Răspuns",
|
||||
"response_id": "ID răspuns",
|
||||
"responses": "Răspunsuri",
|
||||
"restart": "Repornește",
|
||||
"role": "Rolul",
|
||||
@@ -990,7 +989,7 @@
|
||||
"from_your_organization": "din organizația ta",
|
||||
"invitation_sent_once_more": "Invitație trimisă din nou.",
|
||||
"invite_deleted_successfully": "Invitație ștearsă cu succes",
|
||||
"invite_expires_on": "Invitația expiră pe {date}",
|
||||
"invited_on": "Invitat pe {date}",
|
||||
"invites_failed": "Invitații eșuate",
|
||||
"leave_organization": "Părăsește organizația",
|
||||
"leave_organization_description": "Vei părăsi această organizație și vei pierde accesul la toate sondajele și răspunsurile. Poți să te alături din nou doar dacă ești invitat.",
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"request_trial_license": "Запросить пробную лицензию",
|
||||
"reset_to_default": "Сбросить по умолчанию",
|
||||
"response": "Ответ",
|
||||
"response_id": "ID ответа",
|
||||
"responses": "Ответы",
|
||||
"restart": "Перезапустить",
|
||||
"role": "Роль",
|
||||
@@ -990,7 +989,7 @@
|
||||
"from_your_organization": "из вашей организации",
|
||||
"invitation_sent_once_more": "Приглашение отправлено ещё раз.",
|
||||
"invite_deleted_successfully": "Приглашение успешно удалено",
|
||||
"invite_expires_on": "Приглашение истекает {date}",
|
||||
"invited_on": "Приглашён {date}",
|
||||
"invites_failed": "Не удалось отправить приглашения",
|
||||
"leave_organization": "Покинуть организацию",
|
||||
"leave_organization_description": "Вы покинете эту организацию и потеряете доступ ко всем опросам и ответам. Вы сможете вернуться только по новому приглашению.",
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"request_trial_license": "Begär provlicens",
|
||||
"reset_to_default": "Återställ till standard",
|
||||
"response": "Svar",
|
||||
"response_id": "Svar-ID",
|
||||
"responses": "Svar",
|
||||
"restart": "Starta om",
|
||||
"role": "Roll",
|
||||
@@ -990,7 +989,7 @@
|
||||
"from_your_organization": "från din organisation",
|
||||
"invitation_sent_once_more": "Inbjudan skickad igen.",
|
||||
"invite_deleted_successfully": "Inbjudan borttagen",
|
||||
"invite_expires_on": "Inbjudan går ut den {date}",
|
||||
"invited_on": "Inbjuden den {date}",
|
||||
"invites_failed": "Inbjudningar misslyckades",
|
||||
"leave_organization": "Lämna organisation",
|
||||
"leave_organization_description": "Du kommer att lämna denna organisation och förlora åtkomst till alla enkäter och svar. Du kan endast återansluta om du blir inbjuden igen.",
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"request_trial_license": "申请试用许可证",
|
||||
"reset_to_default": "重置为 默认",
|
||||
"response": "响应",
|
||||
"response_id": "响应 ID",
|
||||
"responses": "反馈",
|
||||
"restart": "重新启动",
|
||||
"role": "角色",
|
||||
@@ -990,7 +989,7 @@
|
||||
"from_your_organization": "来自你的组织",
|
||||
"invitation_sent_once_more": "再次发送邀请。",
|
||||
"invite_deleted_successfully": "邀请 删除 成功",
|
||||
"invite_expires_on": "邀请将于 {date} 过期",
|
||||
"invited_on": "受邀于 {date}",
|
||||
"invites_failed": "邀请失败",
|
||||
"leave_organization": "离开 组织",
|
||||
"leave_organization_description": "您将离开此组织,并失去对所有调查和响应的访问权限。只有再次被邀请后,您才能重新加入。",
|
||||
|
||||
@@ -350,7 +350,6 @@
|
||||
"request_trial_license": "請求試用授權",
|
||||
"reset_to_default": "重設為預設值",
|
||||
"response": "回應",
|
||||
"response_id": "回應 ID",
|
||||
"responses": "回應",
|
||||
"restart": "重新開始",
|
||||
"role": "角色",
|
||||
@@ -990,7 +989,7 @@
|
||||
"from_your_organization": "來自您的組織",
|
||||
"invitation_sent_once_more": "已再次發送邀請。",
|
||||
"invite_deleted_successfully": "邀請已成功刪除",
|
||||
"invite_expires_on": "邀請將於 '{'date'}' 過期",
|
||||
"invited_on": "邀請於 '{'date'}'",
|
||||
"invites_failed": "邀請失敗",
|
||||
"leave_organization": "離開組織",
|
||||
"leave_organization_description": "您將離開此組織並失去對所有問卷和回應的存取權限。只有再次收到邀請,您才能重新加入。",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useTransition } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TI18nString } from "@formbricks/types/i18n";
|
||||
import type { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
|
||||
@@ -74,8 +74,6 @@ export function LocalizedEditor({
|
||||
[id, isInvalid, localSurvey.languages, value]
|
||||
);
|
||||
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<Editor
|
||||
@@ -111,45 +109,44 @@ export function LocalizedEditor({
|
||||
sanitizedContent = v.replaceAll(/<a[^>]*>(.*?)<\/a>/gi, "$1");
|
||||
}
|
||||
|
||||
// Check if the elements still exists before updating
|
||||
const currentElement = elements[elementIdx];
|
||||
|
||||
startTransition(() => {
|
||||
// if this is a card, we wanna check if the card exists in the localSurvey
|
||||
if (isCard) {
|
||||
const isWelcomeCard = elementIdx === -1;
|
||||
const isEndingCard = elementIdx >= elements.length;
|
||||
// if this is a card, we wanna check if the card exists in the localSurvey
|
||||
if (isCard) {
|
||||
const isWelcomeCard = elementIdx === -1;
|
||||
const isEndingCard = elementIdx >= elements.length;
|
||||
|
||||
// For ending cards, check if the field exists before updating
|
||||
if (isEndingCard) {
|
||||
const ending = localSurvey.endings.find((ending) => ending.id === elementId);
|
||||
// If the field doesn't exist on the ending card, don't create it
|
||||
if (!ending || ending[id] === undefined) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For welcome cards, check if it exists
|
||||
if (isWelcomeCard && !localSurvey.welcomeCard) {
|
||||
// For ending cards, check if the field exists before updating
|
||||
if (isEndingCard) {
|
||||
const ending = localSurvey.endings.find((ending) => ending.id === elementId);
|
||||
// If the field doesn't exist on the ending card, don't create it
|
||||
if (!ending || ending[id] === undefined) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const translatedContent = {
|
||||
...value,
|
||||
[selectedLanguageCode]: sanitizedContent,
|
||||
};
|
||||
updateElement({ [id]: translatedContent });
|
||||
// For welcome cards, check if it exists
|
||||
if (isWelcomeCard && !localSurvey.welcomeCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the field exists on the element (not just if it's not undefined)
|
||||
if (currentElement && id in currentElement && currentElement[id] !== undefined) {
|
||||
const translatedContent = {
|
||||
...value,
|
||||
[selectedLanguageCode]: sanitizedContent,
|
||||
};
|
||||
updateElement(elementIdx, { [id]: translatedContent });
|
||||
}
|
||||
});
|
||||
const translatedContent = {
|
||||
...value,
|
||||
[selectedLanguageCode]: sanitizedContent,
|
||||
};
|
||||
updateElement({ [id]: translatedContent });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the field exists on the element (not just if it's not undefined)
|
||||
if (currentElement && id in currentElement && currentElement[id] !== undefined) {
|
||||
const translatedContent = {
|
||||
...value,
|
||||
[selectedLanguageCode]: sanitizedContent,
|
||||
};
|
||||
updateElement(elementIdx, { [id]: translatedContent });
|
||||
}
|
||||
}}
|
||||
localSurvey={localSurvey}
|
||||
elementId={elementId}
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { InputCombobox } from "@/modules/ui/components/input-combo-box";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -188,16 +187,14 @@ export const TeamSettingsModal = ({
|
||||
const currentMemberId = watchMembers[index]?.userId;
|
||||
return orgMembers
|
||||
.filter((om) => !selectedMemberIds.includes(om?.id) || om?.id === currentMemberId)
|
||||
.map((om) => ({ label: om?.name, value: om?.id }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
.map((om) => ({ label: om?.name, value: om?.id }));
|
||||
};
|
||||
|
||||
const getProjectOptionsForIndex = (index: number) => {
|
||||
const currentProjectId = watchProjects[index]?.projectId;
|
||||
return orgProjects
|
||||
.filter((op) => !selectedProjectIds.includes(op?.id) || op?.id === currentProjectId)
|
||||
.map((op) => ({ label: op?.name, value: op?.id }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
.map((op) => ({ label: op?.name, value: op?.id }));
|
||||
};
|
||||
|
||||
const handleMemberSelectionChange = (index: number, userId: string) => {
|
||||
@@ -281,21 +278,29 @@ export const TeamSettingsModal = ({
|
||||
|
||||
return (
|
||||
<FormItem className="flex-1">
|
||||
<InputCombobox
|
||||
id={`member-${index}-select`}
|
||||
options={memberOpts}
|
||||
value={member.userId || null}
|
||||
onChangeValue={(val) => {
|
||||
const userId = val as string;
|
||||
field.onChange(userId);
|
||||
handleMemberSelectionChange(index, userId);
|
||||
<Select
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
handleMemberSelectionChange(index, val);
|
||||
}}
|
||||
disabled={!!isSelectDisabled}
|
||||
comboboxClasses="max-w-full"
|
||||
searchPlaceholder={t(
|
||||
"environments.settings.teams.select_member"
|
||||
)}
|
||||
/>
|
||||
disabled={isSelectDisabled}
|
||||
value={member.userId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("environments.settings.teams.select_member")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{memberOpts.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
id={`member-${index}-option`}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{error?.message && (
|
||||
<FormError className="text-left">{error.message}</FormError>
|
||||
)}
|
||||
@@ -421,19 +426,26 @@ export const TeamSettingsModal = ({
|
||||
|
||||
return (
|
||||
<FormItem className="flex-1">
|
||||
<InputCombobox
|
||||
id={`project-${index}-select`}
|
||||
options={projectOpts}
|
||||
value={project.projectId || null}
|
||||
onChangeValue={(val) => {
|
||||
field.onChange(val as string);
|
||||
}}
|
||||
disabled={!!isSelectDisabled}
|
||||
comboboxClasses="max-w-full"
|
||||
searchPlaceholder={t(
|
||||
"environments.settings.teams.select_workspace"
|
||||
)}
|
||||
/>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={project.projectId}
|
||||
disabled={isSelectDisabled}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("environments.settings.teams.select_workspace")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectOpts.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
id={`project-${index}-option`}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{error?.message && (
|
||||
<FormError className="text-left">{error.message}</FormError>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { OrganizationRole } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId, ZUuid } from "@formbricks/types/common";
|
||||
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
|
||||
import { TOrganizationRole, ZOrganizationRole } from "@formbricks/types/memberships";
|
||||
@@ -24,7 +23,7 @@ import {
|
||||
getMembershipsByUserId,
|
||||
getOrganizationOwnerCount,
|
||||
} from "@/modules/organization/settings/teams/lib/membership";
|
||||
import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInvite } from "./lib/invite";
|
||||
import { deleteInvite, getInvite, inviteUser, resendInvite } from "./lib/invite";
|
||||
|
||||
const ZDeleteInviteAction = z.object({
|
||||
inviteId: ZUuid,
|
||||
@@ -58,57 +57,30 @@ const ZCreateInviteTokenAction = z.object({
|
||||
inviteId: ZUuid,
|
||||
});
|
||||
|
||||
export const createInviteTokenAction = authenticatedActionClient.schema(ZCreateInviteTokenAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"invite",
|
||||
async ({
|
||||
parsedInput,
|
||||
ctx,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZCreateInviteTokenAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
|
||||
export const createInviteTokenAction = authenticatedActionClient
|
||||
.schema(ZCreateInviteTokenAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromInviteId(parsedInput.inviteId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Get old expiresAt for audit logging before update
|
||||
const oldInvite = await prisma.invite.findUnique({
|
||||
where: { id: parsedInput.inviteId },
|
||||
select: { email: true, expiresAt: true },
|
||||
});
|
||||
|
||||
if (!oldInvite) {
|
||||
throw new ValidationError("Invite not found");
|
||||
}
|
||||
|
||||
// Refresh the invitation expiration
|
||||
const updatedInvite = await refreshInviteExpiration(parsedInput.inviteId);
|
||||
|
||||
// Set audit context
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
|
||||
ctx.auditLoggingCtx.oldObject = { expiresAt: oldInvite.expiresAt };
|
||||
ctx.auditLoggingCtx.newObject = { expiresAt: updatedInvite.expiresAt };
|
||||
|
||||
const inviteToken = createInviteToken(parsedInput.inviteId, updatedInvite.email, {
|
||||
expiresIn: "7d",
|
||||
});
|
||||
|
||||
return { inviteToken: encodeURIComponent(inviteToken) };
|
||||
const invite = await getInvite(parsedInput.inviteId);
|
||||
if (!invite) {
|
||||
throw new ValidationError("Invite not found");
|
||||
}
|
||||
)
|
||||
);
|
||||
const inviteToken = createInviteToken(parsedInput.inviteId, invite.email, {
|
||||
expiresIn: "7d",
|
||||
});
|
||||
|
||||
return { inviteToken: encodeURIComponent(inviteToken) };
|
||||
});
|
||||
|
||||
const ZDeleteMembershipAction = z.object({
|
||||
userId: ZId,
|
||||
@@ -219,7 +191,6 @@ export const resendInviteAction = authenticatedActionClient.schema(ZResendInvite
|
||||
invite?.creator?.name ?? "",
|
||||
updatedInvite.name ?? ""
|
||||
);
|
||||
|
||||
return updatedInvite;
|
||||
}
|
||||
)
|
||||
|
||||
@@ -80,7 +80,6 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
|
||||
if (createInviteTokenResponse?.data) {
|
||||
setShareInviteToken(createInviteTokenResponse.data.inviteToken);
|
||||
setShowShareInviteModal(true);
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createInviteTokenResponse);
|
||||
toast.error(errorMessage);
|
||||
@@ -100,7 +99,6 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
|
||||
});
|
||||
if (resendInviteResponse?.data) {
|
||||
toast.success(t("environments.settings.general.invitation_sent_once_more"));
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(resendInviteResponse);
|
||||
toast.error(errorMessage);
|
||||
|
||||
@@ -47,8 +47,8 @@ export const MembersInfo = ({
|
||||
<Badge type="gray" text="Expired" size="tiny" data-testid="expired-badge" />
|
||||
) : (
|
||||
<TooltipRenderer
|
||||
tooltipContent={`${t("environments.settings.general.invite_expires_on", {
|
||||
date: getFormattedDateTimeString(member.expiresAt),
|
||||
tooltipContent={`${t("environments.settings.general.invited_on", {
|
||||
date: getFormattedDateTimeString(member.createdAt),
|
||||
})}`}>
|
||||
<Badge type="warning" text="Pending" size="tiny" />
|
||||
</TooltipRenderer>
|
||||
|
||||
@@ -9,14 +9,7 @@ import {
|
||||
} from "@formbricks/types/errors";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { TInvitee } from "../types/invites";
|
||||
import {
|
||||
deleteInvite,
|
||||
getInvite,
|
||||
getInvitesByOrganizationId,
|
||||
inviteUser,
|
||||
refreshInviteExpiration,
|
||||
resendInvite,
|
||||
} from "./invite";
|
||||
import { deleteInvite, getInvite, getInvitesByOrganizationId, inviteUser, resendInvite } from "./invite";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -53,129 +46,32 @@ const mockInvite: Invite = {
|
||||
teamIds: [],
|
||||
};
|
||||
|
||||
describe("refreshInviteExpiration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("updates expiresAt to approximately 7 days from now", async () => {
|
||||
const now = Date.now();
|
||||
const expectedExpiresAt = new Date(now + 1000 * 60 * 60 * 24 * 7);
|
||||
|
||||
vi.mocked(prisma.invite.update).mockResolvedValue({
|
||||
...mockInvite,
|
||||
expiresAt: expectedExpiresAt,
|
||||
});
|
||||
|
||||
const result = await refreshInviteExpiration("invite-1");
|
||||
|
||||
expect(prisma.invite.update).toHaveBeenCalledWith({
|
||||
where: { id: "invite-1" },
|
||||
data: {
|
||||
expiresAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.expiresAt.getTime()).toBeGreaterThanOrEqual(now + 1000 * 60 * 60 * 24 * 7 - 1000);
|
||||
expect(result.expiresAt.getTime()).toBeLessThanOrEqual(now + 1000 * 60 * 60 * 24 * 7 + 1000);
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError if invite not found (P2025)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
|
||||
await expect(refreshInviteExpiration("invite-1")).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on other prisma errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
|
||||
await expect(refreshInviteExpiration("invite-1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws error if non-prisma error", async () => {
|
||||
const error = new Error("db");
|
||||
vi.mocked(prisma.invite.update).mockRejectedValue(error);
|
||||
await expect(refreshInviteExpiration("invite-1")).rejects.toThrow("db");
|
||||
});
|
||||
|
||||
test("returns full invite object with all fields", async () => {
|
||||
const updatedInvite = {
|
||||
...mockInvite,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
|
||||
};
|
||||
vi.mocked(prisma.invite.update).mockResolvedValue(updatedInvite);
|
||||
|
||||
const result = await refreshInviteExpiration("invite-1");
|
||||
|
||||
expect(result).toEqual(updatedInvite);
|
||||
expect(result.id).toBe("invite-1");
|
||||
expect(result.email).toBe("test@example.com");
|
||||
expect(result.name).toBe("Test User");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resendInvite", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns email and name after updating expiration", async () => {
|
||||
const updatedInvite = {
|
||||
...mockInvite,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
|
||||
};
|
||||
vi.mocked(prisma.invite.update).mockResolvedValue(updatedInvite);
|
||||
|
||||
test("returns email and name if invite exists", async () => {
|
||||
vi.mocked(prisma.invite.findUnique).mockResolvedValue({ ...mockInvite, creator: {} });
|
||||
vi.mocked(prisma.invite.update).mockResolvedValue({ ...mockInvite, organizationId: "org-1" });
|
||||
const result = await resendInvite("invite-1");
|
||||
|
||||
expect(result).toEqual({ email: mockInvite.email, name: mockInvite.name });
|
||||
expect(prisma.invite.update).toHaveBeenCalledWith({
|
||||
where: { id: "invite-1" },
|
||||
data: {
|
||||
expiresAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("calls refreshInviteExpiration helper", async () => {
|
||||
const updatedInvite = {
|
||||
...mockInvite,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
|
||||
};
|
||||
vi.mocked(prisma.invite.update).mockResolvedValue(updatedInvite);
|
||||
|
||||
await resendInvite("invite-1");
|
||||
|
||||
expect(prisma.invite.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError if invite not found", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
|
||||
vi.mocked(prisma.invite.findUnique).mockResolvedValue(null);
|
||||
await expect(resendInvite("invite-1")).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on other prisma errors", async () => {
|
||||
test("throws DatabaseError on prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
|
||||
code: "P2002",
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
|
||||
vi.mocked(prisma.invite.findUnique).mockRejectedValue(prismaError);
|
||||
await expect(resendInvite("invite-1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws error if non-prisma error", async () => {
|
||||
test("throws error if prisma error", async () => {
|
||||
const error = new Error("db");
|
||||
vi.mocked(prisma.invite.update).mockRejectedValue(error);
|
||||
vi.mocked(prisma.invite.findUnique).mockRejectedValue(error);
|
||||
await expect(resendInvite("invite-1")).rejects.toThrow("db");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,21 +13,44 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { type InviteWithCreator, type TInvite, type TInvitee } from "../types/invites";
|
||||
|
||||
export const refreshInviteExpiration = async (inviteId: string): Promise<Invite> => {
|
||||
export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "email" | "name">> => {
|
||||
try {
|
||||
const updatedInvite = await prisma.invite.update({
|
||||
where: { id: inviteId },
|
||||
data: {
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7 days
|
||||
const invite = await prisma.invite.findUnique({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
name: true,
|
||||
creator: true,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedInvite;
|
||||
if (!invite) {
|
||||
throw new ResourceNotFoundError("Invite", inviteId);
|
||||
}
|
||||
|
||||
const updatedInvite = await prisma.invite.update({
|
||||
where: {
|
||||
id: inviteId,
|
||||
},
|
||||
data: {
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
organizationId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
email: updatedInvite.email,
|
||||
name: updatedInvite.name,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2025") {
|
||||
throw new ResourceNotFoundError("Invite", inviteId);
|
||||
}
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
@@ -35,16 +58,6 @@ export const refreshInviteExpiration = async (inviteId: string): Promise<Invite>
|
||||
}
|
||||
};
|
||||
|
||||
export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "email" | "name">> => {
|
||||
// Refresh expiration and return the updated invite (single query)
|
||||
const updatedInvite = await refreshInviteExpiration(inviteId);
|
||||
|
||||
return {
|
||||
email: updatedInvite.email,
|
||||
name: updatedInvite.name,
|
||||
};
|
||||
};
|
||||
|
||||
export const getInvitesByOrganizationId = reactCache(
|
||||
async (organizationId: string, page?: number): Promise<TInvite[]> => {
|
||||
validateInputs([organizationId, z.string()], [page, z.number().optional()]);
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
} from "@/modules/survey/editor/lib/utils";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { isEndingCardValid, isWelcomeCardValid, validateElement } from "../lib/validation";
|
||||
import { isEndingCardValid, isWelcomeCardValid, validateSurveyElementsInBatch } from "../lib/validation";
|
||||
|
||||
interface ElementsViewProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -211,6 +211,35 @@ export const ElementsView = ({
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!invalidElements) return;
|
||||
let updatedInvalidElements: string[] = [...invalidElements];
|
||||
|
||||
// Check welcome card
|
||||
if (localSurvey.welcomeCard.enabled && !isWelcomeCardValid(localSurvey.welcomeCard, surveyLanguages)) {
|
||||
if (!updatedInvalidElements.includes("start")) {
|
||||
updatedInvalidElements = [...updatedInvalidElements, "start"];
|
||||
}
|
||||
} else {
|
||||
updatedInvalidElements = updatedInvalidElements.filter((elementId) => elementId !== "start");
|
||||
}
|
||||
|
||||
// Check thank you card
|
||||
localSurvey.endings.forEach((ending) => {
|
||||
if (!isEndingCardValid(ending, surveyLanguages)) {
|
||||
if (!updatedInvalidElements.includes(ending.id)) {
|
||||
updatedInvalidElements = [...updatedInvalidElements, ending.id];
|
||||
}
|
||||
} else {
|
||||
updatedInvalidElements = updatedInvalidElements.filter((elementId) => elementId !== ending.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (JSON.stringify(updatedInvalidElements) !== JSON.stringify(invalidElements)) {
|
||||
setInvalidElements(updatedInvalidElements);
|
||||
}
|
||||
}, [localSurvey.welcomeCard, localSurvey.endings, surveyLanguages, invalidElements, setInvalidElements]);
|
||||
|
||||
const updateElement = (elementIdx: number, updatedAttributes: any) => {
|
||||
// Get element ID from current elements array (for validation)
|
||||
const element = elements[elementIdx];
|
||||
@@ -221,6 +250,7 @@ export const ElementsView = ({
|
||||
|
||||
// Track side effects that need to happen after state update
|
||||
let newActiveElementId: string | null = null;
|
||||
let invalidElementsUpdate: string[] | null = null;
|
||||
|
||||
// Use functional update to ensure we work with the latest state
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
@@ -266,6 +296,13 @@ export const ElementsView = ({
|
||||
const initialElementId = elementId;
|
||||
updatedSurvey = handleElementLogicChange(updatedSurvey, initialElementId, elementLevelAttributes.id);
|
||||
|
||||
// Track side effects to apply after state update
|
||||
if (invalidElements?.includes(initialElementId)) {
|
||||
invalidElementsUpdate = invalidElements.map((id) =>
|
||||
id === initialElementId ? elementLevelAttributes.id : id
|
||||
);
|
||||
}
|
||||
|
||||
// Track new active element ID
|
||||
newActiveElementId = elementLevelAttributes.id;
|
||||
|
||||
@@ -307,6 +344,9 @@ export const ElementsView = ({
|
||||
});
|
||||
|
||||
// Apply side effects after state update is queued
|
||||
if (invalidElementsUpdate) {
|
||||
setInvalidElements(invalidElementsUpdate);
|
||||
}
|
||||
if (newActiveElementId) {
|
||||
setActiveElementId(newActiveElementId);
|
||||
}
|
||||
@@ -724,67 +764,23 @@ export const ElementsView = ({
|
||||
setLocalSurvey(result.data);
|
||||
};
|
||||
|
||||
// Validate survey when changes are made to languages or elements
|
||||
// using set for O(1) lookup
|
||||
useEffect(
|
||||
() => {
|
||||
if (!invalidElements) return;
|
||||
//useEffect to validate survey when changes are made to languages
|
||||
useEffect(() => {
|
||||
if (!invalidElements) return;
|
||||
let updatedInvalidElements: string[] = invalidElements;
|
||||
// Validate each element
|
||||
elements.forEach((element) => {
|
||||
updatedInvalidElements = validateSurveyElementsInBatch(
|
||||
element,
|
||||
updatedInvalidElements,
|
||||
surveyLanguages
|
||||
);
|
||||
});
|
||||
|
||||
const currentInvalidSet = new Set(invalidElements);
|
||||
let hasChanges = false;
|
||||
|
||||
// Validate each element
|
||||
elements.forEach((element) => {
|
||||
const isValid = validateElement(element, surveyLanguages);
|
||||
if (isValid) {
|
||||
if (currentInvalidSet.has(element.id)) {
|
||||
currentInvalidSet.delete(element.id);
|
||||
hasChanges = true;
|
||||
}
|
||||
} else if (!currentInvalidSet.has(element.id)) {
|
||||
currentInvalidSet.add(element.id);
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Check welcome card
|
||||
if (localSurvey.welcomeCard.enabled && !isWelcomeCardValid(localSurvey.welcomeCard, surveyLanguages)) {
|
||||
if (!currentInvalidSet.has("start")) {
|
||||
currentInvalidSet.add("start");
|
||||
hasChanges = true;
|
||||
}
|
||||
} else if (currentInvalidSet.has("start")) {
|
||||
currentInvalidSet.delete("start");
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Check thank you card
|
||||
localSurvey.endings.forEach((ending) => {
|
||||
if (!isEndingCardValid(ending, surveyLanguages)) {
|
||||
if (!currentInvalidSet.has(ending.id)) {
|
||||
currentInvalidSet.add(ending.id);
|
||||
hasChanges = true;
|
||||
}
|
||||
} else if (currentInvalidSet.has(ending.id)) {
|
||||
currentInvalidSet.delete(ending.id);
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
setInvalidElements(Array.from(currentInvalidSet));
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
elements,
|
||||
surveyLanguages,
|
||||
invalidElements,
|
||||
setInvalidElements,
|
||||
localSurvey.welcomeCard,
|
||||
localSurvey.endings,
|
||||
]
|
||||
);
|
||||
if (JSON.stringify(updatedInvalidElements) !== JSON.stringify(invalidElements)) {
|
||||
setInvalidElements(updatedInvalidElements);
|
||||
}
|
||||
}, [elements, surveyLanguages, invalidElements, setInvalidElements]);
|
||||
|
||||
useEffect(() => {
|
||||
const elementWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
|
||||
@@ -795,7 +791,7 @@ export const ElementsView = ({
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeElementId, setActiveElementId, localSurvey, selectedLanguageCode]);
|
||||
}, [activeElementId, setActiveElementId]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
|
||||
@@ -86,7 +86,6 @@ export const SurveyEditor = ({
|
||||
const [activeElementId, setActiveElementId] = useState<string | null>(null);
|
||||
const [localSurvey, setLocalSurvey] = useState<TSurvey | null>(() => structuredClone(survey));
|
||||
const [invalidElements, setInvalidElements] = useState<string[] | null>(null);
|
||||
|
||||
const [selectedLanguageCode, setSelectedLanguageCode] = useState<string>("default");
|
||||
const surveyEditorRef = useRef(null);
|
||||
const [localProject, setLocalProject] = useState<Project>(project);
|
||||
|
||||
@@ -64,7 +64,6 @@ export interface InputComboboxProps {
|
||||
showCheckIcon?: boolean;
|
||||
comboboxClasses?: string;
|
||||
emptyDropdownText?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Helper to flatten all options and their children
|
||||
@@ -88,7 +87,6 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
|
||||
showCheckIcon = false,
|
||||
comboboxClasses,
|
||||
emptyDropdownText,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const resolvedSearchPlaceholder = searchPlaceholder ?? t("common.search");
|
||||
@@ -203,7 +201,6 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
"group/icon flex max-w-[440px] overflow-hidden rounded-md border border-slate-300 hover:border-slate-400",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
comboboxClasses
|
||||
)}>
|
||||
{withInput && inputType !== "dropdown" && (
|
||||
@@ -216,7 +213,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenu open={open} onOpenChange={(o) => !disabled && setOpen(o)}>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild className="z-10">
|
||||
<div
|
||||
id={id}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "0.0.0",
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next coverage",
|
||||
@@ -19,9 +19,9 @@
|
||||
"i18n:generate": "npx lingo.dev@latest run && npx lingo.dev@latest lockfile --force"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.971.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.971.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.971.0",
|
||||
"@aws-sdk/client-s3": "3.879.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.879.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.879.0",
|
||||
"@boxyhq/saml-jackson": "1.52.2",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
|
||||
@@ -80,8 +80,7 @@
|
||||
"xm-and-surveys/surveys/general-features/email-followups",
|
||||
"xm-and-surveys/surveys/general-features/quota-management",
|
||||
"xm-and-surveys/surveys/general-features/spam-protection",
|
||||
"xm-and-surveys/surveys/general-features/tags",
|
||||
"xm-and-surveys/surveys/general-features/validation-rules"
|
||||
"xm-and-surveys/surveys/general-features/tags"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -162,7 +161,6 @@
|
||||
"xm-and-surveys/core-features/integrations/activepieces",
|
||||
"xm-and-surveys/core-features/integrations/airtable",
|
||||
"xm-and-surveys/core-features/integrations/google-sheets",
|
||||
"xm-and-surveys/core-features/integrations/hubspot",
|
||||
"xm-and-surveys/core-features/integrations/make",
|
||||
"xm-and-surveys/core-features/integrations/n8n",
|
||||
"xm-and-surveys/core-features/integrations/notion",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
@@ -1,183 +0,0 @@
|
||||
---
|
||||
title: "HubSpot"
|
||||
description: "Learn how to integrate Formbricks with HubSpot to automatically create or update contacts when survey responses are submitted."
|
||||
---
|
||||
|
||||
<Note>
|
||||
Formbricks doesn't have a native HubSpot integration yet. This guide shows you how to connect Formbricks with HubSpot using automation platforms (Make.com, n8n) or custom webhooks.
|
||||
</Note>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before setting up the integration, you'll need:
|
||||
|
||||
1. **A Formbricks account** with at least one survey that collects email addresses
|
||||
2. **A HubSpot account** with API access
|
||||
3. **HubSpot API credentials** (see authentication options below)
|
||||
4. **Automation service** like Make, n8n, or ActivePieces (for no-code methods)
|
||||
|
||||
### HubSpot Authentication Options
|
||||
|
||||
HubSpot offers two main ways to authenticate API requests:
|
||||
|
||||
#### Option A: OAuth 2.0 via Public App (Recommended)
|
||||
|
||||
OAuth is the recommended approach for production integrations. When using Make.com or n8n, they handle OAuth authentication for you through their built-in HubSpot connectors.
|
||||
|
||||
#### Option B: Legacy Private Apps (Simple Setup)
|
||||
|
||||
For custom webhook handlers, you can use a Legacy Private App which provides a static access token. While marked as "legacy," these apps remain fully supported by HubSpot.
|
||||
|
||||
To create a Legacy Private App:
|
||||
|
||||
<Steps>
|
||||
<Step title="Open HubSpot Settings">
|
||||
Go to your HubSpot account **Settings** and navigate to **Integrations** → **Private Apps**.
|
||||
</Step>
|
||||
<Step title="Create the App">
|
||||
Click **Create a private app** and give it a name (e.g., "Formbricks Integration").
|
||||
</Step>
|
||||
<Step title="Configure Scopes">
|
||||
Under **Scopes**, add `crm.objects.contacts.write` and `crm.objects.contacts.read`.
|
||||
</Step>
|
||||
<Step title="Get Your Access Token">
|
||||
Click **Create app** and copy the access token.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
For more information on HubSpot's authentication options, see the [HubSpot Authentication Overview](https://developers.hubspot.com/docs/guides/apps/authentication/intro-to-auth).
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
## Method 1: Using Make.com (Recommended for No-Code)
|
||||
|
||||
<Note>
|
||||
Before starting, ensure your survey has clear `questionId` values set. You can only update these before publishing. If your survey is already published, duplicate it and update the question IDs in the copy.
|
||||
</Note>
|
||||
|
||||
<Steps>
|
||||
<Step title="Set Up Your Survey">
|
||||
Make sure your survey has meaningful `questionId` values for each question. This makes mapping responses to HubSpot fields easier.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Create a New Make.com Scenario">
|
||||
Go to [Make.com](https://make.com) and create a new scenario. Search for **Formbricks** and select it as your trigger, then choose **Response Finished** as the trigger event.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Connect Formbricks to Make">
|
||||
Click **Create a webhook**, enter your Formbricks API Host (default: `https://app.formbricks.com`), add your Formbricks API Key (see [API Key Setup](/api-reference/rest-api#how-to-generate-an-api-key)), and select the survey you want to connect.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Add the HubSpot Module">
|
||||
Click the **+** button after the Formbricks trigger, search for **HubSpot**, choose **Create or Update a Contact** as the action, and connect your HubSpot account.
|
||||
</Step>
|
||||
<Step title="Map Formbricks Fields to HubSpot">
|
||||
Map the Formbricks response fields to HubSpot contact properties:
|
||||
|
||||
| HubSpot Field | Formbricks Field |
|
||||
| ------------- | ---------------- |
|
||||
| Email | `data.email` (your email question ID) |
|
||||
| First Name | `data.firstName` (if collected) |
|
||||
| Last Name | `data.lastName` (if collected) |
|
||||
| Custom Property | Any other `data.*` field |
|
||||
|
||||
You can also map metadata: `meta.country`, `meta.userAgent.browser`, `survey.title`.
|
||||
</Step>
|
||||
<Step title="Test and Activate">
|
||||
Submit a test response to your Formbricks survey, verify the contact appears in HubSpot, and turn on your Make scenario.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
---
|
||||
|
||||
## Method 2: Using n8n (Self-Hosted Option)
|
||||
|
||||
<Note>
|
||||
The Formbricks n8n node is available as a community node. Install it via **Settings** → **Community Nodes** → install `@formbricks/n8n-nodes-formbricks`.
|
||||
</Note>
|
||||
|
||||
<Steps>
|
||||
<Step title="Set Up the Formbricks Trigger">
|
||||
Create a new workflow in n8n, add the **Formbricks** trigger node, connect it with your Formbricks API Key and host, select **Response Finished** as the event, and choose your survey.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Add the HubSpot Node">
|
||||
Add a new node and search for **HubSpot**, select **Create/Update Contact** as the operation, and connect your HubSpot account (n8n supports both OAuth and access token authentication).
|
||||
</Step>
|
||||
<Step title="Configure Field Mapping">
|
||||
In the HubSpot node, map the fields:
|
||||
|
||||
```
|
||||
Email: {{ $json.data.email }}
|
||||
First Name: {{ $json.data.firstName }}
|
||||
Last Name: {{ $json.data.lastName }}
|
||||
```
|
||||
|
||||
For custom HubSpot properties, use the **Additional Fields** section to add mappings like `survey_source`, `response_id`, and `submission_date`.
|
||||
</Step>
|
||||
<Step title="Test Your Workflow">
|
||||
Click **Listen for event** in the Formbricks trigger, submit a test survey response, verify the data flows through to HubSpot, and activate your workflow.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
---
|
||||
|
||||
## Method 3: Using Webhooks (Custom Integration)
|
||||
|
||||
For maximum flexibility, you can use Formbricks webhooks with a custom endpoint that calls the HubSpot API directly. This approach is ideal for developers who want full control.
|
||||
|
||||
<Note>
|
||||
This method requires a HubSpot access token. You can use a Legacy Private App token (simplest) or implement OAuth 2.0 for production applications.
|
||||
</Note>
|
||||
|
||||
<Steps>
|
||||
<Step title="Create a Formbricks Webhook">
|
||||
Go to **Configuration** → **Integrations** in Formbricks, click **Manage Webhooks** → **Add Webhook**, enter your endpoint URL, select **Response Finished** as the trigger, and choose the surveys to monitor.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Build Your Webhook Handler">
|
||||
Your webhook handler needs to:
|
||||
- **Receive the Formbricks webhook** - Accept POST requests with the survey response payload
|
||||
- **Extract contact data** - Parse the email and other fields from `data.data` (keyed by your `questionId` values)
|
||||
- **Call the HubSpot API** - Use the [HubSpot Contacts API](https://developers.hubspot.com/docs/api/crm/contacts) to create or update contacts
|
||||
- **Handle duplicates** - HubSpot returns a 409 error if a contact with that email exists; search and update instead
|
||||
|
||||
You can deploy using serverless functions (Vercel, AWS Lambda, Cloudflare Workers), traditional servers, or low-code platforms. For webhook signature verification, see the [Webhooks documentation](/xm-and-surveys/core-features/integrations/webhooks).
|
||||
</Step>
|
||||
<Step title="Deploy and Test">
|
||||
Deploy your webhook handler to a publicly accessible URL, add the URL to your Formbricks webhook configuration, submit a test survey response, and verify the contact appears in HubSpot.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Contact Not Created in HubSpot
|
||||
|
||||
1. **Check the email field**: Ensure your survey has an email question and you're mapping the correct `questionId`
|
||||
2. **Verify API token**: Make sure your HubSpot access token has the required scopes (`crm.objects.contacts.write` and `crm.objects.contacts.read`)
|
||||
3. **Check for duplicates**: HubSpot returns a 409 error if a contact with that email already exists
|
||||
|
||||
### Webhook Not Triggering
|
||||
|
||||
1. Verify the webhook URL is publicly accessible
|
||||
2. Check that **Response Finished** trigger is selected
|
||||
3. Ensure the survey is linked to the webhook
|
||||
|
||||
### Testing Your Integration
|
||||
|
||||
1. Use a unique test email for each test
|
||||
2. Check HubSpot's **Contacts** page after submitting a response
|
||||
3. Review your webhook handler logs for errors
|
||||
|
||||
---
|
||||
|
||||
Still struggling or something not working as expected? [Join our GitHub Discussions](https://github.com/formbricks/formbricks/discussions) and we're happy to help!
|
||||
@@ -15,8 +15,6 @@ At Formbricks, we understand the importance of integrating with third-party appl
|
||||
|
||||
* [Google Sheets](/xm-and-surveys/core-features/integrations/google-sheets): Automatically send responses to a Google Sheet of your choice.
|
||||
|
||||
* [HubSpot](/xm-and-surveys/core-features/integrations/hubspot): Create or update HubSpot contacts automatically when survey responses are submitted.
|
||||
|
||||
* [Make](/xm-and-surveys/core-features/integrations/make): Leverage Make's powerful automation capabilities to automate your workflows.
|
||||
|
||||
* [n8n](/xm-and-surveys/core-features/integrations/n8n)(Open Source): Automate workflows with n8n's no-code automation tool
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
---
|
||||
title: "Validation Rules"
|
||||
description: "Validation rules help you ensure that respondents provide data in the correct format and within expected constraints"
|
||||
icon: "check-double"
|
||||
---
|
||||
|
||||
By adding validation rules to your questions, you can improve data quality, reduce errors, and create a better survey experience.
|
||||
|
||||

|
||||
|
||||
## How Validation Rules Work
|
||||
|
||||
Validation rules are evaluated when a respondent submits their answer. If the answer doesn't meet the validation criteria, an error message is displayed and the respondent must correct their input before proceeding.
|
||||
|
||||
You can combine multiple validation rules using **All are true** or **Any is true** logic:
|
||||
- **All are true**: All rules must pass for the response to be valid
|
||||
- **Any is true**: At least one rule must pass for the response to be valid
|
||||
|
||||
## Available Validation Rules by Question Type
|
||||
|
||||
### Free Text Questions
|
||||
|
||||
Free text questions support different validation rules based on the input type:
|
||||
|
||||
#### Text Input Type
|
||||
|
||||
| Rule | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| At least (characters) | Requires at least N characters | At least 10 characters for detailed feedback |
|
||||
| At most (characters) | Limits response to N characters | At most 500 characters for short answers |
|
||||
| Matches Regex Pattern | Matches a regular expression pattern | Custom format validation |
|
||||
| Is | Exact match required | Must equal "CONFIRM" |
|
||||
| Is not | Must not match the value | Cannot be "N/A" |
|
||||
| Contains | Must include the substring | Must contain "@company.com" |
|
||||
| Does not contain | Must not include the substring | Cannot contain profanity |
|
||||
|
||||
#### Email Input Type
|
||||
|
||||
Email input automatically validates email format. Additional rules available:
|
||||
- At least, At most (characters)
|
||||
- Matches Regex Pattern, Is, Is not
|
||||
- Contains, Does not contain
|
||||
|
||||
#### URL Input Type
|
||||
|
||||
URL input automatically validates URL format. Additional rules available:
|
||||
- At least, At most (characters)
|
||||
- Matches Regex Pattern, Is, Is not
|
||||
- Contains, Does not contain
|
||||
|
||||
#### Phone Input Type
|
||||
|
||||
Phone input automatically validates phone number format. Additional rules available:
|
||||
- At least, At most (characters)
|
||||
- Matches Regex Pattern, Is, Is not
|
||||
- Contains, Does not contain
|
||||
|
||||
#### Number Input Type
|
||||
|
||||
| Rule | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| At least | Number must be at least N | Age must be at least 18 |
|
||||
| At most | Number cannot exceed N | Quantity at most 100 |
|
||||
| Is | Number must equal N | Quantity is 1 |
|
||||
| Is not | Number must not equal N | Cannot be 0 |
|
||||
|
||||
### Multiple Choice (Multi-Select) Questions
|
||||
|
||||
| Rule | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| At least (options selected) | Require at least N selections | At least 2 options selected |
|
||||
| At most (options selected) | Limit to N selections | At most 3 options selected |
|
||||
|
||||
### Picture Selection Questions
|
||||
|
||||
| Rule | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| At least (options selected) | Require at least N pictures | At least 1 design selected |
|
||||
| At most (options selected) | Limit to N pictures | At most 2 favorites selected |
|
||||
|
||||
### Date Questions
|
||||
|
||||
| Rule | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| Is later than | Date must be after specified date | Must be after today |
|
||||
| Is earlier than | Date must be before specified date | Must be before Dec 31, 2025 |
|
||||
| Is between | Date must be within range | Between Jan 1 and Dec 31 |
|
||||
| Is not between | Date must be outside range | Cannot be during holidays |
|
||||
|
||||
<Note>
|
||||
Date values should be specified in YYYY-MM-DD format (e.g., 2025-01-15).
|
||||
</Note>
|
||||
|
||||
### Matrix Questions
|
||||
|
||||
| Rule | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| Minimum rows answered | Require at least N rows to be answered | Answer at least 3 rows |
|
||||
| Answer all rows | All rows must have a selection | Complete the entire matrix |
|
||||
|
||||
### Ranking Questions
|
||||
|
||||
| Rule | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| Minimum options ranked | Require at least N items to be ranked | Rank your top 3 |
|
||||
| Rank all options | All options must be ranked | Rank all 5 items |
|
||||
|
||||
### File Upload Questions
|
||||
|
||||
| Rule | Description | Example |
|
||||
|------|-------------|---------|
|
||||
| File extension is | Only allow specific file types | Only .pdf, .docx allowed |
|
||||
| File extension is not | Block specific file types | No .exe files |
|
||||
|
||||
<Note>
|
||||
File size limits are configured separately in the question settings using the "Maximum file size" option.
|
||||
</Note>
|
||||
|
||||
### Address Questions
|
||||
|
||||
Each address field (Address Line 1, Address Line 2, City, State, ZIP, Country) can have its own validation rules:
|
||||
- At least, At most (characters)
|
||||
- Matches Regex Pattern
|
||||
- Is, Is not
|
||||
- Contains, Does not contain
|
||||
|
||||
### Contact Info Questions
|
||||
|
||||
Each contact field can have specific validation rules:
|
||||
|
||||
**First Name, Last Name, Company**:
|
||||
- At least, At most (characters)
|
||||
- Matches Regex Pattern, Is, Is not
|
||||
- Contains, Does not contain
|
||||
|
||||
**Email**: Automatically validates email format, plus text rules above
|
||||
|
||||
**Phone**: Automatically validates phone format, plus text rules above
|
||||
|
||||
## Adding Validation Rules
|
||||
|
||||
<Steps>
|
||||
<Step title="Open the Question Settings">
|
||||
Click on the question you want to validate to open its settings panel.
|
||||
</Step>
|
||||
<Step title="Navigate to Validation Rules">
|
||||
Scroll down to find the "Validation Rules" section and click to expand it.
|
||||
</Step>
|
||||
<Step title="Add a Rule">
|
||||
Click the "Add rule" button to add a new validation rule.
|
||||
</Step>
|
||||
<Step title="Configure the Rule">
|
||||
Select the rule type from the dropdown and enter the required value (if applicable).
|
||||
</Step>
|
||||
<Step title="Set Logic (Optional)">
|
||||
If you have multiple rules, choose whether they should be combined with "All are true" or "Any is true" logic.
|
||||
</Step>
|
||||
<Step title="Save Your Survey">
|
||||
Click "Save" to apply the validation rules to your survey.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Error Messages
|
||||
|
||||
Formbricks automatically generates user-friendly error messages based on your validation rules. Error messages are displayed below the input field when validation fails.
|
||||
|
||||
Example error messages:
|
||||
- "Must be at least 10 characters"
|
||||
- "Must be a valid email address"
|
||||
- "Please select at least 2 options"
|
||||
- "Date must be after 2025-01-01"
|
||||
|
||||
## Multi-Language Support
|
||||
|
||||
Validation rules work with multi-language surveys. Error messages are automatically displayed in the respondent's selected language.
|
||||
|
||||
## Combining Multiple Rules
|
||||
|
||||
When using multiple validation rules:
|
||||
|
||||
**All are true**: Use when all conditions must be met.
|
||||
- Example: Text must be at least 10 characters AND contain "@email.com"
|
||||
|
||||
**Any is true**: Use when any condition is acceptable.
|
||||
- Example: Date is earlier than 2025-01-01 OR is later than 2025-12-31
|
||||
11
package.json
11
package.json
@@ -73,9 +73,9 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"nextBundleAnalysis": {
|
||||
"budget": 358400,
|
||||
"budgetPercentIncreaseRed": 20,
|
||||
@@ -90,13 +90,10 @@
|
||||
"tar-fs": "2.1.4",
|
||||
"typeorm": ">=0.3.26",
|
||||
"systeminformation": "5.27.14",
|
||||
"qs": ">=6.14.1",
|
||||
"preact": ">=10.26.10",
|
||||
"fast-xml-parser": ">=5.3.4",
|
||||
"diff": ">=8.0.3"
|
||||
"qs": ">=6.14.1"
|
||||
},
|
||||
"comments": {
|
||||
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | systeminformation (Dependabot #241) - awaiting @opentelemetry/host-metrics update | qs (Dependabot #245) - awaiting googleapis-common and stripe updates | preact (Dependabot #247) - awaiting next-auth update | fast-xml-parser (Dependabot #270) - awaiting @boxyhq/saml-jackson update | diff (Dependabot #269) - awaiting @microsoft/api-extractor update"
|
||||
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | systeminformation (Dependabot #241) - awaiting @opentelemetry/host-metrics update | qs (Dependabot #245) - awaiting googleapis-common and stripe updates"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.12": "patches/next-auth@4.24.12.patch"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"eslint-config-next": "15.3.2",
|
||||
"eslint-config-prettier": "10.1.5",
|
||||
"eslint-config-turbo": "2.5.3",
|
||||
"eslint-plugin-i18n-json": "4.0.1",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
|
||||
@@ -170,7 +170,7 @@ const runSingleMigration = async (migration: MigrationScript, index: number): Pr
|
||||
|
||||
// Run Prisma migrate
|
||||
// throws when migrate deploy fails
|
||||
await execAsync(`prisma migrate deploy --schema="${PRISMA_SCHEMA_PATH}"`);
|
||||
await execAsync(`pnpm prisma migrate deploy --schema="${PRISMA_SCHEMA_PATH}"`);
|
||||
logger.info(`Successfully applied schema migration: ${migration.name}`);
|
||||
} catch (err) {
|
||||
logger.error(err, `Schema migration ${migration.name} failed`);
|
||||
|
||||
@@ -37,9 +37,9 @@
|
||||
"author": "Formbricks <hola@formbricks.com>",
|
||||
"dependencies": {
|
||||
"@formbricks/logger": "workspace:*",
|
||||
"@aws-sdk/client-s3": "3.971.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.971.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.971.0"
|
||||
"@aws-sdk/client-s3": "3.879.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.879.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.879.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
"respondents_will_not_see_this_card": "A válaszadók nem fogják látni ezt a kártyát",
|
||||
"retry": "Újrapróbálkozás",
|
||||
"retrying": "Újrapróbálkozás…",
|
||||
"select_option": "Lehetőség kiválasztása",
|
||||
"select_options": "Lehetőségek kiválasztása",
|
||||
"select_option": "Válassz egy lehetőséget",
|
||||
"select_options": "Válassz lehetőségeket",
|
||||
"sending_responses": "Válaszok küldése…",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Kevesebb mint 1 percet vesz igénybe} other {Kevesebb mint {count} percet vesz igénybe}}",
|
||||
"takes_x_minutes": "{count, plural, one {1 percet vesz igénybe} other {{count} percet vesz igénybe}}",
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
"invalid_device_error": {
|
||||
"message": "Tiltsa le a szemét elleni védekezést a kérdőív beállításaiban, hogy tovább használhassa ezt az eszközt.",
|
||||
"title": "Ez az eszköz nem támogatja a szemét elleni védekezést."
|
||||
"title": "Ez az eszköz nem támogatja a spam elleni védelmet."
|
||||
},
|
||||
"invalid_format": "Adjon meg egy érvényes formátumot",
|
||||
"is_between": "Válasszon egy dátumot {startDate} és {endDate} között",
|
||||
@@ -71,7 +71,7 @@
|
||||
"please_fill_out_this_field": "Töltse ki ezt a mezőt",
|
||||
"recaptcha_error": {
|
||||
"message": "A válaszát nem sikerült elküldeni, mert automatizált tevékenységként lett megjelölve. Ha lélegzik, akkor próbálja meg újra.",
|
||||
"title": "Nem tudtuk ellenőrizni, hogy Ön ember-e."
|
||||
"title": "Nem tudtuk ellenőrizni, hogy ember vagy."
|
||||
},
|
||||
"value_must_contain": "Az értéknek tartalmaznia kell ezt: {value}",
|
||||
"value_must_equal": "Az értéknek egyenlőnek kell lennie ezzel: {value}",
|
||||
|
||||
1809
pnpm-lock.yaml
generated
1809
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,3 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
|
||||
# Allow lifecycle scripts for packages that need to build native binaries
|
||||
# Required for pnpm v10+ which blocks scripts by default
|
||||
onlyBuiltDependencies:
|
||||
- sharp
|
||||
- esbuild
|
||||
- prisma
|
||||
- "@prisma/client"
|
||||
- "@prisma/engines"
|
||||
|
||||
Reference in New Issue
Block a user