mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-20 19:30:41 -05:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 520c337748 | |||
| 7f5c93b629 |
@@ -23,7 +23,7 @@
|
||||
"@tailwindcss/vite": "4.1.18",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"esbuild": "0.25.12",
|
||||
"esbuild": "0.27.2",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.1.11",
|
||||
"prop-types": "15.8.1",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+18
-13
@@ -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,28 +113,25 @@ 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
|
||||
|
||||
COPY --from=installer /app/node_modules/uuid ./node_modules/uuid
|
||||
RUN chmod -R 755 ./node_modules/uuid
|
||||
|
||||
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
||||
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 +141,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/
|
||||
|
||||
@@ -36,7 +36,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
// Calculate derived values (no queries)
|
||||
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
|
||||
|
||||
const { features, lastChecked, isPendingDowngrade, active, status } = license;
|
||||
const { features, lastChecked, isPendingDowngrade, active } = license;
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
@@ -63,7 +63,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
active={active}
|
||||
environmentId={environment.id}
|
||||
locale={user.locale}
|
||||
status={status}
|
||||
/>
|
||||
|
||||
<div className="flex h-full">
|
||||
|
||||
+18
-27
@@ -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,
|
||||
|
||||
@@ -8,10 +8,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { deleteResponse, getResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import {
|
||||
formatValidationErrorsForV1Api,
|
||||
validateResponseData,
|
||||
} from "@/modules/api/v2/management/responses/lib/validation";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||
@@ -144,24 +140,6 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
// Validate response data against validation rules
|
||||
const validationErrors = validateResponseData(
|
||||
result.survey.blocks,
|
||||
responseUpdate.data,
|
||||
responseUpdate.language ?? "en",
|
||||
result.survey.questions
|
||||
);
|
||||
|
||||
if (validationErrors) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Validation failed",
|
||||
formatValidationErrorsForV1Api(validationErrors),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
|
||||
@@ -7,10 +7,6 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import {
|
||||
formatValidationErrorsForV1Api,
|
||||
validateResponseData,
|
||||
} from "@/modules/api/v2/management/responses/lib/validation";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import {
|
||||
@@ -153,24 +149,6 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
// Validate response data against validation rules
|
||||
const validationErrors = validateResponseData(
|
||||
surveyResult.survey.blocks,
|
||||
responseInput.data,
|
||||
responseInput.language ?? "en",
|
||||
surveyResult.survey.questions
|
||||
);
|
||||
|
||||
if (validationErrors) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Validation failed",
|
||||
formatValidationErrorsForV1Api(validationErrors),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (responseInput.createdAt && !responseInput.updatedAt) {
|
||||
responseInput.updatedAt = responseInput.createdAt;
|
||||
}
|
||||
|
||||
+5
-6
@@ -9,18 +9,17 @@
|
||||
"source": "en-US",
|
||||
"targets": [
|
||||
"de-DE",
|
||||
"es-ES",
|
||||
"fr-FR",
|
||||
"hu-HU",
|
||||
"ja-JP",
|
||||
"nl-NL",
|
||||
"pt-BR",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"ru-RU",
|
||||
"sv-SE",
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW"
|
||||
"zh-Hant-TW",
|
||||
"nl-NL",
|
||||
"es-ES",
|
||||
"sv-SE",
|
||||
"ru-RU"
|
||||
]
|
||||
},
|
||||
"version": 1.8
|
||||
|
||||
+231
-270
File diff suppressed because it is too large
Load Diff
@@ -165,20 +165,19 @@ export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
|
||||
|
||||
export const DEFAULT_LOCALE = "en-US";
|
||||
export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
"de-DE",
|
||||
"en-US",
|
||||
"es-ES",
|
||||
"fr-FR",
|
||||
"hu-HU",
|
||||
"ja-JP",
|
||||
"nl-NL",
|
||||
"de-DE",
|
||||
"pt-BR",
|
||||
"fr-FR",
|
||||
"nl-NL",
|
||||
"zh-Hant-TW",
|
||||
"pt-PT",
|
||||
"ro-RO",
|
||||
"ru-RU",
|
||||
"sv-SE",
|
||||
"ja-JP",
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW",
|
||||
"es-ES",
|
||||
"sv-SE",
|
||||
"ru-RU",
|
||||
];
|
||||
|
||||
// Billing constants
|
||||
|
||||
+32
-38
@@ -126,12 +126,6 @@ export const addMultiLanguageLabels = (object: unknown, languageSymbols: string[
|
||||
};
|
||||
|
||||
export const appLanguages = [
|
||||
{
|
||||
code: "de-DE",
|
||||
label: {
|
||||
"en-US": "German",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "en-US",
|
||||
label: {
|
||||
@@ -139,9 +133,15 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
code: "de-DE",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
"en-US": "German",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pt-BR",
|
||||
label: {
|
||||
"en-US": "Portuguese (Brazil)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -151,27 +151,9 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "hu-HU",
|
||||
code: "zh-Hant-TW",
|
||||
label: {
|
||||
"en-US": "Hungarian",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ja-JP",
|
||||
label: {
|
||||
"en-US": "Japanese",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "nl-NL",
|
||||
label: {
|
||||
"en-US": "Dutch",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pt-BR",
|
||||
label: {
|
||||
"en-US": "Portuguese (Brazil)",
|
||||
"en-US": "Chinese (Traditional)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -187,15 +169,9 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ru-RU",
|
||||
code: "ja-JP",
|
||||
label: {
|
||||
"en-US": "Russian",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "sv-SE",
|
||||
label: {
|
||||
"en-US": "Swedish",
|
||||
"en-US": "Japanese",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -205,9 +181,27 @@ export const appLanguages = [
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-Hant-TW",
|
||||
code: "nl-NL",
|
||||
label: {
|
||||
"en-US": "Chinese (Traditional)",
|
||||
"en-US": "Dutch",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "sv-SE",
|
||||
label: {
|
||||
"en-US": "Swedish",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ru-RU",
|
||||
label: {
|
||||
"en-US": "Russian",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
+15
-17
@@ -1,5 +1,5 @@
|
||||
import { formatDistance, intlFormat } from "date-fns";
|
||||
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
|
||||
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
export const convertDateString = (dateString: string | null) => {
|
||||
@@ -87,30 +87,28 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
|
||||
return de;
|
||||
case "en-US":
|
||||
return enUS;
|
||||
case "es-ES":
|
||||
return es;
|
||||
case "fr-FR":
|
||||
return fr;
|
||||
case "hu-HU":
|
||||
return hu;
|
||||
case "ja-JP":
|
||||
return ja;
|
||||
case "nl-NL":
|
||||
return nl;
|
||||
case "pt-BR":
|
||||
return ptBR;
|
||||
case "fr-FR":
|
||||
return fr;
|
||||
case "nl-NL":
|
||||
return nl;
|
||||
case "sv-SE":
|
||||
return sv;
|
||||
case "zh-Hant-TW":
|
||||
return zhTW;
|
||||
case "pt-PT":
|
||||
return pt;
|
||||
case "ro-RO":
|
||||
return ro;
|
||||
case "ru-RU":
|
||||
return ru;
|
||||
case "sv-SE":
|
||||
return sv;
|
||||
case "ja-JP":
|
||||
return ja;
|
||||
case "zh-Hans-CN":
|
||||
return zhCN;
|
||||
case "zh-Hant-TW":
|
||||
return zhTW;
|
||||
case "es-ES":
|
||||
return es;
|
||||
case "ru-RU":
|
||||
return ru;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { createInstance } from "i18next";
|
||||
import ICU from "i18next-icu";
|
||||
import resourcesToBackend from "i18next-resources-to-backend";
|
||||
import { initReactI18next } from "react-i18next/initReactI18next";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { DEFAULT_LOCALE } from "@/lib/constants";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getLocale } from "@/lingodotdev/language";
|
||||
|
||||
const initI18next = async (lng: string) => {
|
||||
|
||||
+22
-63
@@ -243,6 +243,7 @@
|
||||
"imprint": "Impressum",
|
||||
"in_progress": "Im Gange",
|
||||
"inactive_surveys": "Inaktive Umfragen",
|
||||
"input_type": "Eingabetyp",
|
||||
"integration": "Integration",
|
||||
"integrations": "Integrationen",
|
||||
"invalid_date": "Ungültiges Datum",
|
||||
@@ -254,7 +255,6 @@
|
||||
"label": "Bezeichnung",
|
||||
"language": "Sprache",
|
||||
"learn_more": "Mehr erfahren",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Helle Überlagerung",
|
||||
"limits_reached": "Limits erreicht",
|
||||
"link": "Link",
|
||||
@@ -267,11 +267,13 @@
|
||||
"look_and_feel": "Darstellung",
|
||||
"manage": "Verwalten",
|
||||
"marketing": "Marketing",
|
||||
"maximum": "Maximal",
|
||||
"member": "Mitglied",
|
||||
"members": "Mitglieder",
|
||||
"members_and_teams": "Mitglieder & Teams",
|
||||
"membership_not_found": "Mitgliedschaft nicht gefunden",
|
||||
"metadata": "Metadaten",
|
||||
"minimum": "Minimum",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
|
||||
"mobile_overlay_surveys_look_good": "Keine Sorge – deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
|
||||
"mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!",
|
||||
@@ -324,7 +326,7 @@
|
||||
"placeholder": "Platzhalter",
|
||||
"please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus",
|
||||
"please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus",
|
||||
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
|
||||
"please_upgrade_your_plan": "Bitte upgrade deinen Plan.",
|
||||
"preview": "Vorschau",
|
||||
"preview_survey": "Umfragevorschau",
|
||||
"privacy": "Datenschutz",
|
||||
@@ -350,7 +352,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",
|
||||
@@ -462,8 +463,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Sie haben Ihr Limit von {projectLimit} Workspaces erreicht.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Du hast dein monatliches MIU-Limit erreicht",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Du hast dein monatliches Antwortlimit erreicht",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {date} auf die Community Edition herabgestuft.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {date} auf die Community Edition herabgestuft."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Annehmen",
|
||||
@@ -990,7 +990,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.",
|
||||
@@ -1172,6 +1172,7 @@
|
||||
"adjust_survey_closed_message_description": "Ändere die Nachricht, die Besucher sehen, wenn die Umfrage geschlossen ist.",
|
||||
"adjust_the_theme_in_the": "Passe das Thema an in den",
|
||||
"all_other_answers_will_continue_to": "Alle anderen Antworten werden weiterhin",
|
||||
"allow_file_type": "Dateityp begrenzen",
|
||||
"allow_multi_select": "Mehrfachauswahl erlauben",
|
||||
"allow_multiple_files": "Mehrere Dateien zulassen",
|
||||
"allow_users_to_select_more_than_one_image": "Erlaube Nutzern, mehr als ein Bild auszuwählen",
|
||||
@@ -1237,6 +1238,8 @@
|
||||
"change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.",
|
||||
"changes_saved": "Änderungen gespeichert.",
|
||||
"changing_survey_type_will_remove_existing_distribution_channels": "\"Das Ändern des Umfragetypen beeinflusst, wie er geteilt werden kann. Wenn Teilnehmer bereits Zugriffslinks für den aktuellen Typ haben, könnten sie das Zugriffsrecht nach dem Wechsel verlieren.\"",
|
||||
"character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.",
|
||||
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
|
||||
"checkbox_label": "Checkbox-Beschriftung",
|
||||
"choose_the_actions_which_trigger_the_survey": "Aktionen auswählen, die die Umfrage auslösen.",
|
||||
"choose_the_first_question_on_your_block": "Wählen sie die erste frage in ihrem block",
|
||||
@@ -1256,6 +1259,7 @@
|
||||
"contact_fields": "Kontaktfelder",
|
||||
"contains": "enthält",
|
||||
"continue_to_settings": "Weiter zu den Einstellungen",
|
||||
"control_which_file_types_can_be_uploaded": "Steuere, welche Dateitypen hochgeladen werden können.",
|
||||
"convert_to_multiple_choice": "In Multiple-Choice umwandeln",
|
||||
"convert_to_single_choice": "In Einzelauswahl umwandeln",
|
||||
"country": "Land",
|
||||
@@ -1268,13 +1272,11 @@
|
||||
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
|
||||
"date_format": "Datumsformat",
|
||||
"days_before_showing_this_survey_again": "oder mehr Tage müssen zwischen der zuletzt angezeigten Umfrage und der Anzeige dieser Umfrage vergehen.",
|
||||
"delete_anyways": "Trotzdem löschen",
|
||||
"delete_block": "Block löschen",
|
||||
"delete_choice": "Auswahl löschen",
|
||||
"disable_the_visibility_of_survey_progress": "Deaktiviere die Sichtbarkeit des Umfragefortschritts.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Zeige eine Schätzung der Fertigstellungszeit für die Umfrage an",
|
||||
"display_number_of_responses_for_survey": "Anzahl der Antworten für Umfrage anzeigen",
|
||||
"display_type": "Anzeigetyp",
|
||||
"divide": "Teilen /",
|
||||
"does_not_contain": "Enthält nicht",
|
||||
"does_not_end_with": "Endet nicht mit",
|
||||
@@ -1282,7 +1284,6 @@
|
||||
"does_not_include_all_of": "Enthält nicht alle von",
|
||||
"does_not_include_one_of": "Enthält nicht eines von",
|
||||
"does_not_start_with": "Fängt nicht an mit",
|
||||
"dropdown": "Dropdown",
|
||||
"duplicate_block": "Block duplizieren",
|
||||
"duplicate_question": "Frage duplizieren",
|
||||
"edit_link": "Bearbeitungslink",
|
||||
@@ -1375,7 +1376,7 @@
|
||||
"hide_question_settings": "Frageeinstellungen ausblenden",
|
||||
"hostname": "Hostname",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein",
|
||||
"if_you_need_more_please": "Wenn Sie mehr benötigen, bitte",
|
||||
"if_you_need_more_please": "Wenn Du mehr brauchst, bitte",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Weiterhin anzeigen, wenn ausgelöst, bis eine Antwort abgegeben wird.",
|
||||
"ignore_global_waiting_time": "Abkühlphase ignorieren",
|
||||
"ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.",
|
||||
@@ -1412,10 +1413,10 @@
|
||||
"key": "Schlüssel",
|
||||
"last_name": "Nachname",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Erlaube bis zu 25 Dateien gleichzeitig hochzuladen.",
|
||||
"limit_the_maximum_file_size": "Begrenzen Sie die maximale Dateigröße für Uploads.",
|
||||
"limit_upload_file_size_to": "Upload-Dateigröße begrenzen auf",
|
||||
"limit_file_types": "Dateitypen einschränken",
|
||||
"limit_the_maximum_file_size": "Maximale Dateigröße begrenzen",
|
||||
"limit_upload_file_size_to": "Maximale Dateigröße für Uploads",
|
||||
"link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.",
|
||||
"list": "Liste",
|
||||
"load_segment": "Segment laden",
|
||||
"logic_error_warning": "Änderungen werden zu Logikfehlern führen",
|
||||
"logic_error_warning_text": "Das Ändern des Fragetypen entfernt die Logikbedingungen von dieser Frage",
|
||||
@@ -1426,8 +1427,8 @@
|
||||
"manage_languages": "Sprachen verwalten",
|
||||
"matrix_all_fields": "Alle Felder",
|
||||
"matrix_rows": "Zeilen",
|
||||
"max_file_size": "Maximale Dateigröße",
|
||||
"max_file_size_limit_is": "Die maximale Dateigrößenbeschränkung beträgt",
|
||||
"max_file_size": "Max. Dateigröße",
|
||||
"max_file_size_limit_is": "Max. Dateigröße ist",
|
||||
"move_question_to_block": "Frage in Block verschieben",
|
||||
"multiply": "Multiplizieren *",
|
||||
"needed_for_self_hosted_cal_com_instance": "Benötigt für eine selbstgehostete Cal.com-Instanz",
|
||||
@@ -1459,6 +1460,7 @@
|
||||
"picture_idx": "Bild {idx}",
|
||||
"pin_can_only_contain_numbers": "PIN darf nur Zahlen enthalten.",
|
||||
"pin_must_be_a_four_digit_number": "Die PIN muss eine vierstellige Zahl sein.",
|
||||
"please_enter_a_file_extension": "Bitte gib eine Dateierweiterung ein.",
|
||||
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein (z. B. https://beispiel.de)",
|
||||
"please_set_a_survey_trigger": "Bitte richte einen Umfrage-Trigger ein",
|
||||
"please_specify": "Bitte angeben",
|
||||
@@ -1473,9 +1475,8 @@
|
||||
"question_deleted": "Frage gelöscht.",
|
||||
"question_duplicated": "Frage dupliziert.",
|
||||
"question_id_updated": "Frage-ID aktualisiert",
|
||||
"question_used_in_logic_warning_text": "Elemente aus diesem Block werden in einer Logikregel verwendet. Möchten Sie ihn wirklich löschen?",
|
||||
"question_used_in_logic_warning_title": "Logikinkonsistenz",
|
||||
"question_used_in_quota": "Diese Frage wird in der “{quotaName}” Quote verwendet",
|
||||
"question_used_in_logic": "Diese Frage wird in der Logik der Frage {questionIndex} verwendet.",
|
||||
"question_used_in_quota": "Diese Frage wird in der \"{quotaName}\" Quote verwendet",
|
||||
"question_used_in_recall": "Diese Frage wird in Frage {questionIndex} abgerufen.",
|
||||
"question_used_in_recall_ending_card": "Diese Frage wird in der Abschlusskarte abgerufen.",
|
||||
"quotas": {
|
||||
@@ -1538,7 +1539,6 @@
|
||||
"search_for_images": "Nach Bildern suchen",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "Sekunden nach dem Auslösen wird die Umfrage geschlossen, wenn keine Antwort erfolgt.",
|
||||
"seconds_before_showing_the_survey": "Sekunden, bevor die Umfrage angezeigt wird.",
|
||||
"select_field": "Feld auswählen",
|
||||
"select_or_type_value": "Auswählen oder Wert eingeben",
|
||||
"select_ordering": "Anordnung auswählen",
|
||||
"select_saved_action": "Gespeicherte Aktion auswählen",
|
||||
@@ -1586,6 +1586,8 @@
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Einmal anzeigen, auch wenn sie nicht antworten.",
|
||||
"then": "dann",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Diese Aktion entfernt alle Übersetzungen aus dieser Umfrage.",
|
||||
"this_extension_is_already_added": "Diese Erweiterung ist bereits hinzugefügt.",
|
||||
"this_file_type_is_not_supported": "Dieser Dateityp wird nicht unterstützt.",
|
||||
"three_points": "3 Punkte",
|
||||
"times": "Zeiten",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Um die Platzierung über alle Umfragen hinweg konsistent zu halten, kannst du",
|
||||
@@ -1606,51 +1608,8 @@
|
||||
"upper_label": "Oberes Label",
|
||||
"url_filters": "URL-Filter",
|
||||
"url_not_supported": "URL nicht unterstützt",
|
||||
"validation": {
|
||||
"add_validation_rule": "Validierungsregel hinzufügen",
|
||||
"answer_all_rows": "Alle Zeilen beantworten",
|
||||
"characters": "Zeichen",
|
||||
"contains": "enthält",
|
||||
"delete_validation_rule": "Validierungsregel löschen",
|
||||
"does_not_contain": "enthält nicht",
|
||||
"email": "Ist gültige E-Mail",
|
||||
"end_date": "Enddatum",
|
||||
"file_extension_is": "Dateierweiterung ist",
|
||||
"file_extension_is_not": "Dateierweiterung ist nicht",
|
||||
"is": "ist",
|
||||
"is_between": "ist zwischen",
|
||||
"is_earlier_than": "ist früher als",
|
||||
"is_greater_than": "ist größer als",
|
||||
"is_later_than": "ist später als",
|
||||
"is_less_than": "ist weniger als",
|
||||
"is_not": "ist nicht",
|
||||
"is_not_between": "ist nicht zwischen",
|
||||
"kb": "KB",
|
||||
"max_length": "Höchstens",
|
||||
"max_selections": "Höchstens",
|
||||
"max_value": "Höchstens",
|
||||
"mb": "MB",
|
||||
"min_length": "Mindestens",
|
||||
"min_selections": "Mindestens",
|
||||
"min_value": "Mindestens",
|
||||
"minimum_options_ranked": "Mindestanzahl bewerteter Optionen",
|
||||
"minimum_rows_answered": "Mindestanzahl beantworteter Zeilen",
|
||||
"options_selected": "Optionen ausgewählt",
|
||||
"pattern": "Entspricht Regex-Muster",
|
||||
"phone": "Ist gültige Telefonnummer",
|
||||
"rank_all_options": "Alle Optionen bewerten",
|
||||
"select_file_extensions": "Dateierweiterungen auswählen...",
|
||||
"select_option": "Option auswählen",
|
||||
"start_date": "Startdatum",
|
||||
"url": "Ist gültige URL"
|
||||
},
|
||||
"validation_logic_and": "Alle sind wahr",
|
||||
"validation_logic_or": "mindestens eine ist wahr",
|
||||
"validation_rules": "Validierungsregeln",
|
||||
"validation_rules_description": "Nur Antworten akzeptieren, die die folgenden Kriterien erfüllen",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable “{variableName}” wird in der “{quotaName}” Quote verwendet",
|
||||
"variable_name_conflicts_with_hidden_field": "Der Variablenname steht im Konflikt mit einer vorhandenen Hidden-Field-ID.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
|
||||
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
|
||||
"variable_name_must_start_with_a_letter": "Variablenname muss mit einem Buchstaben beginnen.",
|
||||
"variable_used_in_recall": "Variable \"{variable}\" wird in Frage {questionIndex} abgerufen.",
|
||||
|
||||
+232
-273
File diff suppressed because it is too large
Load Diff
+19
-60
@@ -243,6 +243,7 @@
|
||||
"imprint": "Aviso legal",
|
||||
"in_progress": "En progreso",
|
||||
"inactive_surveys": "Encuestas inactivas",
|
||||
"input_type": "Tipo de entrada",
|
||||
"integration": "integración",
|
||||
"integrations": "Integraciones",
|
||||
"invalid_date": "Fecha no válida",
|
||||
@@ -254,7 +255,6 @@
|
||||
"label": "Etiqueta",
|
||||
"language": "Idioma",
|
||||
"learn_more": "Saber más",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Superposición clara",
|
||||
"limits_reached": "Límites alcanzados",
|
||||
"link": "Enlace",
|
||||
@@ -267,11 +267,13 @@
|
||||
"look_and_feel": "Apariencia",
|
||||
"manage": "Gestionar",
|
||||
"marketing": "Marketing",
|
||||
"maximum": "Máximo",
|
||||
"member": "Miembro",
|
||||
"members": "Miembros",
|
||||
"members_and_teams": "Miembros y equipos",
|
||||
"membership_not_found": "Membresía no encontrada",
|
||||
"metadata": "Metadatos",
|
||||
"minimum": "Mínimo",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "No te preocupes – ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
|
||||
"mobile_overlay_title": "¡Ups, pantalla pequeña detectada!",
|
||||
@@ -324,7 +326,7 @@
|
||||
"placeholder": "Marcador de posición",
|
||||
"please_select_at_least_one_survey": "Por favor, selecciona al menos una encuesta",
|
||||
"please_select_at_least_one_trigger": "Por favor, selecciona al menos un disparador",
|
||||
"please_upgrade_your_plan": "Por favor, actualiza tu plan",
|
||||
"please_upgrade_your_plan": "Por favor, actualiza tu plan.",
|
||||
"preview": "Vista previa",
|
||||
"preview_survey": "Vista previa de la encuesta",
|
||||
"privacy": "Política de privacidad",
|
||||
@@ -350,7 +352,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",
|
||||
@@ -462,8 +463,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Has alcanzado tu límite de {projectLimit} espacios de trabajo.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Has alcanzado tu límite mensual de MIU de",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Has alcanzado tu límite mensual de respuestas de",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Serás degradado a la edición Community el {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Serás degradado a la edición Community el {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Aceptar",
|
||||
@@ -990,7 +990,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.",
|
||||
@@ -1172,6 +1172,7 @@
|
||||
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
|
||||
"adjust_the_theme_in_the": "Ajustar el tema en el",
|
||||
"all_other_answers_will_continue_to": "Todas las demás respuestas continuarán",
|
||||
"allow_file_type": "Permitir tipo de archivo",
|
||||
"allow_multi_select": "Permitir selección múltiple",
|
||||
"allow_multiple_files": "Permitir múltiples archivos",
|
||||
"allow_users_to_select_more_than_one_image": "Permitir a los usuarios seleccionar más de una imagen",
|
||||
@@ -1237,6 +1238,8 @@
|
||||
"change_the_question_color_of_the_survey": "Cambiar el color de las preguntas de la encuesta.",
|
||||
"changes_saved": "Cambios guardados.",
|
||||
"changing_survey_type_will_remove_existing_distribution_channels": "Cambiar el tipo de encuesta afectará a cómo se puede compartir. Si los encuestados ya tienen enlaces de acceso para el tipo actual, podrían perder el acceso después del cambio.",
|
||||
"character_limit_toggle_description": "Limitar lo corta o larga que puede ser una respuesta.",
|
||||
"character_limit_toggle_title": "Añadir límites de caracteres",
|
||||
"checkbox_label": "Etiqueta de casilla de verificación",
|
||||
"choose_the_actions_which_trigger_the_survey": "Elige las acciones que activan la encuesta.",
|
||||
"choose_the_first_question_on_your_block": "Elige la primera pregunta en tu bloque",
|
||||
@@ -1256,6 +1259,7 @@
|
||||
"contact_fields": "Campos de contacto",
|
||||
"contains": "Contiene",
|
||||
"continue_to_settings": "Continuar a ajustes",
|
||||
"control_which_file_types_can_be_uploaded": "Controla qué tipos de archivos se pueden subir.",
|
||||
"convert_to_multiple_choice": "Convertir a selección múltiple",
|
||||
"convert_to_single_choice": "Convertir a selección única",
|
||||
"country": "País",
|
||||
@@ -1268,13 +1272,11 @@
|
||||
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
|
||||
"date_format": "Formato de fecha",
|
||||
"days_before_showing_this_survey_again": "o más días deben transcurrir entre la última encuesta mostrada y la visualización de esta encuesta.",
|
||||
"delete_anyways": "Eliminar de todos modos",
|
||||
"delete_block": "Eliminar bloque",
|
||||
"delete_choice": "Eliminar opción",
|
||||
"disable_the_visibility_of_survey_progress": "Desactivar la visibilidad del progreso de la encuesta.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar una estimación del tiempo de finalización de la encuesta",
|
||||
"display_number_of_responses_for_survey": "Mostrar número de respuestas para la encuesta",
|
||||
"display_type": "Tipo de visualización",
|
||||
"divide": "Dividir /",
|
||||
"does_not_contain": "No contiene",
|
||||
"does_not_end_with": "No termina con",
|
||||
@@ -1282,7 +1284,6 @@
|
||||
"does_not_include_all_of": "No incluye todos los",
|
||||
"does_not_include_one_of": "No incluye uno de",
|
||||
"does_not_start_with": "No comienza con",
|
||||
"dropdown": "Desplegable",
|
||||
"duplicate_block": "Duplicar bloque",
|
||||
"duplicate_question": "Duplicar pregunta",
|
||||
"edit_link": "Editar enlace",
|
||||
@@ -1412,10 +1413,10 @@
|
||||
"key": "Clave",
|
||||
"last_name": "Apellido",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Permitir que las personas suban hasta 25 archivos al mismo tiempo.",
|
||||
"limit_the_maximum_file_size": "Limita el tamaño máximo de archivo para las subidas.",
|
||||
"limit_upload_file_size_to": "Limitar el tamaño de archivo de subida a",
|
||||
"limit_file_types": "Limitar tipos de archivo",
|
||||
"limit_the_maximum_file_size": "Limitar el tamaño máximo de archivo",
|
||||
"limit_upload_file_size_to": "Limitar tamaño de subida de archivos a",
|
||||
"link_survey_description": "Comparte un enlace a una página de encuesta o incrústala en una página web o correo electrónico.",
|
||||
"list": "Lista",
|
||||
"load_segment": "Cargar segmento",
|
||||
"logic_error_warning": "El cambio causará errores lógicos",
|
||||
"logic_error_warning_text": "Cambiar el tipo de pregunta eliminará las condiciones lógicas de esta pregunta",
|
||||
@@ -1459,6 +1460,7 @@
|
||||
"picture_idx": "Imagen {idx}",
|
||||
"pin_can_only_contain_numbers": "El PIN solo puede contener números.",
|
||||
"pin_must_be_a_four_digit_number": "El PIN debe ser un número de cuatro dígitos.",
|
||||
"please_enter_a_file_extension": "Por favor, introduce una extensión de archivo.",
|
||||
"please_enter_a_valid_url": "Por favor, introduce una URL válida (p. ej., https://example.com)",
|
||||
"please_set_a_survey_trigger": "Establece un disparador de encuesta",
|
||||
"please_specify": "Por favor, especifica",
|
||||
@@ -1473,9 +1475,8 @@
|
||||
"question_deleted": "Pregunta eliminada.",
|
||||
"question_duplicated": "Pregunta duplicada.",
|
||||
"question_id_updated": "ID de pregunta actualizado",
|
||||
"question_used_in_logic_warning_text": "Los elementos de este bloque se usan en una regla de lógica, ¿estás seguro de que quieres eliminarlo?",
|
||||
"question_used_in_logic_warning_title": "Inconsistencia de lógica",
|
||||
"question_used_in_quota": "Esta pregunta se está utilizando en la cuota “{quotaName}”",
|
||||
"question_used_in_logic": "Esta pregunta se utiliza en la lógica de la pregunta {questionIndex}.",
|
||||
"question_used_in_quota": "Esta pregunta se está utilizando en la cuota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Esta pregunta se está recordando en la pregunta {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Esta pregunta se está recordando en la Tarjeta Final",
|
||||
"quotas": {
|
||||
@@ -1538,7 +1539,6 @@
|
||||
"search_for_images": "Buscar imágenes",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos después de activarse, la encuesta se cerrará si no hay respuesta",
|
||||
"seconds_before_showing_the_survey": "segundos antes de mostrar la encuesta.",
|
||||
"select_field": "Seleccionar campo",
|
||||
"select_or_type_value": "Selecciona o escribe un valor",
|
||||
"select_ordering": "Seleccionar ordenación",
|
||||
"select_saved_action": "Seleccionar acción guardada",
|
||||
@@ -1586,6 +1586,8 @@
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar una sola vez, incluso si no responden.",
|
||||
"then": "Entonces",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Esta acción eliminará todas las traducciones de esta encuesta.",
|
||||
"this_extension_is_already_added": "Esta extensión ya está añadida.",
|
||||
"this_file_type_is_not_supported": "Este tipo de archivo no es compatible.",
|
||||
"three_points": "3 puntos",
|
||||
"times": "veces",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para mantener la ubicación coherente en todas las encuestas, puedes",
|
||||
@@ -1606,51 +1608,8 @@
|
||||
"upper_label": "Etiqueta superior",
|
||||
"url_filters": "Filtros de URL",
|
||||
"url_not_supported": "URL no compatible",
|
||||
"validation": {
|
||||
"add_validation_rule": "Añadir regla de validación",
|
||||
"answer_all_rows": "Responde todas las filas",
|
||||
"characters": "Caracteres",
|
||||
"contains": "Contiene",
|
||||
"delete_validation_rule": "Eliminar regla de validación",
|
||||
"does_not_contain": "No contiene",
|
||||
"email": "Es un correo electrónico válido",
|
||||
"end_date": "Fecha de finalización",
|
||||
"file_extension_is": "La extensión del archivo es",
|
||||
"file_extension_is_not": "La extensión del archivo no es",
|
||||
"is": "Es",
|
||||
"is_between": "Está entre",
|
||||
"is_earlier_than": "Es anterior a",
|
||||
"is_greater_than": "Es mayor que",
|
||||
"is_later_than": "Es posterior a",
|
||||
"is_less_than": "Es menor que",
|
||||
"is_not": "No es",
|
||||
"is_not_between": "No está entre",
|
||||
"kb": "KB",
|
||||
"max_length": "Como máximo",
|
||||
"max_selections": "Como máximo",
|
||||
"max_value": "Como máximo",
|
||||
"mb": "MB",
|
||||
"min_length": "Al menos",
|
||||
"min_selections": "Al menos",
|
||||
"min_value": "Al menos",
|
||||
"minimum_options_ranked": "Opciones mínimas clasificadas",
|
||||
"minimum_rows_answered": "Filas mínimas respondidas",
|
||||
"options_selected": "Opciones seleccionadas",
|
||||
"pattern": "Coincide con el patrón regex",
|
||||
"phone": "Es un teléfono válido",
|
||||
"rank_all_options": "Clasificar todas las opciones",
|
||||
"select_file_extensions": "Selecciona extensiones de archivo...",
|
||||
"select_option": "Seleccionar opción",
|
||||
"start_date": "Fecha de inicio",
|
||||
"url": "Es una URL válida"
|
||||
},
|
||||
"validation_logic_and": "Todas son verdaderas",
|
||||
"validation_logic_or": "alguna es verdadera",
|
||||
"validation_rules": "Reglas de validación",
|
||||
"validation_rules_description": "Solo aceptar respuestas que cumplan los siguientes criterios",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínala primero de la lógica.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable “{variableName}” se está utilizando en la cuota “{quotaName}”",
|
||||
"variable_name_conflicts_with_hidden_field": "El nombre de la variable entra en conflicto con un ID de campo oculto existente.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" se está utilizando en la cuota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "El nombre de la variable ya está en uso, por favor elige otro.",
|
||||
"variable_name_must_start_with_a_letter": "El nombre de la variable debe comenzar con una letra.",
|
||||
"variable_used_in_recall": "La variable \"{variable}\" se está recuperando en la pregunta {questionIndex}.",
|
||||
|
||||
+21
-62
@@ -243,6 +243,7 @@
|
||||
"imprint": "Empreinte",
|
||||
"in_progress": "En cours",
|
||||
"inactive_surveys": "Sondages inactifs",
|
||||
"input_type": "Type d'entrée",
|
||||
"integration": "intégration",
|
||||
"integrations": "Intégrations",
|
||||
"invalid_date": "Date invalide",
|
||||
@@ -254,7 +255,6 @@
|
||||
"label": "Étiquette",
|
||||
"language": "Langue",
|
||||
"learn_more": "En savoir plus",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Claire",
|
||||
"limits_reached": "Limites atteints",
|
||||
"link": "Lien",
|
||||
@@ -267,11 +267,13 @@
|
||||
"look_and_feel": "Apparence",
|
||||
"manage": "Gérer",
|
||||
"marketing": "Marketing",
|
||||
"maximum": "Max",
|
||||
"member": "Membre",
|
||||
"members": "Membres",
|
||||
"members_and_teams": "Membres & Équipes",
|
||||
"membership_not_found": "Abonnement non trouvé",
|
||||
"metadata": "Métadonnées",
|
||||
"minimum": "Min",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
|
||||
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas – tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
|
||||
"mobile_overlay_title": "Oups, écran minuscule détecté!",
|
||||
@@ -324,7 +326,7 @@
|
||||
"placeholder": "Remplaçant",
|
||||
"please_select_at_least_one_survey": "Veuillez sélectionner au moins une enquête.",
|
||||
"please_select_at_least_one_trigger": "Veuillez sélectionner au moins un déclencheur.",
|
||||
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan",
|
||||
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan.",
|
||||
"preview": "Aperçu",
|
||||
"preview_survey": "Aperçu de l'enquête",
|
||||
"privacy": "Politique de confidentialité",
|
||||
@@ -350,7 +352,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",
|
||||
@@ -462,8 +463,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Vous avez atteint votre limite de {projectLimit} espaces de travail.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Vous avez atteint votre limite mensuelle de MIU de",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Vous avez atteint votre limite de réponses mensuelle de",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Vous serez rétrogradé à l'édition communautaire le {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Vous serez rétrogradé à l'édition communautaire le {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Accepter",
|
||||
@@ -990,7 +990,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é.",
|
||||
@@ -1172,6 +1172,7 @@
|
||||
"adjust_survey_closed_message_description": "Modifiez le message que les visiteurs voient lorsque l'enquête est fermée.",
|
||||
"adjust_the_theme_in_the": "Ajustez le thème dans le",
|
||||
"all_other_answers_will_continue_to": "Toutes les autres réponses continueront à",
|
||||
"allow_file_type": "Autoriser le type de fichier",
|
||||
"allow_multi_select": "Autoriser la sélection multiple",
|
||||
"allow_multiple_files": "Autoriser plusieurs fichiers",
|
||||
"allow_users_to_select_more_than_one_image": "Permettre aux utilisateurs de sélectionner plusieurs images",
|
||||
@@ -1237,6 +1238,8 @@
|
||||
"change_the_question_color_of_the_survey": "Vous pouvez modifier la couleur des questions d'une enquête.",
|
||||
"changes_saved": "Modifications enregistrées.",
|
||||
"changing_survey_type_will_remove_existing_distribution_channels": "Le changement du type de sondage affectera la façon dont il peut être partagé. Si les répondants ont déjà des liens d'accès pour le type actuel, ils peuvent perdre l'accès après le changement.",
|
||||
"character_limit_toggle_description": "Limitez la longueur des réponses.",
|
||||
"character_limit_toggle_title": "Ajouter des limites de caractères",
|
||||
"checkbox_label": "Étiquette de case à cocher",
|
||||
"choose_the_actions_which_trigger_the_survey": "Choisissez les actions qui déclenchent l'enquête.",
|
||||
"choose_the_first_question_on_your_block": "Choisissez la première question de votre bloc",
|
||||
@@ -1256,6 +1259,7 @@
|
||||
"contact_fields": "Champs de contact",
|
||||
"contains": "Contient",
|
||||
"continue_to_settings": "Continuer vers les paramètres",
|
||||
"control_which_file_types_can_be_uploaded": "Contrôlez quels types de fichiers peuvent être téléchargés.",
|
||||
"convert_to_multiple_choice": "Convertir en choix multiples",
|
||||
"convert_to_single_choice": "Convertir en choix unique",
|
||||
"country": "Pays",
|
||||
@@ -1268,13 +1272,11 @@
|
||||
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
|
||||
"date_format": "Format de date",
|
||||
"days_before_showing_this_survey_again": "ou plus de jours doivent s'écouler entre le dernier sondage affiché et l'affichage de ce sondage.",
|
||||
"delete_anyways": "Supprimer quand même",
|
||||
"delete_block": "Supprimer le bloc",
|
||||
"delete_choice": "Supprimer l'option",
|
||||
"disable_the_visibility_of_survey_progress": "Désactiver la visibilité de la progression du sondage.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afficher une estimation du temps de complétion pour l'enquête.",
|
||||
"display_number_of_responses_for_survey": "Afficher le nombre de réponses pour l'enquête",
|
||||
"display_type": "Type d'affichage",
|
||||
"divide": "Diviser /",
|
||||
"does_not_contain": "Ne contient pas",
|
||||
"does_not_end_with": "Ne se termine pas par",
|
||||
@@ -1282,7 +1284,6 @@
|
||||
"does_not_include_all_of": "n'inclut pas tout",
|
||||
"does_not_include_one_of": "n'inclut pas un de",
|
||||
"does_not_start_with": "Ne commence pas par",
|
||||
"dropdown": "Menu déroulant",
|
||||
"duplicate_block": "Dupliquer le bloc",
|
||||
"duplicate_question": "Dupliquer la question",
|
||||
"edit_link": "Modifier le lien",
|
||||
@@ -1375,7 +1376,7 @@
|
||||
"hide_question_settings": "Masquer les paramètres de la question",
|
||||
"hostname": "Nom d'hôte",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}",
|
||||
"if_you_need_more_please": "Si vous avez besoin de plus, veuillez",
|
||||
"if_you_need_more_please": "Si vous en avez besoin de plus, s'il vous plaît",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse soit soumise.",
|
||||
"ignore_global_waiting_time": "Ignorer la période de refroidissement",
|
||||
"ignore_global_waiting_time_description": "Cette enquête peut s'afficher chaque fois que ses conditions sont remplies, même si une autre enquête a été affichée récemment.",
|
||||
@@ -1412,10 +1413,10 @@
|
||||
"key": "Clé",
|
||||
"last_name": "Nom de famille",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Permettre aux utilisateurs de télécharger jusqu'à 25 fichiers en même temps.",
|
||||
"limit_the_maximum_file_size": "Limiter la taille maximale des fichiers pour les téléversements.",
|
||||
"limit_upload_file_size_to": "Limiter la taille de téléversement des fichiers à",
|
||||
"limit_file_types": "Limiter les types de fichiers",
|
||||
"limit_the_maximum_file_size": "Limiter la taille maximale du fichier",
|
||||
"limit_upload_file_size_to": "Limiter la taille des fichiers téléchargés à",
|
||||
"link_survey_description": "Partagez un lien vers une page d'enquête ou intégrez-le dans une page web ou un e-mail.",
|
||||
"list": "Liste",
|
||||
"load_segment": "Segment de chargement",
|
||||
"logic_error_warning": "Changer causera des erreurs logiques",
|
||||
"logic_error_warning_text": "Changer le type de question supprimera les conditions logiques de cette question.",
|
||||
@@ -1427,7 +1428,7 @@
|
||||
"matrix_all_fields": "Tous les champs",
|
||||
"matrix_rows": "Lignes",
|
||||
"max_file_size": "Taille maximale du fichier",
|
||||
"max_file_size_limit_is": "La limite de taille maximale du fichier est",
|
||||
"max_file_size_limit_is": "La taille maximale du fichier est",
|
||||
"move_question_to_block": "Déplacer la question vers le bloc",
|
||||
"multiply": "Multiplier *",
|
||||
"needed_for_self_hosted_cal_com_instance": "Nécessaire pour une instance Cal.com auto-hébergée",
|
||||
@@ -1459,6 +1460,7 @@
|
||||
"picture_idx": "Image {idx}",
|
||||
"pin_can_only_contain_numbers": "Le code PIN ne peut contenir que des chiffres.",
|
||||
"pin_must_be_a_four_digit_number": "Le code PIN doit être un numéro à quatre chiffres.",
|
||||
"please_enter_a_file_extension": "Veuillez entrer une extension de fichier.",
|
||||
"please_enter_a_valid_url": "Veuillez entrer une URL valide (par exemple, https://example.com)",
|
||||
"please_set_a_survey_trigger": "Veuillez définir un déclencheur d'enquête.",
|
||||
"please_specify": "Veuillez préciser",
|
||||
@@ -1473,9 +1475,8 @@
|
||||
"question_deleted": "Question supprimée.",
|
||||
"question_duplicated": "Question dupliquée.",
|
||||
"question_id_updated": "ID de la question mis à jour",
|
||||
"question_used_in_logic_warning_text": "Des éléments de ce bloc sont utilisés dans une règle logique, êtes-vous sûr de vouloir le supprimer ?",
|
||||
"question_used_in_logic_warning_title": "Incohérence de logique",
|
||||
"question_used_in_quota": "Cette question est utilisée dans le quota “{quotaName}”",
|
||||
"question_used_in_logic": "Cette question est utilisée dans la logique de la question '{'questionIndex'}'.",
|
||||
"question_used_in_quota": "Cette question est utilisée dans le quota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Cette question est rappelée dans la question {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Cette question est rappelée dans la carte de fin.",
|
||||
"quotas": {
|
||||
@@ -1538,7 +1539,6 @@
|
||||
"search_for_images": "Rechercher des images",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "Les secondes après le déclenchement, l'enquête sera fermée si aucune réponse n'est donnée.",
|
||||
"seconds_before_showing_the_survey": "secondes avant de montrer l'enquête.",
|
||||
"select_field": "Sélectionner un champ",
|
||||
"select_or_type_value": "Sélectionnez ou saisissez une valeur",
|
||||
"select_ordering": "Choisir l'ordre",
|
||||
"select_saved_action": "Sélectionner une action enregistrée",
|
||||
@@ -1586,6 +1586,8 @@
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Afficher une seule fois, même si la personne ne répond pas.",
|
||||
"then": "Alors",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Cette action supprimera toutes les traductions de cette enquête.",
|
||||
"this_extension_is_already_added": "Cette extension est déjà ajoutée.",
|
||||
"this_file_type_is_not_supported": "Ce type de fichier n'est pas pris en charge.",
|
||||
"three_points": "3 points",
|
||||
"times": "fois",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pour maintenir la cohérence du placement sur tous les sondages, vous pouvez",
|
||||
@@ -1606,51 +1608,8 @@
|
||||
"upper_label": "Étiquette supérieure",
|
||||
"url_filters": "Filtres d'URL",
|
||||
"url_not_supported": "URL non supportée",
|
||||
"validation": {
|
||||
"add_validation_rule": "Ajouter une règle de validation",
|
||||
"answer_all_rows": "Répondre à toutes les lignes",
|
||||
"characters": "Caractères",
|
||||
"contains": "Contient",
|
||||
"delete_validation_rule": "Supprimer la règle de validation",
|
||||
"does_not_contain": "Ne contient pas",
|
||||
"email": "Est un e-mail valide",
|
||||
"end_date": "Date de fin",
|
||||
"file_extension_is": "L'extension de fichier est",
|
||||
"file_extension_is_not": "L'extension de fichier n'est pas",
|
||||
"is": "Est",
|
||||
"is_between": "Est entre",
|
||||
"is_earlier_than": "Est antérieur à",
|
||||
"is_greater_than": "Est supérieur à",
|
||||
"is_later_than": "Est postérieur à",
|
||||
"is_less_than": "Est inférieur à",
|
||||
"is_not": "N'est pas",
|
||||
"is_not_between": "N'est pas entre",
|
||||
"kb": "Ko",
|
||||
"max_length": "Au maximum",
|
||||
"max_selections": "Au maximum",
|
||||
"max_value": "Au maximum",
|
||||
"mb": "Mo",
|
||||
"min_length": "Au moins",
|
||||
"min_selections": "Au moins",
|
||||
"min_value": "Au moins",
|
||||
"minimum_options_ranked": "Nombre minimum d'options classées",
|
||||
"minimum_rows_answered": "Nombre minimum de lignes répondues",
|
||||
"options_selected": "Options sélectionnées",
|
||||
"pattern": "Correspond au modèle d'expression régulière",
|
||||
"phone": "Est un numéro de téléphone valide",
|
||||
"rank_all_options": "Classer toutes les options",
|
||||
"select_file_extensions": "Sélectionner les extensions de fichier...",
|
||||
"select_option": "Sélectionner une option",
|
||||
"start_date": "Date de début",
|
||||
"url": "Est une URL valide"
|
||||
},
|
||||
"validation_logic_and": "Toutes sont vraies",
|
||||
"validation_logic_or": "au moins une est vraie",
|
||||
"validation_rules": "Règles de validation",
|
||||
"validation_rules_description": "Accepter uniquement les réponses qui répondent aux critères suivants",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable “{variableName}” est utilisée dans le quota “{quotaName}”",
|
||||
"variable_name_conflicts_with_hidden_field": "Le nom de la variable est en conflit avec un ID de champ masqué existant.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
|
||||
"variable_name_must_start_with_a_letter": "Le nom de la variable doit commencer par une lettre.",
|
||||
"variable_used_in_recall": "La variable \"{variable}\" est rappelée dans la question {questionIndex}.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+19
-60
@@ -243,6 +243,7 @@
|
||||
"imprint": "企業情報",
|
||||
"in_progress": "進行中",
|
||||
"inactive_surveys": "非アクティブなフォーム",
|
||||
"input_type": "入力タイプ",
|
||||
"integration": "連携",
|
||||
"integrations": "連携",
|
||||
"invalid_date": "無効な日付です",
|
||||
@@ -254,7 +255,6 @@
|
||||
"label": "ラベル",
|
||||
"language": "言語",
|
||||
"learn_more": "詳細を見る",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "明るいオーバーレイ",
|
||||
"limits_reached": "上限に達しました",
|
||||
"link": "リンク",
|
||||
@@ -267,11 +267,13 @@
|
||||
"look_and_feel": "デザイン",
|
||||
"manage": "管理",
|
||||
"marketing": "マーケティング",
|
||||
"maximum": "最大",
|
||||
"member": "メンバー",
|
||||
"members": "メンバー",
|
||||
"members_and_teams": "メンバー&チーム",
|
||||
"membership_not_found": "メンバーシップが見つかりません",
|
||||
"metadata": "メタデータ",
|
||||
"minimum": "最小",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
|
||||
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
|
||||
"mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!",
|
||||
@@ -324,7 +326,7 @@
|
||||
"placeholder": "プレースホルダー",
|
||||
"please_select_at_least_one_survey": "少なくとも1つのフォームを選択してください",
|
||||
"please_select_at_least_one_trigger": "少なくとも1つのトリガーを選択してください",
|
||||
"please_upgrade_your_plan": "プランをアップグレードしてください",
|
||||
"please_upgrade_your_plan": "プランをアップグレードしてください。",
|
||||
"preview": "プレビュー",
|
||||
"preview_survey": "フォームをプレビュー",
|
||||
"privacy": "プライバシーポリシー",
|
||||
@@ -350,7 +352,6 @@
|
||||
"request_trial_license": "トライアルライセンスをリクエスト",
|
||||
"reset_to_default": "デフォルトにリセット",
|
||||
"response": "回答",
|
||||
"response_id": "回答ID",
|
||||
"responses": "回答",
|
||||
"restart": "再開",
|
||||
"role": "役割",
|
||||
@@ -462,8 +463,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "ワークスペースの上限である{projectLimit}件に達しました。",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "月間MIU(月間アクティブユーザー)の上限に達しました",
|
||||
"you_have_reached_your_monthly_response_limit_of": "月間回答数の上限に達しました",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "コミュニティ版へのダウングレードは {date} に行われます。",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "コミュニティ版へのダウングレードは {date} に行われます。"
|
||||
},
|
||||
"emails": {
|
||||
"accept": "承認",
|
||||
@@ -990,7 +990,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": "この組織を離れ、すべてのフォームと回答へのアクセス権を失います。再度招待された場合にのみ再参加できます。",
|
||||
@@ -1172,6 +1172,7 @@
|
||||
"adjust_survey_closed_message_description": "フォームがクローズしたときに訪問者が見るメッセージを変更します。",
|
||||
"adjust_the_theme_in_the": "テーマを",
|
||||
"all_other_answers_will_continue_to": "他のすべての回答は引き続き",
|
||||
"allow_file_type": "ファイルタイプを許可",
|
||||
"allow_multi_select": "複数選択を許可",
|
||||
"allow_multiple_files": "複数のファイルを許可",
|
||||
"allow_users_to_select_more_than_one_image": "ユーザーが複数の画像を選択できるようにする",
|
||||
@@ -1237,6 +1238,8 @@
|
||||
"change_the_question_color_of_the_survey": "フォームの質問の色を変更します。",
|
||||
"changes_saved": "変更を保存しました。",
|
||||
"changing_survey_type_will_remove_existing_distribution_channels": "フォームの種類を変更すると、共有方法に影響します。回答者が現在のタイプのアクセスリンクをすでに持っている場合、切り替え後にアクセスを失う可能性があります。",
|
||||
"character_limit_toggle_description": "回答の長さの上限・下限を設定します。",
|
||||
"character_limit_toggle_title": "文字数制限を追加",
|
||||
"checkbox_label": "チェックボックスのラベル",
|
||||
"choose_the_actions_which_trigger_the_survey": "フォームをトリガーするアクションを選択してください。",
|
||||
"choose_the_first_question_on_your_block": "ブロックの最初の質問を選択してください",
|
||||
@@ -1256,6 +1259,7 @@
|
||||
"contact_fields": "連絡先フィールド",
|
||||
"contains": "を含む",
|
||||
"continue_to_settings": "設定に進む",
|
||||
"control_which_file_types_can_be_uploaded": "アップロードできるファイルの種類を制御します。",
|
||||
"convert_to_multiple_choice": "複数選択に変換",
|
||||
"convert_to_single_choice": "単一選択に変換",
|
||||
"country": "国",
|
||||
@@ -1268,13 +1272,11 @@
|
||||
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
|
||||
"date_format": "日付形式",
|
||||
"days_before_showing_this_survey_again": "最後に表示されたアンケートとこのアンケートを表示するまでに、この日数以上の期間を空ける必要があります。",
|
||||
"delete_anyways": "削除する",
|
||||
"delete_block": "ブロックを削除",
|
||||
"delete_choice": "選択肢を削除",
|
||||
"disable_the_visibility_of_survey_progress": "フォームの進捗状況の表示を無効にする。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "フォームの完了時間の目安を表示",
|
||||
"display_number_of_responses_for_survey": "フォームの回答数を表示",
|
||||
"display_type": "表示タイプ",
|
||||
"divide": "除算 /",
|
||||
"does_not_contain": "を含まない",
|
||||
"does_not_end_with": "で終わらない",
|
||||
@@ -1282,7 +1284,6 @@
|
||||
"does_not_include_all_of": "のすべてを含まない",
|
||||
"does_not_include_one_of": "のいずれも含まない",
|
||||
"does_not_start_with": "で始まらない",
|
||||
"dropdown": "ドロップダウン",
|
||||
"duplicate_block": "ブロックを複製",
|
||||
"duplicate_question": "質問を複製",
|
||||
"edit_link": "編集 リンク",
|
||||
@@ -1412,10 +1413,10 @@
|
||||
"key": "キー",
|
||||
"last_name": "姓",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "一度に最大25個のファイルをアップロードできるようにする。",
|
||||
"limit_the_maximum_file_size": "アップロードの最大ファイルサイズを制限します。",
|
||||
"limit_upload_file_size_to": "アップロードファイルサイズの上限",
|
||||
"limit_file_types": "ファイルタイプを制限",
|
||||
"limit_the_maximum_file_size": "最大ファイルサイズを制限",
|
||||
"limit_upload_file_size_to": "アップロードファイルサイズを以下に制限",
|
||||
"link_survey_description": "フォームページへのリンクを共有するか、ウェブページやメールに埋め込みます。",
|
||||
"list": "リスト",
|
||||
"load_segment": "セグメントを読み込み",
|
||||
"logic_error_warning": "変更するとロジックエラーが発生します",
|
||||
"logic_error_warning_text": "質問の種類を変更すると、この質問のロジック条件が削除されます",
|
||||
@@ -1459,6 +1460,7 @@
|
||||
"picture_idx": "写真 {idx}",
|
||||
"pin_can_only_contain_numbers": "PINは数字のみでなければなりません。",
|
||||
"pin_must_be_a_four_digit_number": "PINは4桁の数字でなければなりません。",
|
||||
"please_enter_a_file_extension": "ファイル拡張子を入力してください。",
|
||||
"please_enter_a_valid_url": "有効な URL を入力してください (例:https://example.com)",
|
||||
"please_set_a_survey_trigger": "フォームのトリガーを設定してください",
|
||||
"please_specify": "具体的に指定してください",
|
||||
@@ -1473,9 +1475,8 @@
|
||||
"question_deleted": "質問を削除しました。",
|
||||
"question_duplicated": "質問を複製しました。",
|
||||
"question_id_updated": "質問IDを更新しました",
|
||||
"question_used_in_logic_warning_text": "このブロックの要素はロジックルールで使用されていますが、本当に削除しますか?",
|
||||
"question_used_in_logic_warning_title": "ロジックの不整合",
|
||||
"question_used_in_quota": "この質問は“{quotaName}”クォータで使用されています",
|
||||
"question_used_in_logic": "この質問は質問 {questionIndex} のロジックで使用されています。",
|
||||
"question_used_in_quota": "この 質問 は \"{quotaName}\" の クオータ に使用されています",
|
||||
"question_used_in_recall": "この 質問 は 質問 {questionIndex} で 呼び出され て います 。",
|
||||
"question_used_in_recall_ending_card": "この 質問 は エンディング カード で 呼び出され て います。",
|
||||
"quotas": {
|
||||
@@ -1538,7 +1539,6 @@
|
||||
"search_for_images": "画像を検索",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "トリガーから数秒後に回答がない場合、フォームは閉じられます",
|
||||
"seconds_before_showing_the_survey": "秒後にフォームを表示します。",
|
||||
"select_field": "フィールドを選択",
|
||||
"select_or_type_value": "値を選択または入力",
|
||||
"select_ordering": "順序を選択",
|
||||
"select_saved_action": "保存済みのアクションを選択",
|
||||
@@ -1586,6 +1586,8 @@
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "回答がなくても1回だけ表示します。",
|
||||
"then": "その後",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "このアクションは、このフォームからすべての翻訳を削除します。",
|
||||
"this_extension_is_already_added": "この拡張機能はすでに追加されています。",
|
||||
"this_file_type_is_not_supported": "このファイルタイプはサポートされていません。",
|
||||
"three_points": "3点",
|
||||
"times": "回",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "すべてのフォームの配置を一貫させるために、",
|
||||
@@ -1606,51 +1608,8 @@
|
||||
"upper_label": "上限ラベル",
|
||||
"url_filters": "URLフィルター",
|
||||
"url_not_supported": "URLはサポートされていません",
|
||||
"validation": {
|
||||
"add_validation_rule": "検証ルールを追加",
|
||||
"answer_all_rows": "すべての行に回答してください",
|
||||
"characters": "文字数",
|
||||
"contains": "を含む",
|
||||
"delete_validation_rule": "検証ルールを削除",
|
||||
"does_not_contain": "を含まない",
|
||||
"email": "有効なメールアドレスである",
|
||||
"end_date": "終了日",
|
||||
"file_extension_is": "ファイル拡張子が次と一致",
|
||||
"file_extension_is_not": "ファイル拡張子が次と一致しない",
|
||||
"is": "である",
|
||||
"is_between": "の間である",
|
||||
"is_earlier_than": "より前である",
|
||||
"is_greater_than": "より大きい",
|
||||
"is_later_than": "より後である",
|
||||
"is_less_than": "より小さい",
|
||||
"is_not": "ではない",
|
||||
"is_not_between": "の間ではない",
|
||||
"kb": "KB",
|
||||
"max_length": "最大",
|
||||
"max_selections": "最大",
|
||||
"max_value": "最大",
|
||||
"mb": "MB",
|
||||
"min_length": "最小",
|
||||
"min_selections": "最小",
|
||||
"min_value": "最小",
|
||||
"minimum_options_ranked": "ランク付けされた最小オプション数",
|
||||
"minimum_rows_answered": "回答された最小行数",
|
||||
"options_selected": "選択されたオプション",
|
||||
"pattern": "正規表現パターンに一致する",
|
||||
"phone": "有効な電話番号である",
|
||||
"rank_all_options": "すべてのオプションをランク付け",
|
||||
"select_file_extensions": "ファイル拡張子を選択...",
|
||||
"select_option": "オプションを選択",
|
||||
"start_date": "開始日",
|
||||
"url": "有効なURLである"
|
||||
},
|
||||
"validation_logic_and": "すべてが真である",
|
||||
"validation_logic_or": "いずれかが真",
|
||||
"validation_rules": "検証ルール",
|
||||
"validation_rules_description": "次の条件を満たす回答のみを受け付ける",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数“{variableName}”は“{quotaName}”クォータで使用されています",
|
||||
"variable_name_conflicts_with_hidden_field": "変数名が既存の非表示フィールドIDと競合しています。",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています",
|
||||
"variable_name_is_already_taken_please_choose_another": "変数名はすでに使用されています。別の名前を選択してください。",
|
||||
"variable_name_must_start_with_a_letter": "変数名はアルファベットで始まらなければなりません。",
|
||||
"variable_used_in_recall": "変数 \"{variable}\" が 質問 {questionIndex} で 呼び出され て います 。",
|
||||
|
||||
+21
-62
@@ -243,6 +243,7 @@
|
||||
"imprint": "Afdruk",
|
||||
"in_progress": "In uitvoering",
|
||||
"inactive_surveys": "Inactieve enquêtes",
|
||||
"input_type": "Invoertype",
|
||||
"integration": "integratie",
|
||||
"integrations": "Integraties",
|
||||
"invalid_date": "Ongeldige datum",
|
||||
@@ -254,7 +255,6 @@
|
||||
"label": "Label",
|
||||
"language": "Taal",
|
||||
"learn_more": "Meer informatie",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Lichte overlay",
|
||||
"limits_reached": "Grenzen bereikt",
|
||||
"link": "Link",
|
||||
@@ -267,11 +267,13 @@
|
||||
"look_and_feel": "Kijk & voel",
|
||||
"manage": "Beheren",
|
||||
"marketing": "Marketing",
|
||||
"maximum": "Maximaal",
|
||||
"member": "Lid",
|
||||
"members": "Leden",
|
||||
"members_and_teams": "Leden & teams",
|
||||
"membership_not_found": "Lidmaatschap niet gevonden",
|
||||
"metadata": "Metagegevens",
|
||||
"minimum": "Minimum",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks werkt het beste op een groter scherm. Schakel over naar een ander apparaat om enquêtes te beheren of samen te stellen.",
|
||||
"mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!",
|
||||
"mobile_overlay_title": "Oeps, klein scherm gedetecteerd!",
|
||||
@@ -324,7 +326,7 @@
|
||||
"placeholder": "Tijdelijke aanduiding",
|
||||
"please_select_at_least_one_survey": "Selecteer ten minste één enquête",
|
||||
"please_select_at_least_one_trigger": "Selecteer ten minste één trigger",
|
||||
"please_upgrade_your_plan": "Upgrade je abonnement",
|
||||
"please_upgrade_your_plan": "Upgrade uw abonnement.",
|
||||
"preview": "Voorbeeld",
|
||||
"preview_survey": "Voorbeeld van enquête",
|
||||
"privacy": "Privacybeleid",
|
||||
@@ -350,7 +352,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",
|
||||
@@ -462,8 +463,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Je hebt je limiet van {projectLimit} werkruimtes bereikt.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "U heeft uw maandelijkse MIU-limiet van bereikt",
|
||||
"you_have_reached_your_monthly_response_limit_of": "U heeft uw maandelijkse responslimiet bereikt van",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Je wordt gedowngraded naar de Community-editie op {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Je wordt gedowngraded naar de Community-editie op {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Accepteren",
|
||||
@@ -990,7 +990,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.",
|
||||
@@ -1172,6 +1172,7 @@
|
||||
"adjust_survey_closed_message_description": "Wijzig het bericht dat bezoekers zien wanneer de enquête wordt gesloten.",
|
||||
"adjust_the_theme_in_the": "Pas het thema aan in de",
|
||||
"all_other_answers_will_continue_to": "Alle andere antwoorden blijven hetzelfde",
|
||||
"allow_file_type": "Bestandstype toestaan",
|
||||
"allow_multi_select": "Multi-select toestaan",
|
||||
"allow_multiple_files": "Meerdere bestanden toestaan",
|
||||
"allow_users_to_select_more_than_one_image": "Sta gebruikers toe meer dan één afbeelding te selecteren",
|
||||
@@ -1237,6 +1238,8 @@
|
||||
"change_the_question_color_of_the_survey": "Verander de vraagkleur van de enquête.",
|
||||
"changes_saved": "Wijzigingen opgeslagen.",
|
||||
"changing_survey_type_will_remove_existing_distribution_channels": "Het wijzigen van het enquêtetype heeft invloed op de manier waarop deze kan worden gedeeld. Als respondenten al toegangslinks hebben voor het huidige type, verliezen ze mogelijk de toegang na de overstap.",
|
||||
"character_limit_toggle_description": "Beperk hoe kort of lang een antwoord mag zijn.",
|
||||
"character_limit_toggle_title": "Tekenlimieten toevoegen",
|
||||
"checkbox_label": "Selectievakje-label",
|
||||
"choose_the_actions_which_trigger_the_survey": "Kies de acties die de enquête activeren.",
|
||||
"choose_the_first_question_on_your_block": "Kies de eerste vraag in je blok",
|
||||
@@ -1256,6 +1259,7 @@
|
||||
"contact_fields": "Contactvelden",
|
||||
"contains": "Bevat",
|
||||
"continue_to_settings": "Ga verder naar Instellingen",
|
||||
"control_which_file_types_can_be_uploaded": "Bepaal welke bestandstypen kunnen worden geüpload.",
|
||||
"convert_to_multiple_choice": "Converteren naar Multi-select",
|
||||
"convert_to_single_choice": "Converteren naar Enkele selectie",
|
||||
"country": "Land",
|
||||
@@ -1268,13 +1272,11 @@
|
||||
"darken_or_lighten_background_of_your_choice": "Maak de achtergrond naar keuze donkerder of lichter.",
|
||||
"date_format": "Datumformaat",
|
||||
"days_before_showing_this_survey_again": "of meer dagen moeten verstrijken tussen de laatst getoonde enquête en het tonen van deze enquête.",
|
||||
"delete_anyways": "Toch verwijderen",
|
||||
"delete_block": "Blok verwijderen",
|
||||
"delete_choice": "Keuze verwijderen",
|
||||
"disable_the_visibility_of_survey_progress": "Schakel de zichtbaarheid van de voortgang van het onderzoek uit.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Geef een schatting weer van de voltooiingstijd voor het onderzoek",
|
||||
"display_number_of_responses_for_survey": "Weergave aantal reacties voor enquête",
|
||||
"display_type": "Weergavetype",
|
||||
"divide": "Verdeling /",
|
||||
"does_not_contain": "Bevat niet",
|
||||
"does_not_end_with": "Eindigt niet met",
|
||||
@@ -1282,7 +1284,6 @@
|
||||
"does_not_include_all_of": "Omvat niet alles",
|
||||
"does_not_include_one_of": "Bevat niet een van",
|
||||
"does_not_start_with": "Begint niet met",
|
||||
"dropdown": "Dropdown",
|
||||
"duplicate_block": "Blok dupliceren",
|
||||
"duplicate_question": "Vraag dupliceren",
|
||||
"edit_link": "Link bewerken",
|
||||
@@ -1375,7 +1376,7 @@
|
||||
"hide_question_settings": "Vraaginstellingen verbergen",
|
||||
"hostname": "Hostnaam",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Hoe funky wil je je kaarten hebben in {surveyTypeDerived} Enquêtes",
|
||||
"if_you_need_more_please": "Als je meer nodig hebt,",
|
||||
"if_you_need_more_please": "Als u meer nodig heeft, alstublieft",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een reactie is ingediend.",
|
||||
"ignore_global_waiting_time": "Afkoelperiode negeren",
|
||||
"ignore_global_waiting_time_description": "Deze enquête kan worden getoond wanneer aan de voorwaarden wordt voldaan, zelfs als er onlangs een andere enquête is getoond.",
|
||||
@@ -1412,10 +1413,10 @@
|
||||
"key": "Sleutel",
|
||||
"last_name": "Achternaam",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Laat mensen maximaal 25 bestanden tegelijk uploaden.",
|
||||
"limit_the_maximum_file_size": "Beperk de maximale bestandsgrootte voor uploads.",
|
||||
"limit_upload_file_size_to": "Beperk uploadbestandsgrootte tot",
|
||||
"limit_file_types": "Beperk bestandstypen",
|
||||
"limit_the_maximum_file_size": "Beperk de maximale bestandsgrootte",
|
||||
"limit_upload_file_size_to": "Beperk de uploadbestandsgrootte tot",
|
||||
"link_survey_description": "Deel een link naar een enquêtepagina of sluit deze in op een webpagina of e-mail.",
|
||||
"list": "Lijst",
|
||||
"load_segment": "Laadsegment",
|
||||
"logic_error_warning": "Wijzigen zal logische fouten veroorzaken",
|
||||
"logic_error_warning_text": "Als u het vraagtype wijzigt, worden de logische voorwaarden van deze vraag verwijderd",
|
||||
@@ -1427,7 +1428,7 @@
|
||||
"matrix_all_fields": "Alle velden",
|
||||
"matrix_rows": "Rijen",
|
||||
"max_file_size": "Maximale bestandsgrootte",
|
||||
"max_file_size_limit_is": "Maximale bestandsgroottelimiet is",
|
||||
"max_file_size_limit_is": "De maximale bestandsgrootte is",
|
||||
"move_question_to_block": "Vraag naar blok verplaatsen",
|
||||
"multiply": "Vermenigvuldig *",
|
||||
"needed_for_self_hosted_cal_com_instance": "Nodig voor een zelf-gehoste Cal.com-instantie",
|
||||
@@ -1459,6 +1460,7 @@
|
||||
"picture_idx": "Afbeelding {idx}",
|
||||
"pin_can_only_contain_numbers": "De pincode kan alleen cijfers bevatten.",
|
||||
"pin_must_be_a_four_digit_number": "De pincode moet uit vier cijfers bestaan.",
|
||||
"please_enter_a_file_extension": "Voer een bestandsextensie in.",
|
||||
"please_enter_a_valid_url": "Voer een geldige URL in (bijvoorbeeld https://example.com)",
|
||||
"please_set_a_survey_trigger": "Stel een enquêtetrigger in",
|
||||
"please_specify": "Gelieve te specificeren",
|
||||
@@ -1473,9 +1475,8 @@
|
||||
"question_deleted": "Vraag verwijderd.",
|
||||
"question_duplicated": "Vraag dubbel gesteld.",
|
||||
"question_id_updated": "Vraag-ID bijgewerkt",
|
||||
"question_used_in_logic_warning_text": "Elementen uit dit blok worden gebruikt in een logische regel, weet je zeker dat je het wilt verwijderen?",
|
||||
"question_used_in_logic_warning_title": "Logica-inconsistentie",
|
||||
"question_used_in_quota": "Deze vraag wordt gebruikt in het quotum “{quotaName}”",
|
||||
"question_used_in_logic": "Deze vraag wordt gebruikt in de logica van vraag {questionIndex}.",
|
||||
"question_used_in_quota": "Deze vraag wordt gebruikt in het quotum '{quotaName}'",
|
||||
"question_used_in_recall": "Deze vraag wordt teruggehaald in vraag {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Deze vraag wordt teruggeroepen in de Eindkaart",
|
||||
"quotas": {
|
||||
@@ -1538,7 +1539,6 @@
|
||||
"search_for_images": "Zoek naar afbeeldingen",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "seconden na trigger wordt de enquête gesloten als er geen reactie is",
|
||||
"seconds_before_showing_the_survey": "seconden voordat de enquête wordt weergegeven.",
|
||||
"select_field": "Selecteer veld",
|
||||
"select_or_type_value": "Selecteer of typ een waarde",
|
||||
"select_ordering": "Selecteer bestellen",
|
||||
"select_saved_action": "Selecteer opgeslagen actie",
|
||||
@@ -1586,6 +1586,8 @@
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Toon één keer, zelfs als ze niet reageren.",
|
||||
"then": "Dan",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Met deze actie worden alle vertalingen uit deze enquête verwijderd.",
|
||||
"this_extension_is_already_added": "Deze extensie is al toegevoegd.",
|
||||
"this_file_type_is_not_supported": "Dit bestandstype wordt niet ondersteund.",
|
||||
"three_points": "3 punten",
|
||||
"times": "keer",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Om de plaatsing over alle enquêtes consistent te houden, kunt u dat doen",
|
||||
@@ -1606,51 +1608,8 @@
|
||||
"upper_label": "Bovenste etiket",
|
||||
"url_filters": "URL-filters",
|
||||
"url_not_supported": "URL niet ondersteund",
|
||||
"validation": {
|
||||
"add_validation_rule": "Validatieregel toevoegen",
|
||||
"answer_all_rows": "Beantwoord alle rijen",
|
||||
"characters": "Tekens",
|
||||
"contains": "Bevat",
|
||||
"delete_validation_rule": "Validatieregel verwijderen",
|
||||
"does_not_contain": "Bevat niet",
|
||||
"email": "Is geldig e-mailadres",
|
||||
"end_date": "Einddatum",
|
||||
"file_extension_is": "Bestandsextensie is",
|
||||
"file_extension_is_not": "Bestandsextensie is niet",
|
||||
"is": "Is",
|
||||
"is_between": "Is tussen",
|
||||
"is_earlier_than": "Is eerder dan",
|
||||
"is_greater_than": "Is groter dan",
|
||||
"is_later_than": "Is later dan",
|
||||
"is_less_than": "Is minder dan",
|
||||
"is_not": "Is niet",
|
||||
"is_not_between": "Is niet tussen",
|
||||
"kb": "KB",
|
||||
"max_length": "Maximaal",
|
||||
"max_selections": "Maximaal",
|
||||
"max_value": "Maximaal",
|
||||
"mb": "MB",
|
||||
"min_length": "Minimaal",
|
||||
"min_selections": "Minimaal",
|
||||
"min_value": "Minimaal",
|
||||
"minimum_options_ranked": "Minimaal aantal gerangschikte opties",
|
||||
"minimum_rows_answered": "Minimaal aantal beantwoorde rijen",
|
||||
"options_selected": "Opties geselecteerd",
|
||||
"pattern": "Komt overeen met regex-patroon",
|
||||
"phone": "Is geldig telefoonnummer",
|
||||
"rank_all_options": "Rangschik alle opties",
|
||||
"select_file_extensions": "Selecteer bestandsextensies...",
|
||||
"select_option": "Optie selecteren",
|
||||
"start_date": "Startdatum",
|
||||
"url": "Is geldige URL"
|
||||
},
|
||||
"validation_logic_and": "Alle zijn waar",
|
||||
"validation_logic_or": "een is waar",
|
||||
"validation_rules": "Validatieregels",
|
||||
"validation_rules_description": "Accepteer alleen antwoorden die voldoen aan de volgende criteria",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabele “{variableName}” wordt gebruikt in het quotum “{quotaName}”",
|
||||
"variable_name_conflicts_with_hidden_field": "Variabelenaam conflicteert met een bestaande verborgen veld-ID.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabele \"{variableName}\" wordt gebruikt in het \"{quotaName}\" quotum",
|
||||
"variable_name_is_already_taken_please_choose_another": "Variabelenaam is al in gebruik, kies een andere.",
|
||||
"variable_name_must_start_with_a_letter": "Variabelenaam moet beginnen met een letter.",
|
||||
"variable_used_in_recall": "Variabele \"{variable}\" wordt opgeroepen in vraag {questionIndex}.",
|
||||
|
||||
+20
-61
@@ -243,6 +243,7 @@
|
||||
"imprint": "impressão",
|
||||
"in_progress": "Em andamento",
|
||||
"inactive_surveys": "Pesquisas inativas",
|
||||
"input_type": "Tipo de entrada",
|
||||
"integration": "integração",
|
||||
"integrations": "Integrações",
|
||||
"invalid_date": "Data inválida",
|
||||
@@ -254,7 +255,6 @@
|
||||
"label": "Etiqueta",
|
||||
"language": "Língua",
|
||||
"learn_more": "Saiba mais",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "sobreposição leve",
|
||||
"limits_reached": "Limites Atingidos",
|
||||
"link": "link",
|
||||
@@ -267,11 +267,13 @@
|
||||
"look_and_feel": "Aparência e Experiência",
|
||||
"manage": "gerenciar",
|
||||
"marketing": "marketing",
|
||||
"maximum": "Máximo",
|
||||
"member": "Membros",
|
||||
"members": "Membros",
|
||||
"members_and_teams": "Membros e equipes",
|
||||
"membership_not_found": "Assinatura não encontrada",
|
||||
"metadata": "metadados",
|
||||
"minimum": "Mínimo",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
|
||||
"mobile_overlay_title": "Eita, tela pequena detectada!",
|
||||
@@ -324,7 +326,7 @@
|
||||
"placeholder": "Espaço reservado",
|
||||
"please_select_at_least_one_survey": "Por favor, selecione pelo menos uma pesquisa",
|
||||
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
|
||||
"please_upgrade_your_plan": "Por favor, atualize seu plano",
|
||||
"please_upgrade_your_plan": "Por favor, atualize seu plano.",
|
||||
"preview": "Prévia",
|
||||
"preview_survey": "Prévia da Pesquisa",
|
||||
"privacy": "Política de Privacidade",
|
||||
@@ -350,7 +352,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ê",
|
||||
@@ -462,8 +463,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Você atingiu seu limite de {projectLimit} espaços de trabalho.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Você atingiu o seu limite mensal de MIU de",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Você atingiu o limite mensal de respostas de",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Você será rebaixado para a Edição Comunitária em {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Você será rebaixado para a Edição Comunitária em {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Aceitar",
|
||||
@@ -990,7 +990,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.",
|
||||
@@ -1172,6 +1172,7 @@
|
||||
"adjust_survey_closed_message_description": "Mude a mensagem que os visitantes veem quando a pesquisa está fechada.",
|
||||
"adjust_the_theme_in_the": "Ajuste o tema no",
|
||||
"all_other_answers_will_continue_to": "Todas as outras respostas continuarão a",
|
||||
"allow_file_type": "Permitir tipo de arquivo",
|
||||
"allow_multi_select": "Permitir seleção múltipla",
|
||||
"allow_multiple_files": "Permitir vários arquivos",
|
||||
"allow_users_to_select_more_than_one_image": "Permitir que os usuários selecionem mais de uma imagem",
|
||||
@@ -1237,6 +1238,8 @@
|
||||
"change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.",
|
||||
"changes_saved": "Mudanças salvas.",
|
||||
"changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de pesquisa afetará a forma como ela pode ser compartilhada. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.",
|
||||
"character_limit_toggle_description": "Limite o quão curta ou longa uma resposta pode ser.",
|
||||
"character_limit_toggle_title": "Adicionar limites de caracteres",
|
||||
"checkbox_label": "Rótulo da Caixa de Seleção",
|
||||
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que disparam a pesquisa.",
|
||||
"choose_the_first_question_on_your_block": "Escolha a primeira pergunta do seu bloco",
|
||||
@@ -1256,6 +1259,7 @@
|
||||
"contact_fields": "Campos de Contato",
|
||||
"contains": "contém",
|
||||
"continue_to_settings": "Continuar para Configurações",
|
||||
"control_which_file_types_can_be_uploaded": "Controlar quais tipos de arquivos podem ser enviados.",
|
||||
"convert_to_multiple_choice": "Converter para Múltipla Escolha",
|
||||
"convert_to_single_choice": "Converter para Escolha Única",
|
||||
"country": "país",
|
||||
@@ -1268,13 +1272,11 @@
|
||||
"darken_or_lighten_background_of_your_choice": "Escureça ou clareie o fundo da sua escolha.",
|
||||
"date_format": "Formato de data",
|
||||
"days_before_showing_this_survey_again": "ou mais dias devem passar entre a última pesquisa exibida e a exibição desta pesquisa.",
|
||||
"delete_anyways": "Excluir mesmo assim",
|
||||
"delete_block": "Excluir bloco",
|
||||
"delete_choice": "Deletar opção",
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa de tempo de conclusão da pesquisa",
|
||||
"display_number_of_responses_for_survey": "Mostrar número de respostas da pesquisa",
|
||||
"display_type": "Tipo de exibição",
|
||||
"divide": "Divida /",
|
||||
"does_not_contain": "não contém",
|
||||
"does_not_end_with": "Não termina com",
|
||||
@@ -1282,7 +1284,6 @@
|
||||
"does_not_include_all_of": "Não inclui todos de",
|
||||
"does_not_include_one_of": "Não inclui um de",
|
||||
"does_not_start_with": "Não começa com",
|
||||
"dropdown": "Menu suspenso",
|
||||
"duplicate_block": "Duplicar bloco",
|
||||
"duplicate_question": "Duplicar pergunta",
|
||||
"edit_link": "Editar link",
|
||||
@@ -1412,10 +1413,10 @@
|
||||
"key": "chave",
|
||||
"last_name": "Sobrenome",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Deixe as pessoas fazerem upload de até 25 arquivos ao mesmo tempo.",
|
||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo de arquivo para uploads.",
|
||||
"limit_upload_file_size_to": "Limitar tamanho de arquivo de upload para",
|
||||
"limit_file_types": "Limitar tipos de arquivos",
|
||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo do arquivo",
|
||||
"limit_upload_file_size_to": "Limitar tamanho do arquivo de upload para",
|
||||
"link_survey_description": "Compartilhe um link para a página da pesquisa ou incorpore-a em uma página da web ou e-mail.",
|
||||
"list": "Lista",
|
||||
"load_segment": "segmento de carga",
|
||||
"logic_error_warning": "Mudar vai causar erros de lógica",
|
||||
"logic_error_warning_text": "Mudar o tipo de pergunta vai remover as condições lógicas dessa pergunta",
|
||||
@@ -1427,7 +1428,7 @@
|
||||
"matrix_all_fields": "Todos os campos",
|
||||
"matrix_rows": "Linhas",
|
||||
"max_file_size": "Tamanho máximo do arquivo",
|
||||
"max_file_size_limit_is": "O limite de tamanho máximo do arquivo é",
|
||||
"max_file_size_limit_is": "Tamanho máximo do arquivo é",
|
||||
"move_question_to_block": "Mover pergunta para o bloco",
|
||||
"multiply": "Multiplicar *",
|
||||
"needed_for_self_hosted_cal_com_instance": "Necessário para uma instância auto-hospedada do Cal.com",
|
||||
@@ -1459,6 +1460,7 @@
|
||||
"picture_idx": "Imagem {idx}",
|
||||
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
|
||||
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
|
||||
"please_enter_a_file_extension": "Por favor, insira uma extensão de arquivo.",
|
||||
"please_enter_a_valid_url": "Por favor, insira uma URL válida (ex.: https://example.com)",
|
||||
"please_set_a_survey_trigger": "Por favor, configure um gatilho para a pesquisa",
|
||||
"please_specify": "Por favor, especifique",
|
||||
@@ -1473,9 +1475,8 @@
|
||||
"question_deleted": "Pergunta deletada.",
|
||||
"question_duplicated": "Pergunta duplicada.",
|
||||
"question_id_updated": "ID da pergunta atualizado",
|
||||
"question_used_in_logic_warning_text": "Elementos deste bloco são usados em uma regra de lógica, tem certeza de que deseja excluí-lo?",
|
||||
"question_used_in_logic_warning_title": "Inconsistência de lógica",
|
||||
"question_used_in_quota": "Esta pergunta está sendo usada na cota \"{quotaName}\"",
|
||||
"question_used_in_logic": "Essa pergunta é usada na lógica da pergunta {questionIndex}.",
|
||||
"question_used_in_quota": "Esta questão está sendo usada na cota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Esta pergunta está sendo recordada na pergunta {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Esta pergunta está sendo recordada no card de Encerramento",
|
||||
"quotas": {
|
||||
@@ -1538,7 +1539,6 @@
|
||||
"search_for_images": "Buscar imagens",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos após acionar, a pesquisa será encerrada se não houver resposta",
|
||||
"seconds_before_showing_the_survey": "segundos antes de mostrar a pesquisa.",
|
||||
"select_field": "Selecionar campo",
|
||||
"select_or_type_value": "Selecionar ou digitar valor",
|
||||
"select_ordering": "Selecionar pedido",
|
||||
"select_saved_action": "Selecionar ação salva",
|
||||
@@ -1586,6 +1586,8 @@
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar uma única vez, mesmo que não respondam.",
|
||||
"then": "Então",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Essa ação vai remover todas as traduções dessa pesquisa.",
|
||||
"this_extension_is_already_added": "Essa extensão já foi adicionada.",
|
||||
"this_file_type_is_not_supported": "Esse tipo de arquivo não é suportado.",
|
||||
"three_points": "3 pontos",
|
||||
"times": "times",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todas as pesquisas, você pode",
|
||||
@@ -1606,51 +1608,8 @@
|
||||
"upper_label": "Etiqueta Superior",
|
||||
"url_filters": "Filtros de URL",
|
||||
"url_not_supported": "URL não suportada",
|
||||
"validation": {
|
||||
"add_validation_rule": "Adicionar regra de validação",
|
||||
"answer_all_rows": "Responda todas as linhas",
|
||||
"characters": "Caracteres",
|
||||
"contains": "Contém",
|
||||
"delete_validation_rule": "Excluir regra de validação",
|
||||
"does_not_contain": "Não contém",
|
||||
"email": "É um e-mail válido",
|
||||
"end_date": "Data final",
|
||||
"file_extension_is": "A extensão do arquivo é",
|
||||
"file_extension_is_not": "A extensão do arquivo não é",
|
||||
"is": "É",
|
||||
"is_between": "Está entre",
|
||||
"is_earlier_than": "É anterior a",
|
||||
"is_greater_than": "É maior que",
|
||||
"is_later_than": "É posterior a",
|
||||
"is_less_than": "É menor que",
|
||||
"is_not": "Não é",
|
||||
"is_not_between": "Não está entre",
|
||||
"kb": "KB",
|
||||
"max_length": "No máximo",
|
||||
"max_selections": "No máximo",
|
||||
"max_value": "No máximo",
|
||||
"mb": "MB",
|
||||
"min_length": "No mínimo",
|
||||
"min_selections": "No mínimo",
|
||||
"min_value": "No mínimo",
|
||||
"minimum_options_ranked": "Mínimo de opções classificadas",
|
||||
"minimum_rows_answered": "Mínimo de linhas respondidas",
|
||||
"options_selected": "Opções selecionadas",
|
||||
"pattern": "Corresponde ao padrão regex",
|
||||
"phone": "É um telefone válido",
|
||||
"rank_all_options": "Classificar todas as opções",
|
||||
"select_file_extensions": "Selecionar extensões de arquivo...",
|
||||
"select_option": "Selecionar opção",
|
||||
"start_date": "Data inicial",
|
||||
"url": "É uma URL válida"
|
||||
},
|
||||
"validation_logic_and": "Todas são verdadeiras",
|
||||
"validation_logic_or": "qualquer uma é verdadeira",
|
||||
"validation_rules": "Regras de validação",
|
||||
"validation_rules_description": "Aceitar apenas respostas que atendam aos seguintes critérios",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} está sendo usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "A variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
|
||||
"variable_name_conflicts_with_hidden_field": "O nome da variável está em conflito com um ID de campo oculto existente.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
|
||||
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
|
||||
"variable_used_in_recall": "Variável \"{variable}\" está sendo recordada na pergunta {questionIndex}.",
|
||||
|
||||
+20
-61
@@ -243,6 +243,7 @@
|
||||
"imprint": "Impressão",
|
||||
"in_progress": "Em Progresso",
|
||||
"inactive_surveys": "Inquéritos inativos",
|
||||
"input_type": "Tipo de entrada",
|
||||
"integration": "integração",
|
||||
"integrations": "Integrações",
|
||||
"invalid_date": "Data inválida",
|
||||
@@ -254,7 +255,6 @@
|
||||
"label": "Etiqueta",
|
||||
"language": "Idioma",
|
||||
"learn_more": "Saiba mais",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Sobreposição leve",
|
||||
"limits_reached": "Limites Atingidos",
|
||||
"link": "Link",
|
||||
@@ -267,11 +267,13 @@
|
||||
"look_and_feel": "Aparência e Sensação",
|
||||
"manage": "Gerir",
|
||||
"marketing": "Marketing",
|
||||
"maximum": "Máximo",
|
||||
"member": "Membro",
|
||||
"members": "Membros",
|
||||
"members_and_teams": "Membros e equipas",
|
||||
"membership_not_found": "Associação não encontrada",
|
||||
"metadata": "Metadados",
|
||||
"minimum": "Mínimo",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
|
||||
"mobile_overlay_surveys_look_good": "Não se preocupe – os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
|
||||
"mobile_overlay_title": "Oops, ecrã pequeno detectado!",
|
||||
@@ -324,7 +326,7 @@
|
||||
"placeholder": "Espaço reservado",
|
||||
"please_select_at_least_one_survey": "Por favor, selecione pelo menos um inquérito",
|
||||
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
|
||||
"please_upgrade_your_plan": "Por favor, atualize o seu plano",
|
||||
"please_upgrade_your_plan": "Por favor, atualize o seu plano.",
|
||||
"preview": "Pré-visualização",
|
||||
"preview_survey": "Pré-visualização do inquérito",
|
||||
"privacy": "Política de Privacidade",
|
||||
@@ -350,7 +352,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",
|
||||
@@ -462,8 +463,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Atingiu o seu limite de {projectLimit} áreas de trabalho.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Atingiu o seu limite mensal de MIU de",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Atingiu o seu limite mensal de respostas de",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Será rebaixado para a Edição Comunitária em {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Será rebaixado para a Edição Comunitária em {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Aceitar",
|
||||
@@ -990,7 +990,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.",
|
||||
@@ -1172,6 +1172,7 @@
|
||||
"adjust_survey_closed_message_description": "Alterar a mensagem que os visitantes veem quando o inquérito está fechado.",
|
||||
"adjust_the_theme_in_the": "Ajustar o tema no",
|
||||
"all_other_answers_will_continue_to": "Todas as outras respostas continuarão a",
|
||||
"allow_file_type": "Permitir tipo de ficheiro",
|
||||
"allow_multi_select": "Permitir seleção múltipla",
|
||||
"allow_multiple_files": "Permitir vários ficheiros",
|
||||
"allow_users_to_select_more_than_one_image": "Permitir aos utilizadores selecionar mais do que uma imagem",
|
||||
@@ -1237,6 +1238,8 @@
|
||||
"change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito",
|
||||
"changes_saved": "Alterações guardadas.",
|
||||
"changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de inquérito afetará como ele pode ser partilhado. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.",
|
||||
"character_limit_toggle_description": "Limitar o quão curta ou longa uma resposta pode ser.",
|
||||
"character_limit_toggle_title": "Adicionar limites de caracteres",
|
||||
"checkbox_label": "Rótulo da Caixa de Seleção",
|
||||
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que desencadeiam o inquérito.",
|
||||
"choose_the_first_question_on_your_block": "Escolha a primeira pergunta no seu bloco",
|
||||
@@ -1256,6 +1259,7 @@
|
||||
"contact_fields": "Campos de Contacto",
|
||||
"contains": "Contém",
|
||||
"continue_to_settings": "Continuar para Definições",
|
||||
"control_which_file_types_can_be_uploaded": "Controlar quais tipos de ficheiros podem ser carregados.",
|
||||
"convert_to_multiple_choice": "Converter para Seleção Múltipla",
|
||||
"convert_to_single_choice": "Converter para Seleção Única",
|
||||
"country": "País",
|
||||
@@ -1268,13 +1272,11 @@
|
||||
"darken_or_lighten_background_of_your_choice": "Escurecer ou clarear o fundo da sua escolha.",
|
||||
"date_format": "Formato da data",
|
||||
"days_before_showing_this_survey_again": "ou mais dias a decorrer entre o último inquérito apresentado e a apresentação deste inquérito.",
|
||||
"delete_anyways": "Eliminar mesmo assim",
|
||||
"delete_block": "Eliminar bloco",
|
||||
"delete_choice": "Eliminar escolha",
|
||||
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa do tempo de conclusão do inquérito",
|
||||
"display_number_of_responses_for_survey": "Mostrar número de respostas do inquérito",
|
||||
"display_type": "Tipo de exibição",
|
||||
"divide": "Dividir /",
|
||||
"does_not_contain": "Não contém",
|
||||
"does_not_end_with": "Não termina com",
|
||||
@@ -1282,7 +1284,6 @@
|
||||
"does_not_include_all_of": "Não inclui todos de",
|
||||
"does_not_include_one_of": "Não inclui um de",
|
||||
"does_not_start_with": "Não começa com",
|
||||
"dropdown": "Menu suspenso",
|
||||
"duplicate_block": "Duplicar bloco",
|
||||
"duplicate_question": "Duplicar pergunta",
|
||||
"edit_link": "Editar link",
|
||||
@@ -1412,10 +1413,10 @@
|
||||
"key": "Chave",
|
||||
"last_name": "Apelido",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Permitir que as pessoas carreguem até 25 ficheiros ao mesmo tempo.",
|
||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo de ficheiro para carregamentos.",
|
||||
"limit_upload_file_size_to": "Limitar o tamanho de ficheiro de carregamento para",
|
||||
"limit_file_types": "Limitar tipos de ficheiros",
|
||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo do ficheiro",
|
||||
"limit_upload_file_size_to": "Limitar tamanho do ficheiro carregado a",
|
||||
"link_survey_description": "Partilhe um link para uma página de inquérito ou incorpore-o numa página web ou email.",
|
||||
"list": "Lista",
|
||||
"load_segment": "Carregar segmento",
|
||||
"logic_error_warning": "A alteração causará erros de lógica",
|
||||
"logic_error_warning_text": "Alterar o tipo de pergunta irá remover as condições lógicas desta pergunta",
|
||||
@@ -1426,8 +1427,8 @@
|
||||
"manage_languages": "Gerir Idiomas",
|
||||
"matrix_all_fields": "Todos os campos",
|
||||
"matrix_rows": "Linhas",
|
||||
"max_file_size": "Tamanho máximo de ficheiro",
|
||||
"max_file_size_limit_is": "O limite de tamanho máximo de ficheiro é",
|
||||
"max_file_size": "Tamanho máximo do ficheiro",
|
||||
"max_file_size_limit_is": "O limite do tamanho máximo do ficheiro é",
|
||||
"move_question_to_block": "Mover pergunta para o bloco",
|
||||
"multiply": "Multiplicar *",
|
||||
"needed_for_self_hosted_cal_com_instance": "Necessário para uma instância auto-hospedada do Cal.com",
|
||||
@@ -1459,6 +1460,7 @@
|
||||
"picture_idx": "Imagem {idx}",
|
||||
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
|
||||
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
|
||||
"please_enter_a_file_extension": "Por favor, insira uma extensão de ficheiro.",
|
||||
"please_enter_a_valid_url": "Por favor, insira um URL válido (por exemplo, https://example.com)",
|
||||
"please_set_a_survey_trigger": "Por favor, defina um desencadeador de inquérito",
|
||||
"please_specify": "Por favor, especifique",
|
||||
@@ -1473,8 +1475,7 @@
|
||||
"question_deleted": "Pergunta eliminada.",
|
||||
"question_duplicated": "Pergunta duplicada.",
|
||||
"question_id_updated": "ID da pergunta atualizado",
|
||||
"question_used_in_logic_warning_text": "Os elementos deste bloco são utilizados numa regra de lógica, tem a certeza de que pretende eliminá-lo?",
|
||||
"question_used_in_logic_warning_title": "Inconsistência de lógica",
|
||||
"question_used_in_logic": "Esta pergunta é usada na lógica da pergunta {questionIndex}.",
|
||||
"question_used_in_quota": "Esta pergunta está a ser usada na quota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Esta pergunta está a ser recordada na pergunta {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Esta pergunta está a ser recordada no Cartão de Conclusão",
|
||||
@@ -1538,7 +1539,6 @@
|
||||
"search_for_images": "Procurar imagens",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos após o acionamento o inquérito será fechado se não houver resposta",
|
||||
"seconds_before_showing_the_survey": "segundos antes de mostrar o inquérito.",
|
||||
"select_field": "Selecionar campo",
|
||||
"select_or_type_value": "Selecionar ou digitar valor",
|
||||
"select_ordering": "Selecionar ordem",
|
||||
"select_saved_action": "Selecionar ação guardada",
|
||||
@@ -1586,6 +1586,8 @@
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar uma única vez, mesmo que não respondam.",
|
||||
"then": "Então",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Esta ação irá remover todas as traduções deste inquérito.",
|
||||
"this_extension_is_already_added": "Esta extensão já está adicionada.",
|
||||
"this_file_type_is_not_supported": "Este tipo de ficheiro não é suportado.",
|
||||
"three_points": "3 pontos",
|
||||
"times": "tempos",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todos os questionários, pode",
|
||||
@@ -1606,51 +1608,8 @@
|
||||
"upper_label": "Etiqueta Superior",
|
||||
"url_filters": "Filtros de URL",
|
||||
"url_not_supported": "URL não suportado",
|
||||
"validation": {
|
||||
"add_validation_rule": "Adicionar regra de validação",
|
||||
"answer_all_rows": "Responda a todas as linhas",
|
||||
"characters": "Caracteres",
|
||||
"contains": "Contém",
|
||||
"delete_validation_rule": "Eliminar regra de validação",
|
||||
"does_not_contain": "Não contém",
|
||||
"email": "É um email válido",
|
||||
"end_date": "Data de fim",
|
||||
"file_extension_is": "A extensão do ficheiro é",
|
||||
"file_extension_is_not": "A extensão do ficheiro não é",
|
||||
"is": "É",
|
||||
"is_between": "Está entre",
|
||||
"is_earlier_than": "É anterior a",
|
||||
"is_greater_than": "É maior que",
|
||||
"is_later_than": "É posterior a",
|
||||
"is_less_than": "É menor que",
|
||||
"is_not": "Não é",
|
||||
"is_not_between": "Não está entre",
|
||||
"kb": "KB",
|
||||
"max_length": "No máximo",
|
||||
"max_selections": "No máximo",
|
||||
"max_value": "No máximo",
|
||||
"mb": "MB",
|
||||
"min_length": "Pelo menos",
|
||||
"min_selections": "Pelo menos",
|
||||
"min_value": "Pelo menos",
|
||||
"minimum_options_ranked": "Opções mínimas classificadas",
|
||||
"minimum_rows_answered": "Linhas mínimas respondidas",
|
||||
"options_selected": "Opções selecionadas",
|
||||
"pattern": "Coincide com o padrão regex",
|
||||
"phone": "É um telefone válido",
|
||||
"rank_all_options": "Classificar todas as opções",
|
||||
"select_file_extensions": "Selecionar extensões de ficheiro...",
|
||||
"select_option": "Selecionar opção",
|
||||
"start_date": "Data de início",
|
||||
"url": "É um URL válido"
|
||||
},
|
||||
"validation_logic_and": "Todas são verdadeiras",
|
||||
"validation_logic_or": "qualquer uma é verdadeira",
|
||||
"validation_rules": "Regras de validação",
|
||||
"validation_rules_description": "Aceitar apenas respostas que cumpram os seguintes critérios",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "A variável \"{variableName}\" está a ser usada na quota \"{quotaName}\"",
|
||||
"variable_name_conflicts_with_hidden_field": "O nome da variável está em conflito com um ID de campo oculto existente.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
|
||||
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
|
||||
"variable_used_in_recall": "Variável \"{variable}\" está a ser recordada na pergunta {questionIndex}.",
|
||||
|
||||
+21
-62
@@ -243,6 +243,7 @@
|
||||
"imprint": "Amprentă",
|
||||
"in_progress": "În progres",
|
||||
"inactive_surveys": "Sondaje inactive",
|
||||
"input_type": "Tipul de intrare",
|
||||
"integration": "integrare",
|
||||
"integrations": "Integrări",
|
||||
"invalid_date": "Dată invalidă",
|
||||
@@ -254,7 +255,6 @@
|
||||
"label": "Etichetă",
|
||||
"language": "Limba",
|
||||
"learn_more": "Află mai multe",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Suprapunere ușoară",
|
||||
"limits_reached": "Limite atinse",
|
||||
"link": "Legătura",
|
||||
@@ -267,11 +267,13 @@
|
||||
"look_and_feel": "Aspect și Comportament",
|
||||
"manage": "Gestionați",
|
||||
"marketing": "Marketing",
|
||||
"maximum": "Maximum",
|
||||
"member": "Membru",
|
||||
"members": "Membri",
|
||||
"members_and_teams": "Membri și echipe",
|
||||
"membership_not_found": "Apartenența nu a fost găsită",
|
||||
"metadata": "Metadate",
|
||||
"minimum": "Minim",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
|
||||
"mobile_overlay_surveys_look_good": "Nu vă faceți griji – chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
|
||||
"mobile_overlay_title": "Ups, ecran mic detectat!",
|
||||
@@ -324,7 +326,7 @@
|
||||
"placeholder": "Marcaj substituent",
|
||||
"please_select_at_least_one_survey": "Vă rugăm să selectați cel puțin un sondaj",
|
||||
"please_select_at_least_one_trigger": "Vă rugăm să selectați cel puțin un declanșator",
|
||||
"please_upgrade_your_plan": "Vă rugăm să faceți upgrade la planul dumneavoastră",
|
||||
"please_upgrade_your_plan": "Vă rugăm să vă actualizați planul.",
|
||||
"preview": "Previzualizare",
|
||||
"preview_survey": "Previzualizare Chestionar",
|
||||
"privacy": "Politica de Confidențialitate",
|
||||
@@ -350,7 +352,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",
|
||||
@@ -462,8 +463,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Ați atins limita de {projectLimit} spații de lucru.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Ați atins limita lunară MIU de",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Ați atins limita lunară de răspunsuri de",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Vei fi retrogradat la ediția Community pe {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Vei fi retrogradat la ediția Community pe {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Acceptă",
|
||||
@@ -990,7 +990,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.",
|
||||
@@ -1172,6 +1172,7 @@
|
||||
"adjust_survey_closed_message_description": "Schimbați mesajul pe care îl văd vizitatorii atunci când sondajul este închis.",
|
||||
"adjust_the_theme_in_the": "Ajustați tema în",
|
||||
"all_other_answers_will_continue_to": "Toate celelalte răspunsuri vor continua să",
|
||||
"allow_file_type": "Permite tipul de fișier",
|
||||
"allow_multi_select": "Permite selectare multiplă",
|
||||
"allow_multiple_files": "Permite fișiere multiple",
|
||||
"allow_users_to_select_more_than_one_image": "Permite utilizatorilor să selecteze mai mult de o imagine",
|
||||
@@ -1237,6 +1238,8 @@
|
||||
"change_the_question_color_of_the_survey": "Schimbați culoarea întrebării chestionarului.",
|
||||
"changes_saved": "Modificările au fost salvate",
|
||||
"changing_survey_type_will_remove_existing_distribution_channels": "Schimbarea tipului chestionarului va afecta modul în care acesta poate fi distribuit. Dacă respondenții au deja linkuri de acces pentru tipul curent, aceștia ar putea pierde accesul după schimbare.",
|
||||
"character_limit_toggle_description": "Limitați cât de scurt sau lung poate fi un răspuns.",
|
||||
"character_limit_toggle_title": "Adăugați limite de caractere",
|
||||
"checkbox_label": "Etichetă casetă de selectare",
|
||||
"choose_the_actions_which_trigger_the_survey": "Alegeți acțiunile care declanșează sondajul.",
|
||||
"choose_the_first_question_on_your_block": "Alege prima întrebare din blocul tău",
|
||||
@@ -1256,6 +1259,7 @@
|
||||
"contact_fields": "Câmpuri de contact",
|
||||
"contains": "Conține",
|
||||
"continue_to_settings": "Continuă către Setări",
|
||||
"control_which_file_types_can_be_uploaded": "Controlează ce tipuri de fișiere pot fi încărcate.",
|
||||
"convert_to_multiple_choice": "Convertiți la selectare multiplă",
|
||||
"convert_to_single_choice": "Convertiți la selectare unică",
|
||||
"country": "Țară",
|
||||
@@ -1268,13 +1272,11 @@
|
||||
"darken_or_lighten_background_of_your_choice": "Întunecați sau luminați fundalul după preferințe.",
|
||||
"date_format": "Format dată",
|
||||
"days_before_showing_this_survey_again": "sau mai multe zile să treacă între ultima afișare a sondajului și afișarea acestui sondaj.",
|
||||
"delete_anyways": "Șterge oricum",
|
||||
"delete_block": "Șterge blocul",
|
||||
"delete_choice": "Șterge alegerea",
|
||||
"disable_the_visibility_of_survey_progress": "Dezactivați vizibilitatea progresului sondajului",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Afișează o estimare a timpului de finalizare pentru sondaj",
|
||||
"display_number_of_responses_for_survey": "Afișează numărul de răspunsuri pentru sondaj",
|
||||
"display_type": "Tip de afișare",
|
||||
"divide": "Împarte /",
|
||||
"does_not_contain": "Nu conține",
|
||||
"does_not_end_with": "Nu se termină cu",
|
||||
@@ -1282,7 +1284,6 @@
|
||||
"does_not_include_all_of": "Nu include toate",
|
||||
"does_not_include_one_of": "Nu include una dintre",
|
||||
"does_not_start_with": "Nu începe cu",
|
||||
"dropdown": "Dropdown",
|
||||
"duplicate_block": "Duplicați blocul",
|
||||
"duplicate_question": "Duplică întrebarea",
|
||||
"edit_link": "Editare legătură",
|
||||
@@ -1375,7 +1376,7 @@
|
||||
"hide_question_settings": "Ascunde setările întrebării",
|
||||
"hostname": "Nume gazdă",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Cât de funky doriți să fie cardurile dumneavoastră în sondajele de tip {surveyTypeDerived}",
|
||||
"if_you_need_more_please": "Dacă aveți nevoie de mai mult, vă rugăm",
|
||||
"if_you_need_more_please": "Dacă aveți nevoie de mai multe, vă rugăm să",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă afișarea ori de câte ori este declanșat până când se trimite un răspuns.",
|
||||
"ignore_global_waiting_time": "Ignoră perioada de răcire",
|
||||
"ignore_global_waiting_time_description": "Acest sondaj poate fi afișat ori de câte ori condițiile sale sunt îndeplinite, chiar dacă un alt sondaj a fost afișat recent.",
|
||||
@@ -1412,10 +1413,10 @@
|
||||
"key": "Cheie",
|
||||
"last_name": "Nume de familie",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Permiteți utilizatorilor să încarce până la 25 de fișiere simultan.",
|
||||
"limit_the_maximum_file_size": "Limitați dimensiunea maximă a fișierului pentru încărcări.",
|
||||
"limit_upload_file_size_to": "Limitați dimensiunea fișierului încărcat la",
|
||||
"limit_file_types": "Limitare tipuri de fișiere",
|
||||
"limit_the_maximum_file_size": "Limitează dimensiunea maximă a fișierului",
|
||||
"limit_upload_file_size_to": "Limitați dimensiunea fișierului de încărcare la",
|
||||
"link_survey_description": "Partajați un link către o pagină de chestionar sau încorporați-l într-o pagină web sau email.",
|
||||
"list": "Listă",
|
||||
"load_segment": "Încarcă segment",
|
||||
"logic_error_warning": "Schimbarea va provoca erori de logică",
|
||||
"logic_error_warning_text": "Schimbarea tipului de întrebare va elimina condițiile de logică din această întrebare",
|
||||
@@ -1427,7 +1428,7 @@
|
||||
"matrix_all_fields": "Toate câmpurile",
|
||||
"matrix_rows": "Rânduri",
|
||||
"max_file_size": "Dimensiune maximă fișier",
|
||||
"max_file_size_limit_is": "Limita maximă pentru dimensiunea fișierului este",
|
||||
"max_file_size_limit_is": "Limita dimensiunii maxime a fișierului este",
|
||||
"move_question_to_block": "Mută întrebarea în bloc",
|
||||
"multiply": "Multiplicare",
|
||||
"needed_for_self_hosted_cal_com_instance": "Necesar pentru un exemplu autogăzduit Cal.com",
|
||||
@@ -1459,6 +1460,7 @@
|
||||
"picture_idx": "Poză {idx}",
|
||||
"pin_can_only_contain_numbers": "PIN-ul poate conține doar numere.",
|
||||
"pin_must_be_a_four_digit_number": "PIN-ul trebuie să fie un număr de patru cifre",
|
||||
"please_enter_a_file_extension": "Vă rugăm să introduceți o extensie de fișier.",
|
||||
"please_enter_a_valid_url": "Vă rugăm să introduceți un URL valid (de exemplu, https://example.com)",
|
||||
"please_set_a_survey_trigger": "Vă rugăm să setați un declanșator sondaj",
|
||||
"please_specify": "Vă rugăm să specificați",
|
||||
@@ -1473,9 +1475,8 @@
|
||||
"question_deleted": "Întrebare ștearsă.",
|
||||
"question_duplicated": "Întrebare duplicată.",
|
||||
"question_id_updated": "ID întrebare actualizat",
|
||||
"question_used_in_logic_warning_text": "Elemente din acest bloc sunt folosite într-o regulă de logică. Sigur doriți să îl ștergeți?",
|
||||
"question_used_in_logic_warning_title": "Inconsistență logică",
|
||||
"question_used_in_quota": "Întrebarea aceasta este folosită în cota „{quotaName}”",
|
||||
"question_used_in_logic": "Această întrebare este folosită în logica întrebării {questionIndex}.",
|
||||
"question_used_in_quota": "Întrebarea aceasta este folosită în cota \"{quotaName}\"",
|
||||
"question_used_in_recall": "Această întrebare este reamintită în întrebarea {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Această întrebare este reamintită în Cardul de Încheiere.",
|
||||
"quotas": {
|
||||
@@ -1538,7 +1539,6 @@
|
||||
"search_for_images": "Căutare de imagini",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "secunde după declanșare sondajul va fi închis dacă nu există niciun răspuns",
|
||||
"seconds_before_showing_the_survey": "secunde înainte de afișarea sondajului",
|
||||
"select_field": "Selectează câmpul",
|
||||
"select_or_type_value": "Selectați sau introduceți valoarea",
|
||||
"select_ordering": "Selectează ordonarea",
|
||||
"select_saved_action": "Selectați acțiunea salvată",
|
||||
@@ -1586,6 +1586,8 @@
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Afișează o singură dată, chiar dacă persoana nu răspunde.",
|
||||
"then": "Apoi",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Această acțiune va elimina toate traducerile din acest sondaj.",
|
||||
"this_extension_is_already_added": "Această extensie este deja adăugată.",
|
||||
"this_file_type_is_not_supported": "Acest tip de fișier nu este acceptat.",
|
||||
"three_points": "3 puncte",
|
||||
"times": "ori",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pentru a menține amplasarea consecventă pentru toate sondajele, puteți",
|
||||
@@ -1606,51 +1608,8 @@
|
||||
"upper_label": "Etichetă superioară",
|
||||
"url_filters": "Filtre URL",
|
||||
"url_not_supported": "URL nesuportat",
|
||||
"validation": {
|
||||
"add_validation_rule": "Adaugă regulă de validare",
|
||||
"answer_all_rows": "Răspunde la toate rândurile",
|
||||
"characters": "Caractere",
|
||||
"contains": "Conține",
|
||||
"delete_validation_rule": "Șterge regula de validare",
|
||||
"does_not_contain": "Nu conține",
|
||||
"email": "Este un email valid",
|
||||
"end_date": "Data de sfârșit",
|
||||
"file_extension_is": "Extensia fișierului este",
|
||||
"file_extension_is_not": "Extensia fișierului nu este",
|
||||
"is": "Este",
|
||||
"is_between": "Este între",
|
||||
"is_earlier_than": "Este mai devreme decât",
|
||||
"is_greater_than": "Este mai mare decât",
|
||||
"is_later_than": "Este mai târziu decât",
|
||||
"is_less_than": "Este mai mic decât",
|
||||
"is_not": "Nu este",
|
||||
"is_not_between": "Nu este între",
|
||||
"kb": "KB",
|
||||
"max_length": "Cel mult",
|
||||
"max_selections": "Cel mult",
|
||||
"max_value": "Cel mult",
|
||||
"mb": "MB",
|
||||
"min_length": "Cel puțin",
|
||||
"min_selections": "Cel puțin",
|
||||
"min_value": "Cel puțin",
|
||||
"minimum_options_ranked": "Număr minim de opțiuni ordonate",
|
||||
"minimum_rows_answered": "Număr minim de rânduri completate",
|
||||
"options_selected": "Opțiuni selectate",
|
||||
"pattern": "Se potrivește cu un șablon regex",
|
||||
"phone": "Este un număr de telefon valid",
|
||||
"rank_all_options": "Ordonați toate opțiunile",
|
||||
"select_file_extensions": "Selectați extensiile de fișier...",
|
||||
"select_option": "Selectează opțiunea",
|
||||
"start_date": "Data de început",
|
||||
"url": "Este un URL valid"
|
||||
},
|
||||
"validation_logic_and": "Toate sunt adevărate",
|
||||
"validation_logic_or": "oricare este adevărată",
|
||||
"validation_rules": "Reguli de validare",
|
||||
"validation_rules_description": "Acceptă doar răspunsurile care îndeplinesc următoarele criterii",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} este folosit în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila „{variableName}” este folosită în cota „{quotaName}”. Vă rugăm să o eliminați mai întâi din cotă",
|
||||
"variable_name_conflicts_with_hidden_field": "Numele variabilei intră în conflict cu un ID de câmp ascuns existent.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.",
|
||||
"variable_name_must_start_with_a_letter": "Numele variabilei trebuie să înceapă cu o literă.",
|
||||
"variable_used_in_recall": "Variabila \"{variable}\" este reamintită în întrebarea {questionIndex}.",
|
||||
|
||||
+19
-60
@@ -243,6 +243,7 @@
|
||||
"imprint": "Выходные данные",
|
||||
"in_progress": "В процессе",
|
||||
"inactive_surveys": "Неактивные опросы",
|
||||
"input_type": "Тип ввода",
|
||||
"integration": "интеграция",
|
||||
"integrations": "Интеграции",
|
||||
"invalid_date": "Неверная дата",
|
||||
@@ -254,7 +255,6 @@
|
||||
"label": "Метка",
|
||||
"language": "Язык",
|
||||
"learn_more": "Подробнее",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Светлый оверлей",
|
||||
"limits_reached": "Достигнуты лимиты",
|
||||
"link": "Ссылка",
|
||||
@@ -267,11 +267,13 @@
|
||||
"look_and_feel": "Внешний вид",
|
||||
"manage": "Управление",
|
||||
"marketing": "Маркетинг",
|
||||
"maximum": "Максимум",
|
||||
"member": "Участник",
|
||||
"members": "Участники",
|
||||
"members_and_teams": "Участники и команды",
|
||||
"membership_not_found": "Участие не найдено",
|
||||
"metadata": "Метаданные",
|
||||
"minimum": "Минимум",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks лучше всего работает на большом экране. Для управления или создания опросов перейдите на другое устройство.",
|
||||
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
|
||||
"mobile_overlay_title": "Ой, обнаружен маленький экран!",
|
||||
@@ -324,7 +326,7 @@
|
||||
"placeholder": "Заполнитель",
|
||||
"please_select_at_least_one_survey": "Пожалуйста, выберите хотя бы один опрос",
|
||||
"please_select_at_least_one_trigger": "Пожалуйста, выберите хотя бы один триггер",
|
||||
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план",
|
||||
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план.",
|
||||
"preview": "Предпросмотр",
|
||||
"preview_survey": "Предпросмотр опроса",
|
||||
"privacy": "Политика конфиденциальности",
|
||||
@@ -350,7 +352,6 @@
|
||||
"request_trial_license": "Запросить пробную лицензию",
|
||||
"reset_to_default": "Сбросить по умолчанию",
|
||||
"response": "Ответ",
|
||||
"response_id": "ID ответа",
|
||||
"responses": "Ответы",
|
||||
"restart": "Перезапустить",
|
||||
"role": "Роль",
|
||||
@@ -462,8 +463,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Вы достигли лимита в {projectLimit} рабочих пространств.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Вы достигли месячного лимита MIU:",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Вы достигли месячного лимита ответов:",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Ваша версия будет понижена до Community Edition {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Ваша версия будет понижена до Community Edition {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Принять",
|
||||
@@ -990,7 +990,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": "Вы покинете эту организацию и потеряете доступ ко всем опросам и ответам. Вы сможете вернуться только по новому приглашению.",
|
||||
@@ -1172,6 +1172,7 @@
|
||||
"adjust_survey_closed_message_description": "Измените сообщение, которое видят посетители, когда опрос закрыт.",
|
||||
"adjust_the_theme_in_the": "Настройте тему в",
|
||||
"all_other_answers_will_continue_to": "Все остальные ответы будут продолжать",
|
||||
"allow_file_type": "Разрешить тип файла",
|
||||
"allow_multi_select": "Разрешить множественный выбор",
|
||||
"allow_multiple_files": "Разрешить несколько файлов",
|
||||
"allow_users_to_select_more_than_one_image": "Разрешить пользователям выбирать более одного изображения",
|
||||
@@ -1237,6 +1238,8 @@
|
||||
"change_the_question_color_of_the_survey": "Изменить цвет вопросов в опросе.",
|
||||
"changes_saved": "Изменения сохранены.",
|
||||
"changing_survey_type_will_remove_existing_distribution_channels": "Изменение типа опроса повлияет на способы его распространения. Если у респондентов уже есть ссылки для доступа к текущему типу, после смены они могут потерять доступ.",
|
||||
"character_limit_toggle_description": "Ограничьте минимальную и максимальную длину ответа.",
|
||||
"character_limit_toggle_title": "Добавить ограничения на количество символов",
|
||||
"checkbox_label": "Метка флажка",
|
||||
"choose_the_actions_which_trigger_the_survey": "Выберите действия, которые запускают опрос.",
|
||||
"choose_the_first_question_on_your_block": "Выберите первый вопрос в вашем блоке",
|
||||
@@ -1256,6 +1259,7 @@
|
||||
"contact_fields": "Поля контакта",
|
||||
"contains": "Содержит",
|
||||
"continue_to_settings": "Перейти к настройкам",
|
||||
"control_which_file_types_can_be_uploaded": "Управляйте типами файлов, которые можно загружать.",
|
||||
"convert_to_multiple_choice": "Преобразовать в мультивыбор",
|
||||
"convert_to_single_choice": "Преобразовать в одиночный выбор",
|
||||
"country": "Страна",
|
||||
@@ -1268,13 +1272,11 @@
|
||||
"darken_or_lighten_background_of_your_choice": "Затемните или осветлите выбранный фон.",
|
||||
"date_format": "Формат даты",
|
||||
"days_before_showing_this_survey_again": "или больше дней должно пройти между последним показом опроса и показом этого опроса.",
|
||||
"delete_anyways": "Удалить в любом случае",
|
||||
"delete_block": "Удалить блок",
|
||||
"delete_choice": "Удалить вариант",
|
||||
"disable_the_visibility_of_survey_progress": "Отключить отображение прогресса опроса.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Показывать примерное время прохождения опроса",
|
||||
"display_number_of_responses_for_survey": "Показывать количество ответов на опрос",
|
||||
"display_type": "Тип отображения",
|
||||
"divide": "Разделить /",
|
||||
"does_not_contain": "Не содержит",
|
||||
"does_not_end_with": "Не заканчивается на",
|
||||
@@ -1282,7 +1284,6 @@
|
||||
"does_not_include_all_of": "Не включает все из",
|
||||
"does_not_include_one_of": "Не включает ни одного из",
|
||||
"does_not_start_with": "Не начинается с",
|
||||
"dropdown": "Выпадающий список",
|
||||
"duplicate_block": "Дублировать блок",
|
||||
"duplicate_question": "Дублировать вопрос",
|
||||
"edit_link": "Редактировать ссылку",
|
||||
@@ -1375,7 +1376,7 @@
|
||||
"hide_question_settings": "Скрыть настройки вопроса",
|
||||
"hostname": "Имя хоста",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Насколько необычными вы хотите сделать карточки в опросах типа {surveyTypeDerived}",
|
||||
"if_you_need_more_please": "Если вам нужно больше, пожалуйста",
|
||||
"if_you_need_more_please": "Если нужно больше, пожалуйста",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Показывать каждый раз при срабатывании, пока не будет получен ответ.",
|
||||
"ignore_global_waiting_time": "Игнорировать период ожидания",
|
||||
"ignore_global_waiting_time_description": "Этот опрос может отображаться при выполнении условий, даже если недавно уже был показан другой опрос.",
|
||||
@@ -1412,10 +1413,10 @@
|
||||
"key": "Ключ",
|
||||
"last_name": "Фамилия",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Разрешить загружать до 25 файлов одновременно.",
|
||||
"limit_the_maximum_file_size": "Ограничьте максимальный размер загружаемых файлов.",
|
||||
"limit_file_types": "Ограничить типы файлов",
|
||||
"limit_the_maximum_file_size": "Ограничить максимальный размер файла",
|
||||
"limit_upload_file_size_to": "Ограничить размер загружаемого файла до",
|
||||
"link_survey_description": "Поделитесь ссылкой на страницу опроса или вставьте её на веб-страницу или в электронное письмо.",
|
||||
"list": "Список",
|
||||
"load_segment": "Загрузить сегмент",
|
||||
"logic_error_warning": "Изменение приведёт к логическим ошибкам",
|
||||
"logic_error_warning_text": "Изменение типа вопроса удалит логические условия из этого вопроса",
|
||||
@@ -1459,6 +1460,7 @@
|
||||
"picture_idx": "Изображение {idx}",
|
||||
"pin_can_only_contain_numbers": "PIN-код может содержать только цифры.",
|
||||
"pin_must_be_a_four_digit_number": "PIN-код должен состоять из четырёх цифр.",
|
||||
"please_enter_a_file_extension": "Пожалуйста, введите расширение файла.",
|
||||
"please_enter_a_valid_url": "Пожалуйста, введите корректный URL (например, https://example.com)",
|
||||
"please_set_a_survey_trigger": "Пожалуйста, установите триггер опроса",
|
||||
"please_specify": "Пожалуйста, уточните",
|
||||
@@ -1473,9 +1475,8 @@
|
||||
"question_deleted": "Вопрос удалён.",
|
||||
"question_duplicated": "Вопрос дублирован.",
|
||||
"question_id_updated": "ID вопроса обновлён",
|
||||
"question_used_in_logic_warning_text": "Элементы из этого блока используются в правиле логики. Вы уверены, что хотите удалить его?",
|
||||
"question_used_in_logic_warning_title": "Несогласованность логики",
|
||||
"question_used_in_quota": "Этот вопрос используется в квоте «{quotaName}»",
|
||||
"question_used_in_logic": "Этот вопрос используется в логике вопроса {questionIndex}.",
|
||||
"question_used_in_quota": "Этот вопрос используется в квоте \"{quotaName}\"",
|
||||
"question_used_in_recall": "Этот вопрос используется в отзыве в вопросе {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Этот вопрос используется в отзыве на финальной карточке",
|
||||
"quotas": {
|
||||
@@ -1538,7 +1539,6 @@
|
||||
"search_for_images": "Поиск изображений",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "секунд после запуска — опрос будет закрыт, если не будет ответа",
|
||||
"seconds_before_showing_the_survey": "секунд до показа опроса.",
|
||||
"select_field": "Выберите поле",
|
||||
"select_or_type_value": "Выберите или введите значение",
|
||||
"select_ordering": "Выберите порядок",
|
||||
"select_saved_action": "Выберите сохранённое действие",
|
||||
@@ -1586,6 +1586,8 @@
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Показать один раз, даже если не будет ответа.",
|
||||
"then": "Затем",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Это действие удалит все переводы из этого опроса.",
|
||||
"this_extension_is_already_added": "Это расширение уже добавлено.",
|
||||
"this_file_type_is_not_supported": "Этот тип файла не поддерживается.",
|
||||
"three_points": "3 балла",
|
||||
"times": "раз",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Чтобы сохранить единое расположение во всех опросах, вы можете",
|
||||
@@ -1606,51 +1608,8 @@
|
||||
"upper_label": "Верхняя метка",
|
||||
"url_filters": "Фильтры URL",
|
||||
"url_not_supported": "URL не поддерживается",
|
||||
"validation": {
|
||||
"add_validation_rule": "Добавить правило проверки",
|
||||
"answer_all_rows": "Ответьте на все строки",
|
||||
"characters": "Символы",
|
||||
"contains": "Содержит",
|
||||
"delete_validation_rule": "Удалить правило проверки",
|
||||
"does_not_contain": "Не содержит",
|
||||
"email": "Корректный email",
|
||||
"end_date": "Дата окончания",
|
||||
"file_extension_is": "Расширение файла —",
|
||||
"file_extension_is_not": "Расширение файла не является",
|
||||
"is": "Является",
|
||||
"is_between": "Находится между",
|
||||
"is_earlier_than": "Ранее чем",
|
||||
"is_greater_than": "Больше чем",
|
||||
"is_later_than": "Позже чем",
|
||||
"is_less_than": "Меньше чем",
|
||||
"is_not": "Не является",
|
||||
"is_not_between": "Не находится между",
|
||||
"kb": "КБ",
|
||||
"max_length": "Не более",
|
||||
"max_selections": "Не более",
|
||||
"max_value": "Не более",
|
||||
"mb": "МБ",
|
||||
"min_length": "Не менее",
|
||||
"min_selections": "Не менее",
|
||||
"min_value": "Не менее",
|
||||
"minimum_options_ranked": "Минимальное количество ранжированных вариантов",
|
||||
"minimum_rows_answered": "Минимальное количество заполненных строк",
|
||||
"options_selected": "Выбранные опции",
|
||||
"pattern": "Соответствует шаблону regex",
|
||||
"phone": "Корректный телефон",
|
||||
"rank_all_options": "Ранжируйте все опции",
|
||||
"select_file_extensions": "Выберите расширения файлов...",
|
||||
"select_option": "Выберите вариант",
|
||||
"start_date": "Дата начала",
|
||||
"url": "Корректный URL"
|
||||
},
|
||||
"validation_logic_and": "Все условия выполняются",
|
||||
"validation_logic_or": "выполняется хотя бы одно условие",
|
||||
"validation_rules": "Правила валидации",
|
||||
"validation_rules_description": "Принимать только ответы, соответствующие следующим критериям",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Переменная «{variableName}» используется в квоте «{quotaName}». Сначала удалите её из квоты.",
|
||||
"variable_name_conflicts_with_hidden_field": "Имя переменной конфликтует с существующим ID скрытого поля.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Переменная «{variableName}» используется в квоте «{quotaName}»",
|
||||
"variable_name_is_already_taken_please_choose_another": "Это имя переменной уже занято, выберите другое.",
|
||||
"variable_name_must_start_with_a_letter": "Имя переменной должно начинаться с буквы.",
|
||||
"variable_used_in_recall": "Переменная «{variable}» используется в вопросе {questionIndex}.",
|
||||
|
||||
+21
-62
@@ -243,6 +243,7 @@
|
||||
"imprint": "Impressum",
|
||||
"in_progress": "Pågående",
|
||||
"inactive_surveys": "Inaktiva enkäter",
|
||||
"input_type": "Inmatningstyp",
|
||||
"integration": "integration",
|
||||
"integrations": "Integrationer",
|
||||
"invalid_date": "Ogiltigt datum",
|
||||
@@ -254,7 +255,6 @@
|
||||
"label": "Etikett",
|
||||
"language": "Språk",
|
||||
"learn_more": "Läs mer",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Ljust överlägg",
|
||||
"limits_reached": "Gränser nådda",
|
||||
"link": "Länk",
|
||||
@@ -267,11 +267,13 @@
|
||||
"look_and_feel": "Utseende",
|
||||
"manage": "Hantera",
|
||||
"marketing": "Marknadsföring",
|
||||
"maximum": "Maximum",
|
||||
"member": "Medlem",
|
||||
"members": "Medlemmar",
|
||||
"members_and_teams": "Medlemmar och team",
|
||||
"membership_not_found": "Medlemskap hittades inte",
|
||||
"metadata": "Metadata",
|
||||
"minimum": "Minimum",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks fungerar bäst på en större skärm. Byt till en annan enhet för att hantera eller bygga enkäter.",
|
||||
"mobile_overlay_surveys_look_good": "Oroa dig inte – dina enkäter ser bra ut på alla enheter och skärmstorlekar!",
|
||||
"mobile_overlay_title": "Hoppsan, liten skärm upptäckt!",
|
||||
@@ -324,7 +326,7 @@
|
||||
"placeholder": "Platshållare",
|
||||
"please_select_at_least_one_survey": "Vänligen välj minst en enkät",
|
||||
"please_select_at_least_one_trigger": "Vänligen välj minst en utlösare",
|
||||
"please_upgrade_your_plan": "Vänligen uppgradera din plan",
|
||||
"please_upgrade_your_plan": "Vänligen uppgradera din plan.",
|
||||
"preview": "Förhandsgranska",
|
||||
"preview_survey": "Förhandsgranska enkät",
|
||||
"privacy": "Integritetspolicy",
|
||||
@@ -350,7 +352,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",
|
||||
@@ -462,8 +463,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Du har nått din gräns på {projectLimit} arbetsytor.",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "Du har nått din månatliga MIU-gräns på",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Du har nått din månatliga svarsgräns på",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Du kommer att nedgraderas till Community Edition den {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Du kommer att nedgraderas till Community Edition den {date}."
|
||||
},
|
||||
"emails": {
|
||||
"accept": "Acceptera",
|
||||
@@ -990,7 +990,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.",
|
||||
@@ -1172,6 +1172,7 @@
|
||||
"adjust_survey_closed_message_description": "Ändra meddelandet besökare ser när enkäten är stängd.",
|
||||
"adjust_the_theme_in_the": "Justera temat i",
|
||||
"all_other_answers_will_continue_to": "Alla andra svar fortsätter till",
|
||||
"allow_file_type": "Tillåt filtyp",
|
||||
"allow_multi_select": "Tillåt flerval",
|
||||
"allow_multiple_files": "Tillåt flera filer",
|
||||
"allow_users_to_select_more_than_one_image": "Tillåt användare att välja mer än en bild",
|
||||
@@ -1237,6 +1238,8 @@
|
||||
"change_the_question_color_of_the_survey": "Ändra enkätens frågefärg.",
|
||||
"changes_saved": "Ändringar sparade.",
|
||||
"changing_survey_type_will_remove_existing_distribution_channels": "Att ändra enkättypen påverkar hur den kan delas. Om respondenter redan har åtkomstlänkar för den nuvarande typen kan de förlora åtkomst efter bytet.",
|
||||
"character_limit_toggle_description": "Begränsa hur kort eller långt ett svar kan vara.",
|
||||
"character_limit_toggle_title": "Lägg till teckengränser",
|
||||
"checkbox_label": "Kryssruteetikett",
|
||||
"choose_the_actions_which_trigger_the_survey": "Välj de åtgärder som utlöser enkäten.",
|
||||
"choose_the_first_question_on_your_block": "Välj den första frågan i ditt block",
|
||||
@@ -1256,6 +1259,7 @@
|
||||
"contact_fields": "Kontaktfält",
|
||||
"contains": "Innehåller",
|
||||
"continue_to_settings": "Fortsätt till inställningar",
|
||||
"control_which_file_types_can_be_uploaded": "Kontrollera vilka filtyper som kan laddas upp.",
|
||||
"convert_to_multiple_choice": "Konvertera till flerval",
|
||||
"convert_to_single_choice": "Konvertera till enkelval",
|
||||
"country": "Land",
|
||||
@@ -1268,13 +1272,11 @@
|
||||
"darken_or_lighten_background_of_your_choice": "Gör bakgrunden mörkare eller ljusare efter eget val.",
|
||||
"date_format": "Datumformat",
|
||||
"days_before_showing_this_survey_again": "eller fler dagar måste gå mellan den senaste visade enkäten och att visa denna enkät.",
|
||||
"delete_anyways": "Ta bort ändå",
|
||||
"delete_block": "Ta bort block",
|
||||
"delete_choice": "Ta bort val",
|
||||
"disable_the_visibility_of_survey_progress": "Inaktivera synligheten av enkätens framsteg.",
|
||||
"display_an_estimate_of_completion_time_for_survey": "Visa en uppskattning av tid för att slutföra enkäten",
|
||||
"display_number_of_responses_for_survey": "Visa antal svar för enkäten",
|
||||
"display_type": "Visningstyp",
|
||||
"divide": "Dividera /",
|
||||
"does_not_contain": "Innehåller inte",
|
||||
"does_not_end_with": "Slutar inte med",
|
||||
@@ -1282,7 +1284,6 @@
|
||||
"does_not_include_all_of": "Inkluderar inte alla av",
|
||||
"does_not_include_one_of": "Inkluderar inte en av",
|
||||
"does_not_start_with": "Börjar inte med",
|
||||
"dropdown": "Rullgardinsmeny",
|
||||
"duplicate_block": "Duplicera block",
|
||||
"duplicate_question": "Duplicera fråga",
|
||||
"edit_link": "Redigera länk",
|
||||
@@ -1375,7 +1376,7 @@
|
||||
"hide_question_settings": "Dölj frågeinställningar",
|
||||
"hostname": "Värdnamn",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Hur coola vill du att dina kort ska vara i {surveyTypeDerived}-enkäter",
|
||||
"if_you_need_more_please": "Om du behöver mer, vänligen",
|
||||
"if_you_need_more_please": "Om du behöver fler, vänligen",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa när villkoren är uppfyllda tills ett svar skickas in.",
|
||||
"ignore_global_waiting_time": "Ignorera väntetid",
|
||||
"ignore_global_waiting_time_description": "Denna enkät kan visas när dess villkor är uppfyllda, även om en annan enkät nyligen visats.",
|
||||
@@ -1412,10 +1413,10 @@
|
||||
"key": "Nyckel",
|
||||
"last_name": "Efternamn",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Låt personer ladda upp upp till 25 filer samtidigt.",
|
||||
"limit_the_maximum_file_size": "Begränsa den maximala filstorleken för uppladdningar.",
|
||||
"limit_upload_file_size_to": "Begränsa uppladdad filstorlek till",
|
||||
"limit_file_types": "Begränsa filtyper",
|
||||
"limit_the_maximum_file_size": "Begränsa maximal filstorlek",
|
||||
"limit_upload_file_size_to": "Begränsa uppladdningsfilstorlek till",
|
||||
"link_survey_description": "Dela en länk till en enkätsida eller bädda in den på en webbsida eller i e-post.",
|
||||
"list": "Lista",
|
||||
"load_segment": "Ladda segment",
|
||||
"logic_error_warning": "Ändring kommer att orsaka logikfel",
|
||||
"logic_error_warning_text": "Att ändra frågetypen kommer att ta bort logikvillkoren från denna fråga",
|
||||
@@ -1427,7 +1428,7 @@
|
||||
"matrix_all_fields": "Alla fält",
|
||||
"matrix_rows": "Rader",
|
||||
"max_file_size": "Max filstorlek",
|
||||
"max_file_size_limit_is": "Maximal filstorleksgräns är",
|
||||
"max_file_size_limit_is": "Maxgräns för filstorlek är",
|
||||
"move_question_to_block": "Flytta fråga till block",
|
||||
"multiply": "Multiplicera *",
|
||||
"needed_for_self_hosted_cal_com_instance": "Behövs för en självhostad Cal.com-instans",
|
||||
@@ -1459,6 +1460,7 @@
|
||||
"picture_idx": "Bild {idx}",
|
||||
"pin_can_only_contain_numbers": "PIN kan endast innehålla siffror.",
|
||||
"pin_must_be_a_four_digit_number": "PIN måste vara ett fyrsiffrigt nummer.",
|
||||
"please_enter_a_file_extension": "Vänligen ange en filändelse.",
|
||||
"please_enter_a_valid_url": "Vänligen ange en giltig URL (t.ex. https://example.com)",
|
||||
"please_set_a_survey_trigger": "Vänligen ställ in en enkätutlösare",
|
||||
"please_specify": "Vänligen specificera",
|
||||
@@ -1473,9 +1475,8 @@
|
||||
"question_deleted": "Fråga borttagen.",
|
||||
"question_duplicated": "Fråga duplicerad.",
|
||||
"question_id_updated": "Fråge-ID uppdaterat",
|
||||
"question_used_in_logic_warning_text": "Element från det här blocket används i en logikregel. Är du säker på att du vill ta bort det?",
|
||||
"question_used_in_logic_warning_title": "Logikkonflikt",
|
||||
"question_used_in_quota": "Denna fråga används i kvoten “{quotaName}”",
|
||||
"question_used_in_logic": "Denna fråga används i logiken för fråga {questionIndex}.",
|
||||
"question_used_in_quota": "Denna fråga används i kvoten \"{quotaName}\"",
|
||||
"question_used_in_recall": "Denna fråga återkallas i fråga {questionIndex}.",
|
||||
"question_used_in_recall_ending_card": "Denna fråga återkallas i avslutningskortet",
|
||||
"quotas": {
|
||||
@@ -1538,7 +1539,6 @@
|
||||
"search_for_images": "Sök efter bilder",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "sekunder efter utlösning stängs enkäten om inget svar",
|
||||
"seconds_before_showing_the_survey": "sekunder innan enkäten visas.",
|
||||
"select_field": "Välj fält",
|
||||
"select_or_type_value": "Välj eller skriv värde",
|
||||
"select_ordering": "Välj ordning",
|
||||
"select_saved_action": "Välj sparad åtgärd",
|
||||
@@ -1586,6 +1586,8 @@
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Visa en enda gång, även om de inte svarar.",
|
||||
"then": "Sedan",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Denna åtgärd kommer att ta bort alla översättningar från denna enkät.",
|
||||
"this_extension_is_already_added": "Denna filändelse är redan tillagd.",
|
||||
"this_file_type_is_not_supported": "Denna filtyp stöds inte.",
|
||||
"three_points": "3 poäng",
|
||||
"times": "gånger",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "För att hålla placeringen konsekvent över alla enkäter kan du",
|
||||
@@ -1606,51 +1608,8 @@
|
||||
"upper_label": "Övre etikett",
|
||||
"url_filters": "URL-filter",
|
||||
"url_not_supported": "URL stöds inte",
|
||||
"validation": {
|
||||
"add_validation_rule": "Lägg till valideringsregel",
|
||||
"answer_all_rows": "Svara på alla rader",
|
||||
"characters": "Tecken",
|
||||
"contains": "Innehåller",
|
||||
"delete_validation_rule": "Ta bort valideringsregel",
|
||||
"does_not_contain": "Innehåller inte",
|
||||
"email": "Är en giltig e-postadress",
|
||||
"end_date": "Slutdatum",
|
||||
"file_extension_is": "Filändelsen är",
|
||||
"file_extension_is_not": "Filändelsen är inte",
|
||||
"is": "Är",
|
||||
"is_between": "Är mellan",
|
||||
"is_earlier_than": "Är tidigare än",
|
||||
"is_greater_than": "Är större än",
|
||||
"is_later_than": "Är senare än",
|
||||
"is_less_than": "Är mindre än",
|
||||
"is_not": "Är inte",
|
||||
"is_not_between": "Är inte mellan",
|
||||
"kb": "KB",
|
||||
"max_length": "Högst",
|
||||
"max_selections": "Högst",
|
||||
"max_value": "Högst",
|
||||
"mb": "MB",
|
||||
"min_length": "Minst",
|
||||
"min_selections": "Minst",
|
||||
"min_value": "Minst",
|
||||
"minimum_options_ranked": "Minsta antal rangordnade alternativ",
|
||||
"minimum_rows_answered": "Minsta antal besvarade rader",
|
||||
"options_selected": "Valda alternativ",
|
||||
"pattern": "Matchar regexmönster",
|
||||
"phone": "Är ett giltigt telefonnummer",
|
||||
"rank_all_options": "Rangordna alla alternativ",
|
||||
"select_file_extensions": "Välj filändelser...",
|
||||
"select_option": "Välj alternativ",
|
||||
"start_date": "Startdatum",
|
||||
"url": "Är en giltig URL"
|
||||
},
|
||||
"validation_logic_and": "Alla är sanna",
|
||||
"validation_logic_or": "någon är sann",
|
||||
"validation_rules": "Valideringsregler",
|
||||
"validation_rules_description": "Acceptera endast svar som uppfyller följande kriterier",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabeln “{variableName}” används i kvoten “{quotaName}”",
|
||||
"variable_name_conflicts_with_hidden_field": "Variabelnamnet krockar med ett befintligt dolt fält-ID.",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabel \"{variableName}\" används i kvoten \"{quotaName}\"",
|
||||
"variable_name_is_already_taken_please_choose_another": "Variabelnamnet är redan taget, vänligen välj ett annat.",
|
||||
"variable_name_must_start_with_a_letter": "Variabelnamnet måste börja med en bokstav.",
|
||||
"variable_used_in_recall": "Variabel \"{variable}\" återkallas i fråga {questionIndex}.",
|
||||
|
||||
@@ -243,6 +243,7 @@
|
||||
"imprint": "印记",
|
||||
"in_progress": "进行中",
|
||||
"inactive_surveys": "不 活跃 调查",
|
||||
"input_type": "输入类型",
|
||||
"integration": "集成",
|
||||
"integrations": "集成",
|
||||
"invalid_date": "无效 日期",
|
||||
@@ -254,7 +255,6 @@
|
||||
"label": "标签",
|
||||
"language": "语言",
|
||||
"learn_more": "了解 更多",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "浅色遮罩层",
|
||||
"limits_reached": "限制 达到",
|
||||
"link": "链接",
|
||||
@@ -267,11 +267,13 @@
|
||||
"look_and_feel": "外观 & 感觉",
|
||||
"manage": "管理",
|
||||
"marketing": "市场营销",
|
||||
"maximum": "最大值",
|
||||
"member": "成员",
|
||||
"members": "成员",
|
||||
"members_and_teams": "成员和团队",
|
||||
"membership_not_found": "未找到会员资格",
|
||||
"metadata": "元数据",
|
||||
"minimum": "最低",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
|
||||
"mobile_overlay_surveys_look_good": "别 担心 – 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
|
||||
"mobile_overlay_title": "噢, 检测 到 小 屏幕!",
|
||||
@@ -324,7 +326,7 @@
|
||||
"placeholder": "占位符",
|
||||
"please_select_at_least_one_survey": "请选择至少 一个调查",
|
||||
"please_select_at_least_one_trigger": "请选择至少 一个触发条件",
|
||||
"please_upgrade_your_plan": "请升级您的计划",
|
||||
"please_upgrade_your_plan": "请 升级 您的 计划。",
|
||||
"preview": "预览",
|
||||
"preview_survey": "预览 Survey",
|
||||
"privacy": "隐私政策",
|
||||
@@ -350,7 +352,6 @@
|
||||
"request_trial_license": "申请试用许可证",
|
||||
"reset_to_default": "重置为 默认",
|
||||
"response": "响应",
|
||||
"response_id": "响应 ID",
|
||||
"responses": "反馈",
|
||||
"restart": "重新启动",
|
||||
"role": "角色",
|
||||
@@ -462,8 +463,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "您已达到 {projectLimit} 个工作区的上限。",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "您 已经 达到 每月 的 MIU 限制",
|
||||
"you_have_reached_your_monthly_response_limit_of": "您 已经 达到 每月 的 响应 限制",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "您将在 {date} 降级到社区版。",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "您将在 {date} 降级到社区版。"
|
||||
},
|
||||
"emails": {
|
||||
"accept": "接受",
|
||||
@@ -990,7 +990,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": "您将离开此组织,并失去对所有调查和响应的访问权限。只有再次被邀请后,您才能重新加入。",
|
||||
@@ -1172,6 +1172,7 @@
|
||||
"adjust_survey_closed_message_description": "更改 访客 看到 调查 关闭 时 的 消息。",
|
||||
"adjust_the_theme_in_the": "调整主题在",
|
||||
"all_other_answers_will_continue_to": "所有其他答案将继续",
|
||||
"allow_file_type": "允许 文件类型",
|
||||
"allow_multi_select": "允许 多选",
|
||||
"allow_multiple_files": "允许 多 个 文件",
|
||||
"allow_users_to_select_more_than_one_image": "允许 用户 选择 多于 一个 图片",
|
||||
@@ -1237,6 +1238,8 @@
|
||||
"change_the_question_color_of_the_survey": "更改调查的 问题颜色",
|
||||
"changes_saved": "更改 已 保存",
|
||||
"changing_survey_type_will_remove_existing_distribution_channels": "更改 调查 类型 会影 响 分享 方式 。 如果 受访者 已经 拥有 当前 类型 的 访问 链接 , 在 更改 之后 ,他们 可能 会 失去 访问 权限 。",
|
||||
"character_limit_toggle_description": "限制 答案的短或长程度。",
|
||||
"character_limit_toggle_title": "添加 字符限制",
|
||||
"checkbox_label": "复选框 标签",
|
||||
"choose_the_actions_which_trigger_the_survey": "选择 触发 调查 的 动作 。",
|
||||
"choose_the_first_question_on_your_block": "选择区块中的第一个问题",
|
||||
@@ -1256,6 +1259,7 @@
|
||||
"contact_fields": "联络字段",
|
||||
"contains": "包含",
|
||||
"continue_to_settings": "继续 到 设置",
|
||||
"control_which_file_types_can_be_uploaded": "控制 可以 上传的 文件 类型",
|
||||
"convert_to_multiple_choice": "转换为 多选",
|
||||
"convert_to_single_choice": "转换为 单选",
|
||||
"country": "国家",
|
||||
@@ -1268,13 +1272,11 @@
|
||||
"darken_or_lighten_background_of_your_choice": "根据 您 的 选择 暗化 或 亮化 背景。",
|
||||
"date_format": "日期格式",
|
||||
"days_before_showing_this_survey_again": "距离上次显示问卷后需间隔不少于指定天数,才能再次显示此问卷。",
|
||||
"delete_anyways": "仍然删除",
|
||||
"delete_block": "删除区块",
|
||||
"delete_choice": "删除 选择",
|
||||
"disable_the_visibility_of_survey_progress": "禁用问卷 进度 的可见性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "显示 调查 预计 完成 时间",
|
||||
"display_number_of_responses_for_survey": "显示 调查 响应 数量",
|
||||
"display_type": "显示类型",
|
||||
"divide": "划分 /",
|
||||
"does_not_contain": "不包含",
|
||||
"does_not_end_with": "不 以 结尾",
|
||||
@@ -1282,7 +1284,6 @@
|
||||
"does_not_include_all_of": "不包括所有 ",
|
||||
"does_not_include_one_of": "不包括一 个",
|
||||
"does_not_start_with": "不 以 开头",
|
||||
"dropdown": "下拉菜单",
|
||||
"duplicate_block": "复制区块",
|
||||
"duplicate_question": "复制问题",
|
||||
"edit_link": "编辑 链接",
|
||||
@@ -1375,7 +1376,7 @@
|
||||
"hide_question_settings": "隐藏问题设置",
|
||||
"hostname": "主 机 名",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "在 {surveyTypeDerived} 调查 中,您 想要 卡片 多么 有趣",
|
||||
"if_you_need_more_please": "如果您需要更多,请",
|
||||
"if_you_need_more_please": "如果你需要更多,请",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "每次触发时都会显示,直到提交回应为止。",
|
||||
"ignore_global_waiting_time": "忽略冷却期",
|
||||
"ignore_global_waiting_time_description": "只要满足条件,此调查即可显示,即使最近刚显示过其他调查。",
|
||||
@@ -1412,10 +1413,10 @@
|
||||
"key": "键",
|
||||
"last_name": "姓",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "允许 人们 同时 上传 最多 25 个 文件",
|
||||
"limit_the_maximum_file_size": "限制上传文件的最大大小。",
|
||||
"limit_upload_file_size_to": "将上传文件大小限制为",
|
||||
"limit_file_types": "限制 文件 类型",
|
||||
"limit_the_maximum_file_size": "限制 最大 文件 大小",
|
||||
"limit_upload_file_size_to": "将 上传 文件 大小 限制 为",
|
||||
"link_survey_description": "分享 问卷 页面 链接 或 将其 嵌入 网页 或 电子邮件 中。",
|
||||
"list": "列表",
|
||||
"load_segment": "载入 段落",
|
||||
"logic_error_warning": "更改 将 导致 逻辑 错误",
|
||||
"logic_error_warning_text": "更改问题类型 会 移除 此问题 的 逻辑条件",
|
||||
@@ -1426,8 +1427,8 @@
|
||||
"manage_languages": "管理 语言",
|
||||
"matrix_all_fields": "所有字段",
|
||||
"matrix_rows": "行",
|
||||
"max_file_size": "最大文件大小",
|
||||
"max_file_size_limit_is": "最大文件大小限制为",
|
||||
"max_file_size": "最大 文件 大小",
|
||||
"max_file_size_limit_is": "最大 文件 大小 限制 是",
|
||||
"move_question_to_block": "将问题移动到区块",
|
||||
"multiply": "乘 *",
|
||||
"needed_for_self_hosted_cal_com_instance": "需要用于 自建 Cal.com 实例",
|
||||
@@ -1459,6 +1460,7 @@
|
||||
"picture_idx": "图片 {idx}",
|
||||
"pin_can_only_contain_numbers": "PIN 只能包含数字。",
|
||||
"pin_must_be_a_four_digit_number": "PIN 必须是 四 位数字。",
|
||||
"please_enter_a_file_extension": "请输入 文件 扩展名。",
|
||||
"please_enter_a_valid_url": "请输入有效的 URL(例如, https://example.com )",
|
||||
"please_set_a_survey_trigger": "请 设置 一个 调查 触发",
|
||||
"please_specify": "请 指定",
|
||||
@@ -1473,9 +1475,8 @@
|
||||
"question_deleted": "问题 已删除",
|
||||
"question_duplicated": "问题重复。",
|
||||
"question_id_updated": "问题 ID 更新",
|
||||
"question_used_in_logic_warning_text": "此区块中的元素已被用于逻辑规则,您确定要删除吗?",
|
||||
"question_used_in_logic_warning_title": "逻辑不一致",
|
||||
"question_used_in_quota": "此问题正在被“{quotaName}”配额使用",
|
||||
"question_used_in_logic": "\"这个 问题 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
|
||||
"question_used_in_quota": "此 问题 正在 被 \"{quotaName}\" 配额 使用",
|
||||
"question_used_in_recall": "此问题正在召回于问题 {questionIndex}。",
|
||||
"question_used_in_recall_ending_card": "此 问题 正在召回于结束 卡片。",
|
||||
"quotas": {
|
||||
@@ -1538,7 +1539,6 @@
|
||||
"search_for_images": "搜索 图片",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "触发后 如果 没有 应答 将 在 几秒 后 关闭 调查",
|
||||
"seconds_before_showing_the_survey": "显示问卷前 几秒",
|
||||
"select_field": "选择字段",
|
||||
"select_or_type_value": "选择 或 输入 值",
|
||||
"select_ordering": "选择排序",
|
||||
"select_saved_action": "选择 保存的 操作",
|
||||
@@ -1586,6 +1586,8 @@
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "仅显示一次,即使他们未回应。",
|
||||
"then": "然后",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "此操作将删除该调查中的所有翻译。",
|
||||
"this_extension_is_already_added": "此扩展已经添加。",
|
||||
"this_file_type_is_not_supported": "此 文件 类型 不 支持。",
|
||||
"three_points": "3 分",
|
||||
"times": "次数",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "为了 保持 所有 调查 的 放置 一致,您 可以",
|
||||
@@ -1606,51 +1608,8 @@
|
||||
"upper_label": "上限标签",
|
||||
"url_filters": "URL 过滤器",
|
||||
"url_not_supported": "URL 不支持",
|
||||
"validation": {
|
||||
"add_validation_rule": "添加验证规则",
|
||||
"answer_all_rows": "请填写所有行",
|
||||
"characters": "字符",
|
||||
"contains": "包含",
|
||||
"delete_validation_rule": "删除验证规则",
|
||||
"does_not_contain": "不包含",
|
||||
"email": "是有效的邮箱地址",
|
||||
"end_date": "结束日期",
|
||||
"file_extension_is": "文件扩展名为",
|
||||
"file_extension_is_not": "文件扩展名不是",
|
||||
"is": "等于",
|
||||
"is_between": "介于",
|
||||
"is_earlier_than": "早于",
|
||||
"is_greater_than": "大于",
|
||||
"is_later_than": "晚于",
|
||||
"is_less_than": "小于",
|
||||
"is_not": "不等于",
|
||||
"is_not_between": "不介于",
|
||||
"kb": "KB",
|
||||
"max_length": "最多",
|
||||
"max_selections": "最多",
|
||||
"max_value": "最多",
|
||||
"mb": "MB",
|
||||
"min_length": "至少",
|
||||
"min_selections": "至少",
|
||||
"min_value": "至少",
|
||||
"minimum_options_ranked": "最少排序选项数",
|
||||
"minimum_rows_answered": "最少回答行数",
|
||||
"options_selected": "已选择的选项",
|
||||
"pattern": "匹配正则表达式模式",
|
||||
"phone": "是有效的手机号",
|
||||
"rank_all_options": "对所有选项进行排序",
|
||||
"select_file_extensions": "选择文件扩展名...",
|
||||
"select_option": "选择选项",
|
||||
"start_date": "开始日期",
|
||||
"url": "是有效的URL"
|
||||
},
|
||||
"validation_logic_and": "全部为真",
|
||||
"validation_logic_or": "任一为真",
|
||||
"validation_rules": "校验规则",
|
||||
"validation_rules_description": "仅接受符合以下条件的回复",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{variable} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量“{variableName}”正在被“{quotaName}”配额使用,请先将其从配额中移除",
|
||||
"variable_name_conflicts_with_hidden_field": "变量名与已有的隐藏字段 ID 冲突。",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量 \"{variableName}\" 正在 被 \"{quotaName}\" 配额 使用",
|
||||
"variable_name_is_already_taken_please_choose_another": "变量名已被占用,请选择其他。",
|
||||
"variable_name_must_start_with_a_letter": "变量名 必须 以字母开头。",
|
||||
"variable_used_in_recall": "变量 \"{variable}\" 正在召回于问题 {questionIndex}。",
|
||||
|
||||
@@ -243,6 +243,7 @@
|
||||
"imprint": "版本訊息",
|
||||
"in_progress": "進行中",
|
||||
"inactive_surveys": "停用中的問卷",
|
||||
"input_type": "輸入類型",
|
||||
"integration": "整合",
|
||||
"integrations": "整合",
|
||||
"invalid_date": "無效日期",
|
||||
@@ -254,7 +255,6 @@
|
||||
"label": "標籤",
|
||||
"language": "語言",
|
||||
"learn_more": "瞭解更多",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "淺色覆蓋",
|
||||
"limits_reached": "已達上限",
|
||||
"link": "連結",
|
||||
@@ -267,11 +267,13 @@
|
||||
"look_and_feel": "外觀與風格",
|
||||
"manage": "管理",
|
||||
"marketing": "行銷",
|
||||
"maximum": "最大值",
|
||||
"member": "成員",
|
||||
"members": "成員",
|
||||
"members_and_teams": "成員與團隊",
|
||||
"membership_not_found": "找不到成員資格",
|
||||
"metadata": "元數據",
|
||||
"minimum": "最小值",
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
|
||||
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
|
||||
"mobile_overlay_title": "糟糕 ,偵測到小螢幕!",
|
||||
@@ -324,7 +326,7 @@
|
||||
"placeholder": "提示文字",
|
||||
"please_select_at_least_one_survey": "請選擇至少一個問卷",
|
||||
"please_select_at_least_one_trigger": "請選擇至少一個觸發器",
|
||||
"please_upgrade_your_plan": "請升級您的方案",
|
||||
"please_upgrade_your_plan": "請升級您的方案。",
|
||||
"preview": "預覽",
|
||||
"preview_survey": "預覽問卷",
|
||||
"privacy": "隱私權政策",
|
||||
@@ -350,7 +352,6 @@
|
||||
"request_trial_license": "請求試用授權",
|
||||
"reset_to_default": "重設為預設值",
|
||||
"response": "回應",
|
||||
"response_id": "回應 ID",
|
||||
"responses": "回應",
|
||||
"restart": "重新開始",
|
||||
"role": "角色",
|
||||
@@ -462,8 +463,7 @@
|
||||
"you_have_reached_your_limit_of_workspace_limit": "您已達到 {projectLimit} 個工作區的上限。",
|
||||
"you_have_reached_your_monthly_miu_limit_of": "您已達到每月 MIU 上限:",
|
||||
"you_have_reached_your_monthly_response_limit_of": "您已達到每月回應上限:",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "您將於 '{'date'}' 降級至社群版。",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "您將於 '{'date'}' 降級至社群版。"
|
||||
},
|
||||
"emails": {
|
||||
"accept": "接受",
|
||||
@@ -990,7 +990,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": "您將離開此組織並失去對所有問卷和回應的存取權限。只有再次收到邀請,您才能重新加入。",
|
||||
@@ -1172,6 +1172,7 @@
|
||||
"adjust_survey_closed_message_description": "變更訪客在問卷關閉時看到的訊息。",
|
||||
"adjust_the_theme_in_the": "在",
|
||||
"all_other_answers_will_continue_to": "所有其他答案將繼續",
|
||||
"allow_file_type": "允許檔案類型",
|
||||
"allow_multi_select": "允許多重選取",
|
||||
"allow_multiple_files": "允許上傳多個檔案",
|
||||
"allow_users_to_select_more_than_one_image": "允許使用者選取多張圖片",
|
||||
@@ -1237,6 +1238,8 @@
|
||||
"change_the_question_color_of_the_survey": "變更問卷的問題顏色。",
|
||||
"changes_saved": "已儲存變更。",
|
||||
"changing_survey_type_will_remove_existing_distribution_channels": "更改問卷類型會影響其共享方式。如果受訪者已擁有當前類型的存取連結,則在切換後可能會失去存取權限。",
|
||||
"character_limit_toggle_description": "限制答案的長度或短度。",
|
||||
"character_limit_toggle_title": "新增字元限制",
|
||||
"checkbox_label": "核取方塊標籤",
|
||||
"choose_the_actions_which_trigger_the_survey": "選擇觸發問卷的操作。",
|
||||
"choose_the_first_question_on_your_block": "選擇此區塊的第一個問題",
|
||||
@@ -1256,6 +1259,7 @@
|
||||
"contact_fields": "聯絡人欄位",
|
||||
"contains": "包含",
|
||||
"continue_to_settings": "繼續設定",
|
||||
"control_which_file_types_can_be_uploaded": "控制可以上傳哪些檔案類型。",
|
||||
"convert_to_multiple_choice": "轉換為多選",
|
||||
"convert_to_single_choice": "轉換為單選",
|
||||
"country": "國家/地區",
|
||||
@@ -1268,13 +1272,11 @@
|
||||
"darken_or_lighten_background_of_your_choice": "變暗或變亮您選擇的背景。",
|
||||
"date_format": "日期格式",
|
||||
"days_before_showing_this_survey_again": "距離上次顯示問卷後,需間隔指定天數才能再次顯示此問卷。",
|
||||
"delete_anyways": "仍要刪除",
|
||||
"delete_block": "刪除區塊",
|
||||
"delete_choice": "刪除選項",
|
||||
"disable_the_visibility_of_survey_progress": "停用問卷進度的可見性。",
|
||||
"display_an_estimate_of_completion_time_for_survey": "顯示問卷的估計完成時間",
|
||||
"display_number_of_responses_for_survey": "顯示問卷的回應數",
|
||||
"display_type": "顯示類型",
|
||||
"divide": "除 /",
|
||||
"does_not_contain": "不包含",
|
||||
"does_not_end_with": "不以...結尾",
|
||||
@@ -1282,7 +1284,6 @@
|
||||
"does_not_include_all_of": "不包含全部",
|
||||
"does_not_include_one_of": "不包含其中之一",
|
||||
"does_not_start_with": "不以...開頭",
|
||||
"dropdown": "下拉選單",
|
||||
"duplicate_block": "複製區塊",
|
||||
"duplicate_question": "複製問題",
|
||||
"edit_link": "編輯 連結",
|
||||
@@ -1412,10 +1413,10 @@
|
||||
"key": "金鑰",
|
||||
"last_name": "姓氏",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "允許使用者同時上傳最多 25 個檔案。",
|
||||
"limit_the_maximum_file_size": "限制上傳檔案的最大大小。",
|
||||
"limit_upload_file_size_to": "將上傳檔案大小限制為",
|
||||
"limit_file_types": "限制檔案類型",
|
||||
"limit_the_maximum_file_size": "限制最大檔案大小",
|
||||
"limit_upload_file_size_to": "限制上傳檔案大小為",
|
||||
"link_survey_description": "分享問卷頁面的連結或將其嵌入網頁或電子郵件中。",
|
||||
"list": "清單",
|
||||
"load_segment": "載入區隔",
|
||||
"logic_error_warning": "變更將導致邏輯錯誤",
|
||||
"logic_error_warning_text": "變更問題類型將會從此問題中移除邏輯條件",
|
||||
@@ -1459,6 +1460,7 @@
|
||||
"picture_idx": "圖片 '{'idx'}'",
|
||||
"pin_can_only_contain_numbers": "PIN 碼只能包含數字。",
|
||||
"pin_must_be_a_four_digit_number": "PIN 碼必須是四位數的數字。",
|
||||
"please_enter_a_file_extension": "請輸入檔案副檔名。",
|
||||
"please_enter_a_valid_url": "請輸入有效的 URL(例如:https://example.com)",
|
||||
"please_set_a_survey_trigger": "請設定問卷觸發器",
|
||||
"please_specify": "請指定",
|
||||
@@ -1473,9 +1475,8 @@
|
||||
"question_deleted": "問題已刪除。",
|
||||
"question_duplicated": "問題已複製。",
|
||||
"question_id_updated": "問題 ID 已更新",
|
||||
"question_used_in_logic_warning_text": "此區塊中的元素已用於邏輯規則,確定要刪除嗎?",
|
||||
"question_used_in_logic_warning_title": "邏輯不一致",
|
||||
"question_used_in_quota": "此問題正被使用於「{quotaName}」配額中",
|
||||
"question_used_in_logic": "此問題用於問題 '{'questionIndex'}' 的邏輯中。",
|
||||
"question_used_in_quota": "此問題 正被使用於 \"{quotaName}\" 配額中",
|
||||
"question_used_in_recall": "此問題於問題 {questionIndex} 中被召回。",
|
||||
"question_used_in_recall_ending_card": "此問題於結尾卡中被召回。",
|
||||
"quotas": {
|
||||
@@ -1538,7 +1539,6 @@
|
||||
"search_for_images": "搜尋圖片",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "如果沒有回應,則在觸發後幾秒關閉問卷",
|
||||
"seconds_before_showing_the_survey": "秒後顯示問卷。",
|
||||
"select_field": "選擇欄位",
|
||||
"select_or_type_value": "選取或輸入值",
|
||||
"select_ordering": "選取排序",
|
||||
"select_saved_action": "選取已儲存的操作",
|
||||
@@ -1586,6 +1586,8 @@
|
||||
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "僅顯示一次,即使他們未回應。",
|
||||
"then": "然後",
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "此操作將從此問卷中移除所有翻譯。",
|
||||
"this_extension_is_already_added": "已新增此擴充功能。",
|
||||
"this_file_type_is_not_supported": "不支援此檔案類型。",
|
||||
"three_points": "3 分",
|
||||
"times": "次",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "若要保持所有問卷的位置一致,您可以",
|
||||
@@ -1606,51 +1608,8 @@
|
||||
"upper_label": "上標籤",
|
||||
"url_filters": "網址篩選器",
|
||||
"url_not_supported": "不支援網址",
|
||||
"validation": {
|
||||
"add_validation_rule": "新增驗證規則",
|
||||
"answer_all_rows": "請填答所有列",
|
||||
"characters": "字元",
|
||||
"contains": "包含",
|
||||
"delete_validation_rule": "刪除驗證規則",
|
||||
"does_not_contain": "不包含",
|
||||
"email": "是有效的電子郵件",
|
||||
"end_date": "結束日期",
|
||||
"file_extension_is": "檔案副檔名為",
|
||||
"file_extension_is_not": "檔案副檔名不是",
|
||||
"is": "等於",
|
||||
"is_between": "介於",
|
||||
"is_earlier_than": "早於",
|
||||
"is_greater_than": "大於",
|
||||
"is_later_than": "晚於",
|
||||
"is_less_than": "小於",
|
||||
"is_not": "不等於",
|
||||
"is_not_between": "不介於",
|
||||
"kb": "KB",
|
||||
"max_length": "最多",
|
||||
"max_selections": "最多",
|
||||
"max_value": "最多",
|
||||
"mb": "MB",
|
||||
"min_length": "至少",
|
||||
"min_selections": "至少",
|
||||
"min_value": "至少",
|
||||
"minimum_options_ranked": "最少排序選項數",
|
||||
"minimum_rows_answered": "最少作答列數",
|
||||
"options_selected": "已選擇的選項",
|
||||
"pattern": "符合正則表達式樣式",
|
||||
"phone": "是有效的電話號碼",
|
||||
"rank_all_options": "請為所有選項排序",
|
||||
"select_file_extensions": "請選擇檔案副檔名...",
|
||||
"select_option": "選擇選項",
|
||||
"start_date": "開始日期",
|
||||
"url": "是有效的 URL"
|
||||
},
|
||||
"validation_logic_and": "全部為真",
|
||||
"validation_logic_or": "任一為真",
|
||||
"validation_rules": "驗證規則",
|
||||
"validation_rules_description": "僅接受符合下列條件的回應",
|
||||
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'variable'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數「{variableName}」正被使用於「{quotaName}」配額中",
|
||||
"variable_name_conflicts_with_hidden_field": "變數名稱與現有的隱藏欄位 ID 衝突。",
|
||||
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數 \"{variableName}\" 正被使用於 \"{quotaName}\" 配額中",
|
||||
"variable_name_is_already_taken_please_choose_another": "已使用此變數名稱,請選擇另一個名稱。",
|
||||
"variable_name_must_start_with_a_letter": "變數名稱必須以字母開頭。",
|
||||
"variable_used_in_recall": "變數 \"{variable}\" 於問題 {questionIndex} 中被召回。",
|
||||
|
||||
-11
@@ -3,7 +3,6 @@
|
||||
import { CheckCircle2Icon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
@@ -68,16 +67,6 @@ export const SingleResponseCardBody = ({
|
||||
<VerifiedEmail responseData={response.data} />
|
||||
)}
|
||||
{elements.map((question) => {
|
||||
// Skip CTA elements without external buttons only if they have no response data
|
||||
// This preserves historical data from when buttonExternal was true
|
||||
if (
|
||||
question.type === TSurveyElementTypeEnum.CTA &&
|
||||
!question.buttonExternal &&
|
||||
!response.data[question.id]
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const skipped = skippedQuestions.find((skippedQuestionElement) =>
|
||||
skippedQuestionElement.includes(question.id)
|
||||
);
|
||||
|
||||
@@ -15,7 +15,6 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { formatValidationErrorsForApi, validateResponseData } from "../lib/validation";
|
||||
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
|
||||
|
||||
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
|
||||
@@ -193,25 +192,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
});
|
||||
}
|
||||
|
||||
// Validate response data against validation rules
|
||||
const validationErrors = validateResponseData(
|
||||
questionsResponse.data.blocks,
|
||||
body.data,
|
||||
body.language ?? "en",
|
||||
questionsResponse.data.questions
|
||||
);
|
||||
|
||||
if (validationErrors) {
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: formatValidationErrorsForApi(validationErrors),
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const response = await updateResponseWithQuotaEvaluation(params.responseId, body);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
|
||||
import {
|
||||
formatValidationErrorsForApi,
|
||||
formatValidationErrorsForV1Api,
|
||||
validateResponseData,
|
||||
} from "./validation";
|
||||
|
||||
const mockTransformQuestionsToBlocks = vi.fn();
|
||||
const mockGetElementsFromBlocks = vi.fn();
|
||||
const mockValidateBlockResponses = vi.fn();
|
||||
|
||||
vi.mock("@/app/lib/api/survey-transformation", () => ({
|
||||
transformQuestionsToBlocks: (...args: unknown[]) => mockTransformQuestionsToBlocks(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/utils", () => ({
|
||||
getElementsFromBlocks: (...args: unknown[]) => mockGetElementsFromBlocks(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/surveys/validation", () => ({
|
||||
validateBlockResponses: (...args: unknown[]) => mockValidateBlockResponses(...args),
|
||||
}));
|
||||
|
||||
describe("validateResponseData", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
const mockBlocks: TSurveyBlock[] = [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "element1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mockQuestions: TSurveyQuestion[] = [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
} as unknown as TSurveyQuestion,
|
||||
];
|
||||
|
||||
const mockResponseData: TResponseData = { element1: "test" };
|
||||
const mockElements = [
|
||||
{
|
||||
id: "element1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
];
|
||||
|
||||
test("should use blocks when provided", () => {
|
||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||
mockValidateBlockResponses.mockReturnValue({});
|
||||
|
||||
const result = validateResponseData(mockBlocks, mockResponseData, "en");
|
||||
|
||||
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(mockBlocks);
|
||||
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return error map when validation fails", () => {
|
||||
const errorMap: TValidationErrorMap = {
|
||||
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length required" }],
|
||||
};
|
||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||
mockValidateBlockResponses.mockReturnValue(errorMap);
|
||||
|
||||
expect(validateResponseData(mockBlocks, mockResponseData, "en")).toEqual(errorMap);
|
||||
});
|
||||
|
||||
test("should transform questions to blocks when blocks are empty", () => {
|
||||
const transformedBlocks = [{ ...mockBlocks[0] }];
|
||||
mockTransformQuestionsToBlocks.mockReturnValue(transformedBlocks);
|
||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||
mockValidateBlockResponses.mockReturnValue({});
|
||||
|
||||
validateResponseData([], mockResponseData, "en", mockQuestions);
|
||||
|
||||
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
|
||||
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
|
||||
});
|
||||
|
||||
test("should prefer blocks over questions", () => {
|
||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||
mockValidateBlockResponses.mockReturnValue({});
|
||||
|
||||
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
|
||||
|
||||
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return null when both blocks and questions are empty", () => {
|
||||
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
|
||||
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
|
||||
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
|
||||
});
|
||||
|
||||
test("should use default language code", () => {
|
||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||
mockValidateBlockResponses.mockReturnValue({});
|
||||
|
||||
validateResponseData(mockBlocks, mockResponseData);
|
||||
|
||||
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatValidationErrorsForApi", () => {
|
||||
test("should convert error map to V2 API format", () => {
|
||||
const errorMap: TValidationErrorMap = {
|
||||
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length required" }],
|
||||
};
|
||||
|
||||
const result = formatValidationErrorsForApi(errorMap);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
field: "response.data.element1",
|
||||
issue: "Min length required",
|
||||
meta: { elementId: "element1", ruleId: "minLength", ruleType: "minLength" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("should handle multiple errors per element", () => {
|
||||
const errorMap: TValidationErrorMap = {
|
||||
element1: [
|
||||
{ ruleId: "minLength", ruleType: "minLength", message: "Min length" },
|
||||
{ ruleId: "maxLength", ruleType: "maxLength", message: "Max length" },
|
||||
],
|
||||
};
|
||||
|
||||
const result = formatValidationErrorsForApi(errorMap);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].field).toBe("response.data.element1");
|
||||
expect(result[1].field).toBe("response.data.element1");
|
||||
});
|
||||
|
||||
test("should handle multiple elements", () => {
|
||||
const errorMap: TValidationErrorMap = {
|
||||
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length" }],
|
||||
element2: [{ ruleId: "maxLength", ruleType: "maxLength", message: "Max length" }],
|
||||
};
|
||||
|
||||
const result = formatValidationErrorsForApi(errorMap);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].field).toBe("response.data.element1");
|
||||
expect(result[1].field).toBe("response.data.element2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatValidationErrorsForV1Api", () => {
|
||||
test("should convert error map to V1 API format", () => {
|
||||
const errorMap: TValidationErrorMap = {
|
||||
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length required" }],
|
||||
};
|
||||
|
||||
expect(formatValidationErrorsForV1Api(errorMap)).toEqual({
|
||||
"response.data.element1": "Min length required",
|
||||
});
|
||||
});
|
||||
|
||||
test("should combine multiple errors with semicolon", () => {
|
||||
const errorMap: TValidationErrorMap = {
|
||||
element1: [
|
||||
{ ruleId: "minLength", ruleType: "minLength", message: "Min length" },
|
||||
{ ruleId: "maxLength", ruleType: "maxLength", message: "Max length" },
|
||||
],
|
||||
};
|
||||
|
||||
expect(formatValidationErrorsForV1Api(errorMap)).toEqual({
|
||||
"response.data.element1": "Min length; Max length",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle multiple elements", () => {
|
||||
const errorMap: TValidationErrorMap = {
|
||||
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length" }],
|
||||
element2: [{ ruleId: "maxLength", ruleType: "maxLength", message: "Max length" }],
|
||||
};
|
||||
|
||||
expect(formatValidationErrorsForV1Api(errorMap)).toEqual({
|
||||
"response.data.element1": "Min length",
|
||||
"response.data.element2": "Max length",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import "server-only";
|
||||
import { validateBlockResponses } from "@formbricks/surveys/validation";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
|
||||
import { transformQuestionsToBlocks } from "@/app/lib/api/survey-transformation";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
|
||||
|
||||
/**
|
||||
* Validates response data against survey validation rules
|
||||
*
|
||||
* @param blocks - Survey blocks containing elements with validation rules (preferred)
|
||||
* @param questions - Survey questions (legacy format, used as fallback if blocks are empty)
|
||||
* @param responseData - Response data to validate (keyed by element ID)
|
||||
* @param languageCode - Language code for error messages (defaults to "en")
|
||||
* @returns Validation error map keyed by element ID, or null if validation passes
|
||||
*/
|
||||
export const validateResponseData = (
|
||||
blocks: TSurveyBlock[] | undefined | null,
|
||||
responseData: TResponseData,
|
||||
languageCode: string = "en",
|
||||
questions?: TSurveyQuestion[] | undefined | null
|
||||
): TValidationErrorMap | null => {
|
||||
// Use blocks if available, otherwise transform questions to blocks
|
||||
let blocksToUse: TSurveyBlock[] = [];
|
||||
|
||||
if (blocks && blocks.length > 0) {
|
||||
blocksToUse = blocks;
|
||||
} else if (questions && questions.length > 0) {
|
||||
// Transform legacy questions format to blocks for validation
|
||||
blocksToUse = transformQuestionsToBlocks(questions, []);
|
||||
} else {
|
||||
// No blocks or questions to validate against
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract elements from blocks
|
||||
const elements = getElementsFromBlocks(blocksToUse);
|
||||
|
||||
// Validate all elements
|
||||
const errorMap = validateBlockResponses(elements, responseData, languageCode);
|
||||
|
||||
// Return null if no errors (validation passed), otherwise return error map
|
||||
return Object.keys(errorMap).length === 0 ? null : errorMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts validation error map to API error response format (V2)
|
||||
*
|
||||
* @param errorMap - Validation error map from validateResponseData
|
||||
* @returns API error response details
|
||||
*/
|
||||
export const formatValidationErrorsForApi = (errorMap: TValidationErrorMap) => {
|
||||
const details: ApiErrorDetails = [];
|
||||
|
||||
for (const [elementId, errors] of Object.entries(errorMap)) {
|
||||
// Include all error messages for each element
|
||||
for (const error of errors) {
|
||||
details.push({
|
||||
field: `response.data.${elementId}`,
|
||||
issue: error.message,
|
||||
meta: {
|
||||
elementId,
|
||||
ruleId: error.ruleId,
|
||||
ruleType: error.ruleType,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return details;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts validation error map to V1 API error response format
|
||||
*
|
||||
* @param errorMap - Validation error map from validateResponseData
|
||||
* @returns V1 API error details as Record<string, string>
|
||||
*/
|
||||
export const formatValidationErrorsForV1Api = (errorMap: TValidationErrorMap): Record<string, string> => {
|
||||
const details: Record<string, string> = {};
|
||||
|
||||
for (const [elementId, errors] of Object.entries(errorMap)) {
|
||||
// Combine all error messages for each element
|
||||
const errorMessages = errors.map((error) => error.message).join("; ");
|
||||
details[`response.data.${elementId}`] = errorMessages;
|
||||
}
|
||||
|
||||
return details;
|
||||
};
|
||||
@@ -13,7 +13,6 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
|
||||
import { formatValidationErrorsForApi, validateResponseData } from "./lib/validation";
|
||||
|
||||
export const GET = async (request: NextRequest) =>
|
||||
authenticatedApiClient({
|
||||
@@ -129,25 +128,6 @@ export const POST = async (request: Request) =>
|
||||
});
|
||||
}
|
||||
|
||||
// Validate response data against validation rules
|
||||
const validationErrors = validateResponseData(
|
||||
surveyQuestions.data.blocks,
|
||||
body.data,
|
||||
body.language ?? "en",
|
||||
surveyQuestions.data.questions
|
||||
);
|
||||
|
||||
if (validationErrors) {
|
||||
return handleApiError(
|
||||
request,
|
||||
{
|
||||
type: "bad_request",
|
||||
details: formatValidationErrorsForApi(validationErrors),
|
||||
},
|
||||
auditLog
|
||||
);
|
||||
}
|
||||
|
||||
const createResponseResult = await createResponseWithQuotaEvaluation(environmentId, body);
|
||||
if (!createResponseResult.ok) {
|
||||
return handleApiError(request, createResponseResult.error, auditLog);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { TwoFactorBackup } from "@/modules/ee/two-factor-auth/components/two-fac
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { safeFormRequestSubmit } from "@/modules/ui/lib/utils";
|
||||
|
||||
const ZLoginForm = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -236,7 +237,7 @@ export const LoginForm = ({
|
||||
// Add a slight delay before focusing the input field to ensure it's visible
|
||||
setTimeout(() => emailRef.current?.focus(), 100);
|
||||
} else if (formRef.current) {
|
||||
formRef.current.requestSubmit();
|
||||
safeFormRequestSubmit(formRef.current);
|
||||
}
|
||||
}}
|
||||
className="relative w-full justify-center"
|
||||
|
||||
@@ -157,7 +157,6 @@ describe("License Core Logic", () => {
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live" as const,
|
||||
status: "active" as const,
|
||||
};
|
||||
|
||||
test("should return cached license from FETCH_LICENSE_CACHE_KEY if available and valid", async () => {
|
||||
@@ -234,7 +233,6 @@ describe("License Core Logic", () => {
|
||||
lastChecked: previousTime,
|
||||
isPendingDowngrade: true,
|
||||
fallbackLevel: "grace" as const,
|
||||
status: "unreachable" as const,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -311,7 +309,6 @@ describe("License Core Logic", () => {
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "unreachable" as const,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -359,7 +356,6 @@ describe("License Core Logic", () => {
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "unreachable" as const,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -393,7 +389,6 @@ describe("License Core Logic", () => {
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "no-license" as const,
|
||||
});
|
||||
expect(mockCache.get).not.toHaveBeenCalled();
|
||||
expect(mockCache.set).not.toHaveBeenCalled();
|
||||
@@ -419,7 +414,6 @@ describe("License Core Logic", () => {
|
||||
lastChecked: expect.any(Date),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "no-license" as const,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,17 +38,6 @@ const CONFIG = {
|
||||
// Types
|
||||
type FallbackLevel = "live" | "cached" | "grace" | "default";
|
||||
|
||||
type TEnterpriseLicenseStatusReturn = "active" | "expired" | "unreachable" | "no-license";
|
||||
|
||||
type TEnterpriseLicenseResult = {
|
||||
active: boolean;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: FallbackLevel;
|
||||
status: TEnterpriseLicenseStatusReturn;
|
||||
};
|
||||
|
||||
type TPreviousResult = {
|
||||
active: boolean;
|
||||
lastChecked: Date;
|
||||
@@ -101,7 +90,7 @@ class LicenseApiError extends LicenseError {
|
||||
|
||||
// Cache keys using enterprise-grade hierarchical patterns
|
||||
const getCacheIdentifier = () => {
|
||||
if (globalThis.window !== undefined) {
|
||||
if (typeof window !== "undefined") {
|
||||
return "browser"; // Browser environment
|
||||
}
|
||||
if (!env.ENTERPRISE_LICENSE_KEY) {
|
||||
@@ -153,50 +142,36 @@ const validateConfig = () => {
|
||||
};
|
||||
|
||||
// Cache functions with async pattern
|
||||
let getPreviousResultPromise: Promise<TPreviousResult> | null = null;
|
||||
|
||||
const getPreviousResult = async (): Promise<TPreviousResult> => {
|
||||
if (getPreviousResultPromise) return getPreviousResultPromise;
|
||||
|
||||
getPreviousResultPromise = (async () => {
|
||||
if (globalThis.window !== undefined) {
|
||||
return {
|
||||
active: false,
|
||||
lastChecked: new Date(0),
|
||||
features: DEFAULT_FEATURES,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await cache.get<TPreviousResult>(getCacheKeys().PREVIOUS_RESULT_CACHE_KEY);
|
||||
if (result.ok && result.data) {
|
||||
return {
|
||||
...result.data,
|
||||
lastChecked: new Date(result.data.lastChecked),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to get previous result from cache");
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
return {
|
||||
active: false,
|
||||
lastChecked: new Date(0),
|
||||
features: DEFAULT_FEATURES,
|
||||
};
|
||||
})();
|
||||
}
|
||||
|
||||
getPreviousResultPromise
|
||||
.finally(() => {
|
||||
getPreviousResultPromise = null;
|
||||
})
|
||||
.catch(() => {});
|
||||
try {
|
||||
const result = await cache.get<TPreviousResult>(getCacheKeys().PREVIOUS_RESULT_CACHE_KEY);
|
||||
if (result.ok && result.data) {
|
||||
return {
|
||||
...result.data,
|
||||
lastChecked: new Date(result.data.lastChecked),
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to get previous result from cache");
|
||||
}
|
||||
|
||||
return getPreviousResultPromise;
|
||||
return {
|
||||
active: false,
|
||||
lastChecked: new Date(0),
|
||||
features: DEFAULT_FEATURES,
|
||||
};
|
||||
};
|
||||
|
||||
const setPreviousResult = async (previousResult: TPreviousResult) => {
|
||||
if (globalThis.window !== undefined) return;
|
||||
if (typeof window !== "undefined") return;
|
||||
|
||||
try {
|
||||
const result = await cache.set(
|
||||
@@ -246,21 +221,12 @@ const validateLicenseDetails = (data: unknown): TEnterpriseLicenseDetails => {
|
||||
};
|
||||
|
||||
// Fallback functions
|
||||
let memoryCache: {
|
||||
data: TEnterpriseLicenseResult;
|
||||
timestamp: number;
|
||||
} | null = null;
|
||||
|
||||
const MEMORY_CACHE_TTL_MS = 60 * 1000; // 1 minute memory cache to avoid stampedes and reduce load when Redis is slow
|
||||
|
||||
let getEnterpriseLicensePromise: Promise<TEnterpriseLicenseResult> | null = null;
|
||||
|
||||
const getFallbackLevel = (
|
||||
liveLicense: TEnterpriseLicenseDetails | null,
|
||||
previousResult: TPreviousResult,
|
||||
currentTime: Date
|
||||
): FallbackLevel => {
|
||||
if (liveLicense?.status === "active") return "live";
|
||||
if (liveLicense) return "live";
|
||||
if (previousResult.active) {
|
||||
const elapsedTime = currentTime.getTime() - previousResult.lastChecked.getTime();
|
||||
return elapsedTime < CONFIG.CACHE.GRACE_PERIOD_MS ? "grace" : "default";
|
||||
@@ -268,7 +234,7 @@ const getFallbackLevel = (
|
||||
return "default";
|
||||
};
|
||||
|
||||
const handleInitialFailure = async (currentTime: Date): Promise<TEnterpriseLicenseResult> => {
|
||||
const handleInitialFailure = async (currentTime: Date) => {
|
||||
const initialFailResult: TPreviousResult = {
|
||||
active: false,
|
||||
features: DEFAULT_FEATURES,
|
||||
@@ -281,13 +247,10 @@ const handleInitialFailure = async (currentTime: Date): Promise<TEnterpriseLicen
|
||||
lastChecked: currentTime,
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "unreachable" as const,
|
||||
};
|
||||
};
|
||||
|
||||
// API functions
|
||||
let fetchLicensePromise: Promise<TEnterpriseLicenseDetails | null> | null = null;
|
||||
|
||||
const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpriseLicenseDetails | null> => {
|
||||
if (!env.ENTERPRISE_LICENSE_KEY) return null;
|
||||
|
||||
@@ -303,7 +266,6 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
// first millisecond of next year => current year is fully included
|
||||
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
|
||||
|
||||
const startTime = Date.now();
|
||||
const [instanceId, responseCount] = await Promise.all([
|
||||
// Skip instance ID during E2E tests to avoid license key conflicts
|
||||
// as the instance ID changes with each test run
|
||||
@@ -317,11 +279,6 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
if (duration > 1000) {
|
||||
logger.warn({ duration, responseCount }, "Slow license check prerequisite data fetching (DB count)");
|
||||
}
|
||||
|
||||
// No organization exists, cannot perform license check
|
||||
// (skip this check during E2E tests as we intentionally use null)
|
||||
@@ -354,19 +311,7 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
|
||||
|
||||
if (res.ok) {
|
||||
const responseJson = (await res.json()) as { data: unknown };
|
||||
const licenseDetails = validateLicenseDetails(responseJson.data);
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
status: licenseDetails.status,
|
||||
instanceId: instanceId ?? "not-set",
|
||||
responseCount,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
"License check API response received"
|
||||
);
|
||||
|
||||
return licenseDetails;
|
||||
return validateLicenseDetails(responseJson.data);
|
||||
}
|
||||
|
||||
const error = new LicenseApiError(`License check API responded with status: ${res.status}`, res.status);
|
||||
@@ -397,41 +342,23 @@ export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null>
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fetchLicensePromise) {
|
||||
return fetchLicensePromise;
|
||||
}
|
||||
|
||||
fetchLicensePromise = (async () => {
|
||||
return await cache.withCache(
|
||||
async () => {
|
||||
return await fetchLicenseFromServerInternal();
|
||||
},
|
||||
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
|
||||
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
|
||||
);
|
||||
})();
|
||||
|
||||
fetchLicensePromise
|
||||
.finally(() => {
|
||||
fetchLicensePromise = null;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return fetchLicensePromise;
|
||||
return await cache.withCache(
|
||||
async () => {
|
||||
return await fetchLicenseFromServerInternal();
|
||||
},
|
||||
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
|
||||
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
|
||||
);
|
||||
};
|
||||
|
||||
export const getEnterpriseLicense = reactCache(async (): Promise<TEnterpriseLicenseResult> => {
|
||||
if (
|
||||
process.env.NODE_ENV !== "test" &&
|
||||
memoryCache &&
|
||||
Date.now() - memoryCache.timestamp < MEMORY_CACHE_TTL_MS
|
||||
) {
|
||||
return memoryCache.data;
|
||||
}
|
||||
|
||||
if (getEnterpriseLicensePromise) return getEnterpriseLicensePromise;
|
||||
|
||||
getEnterpriseLicensePromise = (async () => {
|
||||
export const getEnterpriseLicense = reactCache(
|
||||
async (): Promise<{
|
||||
active: boolean;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: FallbackLevel;
|
||||
}> => {
|
||||
validateConfig();
|
||||
|
||||
if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) {
|
||||
@@ -441,11 +368,12 @@ export const getEnterpriseLicense = reactCache(async (): Promise<TEnterpriseLice
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "no-license" as const,
|
||||
};
|
||||
}
|
||||
|
||||
const currentTime = new Date();
|
||||
const [liveLicenseDetails, previousResult] = await Promise.all([fetchLicense(), getPreviousResult()]);
|
||||
const liveLicenseDetails = await fetchLicense();
|
||||
const previousResult = await getPreviousResult();
|
||||
const fallbackLevel = getFallbackLevel(liveLicenseDetails, previousResult, currentTime);
|
||||
|
||||
trackFallbackUsage(fallbackLevel);
|
||||
@@ -453,84 +381,41 @@ export const getEnterpriseLicense = reactCache(async (): Promise<TEnterpriseLice
|
||||
let currentLicenseState: TPreviousResult | undefined;
|
||||
|
||||
switch (fallbackLevel) {
|
||||
case "live": {
|
||||
case "live":
|
||||
if (!liveLicenseDetails) throw new Error("Invalid state: live license expected");
|
||||
currentLicenseState = {
|
||||
active: liveLicenseDetails.status === "active",
|
||||
features: liveLicenseDetails.features,
|
||||
lastChecked: currentTime,
|
||||
};
|
||||
|
||||
// Only update previous result if it's actually different or if it's old (1 hour)
|
||||
// This prevents hammering Redis on every request when the license is active
|
||||
if (
|
||||
!previousResult.active ||
|
||||
previousResult.active !== currentLicenseState.active ||
|
||||
currentTime.getTime() - previousResult.lastChecked.getTime() > 60 * 60 * 1000
|
||||
) {
|
||||
await setPreviousResult(currentLicenseState);
|
||||
}
|
||||
|
||||
const liveResult: TEnterpriseLicenseResult = {
|
||||
await setPreviousResult(currentLicenseState);
|
||||
return {
|
||||
active: currentLicenseState.active,
|
||||
features: currentLicenseState.features,
|
||||
lastChecked: currentTime,
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live" as const,
|
||||
status: liveLicenseDetails.status,
|
||||
};
|
||||
memoryCache = { data: liveResult, timestamp: Date.now() };
|
||||
return liveResult;
|
||||
}
|
||||
|
||||
case "grace": {
|
||||
case "grace":
|
||||
if (!validateFallback(previousResult)) {
|
||||
return await handleInitialFailure(currentTime);
|
||||
return handleInitialFailure(currentTime);
|
||||
}
|
||||
const graceResult: TEnterpriseLicenseResult = {
|
||||
return {
|
||||
active: previousResult.active,
|
||||
features: previousResult.features,
|
||||
lastChecked: previousResult.lastChecked,
|
||||
isPendingDowngrade: true,
|
||||
fallbackLevel: "grace" as const,
|
||||
status: (liveLicenseDetails?.status as TEnterpriseLicenseStatusReturn) ?? "unreachable",
|
||||
};
|
||||
memoryCache = { data: graceResult, timestamp: Date.now() };
|
||||
return graceResult;
|
||||
}
|
||||
|
||||
case "default": {
|
||||
if (liveLicenseDetails?.status === "expired") {
|
||||
const expiredResult: TEnterpriseLicenseResult = {
|
||||
active: false,
|
||||
features: DEFAULT_FEATURES,
|
||||
lastChecked: currentTime,
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "default" as const,
|
||||
status: "expired" as const,
|
||||
};
|
||||
memoryCache = { data: expiredResult, timestamp: Date.now() };
|
||||
return expiredResult;
|
||||
}
|
||||
const failResult = await handleInitialFailure(currentTime);
|
||||
memoryCache = { data: failResult, timestamp: Date.now() };
|
||||
return failResult;
|
||||
}
|
||||
case "default":
|
||||
return handleInitialFailure(currentTime);
|
||||
}
|
||||
|
||||
const finalFailResult = await handleInitialFailure(currentTime);
|
||||
memoryCache = { data: finalFailResult, timestamp: Date.now() };
|
||||
return finalFailResult;
|
||||
})();
|
||||
|
||||
getEnterpriseLicensePromise
|
||||
.finally(() => {
|
||||
getEnterpriseLicensePromise = null;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return getEnterpriseLicensePromise;
|
||||
});
|
||||
return handleInitialFailure(currentTime);
|
||||
}
|
||||
);
|
||||
|
||||
export const getLicenseFeatures = async (): Promise<TEnterpriseLicenseFeatures | null> => {
|
||||
try {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
getBiggerUploadFileSizePermission,
|
||||
getIsContactsEnabled,
|
||||
getIsMultiOrgEnabled,
|
||||
getIsQuotasEnabled,
|
||||
getIsSamlSsoEnabled,
|
||||
getIsSpamProtectionEnabled,
|
||||
getIsSsoEnabled,
|
||||
@@ -49,7 +48,6 @@ const defaultFeatures: TEnterpriseLicenseFeatures = {
|
||||
auditLogs: false,
|
||||
multiLanguageSurveys: false,
|
||||
accessControl: false,
|
||||
quotas: false,
|
||||
};
|
||||
|
||||
const defaultLicense = {
|
||||
@@ -186,10 +184,10 @@ describe("License Utils", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active but accessControl feature disabled (self-hosted)", async () => {
|
||||
test("should return true if license active but accessControl feature disabled because of fallback", async () => {
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getAccessControlPermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license is inactive", async () => {
|
||||
@@ -275,10 +273,10 @@ describe("License Utils", () => {
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active but multiLanguageSurveys feature disabled (self-hosted)", async () => {
|
||||
test("should return true if license active but multiLanguageSurveys feature disabled because of fallback", async () => {
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license is inactive", async () => {
|
||||
@@ -291,54 +289,6 @@ describe("License Utils", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsQuotasEnabled", () => {
|
||||
test("should return true if license active and quotas feature enabled (self-hosted)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, quotas: true },
|
||||
});
|
||||
const result = await getIsQuotasEnabled(mockOrganization.billing.plan);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true if license active, quotas enabled and plan is CUSTOM (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, quotas: true },
|
||||
});
|
||||
const result = await getIsQuotasEnabled(constants.PROJECT_FEATURE_KEYS.CUSTOM);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false if license active, quotas enabled but plan is not CUSTOM (cloud)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
features: { ...defaultFeatures, quotas: true },
|
||||
});
|
||||
const result = await getIsQuotasEnabled(constants.PROJECT_FEATURE_KEYS.STARTUP);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false if license active but quotas feature disabled (self-hosted)", async () => {
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
|
||||
const result = await getIsQuotasEnabled(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false if license is inactive", async () => {
|
||||
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
|
||||
...defaultLicense,
|
||||
active: false,
|
||||
});
|
||||
const result = await getIsQuotasEnabled(mockOrganization.billing.plan);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsMultiOrgEnabled", () => {
|
||||
test("should return true if feature flag isMultiOrgEnabled is true", async () => {
|
||||
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
|
||||
|
||||
@@ -10,8 +10,6 @@ import { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/ent
|
||||
import { getEnterpriseLicense, getLicenseFeatures } from "./license";
|
||||
|
||||
// Helper function for feature permissions (e.g., removeBranding, whitelabel)
|
||||
// On Cloud: requires active license and non-FREE plan
|
||||
// On Self-hosted: requires active license and feature enabled
|
||||
const getFeaturePermission = async (
|
||||
billingPlan: Organization["billing"]["plan"],
|
||||
featureKey: keyof Pick<TEnterpriseLicenseFeatures, "removeBranding" | "whitelabel">
|
||||
@@ -25,41 +23,6 @@ const getFeaturePermission = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function for enterprise features that require CUSTOM plan on Cloud
|
||||
// On Cloud: requires active license AND feature enabled in license AND CUSTOM billing plan
|
||||
// On Self-hosted: requires active license AND feature enabled in license
|
||||
const getCustomPlanFeaturePermission = async (
|
||||
billingPlan: Organization["billing"]["plan"],
|
||||
featureKey: keyof Pick<TEnterpriseLicenseFeatures, "accessControl" | "multiLanguageSurveys" | "quotas">
|
||||
): Promise<boolean> => {
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
if (!license.active) return false;
|
||||
|
||||
const isFeatureEnabled = license.features?.[featureKey] ?? false;
|
||||
if (!isFeatureEnabled) return false;
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return billingPlan === PROJECT_FEATURE_KEYS.CUSTOM;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Helper function for license-only feature flags (no billing plan check)
|
||||
// Returns true only if the license is active AND the specific feature is enabled in the license
|
||||
// Used for features that are controlled purely by the license key, not billing plans
|
||||
const getSpecificFeatureFlag = async (
|
||||
featureKey: keyof Pick<
|
||||
TEnterpriseLicenseFeatures,
|
||||
"isMultiOrgEnabled" | "contacts" | "twoFactorAuth" | "sso" | "auditLogs"
|
||||
>
|
||||
): Promise<boolean> => {
|
||||
const licenseFeatures = await getLicenseFeatures();
|
||||
if (!licenseFeatures) return false;
|
||||
return typeof licenseFeatures[featureKey] === "boolean" ? licenseFeatures[featureKey] : false;
|
||||
};
|
||||
|
||||
export const getRemoveBrandingPermission = async (
|
||||
billingPlan: Organization["billing"]["plan"]
|
||||
): Promise<boolean> => {
|
||||
@@ -82,6 +45,24 @@ export const getBiggerUploadFileSizePermission = async (
|
||||
return false;
|
||||
};
|
||||
|
||||
const getSpecificFeatureFlag = async (
|
||||
featureKey: keyof Pick<
|
||||
TEnterpriseLicenseFeatures,
|
||||
| "isMultiOrgEnabled"
|
||||
| "contacts"
|
||||
| "twoFactorAuth"
|
||||
| "sso"
|
||||
| "auditLogs"
|
||||
| "multiLanguageSurveys"
|
||||
| "accessControl"
|
||||
| "quotas"
|
||||
>
|
||||
): Promise<boolean> => {
|
||||
const licenseFeatures = await getLicenseFeatures();
|
||||
if (!licenseFeatures) return false;
|
||||
return typeof licenseFeatures[featureKey] === "boolean" ? licenseFeatures[featureKey] : false;
|
||||
};
|
||||
|
||||
export const getIsMultiOrgEnabled = async (): Promise<boolean> => {
|
||||
return getSpecificFeatureFlag("isMultiOrgEnabled");
|
||||
};
|
||||
@@ -99,7 +80,12 @@ export const getIsSsoEnabled = async (): Promise<boolean> => {
|
||||
};
|
||||
|
||||
export const getIsQuotasEnabled = async (billingPlan: Organization["billing"]["plan"]): Promise<boolean> => {
|
||||
return getCustomPlanFeaturePermission(billingPlan, "quotas");
|
||||
const isEnabled = await getSpecificFeatureFlag("quotas");
|
||||
// If the feature is enabled in the license, return true
|
||||
if (isEnabled) return true;
|
||||
|
||||
// If the feature is not enabled in the license, check the fallback(Backwards compatibility)
|
||||
return featureFlagFallback(billingPlan);
|
||||
};
|
||||
|
||||
export const getIsAuditLogsEnabled = async (): Promise<boolean> => {
|
||||
@@ -132,16 +118,33 @@ export const getIsSpamProtectionEnabled = async (
|
||||
return license.active && !!license.features?.spamProtection;
|
||||
};
|
||||
|
||||
const featureFlagFallback = async (billingPlan: Organization["billing"]["plan"]): Promise<boolean> => {
|
||||
const license = await getEnterpriseLicense();
|
||||
if (IS_FORMBRICKS_CLOUD) return license.active && billingPlan === PROJECT_FEATURE_KEYS.CUSTOM;
|
||||
else if (!IS_FORMBRICKS_CLOUD) return license.active;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getMultiLanguagePermission = async (
|
||||
billingPlan: Organization["billing"]["plan"]
|
||||
): Promise<boolean> => {
|
||||
return getCustomPlanFeaturePermission(billingPlan, "multiLanguageSurveys");
|
||||
const isEnabled = await getSpecificFeatureFlag("multiLanguageSurveys");
|
||||
// If the feature is enabled in the license, return true
|
||||
if (isEnabled) return true;
|
||||
|
||||
// If the feature is not enabled in the license, check the fallback(Backwards compatibility)
|
||||
return featureFlagFallback(billingPlan);
|
||||
};
|
||||
|
||||
export const getAccessControlPermission = async (
|
||||
billingPlan: Organization["billing"]["plan"]
|
||||
): Promise<boolean> => {
|
||||
return getCustomPlanFeaturePermission(billingPlan, "accessControl");
|
||||
const isEnabled = await getSpecificFeatureFlag("accessControl");
|
||||
// If the feature is enabled in the license, return true
|
||||
if (isEnabled) return true;
|
||||
|
||||
// If the feature is not enabled in the license, check the fallback(Backwards compatibility)
|
||||
return featureFlagFallback(billingPlan);
|
||||
};
|
||||
|
||||
export const getOrganizationProjectsLimit = async (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -62,7 +62,7 @@ export const QuotaConditionBuilder = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-h-[150px] space-y-4 overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
<ConditionsEditor
|
||||
conditions={genericConditions}
|
||||
config={config}
|
||||
|
||||
@@ -438,45 +438,5 @@ describe("Quota Evaluation Service", () => {
|
||||
"Error evaluating quotas for response"
|
||||
);
|
||||
});
|
||||
|
||||
test("should use 'default' language when provided language matches default language", async () => {
|
||||
const surveyWithLanguages = {
|
||||
...mockSurvey,
|
||||
languages: [
|
||||
{ default: true, language: { code: "en", flag: "🇺🇸" } },
|
||||
{ default: false, language: { code: "fr", flag: "🇫🇷" } },
|
||||
],
|
||||
};
|
||||
|
||||
const input: QuotaEvaluationInput = {
|
||||
surveyId: mockSurveyId,
|
||||
responseId: mockResponseId,
|
||||
data: mockResponseData,
|
||||
variables: mockVariablesData,
|
||||
language: "en",
|
||||
responseFinished: true,
|
||||
tx: mockTx,
|
||||
};
|
||||
|
||||
const evaluateResult = {
|
||||
passedQuotas: [mockQuota],
|
||||
failedQuotas: [],
|
||||
};
|
||||
|
||||
vi.mocked(getQuotas).mockResolvedValue([mockQuota]);
|
||||
vi.mocked(getSurvey).mockResolvedValue(surveyWithLanguages as unknown as TSurvey);
|
||||
vi.mocked(evaluateQuotas).mockReturnValue(evaluateResult);
|
||||
vi.mocked(handleQuotas).mockResolvedValue(null);
|
||||
|
||||
await evaluateResponseQuotas(input);
|
||||
|
||||
expect(evaluateQuotas).toHaveBeenCalledWith(
|
||||
surveyWithLanguages,
|
||||
mockResponseData,
|
||||
mockVariablesData,
|
||||
[mockQuota],
|
||||
"default"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,8 +51,8 @@ export const evaluateResponseQuotas = async (input: QuotaEvaluationInput): Promi
|
||||
if (!survey) {
|
||||
return { shouldEndSurvey: false };
|
||||
}
|
||||
const isDefaultLanguage = survey.languages.find((lang) => lang.default)?.language.code === language;
|
||||
const result = evaluateQuotas(survey, data, variables, quotas, isDefaultLanguage ? "default" : language);
|
||||
|
||||
const result = evaluateQuotas(survey, data, variables, quotas, language);
|
||||
|
||||
const quotaFull = await handleQuotas(surveyId, responseId, result, responseFinished, prismaClient);
|
||||
|
||||
|
||||
@@ -213,8 +213,8 @@ export async function PreviewEmailTemplate({
|
||||
{ "rounded-l-lg border-l": i === 0 },
|
||||
{ "rounded-r-lg": i === firstQuestion.range - 1 },
|
||||
firstQuestion.isColorCodingEnabled &&
|
||||
firstQuestion.scale === "number" &&
|
||||
`border border-t-[6px] border-t-${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`,
|
||||
firstQuestion.scale === "number" &&
|
||||
`border border-t-[6px] border-t-${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`,
|
||||
firstQuestion.scale === "star" && "border-transparent"
|
||||
)}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${(i + 1).toString()}`}
|
||||
|
||||
@@ -71,12 +71,12 @@ export const sendEmail = async (emailData: SendEmailDataProps): Promise<boolean>
|
||||
secure: SMTP_SECURE_ENABLED, // true for 465, false for other ports
|
||||
...(SMTP_AUTHENTICATED
|
||||
? {
|
||||
auth: {
|
||||
type: "LOGIN",
|
||||
user: SMTP_USER,
|
||||
pass: SMTP_PASSWORD,
|
||||
},
|
||||
}
|
||||
auth: {
|
||||
type: "LOGIN",
|
||||
user: SMTP_USER,
|
||||
pass: SMTP_PASSWORD,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
tls: {
|
||||
rejectUnauthorized: SMTP_REJECT_UNAUTHORIZED_TLS,
|
||||
@@ -257,12 +257,12 @@ export const sendResponseFinishedEmail = async (
|
||||
to: email,
|
||||
subject: personEmail
|
||||
? t("emails.response_finished_email_subject_with_email", {
|
||||
personEmail,
|
||||
surveyName: survey.name,
|
||||
})
|
||||
personEmail,
|
||||
surveyName: survey.name,
|
||||
})
|
||||
: t("emails.response_finished_email_subject", {
|
||||
surveyName: survey.name,
|
||||
}),
|
||||
surveyName: survey.name,
|
||||
}),
|
||||
replyTo: personEmail?.toString() ?? MAIL_FROM,
|
||||
html,
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ type TEnterpriseLicense = {
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade: boolean;
|
||||
fallbackLevel: string;
|
||||
status: "active" | "expired" | "unreachable" | "no-license";
|
||||
};
|
||||
|
||||
export const ZEnvironmentAuth = z.object({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
)
|
||||
|
||||
-2
@@ -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);
|
||||
|
||||
+2
-2
@@ -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()]);
|
||||
|
||||
@@ -153,9 +153,9 @@ export const ElementFormInput = ({
|
||||
(currentElement &&
|
||||
(id.includes(".")
|
||||
? // Handle nested properties
|
||||
(currentElement[id.split(".")[0] as keyof TSurveyElement] as any)?.[id.split(".")[1]]
|
||||
(currentElement[id.split(".")[0] as keyof TSurveyElement] as any)?.[id.split(".")[1]]
|
||||
: // Original behavior
|
||||
(currentElement[id as keyof TSurveyElement] as TI18nString))) ||
|
||||
(currentElement[id as keyof TSurveyElement] as TI18nString))) ||
|
||||
createI18nString("", surveyLanguageCodes)
|
||||
);
|
||||
}, [
|
||||
@@ -391,7 +391,7 @@ export const ElementFormInput = ({
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<div className="mt-3 mb-2 flex items-center justify-between">
|
||||
<div className="mb-2 mt-3 flex items-center justify-between">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{id === "headline" && currentElement && updateElement && (
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -521,8 +521,23 @@ export const ElementFormInput = ({
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<div className="mt-3 mb-2 flex items-center justify-between">
|
||||
<div className="mb-2 mt-3 flex items-center justify-between">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{id === "headline" && currentElement && updateElement && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor="required-toggle" className="text-sm">
|
||||
{t("environments.surveys.edit.required")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="required-toggle"
|
||||
checked={currentElement.required}
|
||||
disabled={getIsRequiredToggleDisabled()}
|
||||
onCheckedChange={(checked) => {
|
||||
updateElement(elementIdx, { required: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<MultiLangWrapper
|
||||
@@ -568,9 +583,8 @@ export const ElementFormInput = ({
|
||||
<div className="h-10 w-full"></div>
|
||||
<div
|
||||
ref={highlightContainerRef}
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent ${
|
||||
localSurvey.languages?.length > 1 ? "pr-24" : ""
|
||||
}`}
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ${localSurvey.languages?.length > 1 ? "pr-24" : ""
|
||||
}`}
|
||||
dir="auto"
|
||||
key={highlightedJSX.toString()}>
|
||||
{highlightedJSX}
|
||||
@@ -597,9 +611,8 @@ export const ElementFormInput = ({
|
||||
maxLength={maxLength}
|
||||
ref={inputRef}
|
||||
onBlur={onBlur}
|
||||
className={`absolute top-0 text-black caret-black ${
|
||||
localSurvey.languages?.length > 1 ? "pr-24" : ""
|
||||
} ${className}`}
|
||||
className={`absolute top-0 text-black caret-black ${localSurvey.languages?.length > 1 ? "pr-24" : ""
|
||||
} ${className}`}
|
||||
isInvalid={
|
||||
isInvalid &&
|
||||
text[usedLanguageCode]?.trim() === "" &&
|
||||
|
||||
@@ -4,12 +4,11 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TSurveyAddressElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyAddressElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
||||
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ElementToggleTable } from "@/modules/ui/components/element-toggle-table";
|
||||
|
||||
@@ -160,17 +159,6 @@ export const AddressElementForm = ({
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ValidationRulesEditor
|
||||
elementType={element.type}
|
||||
validation={element.validation}
|
||||
onUpdateValidation={(validation) => {
|
||||
updateElement(elementIdx, {
|
||||
validation,
|
||||
});
|
||||
}}
|
||||
element={element}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ import { PictureSelectionForm } from "@/modules/survey/editor/components/picture
|
||||
import { RankingElementForm } from "@/modules/survey/editor/components/ranking-element-form";
|
||||
import { RatingElementForm } from "@/modules/survey/editor/components/rating-element-form";
|
||||
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
|
||||
import { isLabelValidForAllLanguages } from "@/modules/survey/editor/lib/validation";
|
||||
import { getElementIconMap, getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
|
||||
@@ -127,7 +128,25 @@ export const BlockCard = ({
|
||||
const isBlockOpen = block.elements.some((element) => element.id === activeElementId);
|
||||
|
||||
const hasInvalidElement = block.elements.some((element) => invalidElements?.includes(element.id));
|
||||
const isBlockInvalid = hasInvalidElement;
|
||||
|
||||
// Check if button labels have incomplete translations for any enabled language
|
||||
// A button label is invalid if it exists but doesn't have valid text for all enabled languages
|
||||
const surveyLanguages = localSurvey.languages ?? [];
|
||||
const hasInvalidButtonLabel =
|
||||
block.buttonLabel !== undefined &&
|
||||
block.buttonLabel["default"]?.trim() !== "" &&
|
||||
!isLabelValidForAllLanguages(block.buttonLabel, surveyLanguages);
|
||||
|
||||
// Check if back button label is invalid
|
||||
// Back button label should exist for all blocks except the first one
|
||||
const hasInvalidBackButtonLabel =
|
||||
blockIdx > 0 &&
|
||||
block.backButtonLabel !== undefined &&
|
||||
block.backButtonLabel["default"]?.trim() !== "" &&
|
||||
!isLabelValidForAllLanguages(block.backButtonLabel, surveyLanguages);
|
||||
|
||||
// Block should be highlighted if it has invalid elements OR invalid button labels
|
||||
const isBlockInvalid = hasInvalidElement || hasInvalidButtonLabel || hasInvalidBackButtonLabel;
|
||||
|
||||
const [isBlockCollapsed, setIsBlockCollapsed] = useState(false);
|
||||
const [openAdvanced, setOpenAdvanced] = useState(blockLogic.length > 0);
|
||||
|
||||
@@ -4,12 +4,11 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TSurveyContactInfoElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyContactInfoElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
||||
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ElementToggleTable } from "@/modules/ui/components/element-toggle-table";
|
||||
|
||||
@@ -157,16 +156,6 @@ export const ContactInfoElementForm = ({
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ValidationRulesEditor
|
||||
elementType={element.type}
|
||||
validation={element.validation}
|
||||
onUpdateValidation={(validation) => {
|
||||
updateElement(elementIdx, {
|
||||
validation,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,12 +4,11 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TSurveyDateElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyDateElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
||||
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
|
||||
@@ -127,16 +126,6 @@ export const DateElementForm = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ValidationRulesEditor
|
||||
elementType={element.type}
|
||||
validation={element.validation}
|
||||
onUpdateValidation={(validation) => {
|
||||
updateElement(elementIdx, {
|
||||
validation,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -112,7 +112,6 @@ export const EditorCardMenu = ({
|
||||
choices: card.choices,
|
||||
type,
|
||||
logic: undefined,
|
||||
validation: undefined,
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -129,7 +128,6 @@ export const EditorCardMenu = ({
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
logic: undefined,
|
||||
validation: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -46,15 +46,9 @@ import {
|
||||
renumberBlocks,
|
||||
updateElementInBlock,
|
||||
} from "@/modules/survey/editor/lib/blocks";
|
||||
import {
|
||||
findBlockUsedInLogic,
|
||||
findElementUsedInLogic,
|
||||
isUsedInQuota,
|
||||
isUsedInRecall,
|
||||
} from "@/modules/survey/editor/lib/utils";
|
||||
import { findElementUsedInLogic, isUsedInQuota, isUsedInRecall } 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;
|
||||
@@ -100,16 +94,6 @@ export const ElementsView = ({
|
||||
isExternalUrlsAllowed,
|
||||
}: ElementsViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [logicDeletionWarning, setLogicDeletionWarning] = React.useState<{
|
||||
open: boolean;
|
||||
elementIdx: number;
|
||||
type: "element" | "block";
|
||||
blockId?: string;
|
||||
}>({
|
||||
open: false,
|
||||
elementIdx: 0,
|
||||
type: "element",
|
||||
});
|
||||
|
||||
const elements = useMemo(() => getElementsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
|
||||
|
||||
@@ -211,6 +195,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 +234,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 +280,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 +328,9 @@ export const ElementsView = ({
|
||||
});
|
||||
|
||||
// Apply side effects after state update is queued
|
||||
if (invalidElementsUpdate) {
|
||||
setInvalidElements(invalidElementsUpdate);
|
||||
}
|
||||
if (newActiveElementId) {
|
||||
setActiveElementId(newActiveElementId);
|
||||
}
|
||||
@@ -364,6 +388,14 @@ export const ElementsView = ({
|
||||
};
|
||||
|
||||
const validateElementDeletion = (elementId: string, elementIdx: number): boolean => {
|
||||
const usedElementIdx = findElementUsedInLogic(localSurvey, elementId);
|
||||
if (usedElementIdx !== -1) {
|
||||
toast.error(
|
||||
t("environments.surveys.edit.question_used_in_logic", { questionIndex: usedElementIdx + 1 })
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const recallElementIdx = isUsedInRecall(localSurvey, elementId);
|
||||
if (recallElementIdx === elements.length) {
|
||||
toast.error(t("environments.surveys.edit.question_used_in_recall_ending_card"));
|
||||
@@ -407,11 +439,15 @@ export const ElementsView = ({
|
||||
}
|
||||
};
|
||||
|
||||
const executeDeletion = (elementIdx: number) => {
|
||||
const deleteElement = (elementIdx: number) => {
|
||||
const element = elements[elementIdx];
|
||||
if (!element) return;
|
||||
|
||||
const elementId = element.id;
|
||||
if (!validateElementDeletion(elementId, elementIdx)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElementIdTemp = activeElementId ?? elements[0]?.id;
|
||||
// let updatedSurvey = removeRecallReferences(localSurvey, elementId);
|
||||
let updatedSurvey = structuredClone(localSurvey);
|
||||
@@ -439,24 +475,6 @@ export const ElementsView = ({
|
||||
toast.success(t("environments.surveys.edit.question_deleted"));
|
||||
};
|
||||
|
||||
const deleteElement = (elementIdx: number) => {
|
||||
const element = elements[elementIdx];
|
||||
if (!element) return;
|
||||
|
||||
const elementId = element.id;
|
||||
if (!validateElementDeletion(elementId, elementIdx)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usedElementIdx = findElementUsedInLogic(localSurvey, elementId);
|
||||
if (usedElementIdx !== -1) {
|
||||
setLogicDeletionWarning({ open: true, elementIdx, type: "element" });
|
||||
return;
|
||||
}
|
||||
|
||||
executeDeletion(elementIdx);
|
||||
};
|
||||
|
||||
const duplicateElement = (elementIdx: number) => {
|
||||
const element = elements[elementIdx];
|
||||
if (!element) return;
|
||||
@@ -654,7 +672,7 @@ export const ElementsView = ({
|
||||
toast.success(t("environments.surveys.edit.block_duplicated"));
|
||||
};
|
||||
|
||||
const executeBlockDeletion = (blockId: string) => {
|
||||
const deleteBlockById = (blockId: string) => {
|
||||
// First check if block exists in current state (for validation and calculating next active element)
|
||||
const blockExists = localSurvey.blocks.some((b) => b.id === blockId);
|
||||
if (!blockExists) {
|
||||
@@ -691,28 +709,6 @@ export const ElementsView = ({
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBlockById = (blockId: string) => {
|
||||
// First check if block is used in logic
|
||||
const usedElementIdx = findBlockUsedInLogic(localSurvey, blockId);
|
||||
if (usedElementIdx !== -1) {
|
||||
setLogicDeletionWarning({ open: true, elementIdx: 0, type: "block", blockId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Then check if any element in the block is used in recall/quota
|
||||
const block = localSurvey.blocks.find((b) => b.id === blockId);
|
||||
if (block) {
|
||||
for (const element of block.elements) {
|
||||
const elementIdx = elements.findIndex((e) => e.id === element.id);
|
||||
if (!validateElementDeletion(element.id, elementIdx)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
executeBlockDeletion(blockId);
|
||||
};
|
||||
|
||||
const moveBlockById = (blockId: string, direction: "up" | "down") => {
|
||||
const result = moveBlockHelper(localSurvey, blockId, direction);
|
||||
|
||||
@@ -724,67 +720,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 +747,7 @@ export const ElementsView = ({
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeElementId, setActiveElementId, localSurvey, selectedLanguageCode]);
|
||||
}, [activeElementId, setActiveElementId]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -966,22 +918,6 @@ export const ElementsView = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
open={logicDeletionWarning.open}
|
||||
setOpen={(open) => setLogicDeletionWarning((prev) => ({ ...prev, open: open as boolean }))}
|
||||
title={t("environments.surveys.edit.question_used_in_logic_warning_title")}
|
||||
body={t("environments.surveys.edit.question_used_in_logic_warning_text")}
|
||||
buttonText={t("environments.surveys.edit.delete_anyways")}
|
||||
onConfirm={() => {
|
||||
if (logicDeletionWarning.type === "element") {
|
||||
executeDeletion(logicDeletionWarning.elementIdx);
|
||||
} else if (logicDeletionWarning.type === "block" && logicDeletionWarning.blockId) {
|
||||
executeBlockDeletion(logicDeletionWarning.blockId);
|
||||
}
|
||||
setLogicDeletionWarning((prev) => ({ ...prev, open: false }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -46,9 +46,6 @@ export const EndScreenForm = ({
|
||||
|
||||
const questions = getElementsFromBlocks(localSurvey.blocks);
|
||||
|
||||
const defaultLanguageCode = localSurvey.languages.find((lang) => lang.default)?.language.code ?? "default";
|
||||
const usedLanguageCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
|
||||
|
||||
const [showEndingCardCTA, setshowEndingCardCTA] = useState<boolean>(
|
||||
endingCard.type === "endScreen" &&
|
||||
(!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink)
|
||||
@@ -139,7 +136,7 @@ export const EndScreenForm = ({
|
||||
</Label>
|
||||
</div>
|
||||
{showEndingCardCTA && (
|
||||
<div className="mt-4 space-y-4 rounded-md border border-1 bg-slate-100 p-4 pt-2">
|
||||
<div className="border-1 mt-4 space-y-4 rounded-md border bg-slate-100 p-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<ElementFormInput
|
||||
id="buttonLabel"
|
||||
@@ -177,13 +174,13 @@ export const EndScreenForm = ({
|
||||
}}
|
||||
isRecallAllowed
|
||||
localSurvey={localSurvey}
|
||||
usedLanguageCode={usedLanguageCode}
|
||||
usedLanguageCode={"default"}
|
||||
render={({ value, onChange, highlightedJSX, children }) => {
|
||||
return (
|
||||
<div className="group relative">
|
||||
{/* The highlight container is absolutely positioned behind the input */}
|
||||
<div
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent`}
|
||||
dir="auto"
|
||||
key={highlightedJSX.toString()}>
|
||||
{highlightedJSX}
|
||||
@@ -197,12 +194,12 @@ export const EndScreenForm = ({
|
||||
value={
|
||||
recallToHeadline(
|
||||
{
|
||||
[usedLanguageCode]: value,
|
||||
[selectedLanguageCode]: value,
|
||||
},
|
||||
localSurvey,
|
||||
false,
|
||||
usedLanguageCode
|
||||
)[usedLanguageCode]
|
||||
"default"
|
||||
)[selectedLanguageCode]
|
||||
}
|
||||
onChange={(e) => isExternalUrlsAllowed && onChange(e.target.value)}
|
||||
disabled={!isExternalUrlsAllowed}
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { Project } from "@prisma/client";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { PlusIcon, XCircleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type JSX, useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
|
||||
import { TAllowedFileExtension, ZAllowedFileExtension } from "@formbricks/types/storage";
|
||||
import { TSurveyFileUploadElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
||||
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
@@ -20,7 +20,7 @@ import { useGetBillingInfo } from "@/modules/utils/hooks/useGetBillingInfo";
|
||||
|
||||
interface FileUploadFormProps {
|
||||
localSurvey: TSurvey;
|
||||
project: Project;
|
||||
project?: Project;
|
||||
element: TSurveyFileUploadElement;
|
||||
elementIdx: number;
|
||||
updateElement: (elementIdx: number, updatedAttributes: Partial<TSurveyFileUploadElement>) => void;
|
||||
@@ -47,15 +47,72 @@ export const FileUploadElementForm = ({
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: FileUploadFormProps): JSX.Element => {
|
||||
const [extension, setExtension] = useState("");
|
||||
const { t } = useTranslation();
|
||||
const [isMaxSizeError, setIsMaxSizeError] = useState(false);
|
||||
const [isMaxSizeError, setMaxSizeError] = useState(false);
|
||||
const {
|
||||
billingInfo,
|
||||
error: billingInfoError,
|
||||
isLoading: billingInfoLoading,
|
||||
} = useGetBillingInfo(project.organizationId);
|
||||
} = useGetBillingInfo(project?.organizationId ?? "");
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
|
||||
const handleInputChange = (event) => {
|
||||
setExtension(event.target.value);
|
||||
};
|
||||
|
||||
const addExtension = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
let rawExtension = extension.trim();
|
||||
|
||||
// Remove the dot at the start if it exists
|
||||
if (rawExtension.startsWith(".")) {
|
||||
rawExtension = rawExtension.substring(1);
|
||||
}
|
||||
|
||||
if (!rawExtension) {
|
||||
toast.error(t("environments.surveys.edit.please_enter_a_file_extension"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to lowercase before validation and adding
|
||||
const modifiedExtension = rawExtension.toLowerCase() as TAllowedFileExtension;
|
||||
|
||||
const parsedExtensionResult = ZAllowedFileExtension.safeParse(modifiedExtension);
|
||||
|
||||
if (!parsedExtensionResult.success) {
|
||||
// This error should now be less likely unless the extension itself is invalid (e.g., "exe")
|
||||
toast.error(t("environments.surveys.edit.this_file_type_is_not_supported"));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentExtensions = element.allowedFileExtensions || [];
|
||||
|
||||
// Check if the lowercase extension already exists
|
||||
if (!currentExtensions.includes(modifiedExtension)) {
|
||||
updateElement(elementIdx, {
|
||||
allowedFileExtensions: [...currentExtensions, modifiedExtension],
|
||||
});
|
||||
setExtension(""); // Clear the input field
|
||||
} else {
|
||||
toast.error(t("environments.surveys.edit.this_extension_is_already_added"));
|
||||
}
|
||||
};
|
||||
|
||||
const removeExtension = (event, index: number) => {
|
||||
event.preventDefault();
|
||||
if (element.allowedFileExtensions) {
|
||||
const updatedExtensions = [...(element.allowedFileExtensions || [])];
|
||||
updatedExtensions.splice(index, 1);
|
||||
// Ensure array is set to undefined if empty, matching toggle behavior
|
||||
updateElement(elementIdx, {
|
||||
allowedFileExtensions: updatedExtensions.length > 0 ? updatedExtensions : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const maxSizeInMBLimit = useMemo(() => {
|
||||
if (billingInfoError || billingInfoLoading || !billingInfo) {
|
||||
return 10;
|
||||
@@ -159,20 +216,20 @@ export const FileUploadElementForm = ({
|
||||
id="fileSizeLimit"
|
||||
value={element.maxSizeInMB}
|
||||
onChange={(e) => {
|
||||
const parsedValue = Number.parseInt(e.target.value, 10);
|
||||
const parsedValue = parseInt(e.target.value, 10);
|
||||
|
||||
if (isFormbricksCloud && parsedValue > maxSizeInMBLimit) {
|
||||
toast.error(
|
||||
`${t("environments.surveys.edit.max_file_size_limit_is")} ${maxSizeInMBLimit} MB`
|
||||
);
|
||||
setIsMaxSizeError(true);
|
||||
setMaxSizeError(true);
|
||||
updateElement(elementIdx, { maxSizeInMB: maxSizeInMBLimit });
|
||||
return;
|
||||
}
|
||||
|
||||
updateElement(elementIdx, { maxSizeInMB: Number.parseInt(e.target.value, 10) });
|
||||
updateElement(elementIdx, { maxSizeInMB: parseInt(e.target.value, 10) });
|
||||
}}
|
||||
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
|
||||
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
|
||||
/>
|
||||
MB
|
||||
</p>
|
||||
@@ -190,18 +247,49 @@ export const FileUploadElementForm = ({
|
||||
)}
|
||||
</label>
|
||||
</AdvancedOptionToggle>
|
||||
</div>
|
||||
|
||||
<ValidationRulesEditor
|
||||
elementType={element.type}
|
||||
validation={element.validation}
|
||||
onUpdateValidation={(validation) => {
|
||||
updateElement(elementIdx, {
|
||||
validation,
|
||||
});
|
||||
}}
|
||||
element={element}
|
||||
/>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={!!element.allowedFileExtensions}
|
||||
onToggle={(checked) =>
|
||||
updateElement(elementIdx, { allowedFileExtensions: checked ? [] : undefined })
|
||||
}
|
||||
htmlId="limitFileType"
|
||||
title={t("environments.surveys.edit.limit_file_types")}
|
||||
description={t("environments.surveys.edit.control_which_file_types_can_be_uploaded")}
|
||||
childBorder
|
||||
customContainerClass="p-0">
|
||||
<div className="p-4">
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
{element.allowedFileExtensions?.map((item, index) => (
|
||||
<div
|
||||
key={item}
|
||||
className="mb-2 flex h-8 items-center space-x-2 rounded-full bg-slate-200 px-2">
|
||||
<p className="text-sm text-slate-800">{item}</p>
|
||||
<Button
|
||||
className="inline-flex px-0"
|
||||
variant="ghost"
|
||||
onClick={(e) => removeExtension(e, index)}>
|
||||
<XCircleIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
autoFocus
|
||||
className="mr-2 w-20 rounded-md bg-white placeholder:text-sm"
|
||||
placeholder=".pdf"
|
||||
value={extension}
|
||||
onChange={handleInputChange}
|
||||
type="text"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" onClick={(e) => addExtension(e)}>
|
||||
{t("environments.surveys.edit.allow_file_type")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -203,14 +203,12 @@ export const HiddenFieldsCard = ({
|
||||
const existingElementIds = elements.map((element) => element.id);
|
||||
const existingEndingCardIds = localSurvey.endings.map((ending) => ending.id);
|
||||
const existingHiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
|
||||
const existingVariableNames = localSurvey.variables.map((v) => v.name);
|
||||
const validateIdError = validateId(
|
||||
"Hidden field",
|
||||
hiddenField,
|
||||
existingElementIds,
|
||||
existingEndingCardIds,
|
||||
existingHiddenFieldIds,
|
||||
existingVariableNames
|
||||
existingHiddenFieldIds
|
||||
);
|
||||
|
||||
if (validateIdError) {
|
||||
|
||||
@@ -9,13 +9,12 @@ import { type JSX, useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import type { TSurveyMatrixElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyMatrixElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
||||
import { MatrixSortableItem } from "@/modules/survey/editor/components/matrix-sortable-item";
|
||||
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
|
||||
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
@@ -348,16 +347,6 @@ export const MatrixElementForm = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ValidationRulesEditor
|
||||
elementType={element.type}
|
||||
validation={element.validation}
|
||||
onUpdateValidation={(validation) => {
|
||||
updateElement(elementIdx, {
|
||||
validation,
|
||||
});
|
||||
}}
|
||||
element={element}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,18 +10,16 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { TMultipleChoiceOptionDisplayType, TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyElementTypeEnum, TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements";
|
||||
import { TShuffleOption, TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
||||
import { BulkEditOptionsModal } from "@/modules/survey/editor/components/bulk-edit-options-modal";
|
||||
import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice";
|
||||
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
|
||||
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
|
||||
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
|
||||
|
||||
interface MultipleChoiceElementFormProps {
|
||||
@@ -76,11 +74,6 @@ export const MultipleChoiceElementForm = ({
|
||||
},
|
||||
};
|
||||
|
||||
const multipleChoiceOptionDisplayTypeOptions = [
|
||||
{ value: "list", label: t("environments.surveys.edit.list") },
|
||||
{ value: "dropdown", label: t("environments.surveys.edit.dropdown") },
|
||||
];
|
||||
|
||||
const updateChoice = (choiceIdx: number, updatedAttributes: { label: TI18nString }) => {
|
||||
let newChoices: any[] = [];
|
||||
if (element.choices) {
|
||||
@@ -388,20 +381,6 @@ export const MultipleChoiceElementForm = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Label>{t("environments.surveys.edit.display_type")}</Label>
|
||||
<div className="mt-2">
|
||||
<OptionsSwitch
|
||||
options={multipleChoiceOptionDisplayTypeOptions}
|
||||
currentOption={element.displayType ?? "list"}
|
||||
handleOptionChange={(value: TMultipleChoiceOptionDisplayType) =>
|
||||
updateElement(elementIdx, { displayType: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BulkEditOptionsModal
|
||||
isOpen={isBulkEditOpen}
|
||||
onClose={() => setIsBulkEditOpen(false)}
|
||||
@@ -419,19 +398,6 @@ export const MultipleChoiceElementForm = ({
|
||||
surveyLanguageCodes={surveyLanguageCodes}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
{element.type === TSurveyElementTypeEnum.MultipleChoiceMulti && (
|
||||
<ValidationRulesEditor
|
||||
elementType={element.type}
|
||||
validation={element.validation}
|
||||
onUpdateValidation={(validation) => {
|
||||
updateElement(elementIdx, {
|
||||
validation,
|
||||
});
|
||||
}}
|
||||
element={element}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { JSX } from "react";
|
||||
import { HashIcon, LinkIcon, MailIcon, MessageSquareTextIcon, PhoneIcon, PlusIcon } from "lucide-react";
|
||||
import { JSX, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
TSurveyOpenTextElement,
|
||||
TSurveyOpenTextElementInputType,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyOpenTextElement, TSurveyOpenTextElementInputType } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
||||
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
|
||||
|
||||
interface OpenElementFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -43,10 +42,43 @@ export const OpenElementForm = ({
|
||||
isExternalUrlsAllowed,
|
||||
}: OpenElementFormProps): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const elementTypes = [
|
||||
{ value: "text", label: t("common.text"), icon: <MessageSquareTextIcon className="h-4 w-4" /> },
|
||||
{ value: "email", label: t("common.email"), icon: <MailIcon className="h-4 w-4" /> },
|
||||
{ value: "url", label: t("common.url"), icon: <LinkIcon className="h-4 w-4" /> },
|
||||
{ value: "number", label: t("common.number"), icon: <HashIcon className="h-4 w-4" /> },
|
||||
{ value: "phone", label: t("common.phone"), icon: <PhoneIcon className="h-4 w-4" /> },
|
||||
];
|
||||
const defaultPlaceholder = getPlaceholderByInputType(element.inputType ?? "text");
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages ?? []);
|
||||
|
||||
const [showCharLimits, setShowCharLimits] = useState(element.inputType === "text");
|
||||
|
||||
const handleInputChange = (inputType: TSurveyOpenTextElementInputType) => {
|
||||
const updatedAttributes = {
|
||||
inputType: inputType,
|
||||
placeholder: createI18nString(getPlaceholderByInputType(inputType), surveyLanguageCodes),
|
||||
longAnswer: inputType === "text" ? element.longAnswer : false,
|
||||
charLimit: {
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
},
|
||||
};
|
||||
setIsCharLimitEnabled(false);
|
||||
setShowCharLimits(inputType === "text");
|
||||
updateElement(elementIdx, updatedAttributes);
|
||||
};
|
||||
|
||||
const [parent] = useAutoAnimate();
|
||||
const [isCharLimitEnabled, setIsCharLimitEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (element?.charLimit?.min !== undefined || element?.charLimit?.max !== undefined) {
|
||||
setIsCharLimitEnabled(true);
|
||||
} else {
|
||||
setIsCharLimitEnabled(false);
|
||||
}
|
||||
}, [element?.charLimit?.max, element?.charLimit?.min]);
|
||||
|
||||
return (
|
||||
<form>
|
||||
@@ -124,7 +156,80 @@ export const OpenElementForm = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add a dropdown to select the element type */}
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="elementType">{t("common.input_type")}</Label>
|
||||
<div className="mt-2 flex items-center">
|
||||
<OptionsSwitch
|
||||
options={elementTypes}
|
||||
currentOption={element.inputType}
|
||||
handleOptionChange={handleInputChange} // Use the merged function
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 space-y-6">
|
||||
{showCharLimits && (
|
||||
<AdvancedOptionToggle
|
||||
isChecked={isCharLimitEnabled}
|
||||
onToggle={(checked: boolean) => {
|
||||
setIsCharLimitEnabled(checked);
|
||||
updateElement(elementIdx, {
|
||||
charLimit: {
|
||||
enabled: checked,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
htmlId={`charLimit-${element.id}`}
|
||||
description={t("environments.surveys.edit.character_limit_toggle_description")}
|
||||
childBorder
|
||||
title={t("environments.surveys.edit.character_limit_toggle_title")}
|
||||
customContainerClass="p-0">
|
||||
<div className="flex gap-4 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="minLength">{t("common.minimum")}</Label>
|
||||
<Input
|
||||
id="minLength"
|
||||
name="minLength"
|
||||
type="number"
|
||||
min={0}
|
||||
value={element?.charLimit?.min || ""}
|
||||
aria-label={t("common.minimum")}
|
||||
className="bg-white"
|
||||
onChange={(e) =>
|
||||
updateElement(elementIdx, {
|
||||
charLimit: {
|
||||
...element?.charLimit,
|
||||
min: e.target.value ? parseInt(e.target.value) : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="maxLength">{t("common.maximum")}</Label>
|
||||
<Input
|
||||
id="maxLength"
|
||||
name="maxLength"
|
||||
type="number"
|
||||
min={0}
|
||||
aria-label={t("common.maximum")}
|
||||
value={element?.charLimit?.max || ""}
|
||||
className="bg-white"
|
||||
onChange={(e) =>
|
||||
updateElement(elementIdx, {
|
||||
charLimit: {
|
||||
...element?.charLimit,
|
||||
max: e.target.value ? parseInt(e.target.value) : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<AdvancedOptionToggle
|
||||
isChecked={element.longAnswer !== false}
|
||||
@@ -140,23 +245,6 @@ export const OpenElementForm = ({
|
||||
customContainerClass="p-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ValidationRulesEditor
|
||||
elementType={element.type}
|
||||
validation={element.validation}
|
||||
onUpdateValidation={(validation) => {
|
||||
updateElement(elementIdx, {
|
||||
validation,
|
||||
});
|
||||
}}
|
||||
inputType={element.inputType ?? "text"}
|
||||
onUpdateInputType={(newInputType) => {
|
||||
updateElement(elementIdx, {
|
||||
inputType: newInputType,
|
||||
longAnswer: newInputType === "text",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -5,13 +5,12 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { type JSX } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TSurveyPictureSelectionElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyPictureSelectionElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
||||
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FileInput } from "@/modules/ui/components/file-input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
@@ -157,19 +156,7 @@ export const PictureSelectionForm = ({
|
||||
checked={element.allowMulti}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const newAllowMulti = !element.allowMulti;
|
||||
// If switching to single-select (allowMulti = false), remove validation rules
|
||||
// since they're only applicable for multi-select
|
||||
const updatedAttributes: Partial<TSurveyPictureSelectionElement> = {
|
||||
allowMulti: newAllowMulti,
|
||||
};
|
||||
if (!newAllowMulti && element.validation?.rules && element.validation.rules.length > 0) {
|
||||
updatedAttributes.validation = {
|
||||
...element.validation,
|
||||
rules: [],
|
||||
};
|
||||
}
|
||||
updateElement(elementIdx, updatedAttributes);
|
||||
updateElement(elementIdx, { allowMulti: !element.allowMulti });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="multi-select-toggle" className="cursor-pointer">
|
||||
@@ -183,18 +170,6 @@ export const PictureSelectionForm = ({
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{element.allowMulti && (
|
||||
<ValidationRulesEditor
|
||||
elementType={element.type}
|
||||
validation={element.validation}
|
||||
onUpdateValidation={(validation) => {
|
||||
updateElement(elementIdx, {
|
||||
validation,
|
||||
});
|
||||
}}
|
||||
element={element}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,13 +8,12 @@ import { PlusIcon } from "lucide-react";
|
||||
import { type JSX, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import type { TSurveyRankingElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyRankingElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { ElementFormInput } from "@/modules/survey/components/element-form-input";
|
||||
import { ElementOptionChoice } from "@/modules/survey/editor/components/element-option-choice";
|
||||
import { ValidationRulesEditor } from "@/modules/survey/editor/components/validation-rules-editor";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
|
||||
@@ -247,17 +246,6 @@ export const RankingElementForm = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ValidationRulesEditor
|
||||
elementType={element.type}
|
||||
validation={element.validation}
|
||||
onUpdateValidation={(validation) => {
|
||||
updateElement(elementIdx, {
|
||||
validation,
|
||||
});
|
||||
}}
|
||||
element={element}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ export const RedirectUrlForm = ({ localSurvey, endingCard, updateSurvey }: Redir
|
||||
<div className="group relative">
|
||||
{/* The highlight container is absolutely positioned behind the input */}
|
||||
<div
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent`}
|
||||
dir="auto"
|
||||
key={highlightedJSX.toString()}>
|
||||
{highlightedJSX}
|
||||
@@ -81,7 +81,7 @@ export const RedirectUrlForm = ({ localSurvey, endingCard, updateSurvey }: Redir
|
||||
name="redirectUrlLabel"
|
||||
className="bg-white"
|
||||
placeholder="Formbricks App"
|
||||
value={endingCard.label ?? ""}
|
||||
value={endingCard.label}
|
||||
onChange={(e) => updateSurvey({ label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -425,19 +425,11 @@ export const SurveyMenuBar = ({
|
||||
const segment = await handleSegmentUpdate();
|
||||
clearSurveyLocalStorage();
|
||||
|
||||
const publishResult = await updateSurveyAction({
|
||||
await updateSurveyAction({
|
||||
...localSurvey,
|
||||
status,
|
||||
segment,
|
||||
});
|
||||
|
||||
if (!publishResult?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(publishResult);
|
||||
toast.error(errorMessage);
|
||||
setIsSurveyPublishing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSurveyPublishing(false);
|
||||
// Set flag to prevent beforeunload warning during navigation
|
||||
isSuccessfullySavedRef.current = true;
|
||||
@@ -475,7 +467,7 @@ export const SurveyMenuBar = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
|
||||
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
|
||||
<AutoSaveIndicator isDraft={localSurvey.status === "draft"} lastSaved={lastAutoSaved} />
|
||||
{!isStorageConfigured && (
|
||||
<div>
|
||||
|
||||
@@ -186,10 +186,6 @@ export const SurveyVariablesCardItem = ({
|
||||
if (!/^[a-z]/.test(value)) {
|
||||
return t("environments.surveys.edit.variable_name_must_start_with_a_letter");
|
||||
}
|
||||
const hiddenFieldIds = localSurvey.hiddenFields?.fieldIds ?? [];
|
||||
if (hiddenFieldIds.some((id) => id.toLowerCase() === value.toLowerCase())) {
|
||||
return t("environments.surveys.edit.variable_name_conflicts_with_hidden_field");
|
||||
}
|
||||
},
|
||||
}}
|
||||
render={({ field }) => (
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TValidationLogic } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface ValidationLogicSelectorProps {
|
||||
value: TValidationLogic;
|
||||
onChange: (value: TValidationLogic) => void;
|
||||
}
|
||||
|
||||
export const ValidationLogicSelector = ({ value, onChange }: ValidationLogicSelectorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Select value={value} onValueChange={(val) => onChange(val as TValidationLogic)}>
|
||||
<SelectTrigger className="h-8 w-fit bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="and">{t("environments.surveys.edit.validation_logic_and")}</SelectItem>
|
||||
<SelectItem value="or">{t("environments.surveys.edit.validation_logic_or")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TAddressField, TContactInfoField } from "@formbricks/types/surveys/validation-rules";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
|
||||
interface ValidationRuleFieldSelectorProps {
|
||||
value: TAddressField | TContactInfoField | undefined;
|
||||
onChange: (value: TAddressField | TContactInfoField | undefined) => void;
|
||||
fieldOptions: { value: TAddressField | TContactInfoField; label: string }[];
|
||||
}
|
||||
|
||||
export const ValidationRuleFieldSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
fieldOptions,
|
||||
}: ValidationRuleFieldSelectorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value ?? ""}
|
||||
onValueChange={(val) => onChange(val ? (val as TAddressField | TContactInfoField) : undefined)}>
|
||||
<SelectTrigger className="h-9 min-w-[140px] bg-white">
|
||||
<SelectValue placeholder={t("environments.surveys.edit.select_field")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldOptions.map((field) => (
|
||||
<SelectItem key={field.value} value={field.value}>
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurveyOpenTextElementInputType } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
|
||||
interface ValidationRuleInputTypeSelectorProps {
|
||||
value: TSurveyOpenTextElementInputType;
|
||||
onChange?: (value: TSurveyOpenTextElementInputType) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ValidationRuleInputTypeSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: ValidationRuleInputTypeSelectorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={onChange ? (val) => onChange(val as TSurveyOpenTextElementInputType) : undefined}
|
||||
disabled={disabled}>
|
||||
<SelectTrigger
|
||||
className={cn("h-9 min-w-[120px]", disabled ? "cursor-not-allowed bg-slate-100" : "bg-white")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">{t("common.text")}</SelectItem>
|
||||
<SelectItem value="email">{t("common.email")}</SelectItem>
|
||||
<SelectItem value="url">{t("common.url")}</SelectItem>
|
||||
<SelectItem value="phone">{t("common.phone")}</SelectItem>
|
||||
<SelectItem value="number">{t("common.number")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -1,170 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TAllowedFileExtension } from "@formbricks/types/storage";
|
||||
import {
|
||||
TSurveyElement,
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyOpenTextElementInputType,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TAddressField,
|
||||
TContactInfoField,
|
||||
TValidationRule,
|
||||
TValidationRuleType,
|
||||
} from "@formbricks/types/surveys/validation-rules";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { RULE_TYPE_CONFIG } from "../lib/validation-rules-config";
|
||||
import { getAvailableRuleTypes, getRuleValue } from "../lib/validation-rules-utils";
|
||||
import { ValidationRuleFieldSelector } from "./validation-rule-field-selector";
|
||||
import { ValidationRuleInputTypeSelector } from "./validation-rule-input-type-selector";
|
||||
import { ValidationRuleTypeSelector } from "./validation-rule-type-selector";
|
||||
import { ValidationRuleUnitSelector } from "./validation-rule-unit-selector";
|
||||
import { ValidationRuleValueInput } from "./validation-rule-value-input";
|
||||
|
||||
interface ValidationRuleRowProps {
|
||||
rule: TValidationRule;
|
||||
index: number;
|
||||
elementType: TSurveyElementTypeEnum;
|
||||
element?: TSurveyElement;
|
||||
inputType?: TSurveyOpenTextElementInputType;
|
||||
onInputTypeChange?: (inputType: TSurveyOpenTextElementInputType) => void;
|
||||
fieldOptions: { value: TAddressField | TContactInfoField; label: string }[];
|
||||
needsFieldSelector: boolean;
|
||||
validationRules: TValidationRule[];
|
||||
ruleLabels: Record<string, string>;
|
||||
onFieldChange: (ruleId: string, field: TAddressField | TContactInfoField | undefined) => void;
|
||||
onRuleTypeChange: (ruleId: string, newType: TValidationRuleType) => void;
|
||||
onRuleValueChange: (ruleId: string, value: string) => void;
|
||||
onFileExtensionChange: (ruleId: string, extensions: TAllowedFileExtension[]) => void;
|
||||
onDelete: (ruleId: string) => void;
|
||||
onAdd: (insertAfterIndex: number) => void;
|
||||
canAddMore: boolean;
|
||||
}
|
||||
|
||||
export const ValidationRuleRow = ({
|
||||
rule,
|
||||
index,
|
||||
elementType,
|
||||
element,
|
||||
inputType,
|
||||
onInputTypeChange,
|
||||
fieldOptions,
|
||||
needsFieldSelector,
|
||||
validationRules,
|
||||
ruleLabels,
|
||||
onFieldChange,
|
||||
onRuleTypeChange,
|
||||
onRuleValueChange,
|
||||
onFileExtensionChange,
|
||||
onDelete,
|
||||
onAdd,
|
||||
canAddMore,
|
||||
}: ValidationRuleRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const ruleType = rule.type;
|
||||
const config = RULE_TYPE_CONFIG[ruleType];
|
||||
const currentValue = getRuleValue(rule);
|
||||
|
||||
// Get available types for this rule (current type + unused types, no duplicates)
|
||||
// For address/contact info, filter by selected field
|
||||
const ruleField = rule.field;
|
||||
const otherAvailableTypes = getAvailableRuleTypes(
|
||||
elementType,
|
||||
validationRules.filter((r) => r.id !== rule.id),
|
||||
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined,
|
||||
ruleField
|
||||
).filter((t) => t !== ruleType);
|
||||
const availableTypesForSelect = [ruleType, ...otherAvailableTypes];
|
||||
|
||||
// Check if this is OpenText and first rule - show input type selector
|
||||
const isOpenText = elementType === TSurveyElementTypeEnum.OpenText;
|
||||
const isFirstRule = index === 0;
|
||||
const showInputTypeSelector = isOpenText && isFirstRule;
|
||||
|
||||
const handleFileExtensionChange = (extensions: TAllowedFileExtension[]) => {
|
||||
onFileExtensionChange(rule.id, extensions);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{/* Field Selector (for Address and Contact Info elements) */}
|
||||
{needsFieldSelector && (
|
||||
<ValidationRuleFieldSelector
|
||||
value={rule.field}
|
||||
onChange={(value) => onFieldChange(rule.id, value)}
|
||||
fieldOptions={fieldOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Input Type Selector (only for OpenText, first rule) */}
|
||||
{showInputTypeSelector && inputType !== undefined && onInputTypeChange && (
|
||||
<ValidationRuleInputTypeSelector value={inputType} onChange={onInputTypeChange} />
|
||||
)}
|
||||
|
||||
{/* Input Type Display (disabled, for subsequent rules) */}
|
||||
{isOpenText && !isFirstRule && inputType !== undefined && (
|
||||
<ValidationRuleInputTypeSelector value={inputType} disabled />
|
||||
)}
|
||||
|
||||
{/* Rule Type Selector */}
|
||||
<ValidationRuleTypeSelector
|
||||
value={ruleType}
|
||||
onChange={(value) => onRuleTypeChange(rule.id, value)}
|
||||
availableTypes={availableTypesForSelect}
|
||||
ruleLabels={ruleLabels}
|
||||
needsValue={config.needsValue}
|
||||
/>
|
||||
|
||||
{/* Value Input (if needed) */}
|
||||
{config.needsValue && (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<ValidationRuleValueInput
|
||||
rule={rule}
|
||||
ruleType={ruleType}
|
||||
config={config}
|
||||
currentValue={currentValue}
|
||||
onChange={(value) => onRuleValueChange(rule.id, value)}
|
||||
onFileExtensionChange={handleFileExtensionChange}
|
||||
element={element}
|
||||
/>
|
||||
|
||||
{/* Unit selector (if applicable) */}
|
||||
{config.unitOptions && config.unitOptions.length > 0 && (
|
||||
<ValidationRuleUnitSelector
|
||||
value={config.unitOptions[0].value}
|
||||
unitOptions={config.unitOptions}
|
||||
ruleLabels={ruleLabels}
|
||||
disabled={config.unitOptions.length === 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => onDelete(rule.id)}
|
||||
className="shrink-0 bg-white"
|
||||
aria-label={t("environments.surveys.edit.validation.delete_validation_rule")}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Add button */}
|
||||
{canAddMore && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => onAdd(index)}
|
||||
className="shrink-0 bg-white"
|
||||
aria-label={t("environments.surveys.edit.validation.add_validation_rule")}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { capitalize } from "lodash";
|
||||
import { TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { RULE_TYPE_CONFIG } from "../lib/validation-rules-config";
|
||||
|
||||
interface ValidationRuleTypeSelectorProps {
|
||||
value: TValidationRuleType;
|
||||
onChange: (value: TValidationRuleType) => void;
|
||||
availableTypes: TValidationRuleType[];
|
||||
ruleLabels: Record<string, string>;
|
||||
needsValue: boolean;
|
||||
}
|
||||
|
||||
export const ValidationRuleTypeSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
availableTypes,
|
||||
ruleLabels,
|
||||
needsValue,
|
||||
}: ValidationRuleTypeSelectorProps) => {
|
||||
return (
|
||||
<Select value={value} onValueChange={(val) => onChange(val as TValidationRuleType)}>
|
||||
<SelectTrigger className={cn("bg-white", needsValue ? "min-w-[200px]" : "flex-1")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{capitalize(ruleLabels[RULE_TYPE_CONFIG[type].labelKey])}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
|
||||
interface UnitOption {
|
||||
value: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
interface ValidationRuleUnitSelectorProps {
|
||||
value: string;
|
||||
unitOptions: UnitOption[];
|
||||
ruleLabels: Record<string, string>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ValidationRuleUnitSelector = ({
|
||||
value,
|
||||
unitOptions,
|
||||
ruleLabels,
|
||||
disabled = false,
|
||||
}: ValidationRuleUnitSelectorProps) => {
|
||||
return (
|
||||
<Select value={value} onValueChange={() => {}} disabled={disabled || unitOptions.length === 1}>
|
||||
<SelectTrigger className={cn("h-9 min-w-[180px] flex-1 bg-white", disabled && "cursor-not-allowed")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{unitOptions.map((unit) => (
|
||||
<SelectItem key={unit.value} value={unit.value} className="truncate">
|
||||
{ruleLabels[unit.labelKey]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -1,137 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ALLOWED_FILE_EXTENSIONS, TAllowedFileExtension } from "@formbricks/types/storage";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TValidationRule, TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { RULE_TYPE_CONFIG } from "../lib/validation-rules-config";
|
||||
|
||||
interface ValidationRuleValueInputProps {
|
||||
rule: TValidationRule;
|
||||
ruleType: TValidationRuleType;
|
||||
config: (typeof RULE_TYPE_CONFIG)[TValidationRuleType];
|
||||
currentValue: number | string | undefined;
|
||||
onChange: (value: string) => void;
|
||||
onFileExtensionChange: (extensions: TAllowedFileExtension[]) => void;
|
||||
element?: TSurveyElement;
|
||||
}
|
||||
|
||||
export const ValidationRuleValueInput = ({
|
||||
rule,
|
||||
ruleType,
|
||||
config,
|
||||
currentValue,
|
||||
onChange,
|
||||
onFileExtensionChange,
|
||||
element,
|
||||
}: ValidationRuleValueInputProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Determine HTML input type for value inputs
|
||||
let htmlInputType: "number" | "date" | "text" = "text";
|
||||
if (config.valueType === "number") {
|
||||
htmlInputType = "number";
|
||||
} else if (
|
||||
ruleType.startsWith("is") &&
|
||||
(ruleType.includes("Later") || ruleType.includes("Earlier") || ruleType.includes("On"))
|
||||
) {
|
||||
htmlInputType = "date";
|
||||
}
|
||||
|
||||
// Special handling for date range inputs
|
||||
if (ruleType === "isBetween" || ruleType === "isNotBetween") {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={(currentValue as string)?.split(",")?.[0] ?? ""}
|
||||
onChange={(e) => {
|
||||
const currentEndDate = (currentValue as string)?.split(",")?.[1] ?? "";
|
||||
onChange(`${e.target.value},${currentEndDate}`);
|
||||
}}
|
||||
placeholder={t("environments.surveys.edit.validation.start_date")}
|
||||
className="h-9 flex-1 bg-white"
|
||||
/>
|
||||
<span className="text-sm text-slate-500">{t("common.and")}</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={(currentValue as string)?.split(",")?.[1] ?? ""}
|
||||
onChange={(e) => {
|
||||
const currentStartDate = (currentValue as string)?.split(",")?.[0] ?? "";
|
||||
onChange(`${currentStartDate},${e.target.value}`);
|
||||
}}
|
||||
placeholder={t("environments.surveys.edit.validation.end_date")}
|
||||
className="h-9 flex-1 bg-white"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Option selector for single select validation rules
|
||||
if (config.valueType === "option") {
|
||||
const optionValue = typeof currentValue === "string" ? currentValue : "";
|
||||
return (
|
||||
<Select value={optionValue} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-9 min-w-[200px] bg-white">
|
||||
<SelectValue placeholder={t("environments.surveys.edit.validation.select_option")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{element &&
|
||||
"choices" in element &&
|
||||
element.choices
|
||||
.filter((choice) => choice.id !== "other" && choice.id !== "none" && "label" in choice)
|
||||
.map((choice) => {
|
||||
const choiceLabel =
|
||||
"label" in choice
|
||||
? choice.label.default || Object.values(choice.label)[0] || choice.id
|
||||
: choice.id;
|
||||
return (
|
||||
<SelectItem key={choice.id} value={choice.id}>
|
||||
{choiceLabel}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// File extension MultiSelect
|
||||
if (ruleType === "fileExtensionIs" || ruleType === "fileExtensionIsNot") {
|
||||
const extensionOptions = ALLOWED_FILE_EXTENSIONS.map((ext) => ({
|
||||
value: ext,
|
||||
label: `.${ext}`,
|
||||
}));
|
||||
const selectedExtensions = (rule.params as { extensions: string[] })?.extensions || [];
|
||||
return (
|
||||
<MultiSelect
|
||||
options={extensionOptions}
|
||||
value={selectedExtensions as TAllowedFileExtension[]}
|
||||
onChange={onFileExtensionChange}
|
||||
placeholder={t("environments.surveys.edit.validation.select_file_extensions")}
|
||||
disabled={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default text/number input
|
||||
return (
|
||||
<Input
|
||||
type={htmlInputType}
|
||||
value={currentValue ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={config.valuePlaceholder}
|
||||
className="h-9 min-w-[80px] bg-white"
|
||||
min={config.valueType === "number" ? 0 : ""}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,342 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { TAllowedFileExtension } from "@formbricks/types/storage";
|
||||
import {
|
||||
TSurveyElement,
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyOpenTextElementInputType,
|
||||
TValidationLogic,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TAddressField,
|
||||
TContactInfoField,
|
||||
TValidationRule,
|
||||
TValidationRuleType,
|
||||
} from "@formbricks/types/surveys/validation-rules";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { RULE_TYPE_CONFIG } from "../lib/validation-rules-config";
|
||||
import {
|
||||
getAddressFields,
|
||||
getContactInfoFields,
|
||||
getDefaultRuleValue,
|
||||
getRuleLabels,
|
||||
parseRuleValue,
|
||||
} from "../lib/validation-rules-helpers";
|
||||
import { RULES_BY_INPUT_TYPE, createRuleParams, getAvailableRuleTypes } from "../lib/validation-rules-utils";
|
||||
import { ValidationLogicSelector } from "./validation-logic-selector";
|
||||
import { ValidationRuleRow } from "./validation-rule-row";
|
||||
|
||||
type TValidationField = TAddressField | TContactInfoField | undefined;
|
||||
|
||||
interface ValidationRulesEditorProps {
|
||||
elementType: TSurveyElementTypeEnum;
|
||||
validation?: { rules: TValidationRule[]; logic?: TValidationLogic };
|
||||
onUpdateValidation: (validation: { rules: TValidationRule[]; logic: TValidationLogic }) => void;
|
||||
element?: TSurveyElement;
|
||||
// For OpenText: input type and callback to update it
|
||||
inputType?: TSurveyOpenTextElementInputType;
|
||||
onUpdateInputType?: (inputType: TSurveyOpenTextElementInputType) => void;
|
||||
}
|
||||
|
||||
export const ValidationRulesEditor = ({
|
||||
elementType,
|
||||
validation,
|
||||
onUpdateValidation,
|
||||
element,
|
||||
inputType,
|
||||
onUpdateInputType,
|
||||
}: ValidationRulesEditorProps) => {
|
||||
const validationRules = validation?.rules ?? [];
|
||||
const validationLogic = validation?.logic ?? "and";
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Field options for address and contact info elements
|
||||
const isAddress = elementType === TSurveyElementTypeEnum.Address;
|
||||
const isContactInfo = elementType === TSurveyElementTypeEnum.ContactInfo;
|
||||
const needsFieldSelector = isAddress || isContactInfo;
|
||||
|
||||
let fieldOptions: { value: TAddressField | TContactInfoField; label: string }[] = [];
|
||||
if (isAddress) {
|
||||
fieldOptions = getAddressFields(t);
|
||||
} else if (isContactInfo) {
|
||||
fieldOptions = getContactInfoFields(t);
|
||||
}
|
||||
|
||||
const ruleLabels = getRuleLabels(t);
|
||||
|
||||
const isEnabled = validationRules.length > 0;
|
||||
|
||||
const handleEnable = () => {
|
||||
// For address/contact info, get rules for first field
|
||||
const defaultField = needsFieldSelector && fieldOptions.length > 0 ? fieldOptions[0].value : undefined;
|
||||
const availableRules = getAvailableRuleTypes(
|
||||
elementType,
|
||||
[],
|
||||
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined,
|
||||
defaultField
|
||||
);
|
||||
if (availableRules.length > 0) {
|
||||
const defaultRuleType = availableRules[0];
|
||||
const config = RULE_TYPE_CONFIG[defaultRuleType];
|
||||
const defaultValue = getDefaultRuleValue(config, element);
|
||||
const newRule: TValidationRule = {
|
||||
id: uuidv7(),
|
||||
type: defaultRuleType,
|
||||
params: createRuleParams(defaultRuleType, defaultValue),
|
||||
// For address/contact info, set field to first available field if not set
|
||||
field: needsFieldSelector && fieldOptions.length > 0 ? fieldOptions[0].value : undefined,
|
||||
} as TValidationRule;
|
||||
onUpdateValidation({ rules: [newRule], logic: validationLogic });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = () => {
|
||||
onUpdateValidation({ rules: [], logic: validationLogic });
|
||||
};
|
||||
|
||||
const handleAddRule = (insertAfterIndex: number) => {
|
||||
// For address/contact info, get rules for the field of the rule we're inserting after (or first field)
|
||||
const insertAfterRule = validationRules[insertAfterIndex];
|
||||
let fieldForNewRule: TValidationField;
|
||||
if (insertAfterRule?.field) {
|
||||
fieldForNewRule = insertAfterRule.field;
|
||||
} else if (needsFieldSelector && fieldOptions.length > 0) {
|
||||
fieldForNewRule = fieldOptions[0].value;
|
||||
}
|
||||
|
||||
const availableRules = getAvailableRuleTypes(
|
||||
elementType,
|
||||
validationRules,
|
||||
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined,
|
||||
fieldForNewRule
|
||||
);
|
||||
if (availableRules.length === 0) return;
|
||||
|
||||
const newRuleType = availableRules[0];
|
||||
const config = RULE_TYPE_CONFIG[newRuleType];
|
||||
const defaultValue = getDefaultRuleValue(config, element);
|
||||
|
||||
let defaultField: TValidationField;
|
||||
if (needsFieldSelector && fieldOptions.length > 0) {
|
||||
defaultField = fieldOptions[0].value;
|
||||
}
|
||||
|
||||
const newRule: TValidationRule = {
|
||||
id: uuidv7(),
|
||||
type: newRuleType,
|
||||
params: createRuleParams(newRuleType, defaultValue),
|
||||
field: defaultField,
|
||||
} as TValidationRule;
|
||||
const newRules = [...validationRules];
|
||||
newRules.splice(insertAfterIndex + 1, 0, newRule);
|
||||
onUpdateValidation({ rules: newRules, logic: validationLogic });
|
||||
};
|
||||
|
||||
const handleDeleteRule = (ruleId: string) => {
|
||||
const updated = validationRules.filter((r) => r.id !== ruleId);
|
||||
onUpdateValidation({ rules: updated, logic: validationLogic });
|
||||
};
|
||||
|
||||
const handleRuleTypeChange = (ruleId: string, newType: TValidationRuleType) => {
|
||||
const ruleToUpdate = validationRules.find((r) => r.id === ruleId);
|
||||
if (!ruleToUpdate) return;
|
||||
|
||||
// For address/contact info, verify the new rule type is valid for the selected field
|
||||
if (needsFieldSelector && ruleToUpdate.field) {
|
||||
const availableRulesForField = getAvailableRuleTypes(
|
||||
elementType,
|
||||
validationRules.filter((r) => r.id !== ruleId),
|
||||
undefined,
|
||||
ruleToUpdate.field
|
||||
);
|
||||
|
||||
// If the new rule type is not available for this field, don't change it
|
||||
if (!availableRulesForField.includes(newType)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = validationRules.map((rule) => {
|
||||
if (rule.id !== ruleId) return rule;
|
||||
return {
|
||||
...rule,
|
||||
type: newType,
|
||||
params: createRuleParams(newType),
|
||||
} as TValidationRule;
|
||||
});
|
||||
onUpdateValidation({ rules: updated, logic: validationLogic });
|
||||
};
|
||||
|
||||
const handleFieldChange = (ruleId: string, field: TValidationField) => {
|
||||
const ruleToUpdate = validationRules.find((r) => r.id === ruleId);
|
||||
if (!ruleToUpdate) return;
|
||||
|
||||
// If changing field, check if current rule type is still valid for the new field
|
||||
// If not, change to first available rule type for that field
|
||||
let updatedRule = { ...ruleToUpdate, field } as TValidationRule;
|
||||
|
||||
if (field) {
|
||||
const availableRulesForField = getAvailableRuleTypes(
|
||||
elementType,
|
||||
validationRules.filter((r) => r.id !== ruleId),
|
||||
undefined,
|
||||
field
|
||||
);
|
||||
|
||||
// If current rule type is not available for the new field, change it
|
||||
if (!availableRulesForField.includes(ruleToUpdate.type) && availableRulesForField.length > 0) {
|
||||
updatedRule = {
|
||||
...updatedRule,
|
||||
type: availableRulesForField[0],
|
||||
params: createRuleParams(availableRulesForField[0]),
|
||||
} as TValidationRule;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = validationRules.map((rule) => {
|
||||
if (rule.id !== ruleId) return rule;
|
||||
return updatedRule;
|
||||
});
|
||||
onUpdateValidation({ rules: updated, logic: validationLogic });
|
||||
};
|
||||
|
||||
const handleRuleValueChange = (ruleId: string, value: string) => {
|
||||
const updated = validationRules.map((rule) => {
|
||||
if (rule.id !== ruleId) return rule;
|
||||
const ruleType = rule.type;
|
||||
const config = RULE_TYPE_CONFIG[ruleType];
|
||||
const parsedValue = parseRuleValue(ruleType, value, config);
|
||||
|
||||
return {
|
||||
...rule,
|
||||
params: createRuleParams(ruleType, parsedValue),
|
||||
} as TValidationRule;
|
||||
});
|
||||
onUpdateValidation({ rules: updated, logic: validationLogic });
|
||||
};
|
||||
|
||||
const handleFileExtensionChange = (ruleId: string, extensions: TAllowedFileExtension[]) => {
|
||||
const updated = validationRules.map((r) => {
|
||||
if (r.id !== ruleId) return r;
|
||||
return {
|
||||
...r,
|
||||
params: {
|
||||
extensions,
|
||||
},
|
||||
} as TValidationRule;
|
||||
});
|
||||
onUpdateValidation({ rules: updated, logic: validationLogic });
|
||||
};
|
||||
|
||||
// Handle input type change for OpenText
|
||||
const handleInputTypeChange = (newInputType: TSurveyOpenTextElementInputType) => {
|
||||
if (!onUpdateInputType) return;
|
||||
|
||||
// Update element input type
|
||||
onUpdateInputType(newInputType);
|
||||
|
||||
// Filter out incompatible rules based on new input type
|
||||
// Also remove redundant "email"/"url"/"phone" rules when inputType matches
|
||||
const compatibleRules = RULES_BY_INPUT_TYPE[newInputType] ?? [];
|
||||
const filteredRules = validationRules.filter((rule) => {
|
||||
// Remove rules that aren't compatible with the new input type
|
||||
if (!compatibleRules.includes(rule.type)) {
|
||||
return false;
|
||||
}
|
||||
// Remove redundant validation rules when inputType matches
|
||||
if (newInputType === "email" && rule.type === "email") {
|
||||
return false;
|
||||
}
|
||||
if (newInputType === "url" && rule.type === "url") {
|
||||
return false;
|
||||
}
|
||||
if (newInputType === "phone" && rule.type === "phone") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// If no compatible rules remain, add a default rule
|
||||
if (filteredRules.length === 0 && compatibleRules.length > 0) {
|
||||
const defaultRuleType = compatibleRules[0];
|
||||
const config = RULE_TYPE_CONFIG[defaultRuleType];
|
||||
let defaultValue: number | string | undefined = undefined;
|
||||
if (config.needsValue && config.valueType === "number") {
|
||||
defaultValue = 0;
|
||||
} else if (config.needsValue && config.valueType === "text") {
|
||||
defaultValue = "";
|
||||
}
|
||||
const defaultRule: TValidationRule = {
|
||||
id: uuidv7(),
|
||||
type: defaultRuleType,
|
||||
params: createRuleParams(defaultRuleType, defaultValue),
|
||||
} as TValidationRule;
|
||||
onUpdateValidation({ rules: [defaultRule], logic: validationLogic });
|
||||
} else if (filteredRules.length !== validationRules.length) {
|
||||
onUpdateValidation({ rules: filteredRules, logic: validationLogic });
|
||||
}
|
||||
};
|
||||
|
||||
// For address/contact info, use first field if no rules exist, or use the field from last rule
|
||||
let defaultField: TValidationField;
|
||||
if (needsFieldSelector && validationRules.length > 0) {
|
||||
defaultField = validationRules.at(-1)?.field;
|
||||
} else if (needsFieldSelector && fieldOptions.length > 0) {
|
||||
defaultField = fieldOptions[0].value;
|
||||
} else {
|
||||
defaultField = undefined;
|
||||
}
|
||||
|
||||
const availableRulesForAdd = getAvailableRuleTypes(
|
||||
elementType,
|
||||
validationRules,
|
||||
elementType === TSurveyElementTypeEnum.OpenText ? inputType : undefined,
|
||||
defaultField
|
||||
);
|
||||
const canAddMore = availableRulesForAdd.length > 0;
|
||||
|
||||
return (
|
||||
<AdvancedOptionToggle
|
||||
isChecked={isEnabled}
|
||||
onToggle={(checked) => (checked ? handleEnable() : handleDisable())}
|
||||
htmlId="validation-rules-toggle"
|
||||
title={t("environments.surveys.edit.validation_rules")}
|
||||
description={t("environments.surveys.edit.validation_rules_description")}
|
||||
customContainerClass="p-0 mt-4"
|
||||
childrenContainerClass="flex-col p-3 gap-2">
|
||||
{/* Validation Logic Selector - only show when there are 2+ rules */}
|
||||
{validationRules.length >= 2 && (
|
||||
<ValidationLogicSelector
|
||||
value={validationLogic}
|
||||
onChange={(value) => onUpdateValidation({ rules: validationRules, logic: value })}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{validationRules.map((rule, index) => (
|
||||
<ValidationRuleRow
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
index={index}
|
||||
elementType={elementType}
|
||||
element={element}
|
||||
inputType={inputType}
|
||||
onInputTypeChange={handleInputTypeChange}
|
||||
fieldOptions={fieldOptions}
|
||||
needsFieldSelector={needsFieldSelector}
|
||||
validationRules={validationRules}
|
||||
ruleLabels={ruleLabels}
|
||||
onFieldChange={handleFieldChange}
|
||||
onRuleTypeChange={handleRuleTypeChange}
|
||||
onRuleValueChange={handleRuleValueChange}
|
||||
onFileExtensionChange={handleFileExtensionChange}
|
||||
onDelete={handleDeleteRule}
|
||||
onAdd={handleAddRule}
|
||||
canAddMore={canAddMore}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
);
|
||||
};
|
||||
@@ -1300,14 +1300,6 @@ export const findElementUsedInLogic = (survey: TSurvey, elementId: string): numb
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
action.objective === "calculate" &&
|
||||
action.value.type === "element" &&
|
||||
action.value.value === elementId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return action.objective === "jumpToBlock" && action.target === block.id;
|
||||
};
|
||||
|
||||
@@ -1330,45 +1322,6 @@ export const findElementUsedInLogic = (survey: TSurvey, elementId: string): numb
|
||||
});
|
||||
};
|
||||
|
||||
export const findBlockUsedInLogic = (survey: TSurvey, blockId: string): number => {
|
||||
const targetBlock = survey.blocks.find((b) => b.id === blockId);
|
||||
if (!targetBlock) return -1;
|
||||
|
||||
const isUsedInAction = (action: TSurveyBlockLogicAction): boolean => {
|
||||
return action.objective === "jumpToBlock" && action.target === blockId;
|
||||
};
|
||||
|
||||
const isUsedInLogicRule = (logicRule: TSurveyBlockLogic): boolean => {
|
||||
return logicRule.actions.some(isUsedInAction);
|
||||
};
|
||||
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
const blockUsageIndex = elements.findIndex((element) => {
|
||||
const { block } = findElementLocation(survey, element.id);
|
||||
|
||||
if (!block) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return block.id !== blockId && (block.logic?.some(isUsedInLogicRule) || block.logicFallback === blockId);
|
||||
});
|
||||
|
||||
if (blockUsageIndex !== -1) {
|
||||
return blockUsageIndex;
|
||||
}
|
||||
|
||||
// Check if any element in the block is used in logic
|
||||
for (const element of targetBlock.elements) {
|
||||
const elementUsedIndex = findElementUsedInLogic(survey, element.id);
|
||||
if (elementUsedIndex !== -1) {
|
||||
return elementUsedIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
};
|
||||
|
||||
export const isUsedInQuota = (
|
||||
quota: TSurveyQuota,
|
||||
{
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
|
||||
import { RULE_TYPE_CONFIG } from "./validation-rules-config";
|
||||
|
||||
describe("RULE_TYPE_CONFIG", () => {
|
||||
test("should have config for all validation rule types", () => {
|
||||
const allRuleTypes: TValidationRuleType[] = [
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
"email",
|
||||
"url",
|
||||
"phone",
|
||||
"minValue",
|
||||
"maxValue",
|
||||
"minSelections",
|
||||
"maxSelections",
|
||||
];
|
||||
|
||||
allRuleTypes.forEach((ruleType) => {
|
||||
expect(RULE_TYPE_CONFIG[ruleType]).toBeDefined();
|
||||
expect(RULE_TYPE_CONFIG[ruleType].labelKey).toBeDefined();
|
||||
expect(typeof RULE_TYPE_CONFIG[ruleType].labelKey).toBe("string");
|
||||
expect(typeof RULE_TYPE_CONFIG[ruleType].needsValue).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("minLength rule", () => {
|
||||
test("should have correct config", () => {
|
||||
const config = RULE_TYPE_CONFIG.minLength;
|
||||
expect(config.labelKey).toBe("min_length");
|
||||
expect(config.needsValue).toBe(true);
|
||||
expect(config.valueType).toBe("number");
|
||||
expect(config.valuePlaceholder).toBe("100");
|
||||
expect(config.unitOptions).toEqual([{ value: "characters", labelKey: "characters" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maxLength rule", () => {
|
||||
test("should have correct config", () => {
|
||||
const config = RULE_TYPE_CONFIG.maxLength;
|
||||
expect(config.labelKey).toBe("max_length");
|
||||
expect(config.needsValue).toBe(true);
|
||||
expect(config.valueType).toBe("number");
|
||||
expect(config.valuePlaceholder).toBe("500");
|
||||
expect(config.unitOptions).toEqual([{ value: "characters", labelKey: "characters" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pattern rule", () => {
|
||||
test("should have correct config", () => {
|
||||
const config = RULE_TYPE_CONFIG.pattern;
|
||||
expect(config.labelKey).toBe("pattern");
|
||||
expect(config.needsValue).toBe(true);
|
||||
expect(config.valueType).toBe("text");
|
||||
expect(config.valuePlaceholder).toBe("^[A-Z].*");
|
||||
expect(config.unitOptions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("email rule", () => {
|
||||
test("should have correct config", () => {
|
||||
const config = RULE_TYPE_CONFIG.email;
|
||||
expect(config.labelKey).toBe("email");
|
||||
expect(config.needsValue).toBe(false);
|
||||
expect(config.valueType).toBeUndefined();
|
||||
expect(config.valuePlaceholder).toBeUndefined();
|
||||
expect(config.unitOptions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("url rule", () => {
|
||||
test("should have correct config", () => {
|
||||
const config = RULE_TYPE_CONFIG.url;
|
||||
expect(config.labelKey).toBe("url");
|
||||
expect(config.needsValue).toBe(false);
|
||||
expect(config.valueType).toBeUndefined();
|
||||
expect(config.valuePlaceholder).toBeUndefined();
|
||||
expect(config.unitOptions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("phone rule", () => {
|
||||
test("should have correct config", () => {
|
||||
const config = RULE_TYPE_CONFIG.phone;
|
||||
expect(config.labelKey).toBe("phone");
|
||||
expect(config.needsValue).toBe(false);
|
||||
expect(config.valueType).toBeUndefined();
|
||||
expect(config.valuePlaceholder).toBeUndefined();
|
||||
expect(config.unitOptions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("minValue rule", () => {
|
||||
test("should have correct config", () => {
|
||||
const config = RULE_TYPE_CONFIG.minValue;
|
||||
expect(config.labelKey).toBe("min_value");
|
||||
expect(config.needsValue).toBe(true);
|
||||
expect(config.valueType).toBe("number");
|
||||
expect(config.valuePlaceholder).toBe("0");
|
||||
expect(config.unitOptions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("maxValue rule", () => {
|
||||
test("should have correct config", () => {
|
||||
const config = RULE_TYPE_CONFIG.maxValue;
|
||||
expect(config.labelKey).toBe("max_value");
|
||||
expect(config.needsValue).toBe(true);
|
||||
expect(config.valueType).toBe("number");
|
||||
expect(config.valuePlaceholder).toBe("100");
|
||||
expect(config.unitOptions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("minSelections rule", () => {
|
||||
test("should have correct config", () => {
|
||||
const config = RULE_TYPE_CONFIG.minSelections;
|
||||
expect(config.labelKey).toBe("min_selections");
|
||||
expect(config.needsValue).toBe(true);
|
||||
expect(config.valueType).toBe("number");
|
||||
expect(config.valuePlaceholder).toBe("1");
|
||||
expect(config.unitOptions).toEqual([{ value: "options", labelKey: "options_selected" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maxSelections rule", () => {
|
||||
test("should have correct config", () => {
|
||||
const config = RULE_TYPE_CONFIG.maxSelections;
|
||||
expect(config.labelKey).toBe("max_selections");
|
||||
expect(config.needsValue).toBe(true);
|
||||
expect(config.valueType).toBe("number");
|
||||
expect(config.valuePlaceholder).toBe("3");
|
||||
expect(config.unitOptions).toEqual([{ value: "options", labelKey: "options_selected" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("valueType validation", () => {
|
||||
test("should have valueType 'number' for numeric rules", () => {
|
||||
expect(RULE_TYPE_CONFIG.minLength.valueType).toBe("number");
|
||||
expect(RULE_TYPE_CONFIG.maxLength.valueType).toBe("number");
|
||||
expect(RULE_TYPE_CONFIG.minValue.valueType).toBe("number");
|
||||
expect(RULE_TYPE_CONFIG.maxValue.valueType).toBe("number");
|
||||
expect(RULE_TYPE_CONFIG.minSelections.valueType).toBe("number");
|
||||
expect(RULE_TYPE_CONFIG.maxSelections.valueType).toBe("number");
|
||||
});
|
||||
|
||||
test("should have valueType 'text' for text rules", () => {
|
||||
expect(RULE_TYPE_CONFIG.pattern.valueType).toBe("text");
|
||||
});
|
||||
|
||||
test("should not have valueType for rules that don't need values", () => {
|
||||
expect(RULE_TYPE_CONFIG.email.valueType).toBeUndefined();
|
||||
expect(RULE_TYPE_CONFIG.url.valueType).toBeUndefined();
|
||||
expect(RULE_TYPE_CONFIG.phone.valueType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unitOptions validation", () => {
|
||||
test("should have unitOptions for length and selection rules", () => {
|
||||
expect(RULE_TYPE_CONFIG.minLength.unitOptions).toBeDefined();
|
||||
expect(RULE_TYPE_CONFIG.maxLength.unitOptions).toBeDefined();
|
||||
expect(RULE_TYPE_CONFIG.minSelections.unitOptions).toBeDefined();
|
||||
expect(RULE_TYPE_CONFIG.maxSelections.unitOptions).toBeDefined();
|
||||
});
|
||||
|
||||
test("should not have unitOptions for other rules", () => {
|
||||
expect(RULE_TYPE_CONFIG.pattern.unitOptions).toBeUndefined();
|
||||
expect(RULE_TYPE_CONFIG.email.unitOptions).toBeUndefined();
|
||||
expect(RULE_TYPE_CONFIG.url.unitOptions).toBeUndefined();
|
||||
expect(RULE_TYPE_CONFIG.phone.unitOptions).toBeUndefined();
|
||||
expect(RULE_TYPE_CONFIG.minValue.unitOptions).toBeUndefined();
|
||||
expect(RULE_TYPE_CONFIG.maxValue.unitOptions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
import { TValidationRuleType } from "@formbricks/types/surveys/validation-rules";
|
||||
|
||||
// Rule type definitions with i18n keys
|
||||
export const RULE_TYPE_CONFIG: Record<
|
||||
TValidationRuleType,
|
||||
{
|
||||
labelKey: string;
|
||||
needsValue: boolean;
|
||||
valueType?: "number" | "text" | "option" | "ranking";
|
||||
valuePlaceholder?: string;
|
||||
unitOptions?: { value: string; labelKey: string }[];
|
||||
}
|
||||
> = {
|
||||
minLength: {
|
||||
labelKey: "min_length",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "100",
|
||||
unitOptions: [{ value: "characters", labelKey: "characters" }],
|
||||
},
|
||||
maxLength: {
|
||||
labelKey: "max_length",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "500",
|
||||
unitOptions: [{ value: "characters", labelKey: "characters" }],
|
||||
},
|
||||
pattern: {
|
||||
labelKey: "pattern",
|
||||
needsValue: true,
|
||||
valueType: "text",
|
||||
valuePlaceholder: "^[A-Z].*",
|
||||
},
|
||||
email: {
|
||||
labelKey: "email",
|
||||
needsValue: false,
|
||||
},
|
||||
url: {
|
||||
labelKey: "url",
|
||||
needsValue: false,
|
||||
},
|
||||
phone: {
|
||||
labelKey: "phone",
|
||||
needsValue: false,
|
||||
},
|
||||
minValue: {
|
||||
labelKey: "min_value",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "0",
|
||||
},
|
||||
maxValue: {
|
||||
labelKey: "max_value",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "100",
|
||||
},
|
||||
minSelections: {
|
||||
labelKey: "min_selections",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "1",
|
||||
unitOptions: [{ value: "options", labelKey: "options_selected" }],
|
||||
},
|
||||
maxSelections: {
|
||||
labelKey: "max_selections",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "3",
|
||||
unitOptions: [{ value: "options", labelKey: "options_selected" }],
|
||||
},
|
||||
equals: {
|
||||
labelKey: "is",
|
||||
needsValue: true,
|
||||
valueType: "text",
|
||||
},
|
||||
doesNotEqual: {
|
||||
labelKey: "is_not",
|
||||
needsValue: true,
|
||||
valueType: "text",
|
||||
},
|
||||
contains: {
|
||||
labelKey: "contains",
|
||||
needsValue: true,
|
||||
valueType: "text",
|
||||
},
|
||||
doesNotContain: {
|
||||
labelKey: "does_not_contain",
|
||||
needsValue: true,
|
||||
valueType: "text",
|
||||
},
|
||||
isGreaterThan: {
|
||||
labelKey: "is_greater_than",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "0",
|
||||
},
|
||||
isLessThan: {
|
||||
labelKey: "is_less_than",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "100",
|
||||
},
|
||||
isLaterThan: {
|
||||
labelKey: "is_later_than",
|
||||
needsValue: true,
|
||||
valueType: "text",
|
||||
valuePlaceholder: "YYYY-MM-DD",
|
||||
},
|
||||
isEarlierThan: {
|
||||
labelKey: "is_earlier_than",
|
||||
needsValue: true,
|
||||
valueType: "text",
|
||||
valuePlaceholder: "YYYY-MM-DD",
|
||||
},
|
||||
isBetween: {
|
||||
labelKey: "is_between",
|
||||
needsValue: true,
|
||||
valueType: "text",
|
||||
valuePlaceholder: "YYYY-MM-DD,YYYY-MM-DD",
|
||||
},
|
||||
isNotBetween: {
|
||||
labelKey: "is_not_between",
|
||||
needsValue: true,
|
||||
valueType: "text",
|
||||
valuePlaceholder: "YYYY-MM-DD,YYYY-MM-DD",
|
||||
},
|
||||
minRanked: {
|
||||
labelKey: "minimum_options_ranked",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "1",
|
||||
},
|
||||
rankAll: {
|
||||
labelKey: "rank_all_options",
|
||||
needsValue: false,
|
||||
},
|
||||
minRowsAnswered: {
|
||||
labelKey: "minimum_rows_answered",
|
||||
needsValue: true,
|
||||
valueType: "number",
|
||||
valuePlaceholder: "1",
|
||||
},
|
||||
answerAllRows: {
|
||||
labelKey: "answer_all_rows",
|
||||
needsValue: false,
|
||||
},
|
||||
fileExtensionIs: {
|
||||
labelKey: "file_extension_is",
|
||||
needsValue: true,
|
||||
valueType: "text",
|
||||
},
|
||||
fileExtensionIsNot: {
|
||||
labelKey: "file_extension_is_not",
|
||||
needsValue: true,
|
||||
valueType: "text",
|
||||
},
|
||||
};
|
||||
@@ -1,237 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import type {
|
||||
TSurveyElement,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyRankingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { RULE_TYPE_CONFIG } from "./validation-rules-config";
|
||||
import {
|
||||
getAddressFields,
|
||||
getContactInfoFields,
|
||||
getDefaultRuleValue,
|
||||
getRuleLabels,
|
||||
normalizeFileExtension,
|
||||
parseRuleValue,
|
||||
} from "./validation-rules-helpers";
|
||||
|
||||
// Mock translation function
|
||||
const mockT = (key: string): string => key;
|
||||
|
||||
describe("getAddressFields", () => {
|
||||
test("should return all address fields with correct labels", () => {
|
||||
const fields = getAddressFields(mockT);
|
||||
expect(fields).toHaveLength(6);
|
||||
expect(fields.map((f) => f.value)).toEqual([
|
||||
"addressLine1",
|
||||
"addressLine2",
|
||||
"city",
|
||||
"state",
|
||||
"zip",
|
||||
"country",
|
||||
]);
|
||||
expect(fields[0].label).toBe("environments.surveys.edit.address_line_1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContactInfoFields", () => {
|
||||
test("should return all contact info fields with correct labels", () => {
|
||||
const fields = getContactInfoFields(mockT);
|
||||
expect(fields).toHaveLength(5);
|
||||
expect(fields.map((f) => f.value)).toEqual(["firstName", "lastName", "email", "phone", "company"]);
|
||||
expect(fields[0].label).toBe("environments.surveys.edit.first_name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuleLabels", () => {
|
||||
test("should return all rule labels", () => {
|
||||
const labels = getRuleLabels(mockT);
|
||||
expect(labels).toHaveProperty("min_length");
|
||||
expect(labels).toHaveProperty("max_length");
|
||||
expect(labels).toHaveProperty("pattern");
|
||||
expect(labels).toHaveProperty("email");
|
||||
expect(labels).toHaveProperty("url");
|
||||
expect(labels).toHaveProperty("phone");
|
||||
expect(labels).toHaveProperty("min_value");
|
||||
expect(labels).toHaveProperty("max_value");
|
||||
expect(labels).toHaveProperty("min_selections");
|
||||
expect(labels).toHaveProperty("max_selections");
|
||||
expect(labels).toHaveProperty("characters");
|
||||
expect(labels).toHaveProperty("options_selected");
|
||||
expect(labels).toHaveProperty("is");
|
||||
expect(labels).toHaveProperty("is_not");
|
||||
expect(labels).toHaveProperty("contains");
|
||||
expect(labels).toHaveProperty("does_not_contain");
|
||||
expect(labels).toHaveProperty("is_greater_than");
|
||||
expect(labels).toHaveProperty("is_less_than");
|
||||
expect(labels).toHaveProperty("is_later_than");
|
||||
expect(labels).toHaveProperty("is_earlier_than");
|
||||
expect(labels).toHaveProperty("is_between");
|
||||
expect(labels).toHaveProperty("is_not_between");
|
||||
expect(labels).toHaveProperty("minimum_options_ranked");
|
||||
expect(labels).toHaveProperty("rank_all_options");
|
||||
expect(labels).toHaveProperty("minimum_rows_answered");
|
||||
expect(labels).toHaveProperty("file_extension_is");
|
||||
expect(labels).toHaveProperty("file_extension_is_not");
|
||||
expect(labels).toHaveProperty("kb");
|
||||
expect(labels).toHaveProperty("mb");
|
||||
});
|
||||
|
||||
test("should return correct translation keys", () => {
|
||||
const labels = getRuleLabels(mockT);
|
||||
expect(labels.min_length).toBe("environments.surveys.edit.validation.min_length");
|
||||
expect(labels.email).toBe("environments.surveys.edit.validation.email");
|
||||
expect(labels.rank_all_options).toBe("environments.surveys.edit.validation.rank_all_options");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDefaultRuleValue", () => {
|
||||
test("should return undefined when config does not need value", () => {
|
||||
const config = RULE_TYPE_CONFIG.email;
|
||||
const value = getDefaultRuleValue(config);
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should return empty string for text value type", () => {
|
||||
const config = RULE_TYPE_CONFIG.pattern;
|
||||
const value = getDefaultRuleValue(config);
|
||||
expect(value).toBe("");
|
||||
});
|
||||
|
||||
test("should return empty string for equals rule (has valueType: text, not option)", () => {
|
||||
const element: TSurveyElement = {
|
||||
id: "multi1",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
choices: [
|
||||
{ id: "opt1", label: { default: "Option 1" } },
|
||||
{ id: "opt2", label: { default: "Option 2" } },
|
||||
{ id: "other", label: { default: "Other" } },
|
||||
],
|
||||
} as TSurveyMultipleChoiceElement;
|
||||
|
||||
const config = RULE_TYPE_CONFIG.equals;
|
||||
const value = getDefaultRuleValue(config, element);
|
||||
// equals has valueType: "text", not "option", so it returns "" (empty string for text type)
|
||||
expect(value).toBe("");
|
||||
});
|
||||
|
||||
test("should return empty string when config valueType is text (not option)", () => {
|
||||
const element: TSurveyElement = {
|
||||
id: "multi1",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
choices: [
|
||||
{ id: "other", label: { default: "Other" } },
|
||||
{ id: "none", label: { default: "None" } },
|
||||
{ id: "opt1", label: { default: "Option 1" } },
|
||||
],
|
||||
} as TSurveyMultipleChoiceElement;
|
||||
|
||||
const config = RULE_TYPE_CONFIG.equals;
|
||||
const value = getDefaultRuleValue(config, element);
|
||||
// equals has valueType: "text", so it returns "" regardless of element choices
|
||||
expect(value).toBe("");
|
||||
});
|
||||
|
||||
test("should return empty string when no valid choices found for option value type", () => {
|
||||
const element: TSurveyElement = {
|
||||
id: "multi1",
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
choices: [
|
||||
{ id: "other", label: { default: "Other" } },
|
||||
{ id: "none", label: { default: "None" } },
|
||||
],
|
||||
} as TSurveyMultipleChoiceElement;
|
||||
|
||||
const config = RULE_TYPE_CONFIG.equals;
|
||||
const value = getDefaultRuleValue(config, element);
|
||||
expect(value).toBe("");
|
||||
});
|
||||
|
||||
test("should return empty string for option value type when element is not provided", () => {
|
||||
const config = RULE_TYPE_CONFIG.equals;
|
||||
const value = getDefaultRuleValue(config);
|
||||
expect(value).toBe("");
|
||||
});
|
||||
|
||||
test("should return undefined for number value type (minRanked uses number, not ranking)", () => {
|
||||
const element: TSurveyElement = {
|
||||
id: "rank1",
|
||||
type: TSurveyElementTypeEnum.Ranking,
|
||||
choices: [
|
||||
{ id: "opt1", label: { default: "Option 1" } },
|
||||
{ id: "opt2", label: { default: "Option 2" } },
|
||||
],
|
||||
} as TSurveyRankingElement;
|
||||
|
||||
const config = RULE_TYPE_CONFIG.minRanked;
|
||||
const value = getDefaultRuleValue(config, element);
|
||||
// minRanked has valueType: "number", not "ranking", so it returns undefined
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should return undefined for number value type when element is not provided", () => {
|
||||
const config = RULE_TYPE_CONFIG.minRanked;
|
||||
const value = getDefaultRuleValue(config);
|
||||
expect(value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeFileExtension", () => {
|
||||
test("should add dot prefix when missing", () => {
|
||||
expect(normalizeFileExtension("pdf")).toBe(".pdf");
|
||||
expect(normalizeFileExtension("jpg")).toBe(".jpg");
|
||||
});
|
||||
|
||||
test("should not add dot prefix when already present", () => {
|
||||
expect(normalizeFileExtension(".pdf")).toBe(".pdf");
|
||||
expect(normalizeFileExtension(".jpg")).toBe(".jpg");
|
||||
});
|
||||
|
||||
test("should handle empty string", () => {
|
||||
expect(normalizeFileExtension("")).toBe(".");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseRuleValue", () => {
|
||||
test("should normalize file extension for fileExtensionIs", () => {
|
||||
const config = RULE_TYPE_CONFIG.fileExtensionIs;
|
||||
const value = parseRuleValue("fileExtensionIs", "pdf", config);
|
||||
expect(value).toBe(".pdf");
|
||||
});
|
||||
|
||||
test("should normalize file extension for fileExtensionIsNot", () => {
|
||||
const config = RULE_TYPE_CONFIG.fileExtensionIsNot;
|
||||
const value = parseRuleValue("fileExtensionIsNot", "jpg", config);
|
||||
expect(value).toBe(".jpg");
|
||||
});
|
||||
|
||||
test("should not add dot if already present for file extension", () => {
|
||||
const config = RULE_TYPE_CONFIG.fileExtensionIs;
|
||||
const value = parseRuleValue("fileExtensionIs", ".pdf", config);
|
||||
expect(value).toBe(".pdf");
|
||||
});
|
||||
|
||||
test("should parse number for number value type", () => {
|
||||
const config = RULE_TYPE_CONFIG.minLength;
|
||||
const value = parseRuleValue("minLength", "10", config);
|
||||
expect(value).toBe(10);
|
||||
});
|
||||
|
||||
test("should return 0 for invalid number string", () => {
|
||||
const config = RULE_TYPE_CONFIG.minLength;
|
||||
const value = parseRuleValue("minLength", "invalid", config);
|
||||
expect(value).toBe(0);
|
||||
});
|
||||
|
||||
test("should return string as-is for text value type", () => {
|
||||
const config = RULE_TYPE_CONFIG.pattern;
|
||||
const value = parseRuleValue("pattern", "test-pattern", config);
|
||||
expect(value).toBe("test-pattern");
|
||||
});
|
||||
|
||||
test("should return string as-is for equals rule", () => {
|
||||
const config = RULE_TYPE_CONFIG.equals;
|
||||
const value = parseRuleValue("equals", "test-value", config);
|
||||
expect(value).toBe("test-value");
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TAddressField,
|
||||
TContactInfoField,
|
||||
TValidationRuleType,
|
||||
} from "@formbricks/types/surveys/validation-rules";
|
||||
import { RULE_TYPE_CONFIG } from "./validation-rules-config";
|
||||
|
||||
// Field options for address elements
|
||||
export const getAddressFields = (t: (key: string) => string): { value: TAddressField; label: string }[] => [
|
||||
{ value: "addressLine1", label: t("environments.surveys.edit.address_line_1") },
|
||||
{ value: "addressLine2", label: t("environments.surveys.edit.address_line_2") },
|
||||
{ value: "city", label: t("environments.surveys.edit.city") },
|
||||
{ value: "state", label: t("environments.surveys.edit.state") },
|
||||
{ value: "zip", label: t("environments.surveys.edit.zip") },
|
||||
{ value: "country", label: t("environments.surveys.edit.country") },
|
||||
];
|
||||
|
||||
// Field options for contact info elements
|
||||
export const getContactInfoFields = (
|
||||
t: (key: string) => string
|
||||
): { value: TContactInfoField; label: string }[] => [
|
||||
{ value: "firstName", label: t("environments.surveys.edit.first_name") },
|
||||
{ value: "lastName", label: t("environments.surveys.edit.last_name") },
|
||||
{ value: "email", label: t("common.email") },
|
||||
{ value: "phone", label: t("common.phone") },
|
||||
{ value: "company", label: t("environments.surveys.edit.company") },
|
||||
];
|
||||
|
||||
// Rule labels mapping
|
||||
export const getRuleLabels = (t: (key: string) => string): Record<string, string> => ({
|
||||
min_length: t("environments.surveys.edit.validation.min_length"),
|
||||
max_length: t("environments.surveys.edit.validation.max_length"),
|
||||
pattern: t("environments.surveys.edit.validation.pattern"),
|
||||
email: t("environments.surveys.edit.validation.email"),
|
||||
url: t("environments.surveys.edit.validation.url"),
|
||||
phone: t("environments.surveys.edit.validation.phone"),
|
||||
min_value: t("environments.surveys.edit.validation.min_value"),
|
||||
max_value: t("environments.surveys.edit.validation.max_value"),
|
||||
min_selections: t("environments.surveys.edit.validation.min_selections"),
|
||||
max_selections: t("environments.surveys.edit.validation.max_selections"),
|
||||
characters: t("environments.surveys.edit.validation.characters"),
|
||||
options_selected: t("environments.surveys.edit.validation.options_selected"),
|
||||
is: t("environments.surveys.edit.validation.is"),
|
||||
is_not: t("environments.surveys.edit.validation.is_not"),
|
||||
contains: t("environments.surveys.edit.validation.contains"),
|
||||
does_not_contain: t("environments.surveys.edit.validation.does_not_contain"),
|
||||
is_greater_than: t("environments.surveys.edit.validation.is_greater_than"),
|
||||
is_less_than: t("environments.surveys.edit.validation.is_less_than"),
|
||||
is_later_than: t("environments.surveys.edit.validation.is_later_than"),
|
||||
is_earlier_than: t("environments.surveys.edit.validation.is_earlier_than"),
|
||||
is_between: t("environments.surveys.edit.validation.is_between"),
|
||||
is_not_between: t("environments.surveys.edit.validation.is_not_between"),
|
||||
minimum_options_ranked: t("environments.surveys.edit.validation.minimum_options_ranked"),
|
||||
rank_all_options: t("environments.surveys.edit.validation.rank_all_options"),
|
||||
minimum_rows_answered: t("environments.surveys.edit.validation.minimum_rows_answered"),
|
||||
answer_all_rows: t("environments.surveys.edit.validation.answer_all_rows"),
|
||||
file_extension_is: t("environments.surveys.edit.validation.file_extension_is"),
|
||||
file_extension_is_not: t("environments.surveys.edit.validation.file_extension_is_not"),
|
||||
kb: t("environments.surveys.edit.validation.kb"),
|
||||
mb: t("environments.surveys.edit.validation.mb"),
|
||||
});
|
||||
|
||||
// Helper function to get default value for a validation rule based on its config and element
|
||||
export const getDefaultRuleValue = (
|
||||
config: (typeof RULE_TYPE_CONFIG)[TValidationRuleType],
|
||||
element?: TSurveyElement
|
||||
): number | string | undefined => {
|
||||
if (!config.needsValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (config.valueType === "text") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (config.valueType === "option") {
|
||||
if (element && "choices" in element) {
|
||||
const firstChoice = element.choices.find((c) => c.id !== "other" && c.id !== "none");
|
||||
return firstChoice?.id ?? "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
if (config.valueType === "ranking") {
|
||||
if (element && "choices" in element) {
|
||||
const firstChoice = element.choices.find((c) => c.id !== "other" && c.id !== "none");
|
||||
return firstChoice ? `${firstChoice.id},1` : ",1";
|
||||
}
|
||||
return ",1";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Helper function to normalize file extension format
|
||||
export const normalizeFileExtension = (value: string): string => {
|
||||
return value.startsWith(".") ? value : `.${value}`;
|
||||
};
|
||||
|
||||
// Helper function to parse and validate rule value based on rule type
|
||||
export const parseRuleValue = (
|
||||
ruleType: TValidationRuleType,
|
||||
value: string,
|
||||
config: (typeof RULE_TYPE_CONFIG)[TValidationRuleType]
|
||||
): string | number => {
|
||||
// Handle file extension formatting: auto-add dot if missing
|
||||
if (ruleType === "fileExtensionIs" || ruleType === "fileExtensionIsNot") {
|
||||
return normalizeFileExtension(value);
|
||||
}
|
||||
|
||||
if (config.valueType === "number") {
|
||||
return Number(value) || 0;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
@@ -1,480 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TValidationRule } from "@formbricks/types/surveys/validation-rules";
|
||||
import { createRuleParams, getAvailableRuleTypes, getRuleValue } from "./validation-rules-utils";
|
||||
|
||||
describe("getAvailableRuleTypes", () => {
|
||||
test("should return text rules for openText element with text inputType when no rules exist", () => {
|
||||
const elementType = TSurveyElementTypeEnum.OpenText;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules, "text");
|
||||
|
||||
expect(available).toContain("minLength");
|
||||
expect(available).toContain("maxLength");
|
||||
expect(available).toContain("pattern");
|
||||
expect(available).not.toContain("email"); // Excluded - redundant
|
||||
expect(available).not.toContain("url"); // Excluded - redundant
|
||||
expect(available).not.toContain("phone"); // Excluded - redundant
|
||||
expect(available).not.toContain("minValue"); // Only for number inputType
|
||||
});
|
||||
|
||||
test("should return text rules for openText element with email inputType", () => {
|
||||
const elementType = TSurveyElementTypeEnum.OpenText;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules, "email");
|
||||
|
||||
expect(available).toContain("minLength");
|
||||
expect(available).toContain("maxLength");
|
||||
expect(available).not.toContain("email"); // Excluded - redundant when inputType=email
|
||||
expect(available).not.toContain("minValue"); // Only for number inputType
|
||||
});
|
||||
|
||||
test("should return numeric rules for openText element with number inputType", () => {
|
||||
const elementType = TSurveyElementTypeEnum.OpenText;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules, "number");
|
||||
|
||||
expect(available).toContain("minValue");
|
||||
expect(available).toContain("maxValue");
|
||||
expect(available).not.toContain("isGreaterThan"); // Removed - redundant with minValue
|
||||
expect(available).not.toContain("isLessThan"); // Removed - redundant with maxValue
|
||||
expect(available).not.toContain("minLength"); // Only for text inputType
|
||||
expect(available).not.toContain("email"); // Excluded
|
||||
});
|
||||
|
||||
test("should filter out already added rules", () => {
|
||||
const elementType = TSurveyElementTypeEnum.OpenText;
|
||||
const existingRules: TValidationRule[] = [
|
||||
{
|
||||
id: "rule2",
|
||||
type: "minLength",
|
||||
params: { min: 10 },
|
||||
},
|
||||
];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules, "text");
|
||||
|
||||
expect(available).not.toContain("minLength");
|
||||
expect(available).toContain("maxLength");
|
||||
expect(available).toContain("pattern");
|
||||
});
|
||||
|
||||
test("should return empty array for multipleChoiceSingle element (no validation rules)", () => {
|
||||
const elementType = TSurveyElementTypeEnum.MultipleChoiceSingle;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||
|
||||
expect(available).toEqual([]);
|
||||
});
|
||||
|
||||
test("should return minSelections, maxSelections for multipleChoiceMulti element", () => {
|
||||
const elementType = TSurveyElementTypeEnum.MultipleChoiceMulti;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||
|
||||
expect(available).toContain("minSelections");
|
||||
expect(available).toContain("maxSelections");
|
||||
expect(available.length).toBe(2);
|
||||
});
|
||||
|
||||
test("should return empty array for rating element (no validation rules)", () => {
|
||||
const elementType = TSurveyElementTypeEnum.Rating;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||
|
||||
expect(available).toEqual([]);
|
||||
});
|
||||
|
||||
test("should return empty array for nps element (no validation rules)", () => {
|
||||
const elementType = TSurveyElementTypeEnum.NPS;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||
|
||||
expect(available).toEqual([]);
|
||||
});
|
||||
|
||||
test("should return date validation rules for date element", () => {
|
||||
const elementType = TSurveyElementTypeEnum.Date;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||
|
||||
expect(available).toContain("isLaterThan");
|
||||
expect(available).toContain("isEarlierThan");
|
||||
expect(available).toContain("isBetween");
|
||||
expect(available).toContain("isNotBetween");
|
||||
});
|
||||
|
||||
test("should return empty array for consent element (no validation rules)", () => {
|
||||
const elementType = TSurveyElementTypeEnum.Consent;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||
|
||||
expect(available).toEqual([]);
|
||||
});
|
||||
|
||||
test("should return matrix validation rules for matrix element", () => {
|
||||
const elementType = TSurveyElementTypeEnum.Matrix;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||
|
||||
expect(available).toContain("minRowsAnswered");
|
||||
expect(available).toContain("answerAllRows");
|
||||
expect(available.length).toBe(2);
|
||||
});
|
||||
|
||||
test("should return ranking validation rules for ranking element", () => {
|
||||
const elementType = TSurveyElementTypeEnum.Ranking;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||
|
||||
expect(available).toContain("minRanked");
|
||||
expect(available).toContain("rankAll");
|
||||
expect(available.length).toBe(2);
|
||||
});
|
||||
|
||||
test("should return file validation rules for fileUpload element", () => {
|
||||
const elementType = TSurveyElementTypeEnum.FileUpload;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||
|
||||
expect(available).toContain("fileExtensionIs");
|
||||
expect(available).toContain("fileExtensionIsNot");
|
||||
});
|
||||
|
||||
test("should return minSelections, maxSelections for pictureSelection element", () => {
|
||||
const elementType = TSurveyElementTypeEnum.PictureSelection;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||
|
||||
expect(available).toContain("minSelections");
|
||||
expect(available).toContain("maxSelections");
|
||||
expect(available.length).toBe(2);
|
||||
});
|
||||
|
||||
test("should return empty array for address element (no validation rules)", () => {
|
||||
const elementType = TSurveyElementTypeEnum.Address;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||
|
||||
expect(available).toEqual([]);
|
||||
});
|
||||
|
||||
test("should return empty array for contactInfo element (no validation rules)", () => {
|
||||
const elementType = TSurveyElementTypeEnum.ContactInfo;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||
|
||||
expect(available).toEqual([]);
|
||||
});
|
||||
|
||||
test("should return empty array for cal element (no validation rules)", () => {
|
||||
const elementType = TSurveyElementTypeEnum.Cal;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||
|
||||
expect(available).toEqual([]);
|
||||
});
|
||||
|
||||
test("should return empty array for cta element", () => {
|
||||
const elementType = TSurveyElementTypeEnum.CTA;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||
|
||||
expect(available).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle unknown element type gracefully", () => {
|
||||
const elementType = "unknown" as TSurveyElementTypeEnum;
|
||||
const existingRules: TValidationRule[] = [];
|
||||
|
||||
const available = getAvailableRuleTypes(elementType, existingRules);
|
||||
|
||||
expect(available).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuleValue", () => {
|
||||
test("should return min value for minLength rule", () => {
|
||||
const rule: TValidationRule = {
|
||||
id: "rule1",
|
||||
type: "minLength",
|
||||
params: { min: 10 },
|
||||
};
|
||||
|
||||
expect(getRuleValue(rule)).toBe(10);
|
||||
});
|
||||
|
||||
test("should return max value for maxLength rule", () => {
|
||||
const rule: TValidationRule = {
|
||||
id: "rule2",
|
||||
type: "maxLength",
|
||||
params: { max: 100 },
|
||||
};
|
||||
|
||||
expect(getRuleValue(rule)).toBe(100);
|
||||
});
|
||||
|
||||
test("should return pattern string for pattern rule", () => {
|
||||
const rule: TValidationRule = {
|
||||
id: "rule3",
|
||||
type: "pattern",
|
||||
params: { pattern: "^[A-Z].*" },
|
||||
};
|
||||
|
||||
expect(getRuleValue(rule)).toBe("^[A-Z].*");
|
||||
});
|
||||
|
||||
test("should return pattern string with flags for pattern rule", () => {
|
||||
const rule: TValidationRule = {
|
||||
id: "rule3",
|
||||
type: "pattern",
|
||||
params: { pattern: "^[A-Z].*", flags: "i" },
|
||||
};
|
||||
|
||||
expect(getRuleValue(rule)).toBe("^[A-Z].*");
|
||||
});
|
||||
|
||||
test("should return min value for minValue rule", () => {
|
||||
const rule: TValidationRule = {
|
||||
id: "rule4",
|
||||
type: "minValue",
|
||||
params: { min: 5 },
|
||||
};
|
||||
|
||||
expect(getRuleValue(rule)).toBe(5);
|
||||
});
|
||||
|
||||
test("should return max value for maxValue rule", () => {
|
||||
const rule: TValidationRule = {
|
||||
id: "rule5",
|
||||
type: "maxValue",
|
||||
params: { max: 50 },
|
||||
};
|
||||
|
||||
expect(getRuleValue(rule)).toBe(50);
|
||||
});
|
||||
|
||||
test("should return min value for minSelections rule", () => {
|
||||
const rule: TValidationRule = {
|
||||
id: "rule6",
|
||||
type: "minSelections",
|
||||
params: { min: 2 },
|
||||
};
|
||||
|
||||
expect(getRuleValue(rule)).toBe(2);
|
||||
});
|
||||
|
||||
test("should return max value for maxSelections rule", () => {
|
||||
const rule: TValidationRule = {
|
||||
id: "rule7",
|
||||
type: "maxSelections",
|
||||
params: { max: 5 },
|
||||
};
|
||||
|
||||
expect(getRuleValue(rule)).toBe(5);
|
||||
});
|
||||
|
||||
test("should return undefined for email rule", () => {
|
||||
const rule: TValidationRule = {
|
||||
id: "rule9",
|
||||
type: "email",
|
||||
params: {},
|
||||
};
|
||||
|
||||
expect(getRuleValue(rule)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should return undefined for url rule", () => {
|
||||
const rule: TValidationRule = {
|
||||
id: "rule10",
|
||||
type: "url",
|
||||
params: {},
|
||||
};
|
||||
|
||||
expect(getRuleValue(rule)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should return undefined for phone rule", () => {
|
||||
const rule: TValidationRule = {
|
||||
id: "rule11",
|
||||
type: "phone",
|
||||
params: {},
|
||||
};
|
||||
|
||||
expect(getRuleValue(rule)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should return empty string for pattern rule with empty pattern", () => {
|
||||
const rule: TValidationRule = {
|
||||
id: "rule12",
|
||||
type: "pattern",
|
||||
params: { pattern: "" },
|
||||
};
|
||||
|
||||
expect(getRuleValue(rule)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRuleParams", () => {
|
||||
test("should create params for minLength rule with value", () => {
|
||||
const params = createRuleParams("minLength", 10);
|
||||
expect(params).toEqual({ min: 10 });
|
||||
});
|
||||
|
||||
test("should create params for minLength rule without value (defaults to 0)", () => {
|
||||
const params = createRuleParams("minLength");
|
||||
expect(params).toEqual({ min: 0 });
|
||||
});
|
||||
|
||||
test("should create params for maxLength rule with value", () => {
|
||||
const params = createRuleParams("maxLength", 100);
|
||||
expect(params).toEqual({ max: 100 });
|
||||
});
|
||||
|
||||
test("should create params for maxLength rule without value (defaults to 100)", () => {
|
||||
const params = createRuleParams("maxLength");
|
||||
expect(params).toEqual({ max: 100 });
|
||||
});
|
||||
|
||||
test("should create params for pattern rule with string value", () => {
|
||||
const params = createRuleParams("pattern", "^[A-Z].*");
|
||||
expect(params).toEqual({ pattern: "^[A-Z].*" });
|
||||
});
|
||||
|
||||
test("should create params for pattern rule without value (defaults to empty string)", () => {
|
||||
const params = createRuleParams("pattern");
|
||||
expect(params).toEqual({ pattern: "" });
|
||||
});
|
||||
|
||||
test("should create empty params for email rule", () => {
|
||||
const params = createRuleParams("email");
|
||||
expect(params).toEqual({});
|
||||
});
|
||||
|
||||
test("should create empty params for url rule", () => {
|
||||
const params = createRuleParams("url");
|
||||
expect(params).toEqual({});
|
||||
});
|
||||
|
||||
test("should create empty params for phone rule", () => {
|
||||
const params = createRuleParams("phone");
|
||||
expect(params).toEqual({});
|
||||
});
|
||||
|
||||
test("should create params for minValue rule with value", () => {
|
||||
const params = createRuleParams("minValue", 5);
|
||||
expect(params).toEqual({ min: 5 });
|
||||
});
|
||||
|
||||
test("should create params for minValue rule without value (defaults to 0)", () => {
|
||||
const params = createRuleParams("minValue");
|
||||
expect(params).toEqual({ min: 0 });
|
||||
});
|
||||
|
||||
test("should create params for maxValue rule with value", () => {
|
||||
const params = createRuleParams("maxValue", 50);
|
||||
expect(params).toEqual({ max: 50 });
|
||||
});
|
||||
|
||||
test("should create params for maxValue rule without value (defaults to 100)", () => {
|
||||
const params = createRuleParams("maxValue");
|
||||
expect(params).toEqual({ max: 100 });
|
||||
});
|
||||
|
||||
test("should create params for minSelections rule with value", () => {
|
||||
const params = createRuleParams("minSelections", 2);
|
||||
expect(params).toEqual({ min: 2 });
|
||||
});
|
||||
|
||||
test("should create params for minSelections rule without value (defaults to 1)", () => {
|
||||
const params = createRuleParams("minSelections");
|
||||
expect(params).toEqual({ min: 1 });
|
||||
});
|
||||
|
||||
test("should create params for maxSelections rule with value", () => {
|
||||
const params = createRuleParams("maxSelections", 5);
|
||||
expect(params).toEqual({ max: 5 });
|
||||
});
|
||||
|
||||
test("should create params for maxSelections rule without value (defaults to 3)", () => {
|
||||
const params = createRuleParams("maxSelections");
|
||||
expect(params).toEqual({ max: 3 });
|
||||
});
|
||||
|
||||
test("should convert string number to number for minLength", () => {
|
||||
const params = createRuleParams("minLength", "10");
|
||||
expect(params).toEqual({ min: 10 });
|
||||
});
|
||||
|
||||
test("should convert string number to number for maxLength", () => {
|
||||
const params = createRuleParams("maxLength", "100");
|
||||
expect(params).toEqual({ max: 100 });
|
||||
});
|
||||
|
||||
test("should convert string number to number for minValue", () => {
|
||||
const params = createRuleParams("minValue", "5");
|
||||
expect(params).toEqual({ min: 5 });
|
||||
});
|
||||
|
||||
test("should convert string number to number for maxValue", () => {
|
||||
const params = createRuleParams("maxValue", "50");
|
||||
expect(params).toEqual({ max: 50 });
|
||||
});
|
||||
|
||||
test("should convert string number to number for minSelections", () => {
|
||||
const params = createRuleParams("minSelections", "2");
|
||||
expect(params).toEqual({ min: 2 });
|
||||
});
|
||||
|
||||
test("should convert string number to number for maxSelections", () => {
|
||||
const params = createRuleParams("maxSelections", "5");
|
||||
expect(params).toEqual({ max: 5 });
|
||||
});
|
||||
|
||||
test("should handle invalid string number (defaults to 0 for minLength)", () => {
|
||||
const params = createRuleParams("minLength", "invalid");
|
||||
expect(params).toEqual({ min: 0 });
|
||||
});
|
||||
|
||||
test("should handle invalid string number (defaults to 100 for maxLength)", () => {
|
||||
const params = createRuleParams("maxLength", "invalid");
|
||||
expect(params).toEqual({ max: 100 });
|
||||
});
|
||||
|
||||
test("should handle invalid string number (defaults to 0 for minValue)", () => {
|
||||
const params = createRuleParams("minValue", "invalid");
|
||||
expect(params).toEqual({ min: 0 });
|
||||
});
|
||||
|
||||
test("should handle invalid string number (defaults to 100 for maxValue)", () => {
|
||||
const params = createRuleParams("maxValue", "invalid");
|
||||
expect(params).toEqual({ max: 100 });
|
||||
});
|
||||
|
||||
test("should handle invalid string number (defaults to 1 for minSelections)", () => {
|
||||
const params = createRuleParams("minSelections", "invalid");
|
||||
expect(params).toEqual({ min: 1 });
|
||||
});
|
||||
|
||||
test("should handle invalid string number (defaults to 3 for maxSelections)", () => {
|
||||
const params = createRuleParams("maxSelections", "invalid");
|
||||
expect(params).toEqual({ max: 3 });
|
||||
});
|
||||
});
|
||||
@@ -1,288 +0,0 @@
|
||||
import { TSurveyElementTypeEnum, TSurveyOpenTextElementInputType } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
APPLICABLE_RULES,
|
||||
TAddressField,
|
||||
TContactInfoField,
|
||||
TValidationRule,
|
||||
TValidationRuleType,
|
||||
} from "@formbricks/types/surveys/validation-rules";
|
||||
|
||||
const stringRules: TValidationRuleType[] = [
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
"equals",
|
||||
"doesNotEqual",
|
||||
"contains",
|
||||
"doesNotContain",
|
||||
];
|
||||
|
||||
// Rules applicable per field for Address elements
|
||||
// General text fields don't support format-specific validators (email, url, phone)
|
||||
export const RULES_BY_ADDRESS_FIELD: Record<TAddressField, TValidationRuleType[]> = {
|
||||
addressLine1: stringRules,
|
||||
addressLine2: stringRules,
|
||||
city: stringRules,
|
||||
state: stringRules,
|
||||
zip: stringRules,
|
||||
country: stringRules,
|
||||
};
|
||||
|
||||
// Rules applicable per field for Contact Info elements
|
||||
// Note: "email" and "phone" validation are automatically enforced for their respective fields
|
||||
// and should not appear as selectable options in the UI
|
||||
export const RULES_BY_CONTACT_INFO_FIELD: Record<TContactInfoField, TValidationRuleType[]> = {
|
||||
firstName: stringRules,
|
||||
lastName: stringRules,
|
||||
email: stringRules,
|
||||
phone: ["equals", "doesNotEqual", "contains", "doesNotContain"],
|
||||
company: stringRules,
|
||||
};
|
||||
|
||||
// Rules applicable per input type for OpenText
|
||||
export const RULES_BY_INPUT_TYPE: Record<TSurveyOpenTextElementInputType, TValidationRuleType[]> = {
|
||||
text: [
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
// "email", "url", "phone" excluded - redundant for text inputType
|
||||
"equals",
|
||||
"doesNotEqual",
|
||||
"contains",
|
||||
"doesNotContain",
|
||||
],
|
||||
email: [
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
// "email" rule excluded - redundant when inputType=email (HTML5 already validates)
|
||||
"equals",
|
||||
"doesNotEqual",
|
||||
"contains",
|
||||
"doesNotContain",
|
||||
],
|
||||
url: [
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
// "url" rule excluded - redundant when inputType=url (HTML5 already validates)
|
||||
"equals",
|
||||
"doesNotEqual",
|
||||
"contains",
|
||||
"doesNotContain",
|
||||
],
|
||||
phone: [
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
// "phone" rule excluded - redundant when inputType=phone (HTML5 already validates)
|
||||
"equals",
|
||||
"doesNotEqual",
|
||||
"contains",
|
||||
"doesNotContain",
|
||||
],
|
||||
number: ["minValue", "maxValue", "equals", "doesNotEqual"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Get available rule types for an element type, excluding already added rules
|
||||
* For OpenText elements, filters rules based on inputType
|
||||
* For Address/ContactInfo elements, filters rules based on field
|
||||
*/
|
||||
export const getAvailableRuleTypes = (
|
||||
elementType: TSurveyElementTypeEnum,
|
||||
existingRules: TValidationRule[],
|
||||
inputType?: TSurveyOpenTextElementInputType,
|
||||
field?: TAddressField | TContactInfoField
|
||||
): TValidationRuleType[] => {
|
||||
const elementTypeKey = elementType.toString();
|
||||
|
||||
// For OpenText, use input-type-based filtering
|
||||
if (elementType === TSurveyElementTypeEnum.OpenText && inputType) {
|
||||
const applicable = RULES_BY_INPUT_TYPE[inputType] ?? [];
|
||||
const existingTypes = new Set(existingRules.map((r) => r.type));
|
||||
return applicable.filter((ruleType) => !existingTypes.has(ruleType));
|
||||
}
|
||||
|
||||
// For Address elements, use field-based filtering
|
||||
if (elementType === TSurveyElementTypeEnum.Address) {
|
||||
if (!field) {
|
||||
// Address elements require a field to be specified for validation rules
|
||||
return [];
|
||||
}
|
||||
const applicable = RULES_BY_ADDRESS_FIELD[field as TAddressField] ?? [];
|
||||
const existingTypes = new Set(existingRules.map((r) => r.type));
|
||||
return applicable.filter((ruleType) => !existingTypes.has(ruleType));
|
||||
}
|
||||
|
||||
// For Contact Info elements, use field-based filtering
|
||||
if (elementType === TSurveyElementTypeEnum.ContactInfo) {
|
||||
if (!field) {
|
||||
// Contact Info elements require a field to be specified for validation rules
|
||||
return [];
|
||||
}
|
||||
const applicable = RULES_BY_CONTACT_INFO_FIELD[field as TContactInfoField] ?? [];
|
||||
const existingTypes = new Set(existingRules.map((r) => r.type));
|
||||
return applicable.filter((ruleType) => !existingTypes.has(ruleType));
|
||||
}
|
||||
|
||||
if (elementType === TSurveyElementTypeEnum.PictureSelection) {
|
||||
const applicable = APPLICABLE_RULES[elementTypeKey] ?? [];
|
||||
const existingTypes = new Set(existingRules.map((r) => r.type));
|
||||
return applicable.filter((ruleType) => !existingTypes.has(ruleType));
|
||||
}
|
||||
|
||||
// For other element types, use standard filtering
|
||||
const applicable = APPLICABLE_RULES[elementTypeKey] ?? [];
|
||||
const existingTypes = new Set(existingRules.map((r) => r.type));
|
||||
|
||||
return applicable.filter((ruleType) => {
|
||||
// Allow only one of each rule type
|
||||
return !existingTypes.has(ruleType);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the value from rule params based on rule type
|
||||
*/
|
||||
export const getRuleValue = (rule: TValidationRule): number | string | undefined => {
|
||||
const params = rule.params;
|
||||
if ("min" in params) return params.min;
|
||||
if ("max" in params) return params.max;
|
||||
if ("pattern" in params) {
|
||||
const pattern = params.pattern;
|
||||
return pattern ?? "";
|
||||
}
|
||||
if ("value" in params) {
|
||||
return params.value;
|
||||
}
|
||||
if ("date" in params) {
|
||||
return params.date;
|
||||
}
|
||||
if ("startDate" in params && "endDate" in params) {
|
||||
return `${params.startDate},${params.endDate}`;
|
||||
}
|
||||
if ("extensions" in params) {
|
||||
// For file extension rules, return extensions array as comma-separated string for display
|
||||
const extensions = params.extensions;
|
||||
return extensions.length > 0 ? extensions.join(", ") : "";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper functions to create params for different rule types
|
||||
*/
|
||||
const createStringValueParams = (value?: number | string) => ({
|
||||
value: value === undefined || value === null ? "" : String(value),
|
||||
});
|
||||
|
||||
const createMinParams = (value?: number | string, defaultValue = 0) => ({
|
||||
min: Number(value) || defaultValue,
|
||||
});
|
||||
|
||||
const createMaxParams = (value?: number | string, defaultValue = 100) => ({
|
||||
max: Number(value) || defaultValue,
|
||||
});
|
||||
|
||||
const createDateParams = (value?: number | string) => ({
|
||||
date: value === undefined || value === null ? "" : String(value),
|
||||
});
|
||||
|
||||
const createDateRangeParams = (value?: number | string) => {
|
||||
if (typeof value === "string" && value.includes(",")) {
|
||||
const [startDate, endDate] = value.split(",");
|
||||
return {
|
||||
startDate: startDate?.trim() || "",
|
||||
endDate: endDate?.trim() || "",
|
||||
};
|
||||
}
|
||||
return { startDate: "", endDate: "" };
|
||||
};
|
||||
|
||||
const createFileExtensionParams = (value?: number | string) => {
|
||||
if (Array.isArray(value)) {
|
||||
return { extensions: value };
|
||||
}
|
||||
if (typeof value === "string" && value.includes(",")) {
|
||||
return { extensions: value.split(",").map((ext) => ext.trim()) };
|
||||
}
|
||||
const extensionValue = value === undefined || value === null ? "" : String(value);
|
||||
return { extensions: extensionValue ? [extensionValue] : [] };
|
||||
};
|
||||
|
||||
/**
|
||||
* Create params object from rule type and value (without type field)
|
||||
*/
|
||||
export const createRuleParams = (
|
||||
ruleType: TValidationRuleType,
|
||||
value?: number | string
|
||||
): TValidationRule["params"] => {
|
||||
// Rules that return empty params
|
||||
if (
|
||||
ruleType === "email" ||
|
||||
ruleType === "url" ||
|
||||
ruleType === "phone" ||
|
||||
ruleType === "rankAll" ||
|
||||
ruleType === "answerAllRows"
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Rules that use string value params
|
||||
if (
|
||||
ruleType === "equals" ||
|
||||
ruleType === "doesNotEqual" ||
|
||||
ruleType === "contains" ||
|
||||
ruleType === "doesNotContain"
|
||||
) {
|
||||
return createStringValueParams(value);
|
||||
}
|
||||
|
||||
// Rules that use min params
|
||||
if (
|
||||
ruleType === "minLength" ||
|
||||
ruleType === "minValue" ||
|
||||
ruleType === "isGreaterThan" ||
|
||||
ruleType === "minSelections" ||
|
||||
ruleType === "minRanked" ||
|
||||
ruleType === "minRowsAnswered"
|
||||
) {
|
||||
const defaultValue =
|
||||
ruleType === "minSelections" || ruleType === "minRanked" || ruleType === "minRowsAnswered" ? 1 : 0;
|
||||
return createMinParams(value, defaultValue);
|
||||
}
|
||||
|
||||
// Rules that use max params
|
||||
if (
|
||||
ruleType === "maxLength" ||
|
||||
ruleType === "maxValue" ||
|
||||
ruleType === "isLessThan" ||
|
||||
ruleType === "maxSelections"
|
||||
) {
|
||||
const defaultValue = ruleType === "maxSelections" ? 3 : 100;
|
||||
return createMaxParams(value, defaultValue);
|
||||
}
|
||||
|
||||
// Rules that use date params
|
||||
if (ruleType === "isLaterThan" || ruleType === "isEarlierThan") {
|
||||
return createDateParams(value);
|
||||
}
|
||||
|
||||
// Rules that use date range params
|
||||
if (ruleType === "isBetween" || ruleType === "isNotBetween") {
|
||||
return createDateRangeParams(value);
|
||||
}
|
||||
|
||||
// Rules that use file extension params
|
||||
if (ruleType === "fileExtensionIs" || ruleType === "fileExtensionIsNot") {
|
||||
return createFileExtensionParams(value);
|
||||
}
|
||||
|
||||
// Pattern rule
|
||||
if (ruleType === "pattern") {
|
||||
return { pattern: value === undefined || value === null ? "" : String(value) };
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
@@ -274,24 +274,6 @@ describe("validation.isEndingCardValid", () => {
|
||||
expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for endScreen card with http:// URL", () => {
|
||||
const card: TSurveyEndScreenCard = {
|
||||
...baseEndScreenCard,
|
||||
buttonLabel: { default: "Go", en: "Go", de: "Los" },
|
||||
buttonLink: "http://example.com",
|
||||
};
|
||||
expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for endScreen card with dynamic URL containing recall", () => {
|
||||
const card: TSurveyEndScreenCard = {
|
||||
...baseEndScreenCard,
|
||||
buttonLabel: { default: "Go", en: "Go", de: "Los" },
|
||||
buttonLink: "https://#recall:test123/fallback:example.com",
|
||||
};
|
||||
expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
// RedirectURL Card tests
|
||||
test("should return true for a valid redirectUrl card", () => {
|
||||
expect(validation.isEndingCardValid(baseRedirectUrlCard, surveyLanguagesEnabled)).toBe(true);
|
||||
@@ -302,16 +284,6 @@ describe("validation.isEndingCardValid", () => {
|
||||
expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for redirectUrl card with http:// URL", () => {
|
||||
const card = { ...baseRedirectUrlCard, url: "http://example.com" };
|
||||
expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for redirectUrl card with dynamic URL containing recall", () => {
|
||||
const card = { ...baseRedirectUrlCard, url: "https://#recall:test123/fallback:example.com" };
|
||||
expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for redirectUrl card if label is empty", () => {
|
||||
const card = { ...baseRedirectUrlCard, label: " " };
|
||||
expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// extend this object in order to add more validation rules
|
||||
import { TFunction } from "i18next";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { ZEndingCardUrl } from "@formbricks/types/common";
|
||||
import { z } from "zod";
|
||||
import { TI18nString } from "@formbricks/types/i18n";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import {
|
||||
@@ -206,15 +206,9 @@ export const isEndingCardValid = (
|
||||
surveyLanguages: TSurveyLanguage[]
|
||||
) => {
|
||||
if (card.type === "endScreen") {
|
||||
// Use ZEndingCardUrl for consistent validation - allows dynamic URLs via hidden fields/recall values
|
||||
if (card.buttonLabel !== undefined) {
|
||||
if (!card.buttonLink) {
|
||||
return false;
|
||||
}
|
||||
const parseResult = ZEndingCardUrl.safeParse(card.buttonLink.trim());
|
||||
if (!parseResult.success) {
|
||||
return false;
|
||||
}
|
||||
const parseResult = z.string().url().safeParse(card.buttonLink);
|
||||
if (card.buttonLabel !== undefined && !parseResult.success) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -223,15 +217,12 @@ export const isEndingCardValid = (
|
||||
isContentValid(card.buttonLabel, surveyLanguages)
|
||||
);
|
||||
} else {
|
||||
// Use ZEndingCardUrl for consistent validation - allows dynamic URLs via hidden fields/recall values
|
||||
if (!card.url || card.url.trim() === "") {
|
||||
const parseResult = z.string().url().safeParse(card.url);
|
||||
if (parseResult.success) {
|
||||
return card.label?.trim() !== "";
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
const parseResult = ZEndingCardUrl.safeParse(card.url.trim());
|
||||
if (!parseResult.success) {
|
||||
return false;
|
||||
}
|
||||
return card.label?.trim() !== "";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -56,25 +56,9 @@ export const CustomScriptsInjector = ({
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
|
||||
// Copy inline script content with error handling
|
||||
// Copy inline script content
|
||||
if (script.textContent) {
|
||||
// Wrap inline scripts in try-catch to prevent user script errors from breaking the survey
|
||||
newScript.textContent = `
|
||||
(function() {
|
||||
try {
|
||||
${script.textContent}
|
||||
} catch (error) {
|
||||
console.warn('[Formbricks] Error in custom script:', error);
|
||||
}
|
||||
})();
|
||||
`.trim();
|
||||
}
|
||||
|
||||
// Add error handler for external scripts
|
||||
if (script.src) {
|
||||
newScript.onerror = (error) => {
|
||||
console.warn("[Formbricks] Error loading external script:", script.src, error);
|
||||
};
|
||||
newScript.textContent = script.textContent;
|
||||
}
|
||||
|
||||
document.head.appendChild(newScript);
|
||||
|
||||
@@ -24,7 +24,6 @@ interface LinkSurveyWrapperProps {
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
publicDomain: string;
|
||||
isBrandingEnabled: boolean;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
}
|
||||
|
||||
export const LinkSurveyWrapper = ({
|
||||
@@ -42,7 +41,6 @@ export const LinkSurveyWrapper = ({
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
publicDomain,
|
||||
isBrandingEnabled,
|
||||
dir = "auto",
|
||||
}: LinkSurveyWrapperProps) => {
|
||||
//for embedded survey strip away all surrounding css
|
||||
const [isBackgroundLoaded, setIsBackgroundLoaded] = useState(false);
|
||||
@@ -82,11 +80,11 @@ export const LinkSurveyWrapper = ({
|
||||
onBackgroundLoaded={handleBackgroundLoaded}>
|
||||
<div className="flex max-h-dvh min-h-dvh items-center justify-center overflow-clip">
|
||||
{!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) && (
|
||||
<ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} dir={dir} />
|
||||
<ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} />
|
||||
)}
|
||||
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
|
||||
{isPreview && (
|
||||
<div className="fixed top-0 left-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
|
||||
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
|
||||
<div />
|
||||
Survey Preview 👀
|
||||
<ResetProgressButton onClick={handleResetSurvey} />
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { CustomScriptsInjector } from "@/modules/survey/link/components/custom-scripts-injector";
|
||||
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
|
||||
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
|
||||
import { isRTLLanguage } from "@/modules/survey/link/lib/utils";
|
||||
import { SurveyInline } from "@/modules/ui/components/survey";
|
||||
|
||||
interface SurveyClientWrapperProps {
|
||||
@@ -117,11 +116,6 @@ export const SurveyClientWrapper = ({
|
||||
}
|
||||
setResponseData({});
|
||||
};
|
||||
// Determine text direction based on language code for logo positioning only
|
||||
// which checks both language code and survey content. This is only for logo UI positioning.
|
||||
const logoDir = useMemo(() => {
|
||||
return isRTLLanguage(survey, languageCode) ? "rtl" : "auto";
|
||||
}, [languageCode, survey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -146,8 +140,7 @@ export const SurveyClientWrapper = ({
|
||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||
IMPRINT_URL={IMPRINT_URL}
|
||||
PRIVACY_URL={PRIVACY_URL}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
dir={logoDir}>
|
||||
isBrandingEnabled={project.linkSurveyBranding}>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
environmentId={survey.environmentId}
|
||||
|
||||
@@ -1,195 +1,85 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getElementsFromSurveyBlocks, getWebAppLocale, isRTL, isRTLLanguage } from "./utils";
|
||||
|
||||
const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey =>
|
||||
({
|
||||
id: "survey-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test",
|
||||
type: "link",
|
||||
environmentId: "env-1",
|
||||
createdBy: null,
|
||||
status: "draft",
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: { default: "Welcome" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
questions: [],
|
||||
blocks: [],
|
||||
endings: [],
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
variables: [],
|
||||
styling: null,
|
||||
segment: null,
|
||||
languages,
|
||||
displayPercentage: null,
|
||||
isVerifyEmailEnabled: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
projectOverwrites: null,
|
||||
surveyClosedMessage: null,
|
||||
followUps: [],
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
showLanguageSwitch: null,
|
||||
recaptcha: null,
|
||||
isBackButtonHidden: false,
|
||||
isCaptureIpEnabled: false,
|
||||
slug: null,
|
||||
metadata: {},
|
||||
}) as TSurvey;
|
||||
import { getWebAppLocale } from "./utils";
|
||||
|
||||
describe("getWebAppLocale", () => {
|
||||
test("maps language codes and handles defaults", () => {
|
||||
expect(getWebAppLocale("en", createMockSurvey())).toBe("en-US");
|
||||
expect(getWebAppLocale("de", createMockSurvey())).toBe("de-DE");
|
||||
const surveyWithLang = createMockSurvey([
|
||||
{
|
||||
language: {
|
||||
id: "l1",
|
||||
code: "de",
|
||||
alias: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "p1",
|
||||
},
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
expect(getWebAppLocale("default", surveyWithLang)).toBe("de-DE");
|
||||
expect(getWebAppLocale("xx", createMockSurvey())).toBe("en-US");
|
||||
});
|
||||
|
||||
test("returns en-US when default requested but no default language", () => {
|
||||
const surveyNoDefault = createMockSurvey([
|
||||
{
|
||||
language: {
|
||||
id: "l1",
|
||||
code: "de",
|
||||
alias: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "p1",
|
||||
},
|
||||
default: false,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
expect(getWebAppLocale("default", surveyNoDefault)).toBe("en-US");
|
||||
});
|
||||
|
||||
test("matches base language code for variants", () => {
|
||||
expect(getWebAppLocale("pt-PT", createMockSurvey())).toBe("pt-PT");
|
||||
expect(getWebAppLocale("es-MX", createMockSurvey())).toBe("es-ES");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRTL", () => {
|
||||
test("detects RTL characters", () => {
|
||||
expect(isRTL("مرحبا")).toBe(true);
|
||||
expect(isRTL("שלום")).toBe(true);
|
||||
expect(isRTL("Hello")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRTLLanguage", () => {
|
||||
const createJsSurvey = (
|
||||
languages: TJsEnvironmentStateSurvey["languages"] = [],
|
||||
blocks: TSurveyBlock[] = []
|
||||
): TJsEnvironmentStateSurvey =>
|
||||
({
|
||||
id: "s1",
|
||||
const createMockSurvey = (languages: TSurvey["languages"] = []): TSurvey => {
|
||||
return {
|
||||
id: "survey-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test",
|
||||
name: "Test Survey",
|
||||
type: "link",
|
||||
environmentId: "env-1",
|
||||
createdBy: null,
|
||||
status: "draft",
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: { default: "Welcome" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
blocks,
|
||||
questions: [],
|
||||
blocks: [],
|
||||
endings: [],
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
variables: [],
|
||||
styling: null,
|
||||
segment: null,
|
||||
languages,
|
||||
}) as unknown as TJsEnvironmentStateSurvey;
|
||||
displayPercentage: null,
|
||||
isVerifyEmailEnabled: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
projectOverwrites: null,
|
||||
surveyClosedMessage: null,
|
||||
followUps: [],
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
showLanguageSwitch: null,
|
||||
recaptcha: null,
|
||||
isBackButtonHidden: false,
|
||||
isCaptureIpEnabled: false,
|
||||
slug: null,
|
||||
metadata: {},
|
||||
} as TSurvey;
|
||||
};
|
||||
|
||||
test("checks language codes when multi-language enabled", () => {
|
||||
const survey = createJsSurvey([
|
||||
test("maps language codes to web app locales", () => {
|
||||
const survey = createMockSurvey();
|
||||
expect(getWebAppLocale("en", survey)).toBe("en-US");
|
||||
expect(getWebAppLocale("de", survey)).toBe("de-DE");
|
||||
expect(getWebAppLocale("pt-BR", survey)).toBe("pt-BR");
|
||||
});
|
||||
|
||||
test("handles 'default' languageCode by finding default language in survey", () => {
|
||||
const survey = createMockSurvey([
|
||||
{
|
||||
language: {
|
||||
id: "l1",
|
||||
code: "ar",
|
||||
id: "lang1",
|
||||
code: "de",
|
||||
alias: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "p1",
|
||||
projectId: "proj1",
|
||||
},
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
expect(isRTLLanguage(survey, "ar")).toBe(true);
|
||||
expect(isRTLLanguage(survey, "en")).toBe(false);
|
||||
|
||||
expect(getWebAppLocale("default", survey)).toBe("de-DE");
|
||||
});
|
||||
|
||||
test("checks content when no languages configured", () => {
|
||||
const element = {
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "مرحبا" },
|
||||
required: false,
|
||||
} as unknown as TSurveyElement;
|
||||
const block = { id: "b1", name: "Block", elements: [element] } as TSurveyBlock;
|
||||
expect(isRTLLanguage(createJsSurvey([], [block]), "default")).toBe(true);
|
||||
});
|
||||
|
||||
test("checks welcomeCard headline when enabled and no languages", () => {
|
||||
const survey = {
|
||||
...createJsSurvey([], []),
|
||||
welcomeCard: { enabled: true, headline: { default: "مرحبا" } },
|
||||
} as unknown as TJsEnvironmentStateSurvey;
|
||||
expect(isRTLLanguage(survey, "default")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when no languages and no headlines found", () => {
|
||||
const element = { id: "q1", type: TSurveyElementTypeEnum.OpenText, headline: {}, required: false };
|
||||
const block = { id: "b1", name: "Block", elements: [element] } as TSurveyBlock;
|
||||
expect(isRTLLanguage(createJsSurvey([], [block]), "default")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getElementsFromSurveyBlocks", () => {
|
||||
test("extracts elements from blocks", () => {
|
||||
const el1 = {
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
} as unknown as TSurveyElement;
|
||||
const el2 = {
|
||||
id: "q2",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q2" },
|
||||
required: false,
|
||||
} as unknown as TSurveyElement;
|
||||
const block = { id: "b1", name: "Block", elements: [el1, el2] } as TSurveyBlock;
|
||||
const result = getElementsFromSurveyBlocks([block]);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe("q1");
|
||||
test("falls back to en-US when language is not supported", () => {
|
||||
const survey = createMockSurvey();
|
||||
expect(getWebAppLocale("default", survey)).toBe("en-US");
|
||||
expect(getWebAppLocale("xx", survey)).toBe("en-US");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,60 +1,5 @@
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export function isRTL(text: string): boolean {
|
||||
const rtlCharRegex = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
|
||||
return rtlCharRegex.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* List of RTL language codes
|
||||
*/
|
||||
const RTL_LANGUAGES = ["ar", "ar-SA", "ar-EG", "ar-AE", "ar-MA", "he", "fa", "ur"];
|
||||
|
||||
/**
|
||||
* Returns true if the language code represents an RTL language.
|
||||
* @param survey The survey to test
|
||||
* @param languageCode The language code to test (e.g., "ar", "ar-SA", "he")
|
||||
*/
|
||||
export function isRTLLanguage(survey: TJsEnvironmentStateSurvey, languageCode: string): boolean {
|
||||
if (survey.languages.length === 0) {
|
||||
if (survey.welcomeCard.enabled) {
|
||||
const welcomeCardHeadline = survey.welcomeCard.headline?.[languageCode];
|
||||
if (welcomeCardHeadline) {
|
||||
return isRTL(welcomeCardHeadline);
|
||||
}
|
||||
}
|
||||
|
||||
const questions = getElementsFromSurveyBlocks(survey.blocks);
|
||||
for (const question of questions) {
|
||||
const questionHeadline = question.headline[languageCode];
|
||||
|
||||
// the first non-empty question headline is the survey direction
|
||||
if (questionHeadline) {
|
||||
return isRTL(questionHeadline);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
const code =
|
||||
languageCode === "default"
|
||||
? survey.languages.find((language) => language.default)?.language.code
|
||||
: languageCode;
|
||||
const baseCode = code?.split("-")[0].toLowerCase() ?? "en";
|
||||
return RTL_LANGUAGES.some((rtl) => rtl.toLowerCase().startsWith(baseCode));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a flat array of elements from the survey's blocks structure.
|
||||
* @param blocks The blocks array
|
||||
* @returns An array of TSurveyElement (pure elements without block-level properties)
|
||||
*/
|
||||
export const getElementsFromSurveyBlocks = (blocks: TSurveyBlock[]): TSurveyElement[] =>
|
||||
blocks.flatMap((block) => block.elements);
|
||||
|
||||
/**
|
||||
* Maps survey language codes to web app locale codes.
|
||||
* Falls back to "en-US" if the language is not available in web app locales.
|
||||
@@ -68,7 +13,6 @@ export const getWebAppLocale = (languageCode: string, survey: TSurvey): string =
|
||||
"pt-BR": "pt-BR",
|
||||
"pt-PT": "pt-PT",
|
||||
fr: "fr-FR",
|
||||
hu: "hu-HU",
|
||||
nl: "nl-NL",
|
||||
zh: "zh-Hans-CN", // Default to Simplified Chinese
|
||||
"zh-Hans": "zh-Hans-CN",
|
||||
|
||||
@@ -13,7 +13,6 @@ interface ClientLogoProps {
|
||||
projectLogo: Project["logo"] | null;
|
||||
surveyLogo?: TLogo | null;
|
||||
previewSurvey?: boolean;
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
}
|
||||
|
||||
export const ClientLogo = ({
|
||||
@@ -21,23 +20,13 @@ export const ClientLogo = ({
|
||||
projectLogo,
|
||||
surveyLogo,
|
||||
previewSurvey = false,
|
||||
dir = "auto",
|
||||
}: ClientLogoProps) => {
|
||||
const { t } = useTranslation();
|
||||
const logoToUse = surveyLogo?.url ? surveyLogo : projectLogo;
|
||||
|
||||
let positionClasses = "";
|
||||
if (!previewSurvey) {
|
||||
if (dir === "rtl") {
|
||||
positionClasses = "top-3 right-3 md:top-7 md:right-7";
|
||||
} else {
|
||||
positionClasses = "top-3 left-3 md:top-7 md:left-7";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(positionClasses, "group absolute z-0 rounded-lg")}
|
||||
className={cn(previewSurvey ? "" : "top-3 left-3 md:top-7 md:left-7", "group absolute z-0 rounded-lg")}
|
||||
style={{ backgroundColor: logoToUse?.bgColor }}>
|
||||
{previewSurvey && environmentId && (
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
export const AngryBirdRage2Icon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg width="79" height="75" viewBox="0 0 79 75" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M70.1683 50C70.1683 66.4 56.4144 73.4375 39.4459 73.4375C22.4774 73.4375 8.72339 66.4 8.72339 50C8.72339 33.6 22.4806 10.9375 39.4459 10.9375C56.4111 10.9375 70.1683 33.6062 70.1683 50Z"
|
||||
fill="#00E6CA"
|
||||
/>
|
||||
<path
|
||||
d="M39.4457 23.4375C54.2216 23.4375 66.5591 40.625 69.502 55.8906C69.9564 53.9582 70.1799 51.9817 70.1682 50C70.1682 33.6063 56.4142 10.9375 39.4457 10.9375C22.4772 10.9375 8.72323 33.6063 8.72323 50C8.7131 51.9816 8.9366 53.9579 9.38942 55.8906C12.3355 40.625 24.673 23.4375 39.4457 23.4375Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M70.1683 50C70.1683 66.4 56.4144 73.4375 39.4459 73.4375C22.4774 73.4375 8.72339 66.4 8.72339 50C8.72339 33.6 22.4806 10.9375 39.4459 10.9375C56.4111 10.9375 70.1683 33.6062 70.1683 50Z"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M29.5239 56L38.2168 65.8031C38.3686 65.9747 38.557 66.1124 38.7692 66.2068C38.9813 66.3012 39.2121 66.3501 39.4457 66.3501C39.6792 66.3501 39.91 66.3012 40.1222 66.2068C40.3343 66.1124 40.5228 65.9747 40.6746 65.8031L49.3674 56"
|
||||
fill="#00E6CA"
|
||||
/>
|
||||
<path
|
||||
d="M29.5239 56L38.2168 65.8031C38.3686 65.9747 38.557 66.1124 38.7692 66.2068C38.9813 66.3012 39.2121 66.3501 39.4457 66.3501C39.6792 66.3501 39.91 66.3012 40.1222 66.2068C40.3343 66.1124 40.5228 65.9747 40.6746 65.8031L49.3674 56"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M30.9826 28.2407C29.6749 27.9941 28.3204 28.1438 27.104 28.6693C25.8877 29.1948 24.8691 30.0705 24.1872 31.1766C23.5054 32.2827 23.1937 33.5653 23.2948 34.849C23.3958 36.1328 23.9047 37.3551 24.7518 38.3488C25.5989 39.3425 26.7429 40.0592 28.0275 40.4009C29.312 40.7426 30.6745 40.6926 31.9285 40.2577C33.1826 39.8229 34.2671 39.0244 35.0337 37.9715C35.8004 36.9186 36.2119 35.6625 36.2119 34.3751C36.213 33.3778 35.9657 32.3949 35.4907 31.5094"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M30.9826 28.2407C29.6749 27.9941 28.3204 28.1438 27.104 28.6693C25.8877 29.1948 24.8691 30.0705 24.1872 31.1766C23.5054 32.2827 23.1937 33.5653 23.2948 34.849C23.3958 36.1328 23.9047 37.3551 24.7518 38.3488C25.5989 39.3425 26.7429 40.0592 28.0275 40.4009C29.312 40.7426 30.6745 40.6926 31.9285 40.2577C33.1826 39.8229 34.2671 39.0244 35.0337 37.9715C35.8004 36.9186 36.2119 35.6625 36.2119 34.3751C36.213 33.3778 35.9657 32.3949 35.4907 31.5094"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M47.909 28.2407C49.2167 27.9941 50.5712 28.1438 51.7875 28.6693C53.0039 29.1948 54.0225 30.0705 54.7044 31.1766C55.3862 32.2827 55.6979 33.5653 55.5968 34.849C55.4958 36.1328 54.9869 37.3551 54.1398 38.3488C53.2927 39.3425 52.1487 40.0592 50.8641 40.4009C49.5796 40.7426 48.2171 40.6926 46.9631 40.2577C45.709 39.8229 44.6245 39.0244 43.8579 37.9715C43.0912 36.9186 42.6797 35.6625 42.6797 34.3751C42.6786 33.3778 42.9259 32.3949 43.4009 31.5094"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M47.909 28.2407C49.2167 27.9941 50.5712 28.1438 51.7875 28.6693C53.0039 29.1948 54.0225 30.0705 54.7044 31.1766C55.3862 32.2827 55.6979 33.5653 55.5968 34.849C55.4958 36.1328 54.9869 37.3551 54.1398 38.3488C53.2927 39.3425 52.1487 40.0592 50.8641 40.4009C49.5796 40.7426 48.2171 40.6926 46.9631 40.2577C45.709 39.8229 44.6245 39.0244 43.8579 37.9715C43.0912 36.9186 42.6797 35.6625 42.6797 34.3751C42.6786 33.3778 42.9259 32.3949 43.4009 31.5094"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M25.5722 54.2156C25.3243 54.1002 25.111 53.9255 24.9525 53.7082C24.794 53.4908 24.6955 53.238 24.6663 52.9736C24.6372 52.7092 24.6783 52.4419 24.7859 52.1972C24.8935 51.9525 25.0639 51.7383 25.2811 51.575C28.819 48.9125 36.1083 43.75 39.4458 43.75C42.7832 43.75 50.0725 48.9125 53.6105 51.5625C53.8276 51.7258 53.998 51.94 54.1056 52.1847C54.2132 52.4294 54.2544 52.6967 54.2252 52.9611C54.1961 53.2255 54.0976 53.4783 53.9391 53.6957C53.7806 53.913 53.5673 54.0877 53.3194 54.2031C48.9386 56.4845 44.2772 58.2223 39.4458 59.375C34.615 58.2262 29.9536 56.4927 25.5722 54.2156Z"
|
||||
fill="#00E6CA"
|
||||
/>
|
||||
<path
|
||||
d="M39.4458 59.375C34.615 58.2262 29.9536 56.4927 25.5722 54.2156C25.3243 54.1002 25.111 53.9255 24.9525 53.7082C24.794 53.4908 24.6955 53.238 24.6663 52.9736C24.6372 52.7092 24.6783 52.4419 24.7859 52.1972C24.8935 51.9525 25.0639 51.7383 25.2811 51.575C28.819 48.9125 36.1083 43.75 39.4458 43.75V59.375Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M25.5722 54.2156C25.3243 54.1002 25.111 53.9255 24.9525 53.7082C24.794 53.4908 24.6955 53.238 24.6663 52.9736C24.6372 52.7092 24.6783 52.4419 24.7859 52.1972C24.8935 51.9525 25.0639 51.7383 25.2811 51.575C28.819 48.9125 36.1083 43.75 39.4458 43.75C42.7832 43.75 50.0725 48.9125 53.6105 51.5625C53.8276 51.7258 53.998 51.94 54.1056 52.1847C54.2132 52.4294 54.2544 52.6967 54.2252 52.9611C54.1961 53.2255 54.0976 53.4783 53.9391 53.6957C53.7806 53.913 53.5673 54.0877 53.3194 54.2031C48.9386 56.4845 44.2772 58.2223 39.4458 59.375C34.615 58.2262 29.9536 56.4927 25.5722 54.2156V54.2156Z"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M30.9275 12.8469L20.042 7.8125"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M33.8349 11.7687L26.51 1.5625"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M26.51 25L39.4458 34.375L52.3816 25"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M29.7439 33.5938C29.9583 33.5938 30.164 33.6761 30.3156 33.8226C30.4672 33.9691 30.5524 34.1678 30.5524 34.375"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M28.9355 34.375C28.9355 34.1678 29.0207 33.9691 29.1723 33.8226C29.324 33.6761 29.5296 33.5938 29.744 33.5938"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M29.744 35.1562C29.5296 35.1562 29.324 35.0739 29.1723 34.9274C29.0207 34.7809 28.9355 34.5822 28.9355 34.375"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M30.5524 34.375C30.5524 34.5822 30.4672 34.7809 30.3156 34.9274C30.164 35.0739 29.9583 35.1562 29.7439 35.1562"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M49.1476 33.5938C48.9332 33.5938 48.7275 33.6761 48.5759 33.8226C48.4243 33.9691 48.3391 34.1678 48.3391 34.375"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M49.9562 34.375C49.9562 34.1678 49.871 33.9691 49.7194 33.8226C49.5678 33.6761 49.3621 33.5938 49.1477 33.5938"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M49.1477 35.1562C49.3621 35.1562 49.5678 35.0739 49.7194 34.9274C49.871 34.7809 49.9562 34.5822 49.9562 34.375"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M48.3391 34.375C48.3391 34.5822 48.4243 34.7809 48.5759 34.9274C48.7275 35.0739 48.9332 35.1562 49.1476 35.1562"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
export const AngryBirdRageIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<title>{"video-game-angry-birds"}</title>
|
||||
<path
|
||||
d="M21.5,16c0,5.248-4.253,7.5-9.5,7.5S2.5,21.248,2.5,16,6.754,3.5,12,3.5,21.5,10.754,21.5,16Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M12,7.5c4.569,0,8.384,5.5,9.294,10.385A8.293,8.293,0,0,0,21.5,16c0-5.246-4.253-12.5-9.5-12.5S2.5,10.754,2.5,16a8.35,8.35,0,0,0,.206,1.885C3.617,13,7.432,7.5,12,7.5Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M21.5,16c0,5.248-4.253,7.5-9.5,7.5S2.5,21.248,2.5,16,6.754,3.5,12,3.5,21.5,10.754,21.5,16Z"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M8.932,17.92l2.688,3.137a.5.5,0,0,0,.76,0l2.688-3.137"
|
||||
fill="#00e6ca"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.383,9.037A2,2,0,1,0,11,11a1.988,1.988,0,0,0-.223-.917"
|
||||
fill="#f8fafc"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14.617,9.037A2,2,0,1,1,13,11a1.988,1.988,0,0,1,.223-.917"
|
||||
fill="#f8fafc"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.71,17.349a.5.5,0,0,1-.09-.845C8.714,15.652,10.968,14,12,14s3.286,1.652,4.38,2.5a.5.5,0,0,1-.09.845A18.278,18.278,0,0,1,12,19,18.278,18.278,0,0,1,7.71,17.349Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M12,19a18.278,18.278,0,0,1-4.29-1.651.5.5,0,0,1-.09-.845C8.714,15.652,10.968,14,12,14Z"
|
||||
fill="#c4f0eb"
|
||||
/>
|
||||
<path
|
||||
d="M7.71,17.349a.5.5,0,0,1-.09-.845C8.714,15.652,10.968,14,12,14s3.286,1.652,4.38,2.5a.5.5,0,0,1-.09.845A18.278,18.278,0,0,1,12,19,18.278,18.278,0,0,1,7.71,17.349Z"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={9.366}
|
||||
y1={4.111}
|
||||
x2={6}
|
||||
y2={2.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<line
|
||||
x1={10.265}
|
||||
y1={3.766}
|
||||
x2={8}
|
||||
y2={0.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<polyline
|
||||
points="8 8 12 11 16 8"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M9,10.75a.25.25,0,0,1,.25.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M8.75,11A.25.25,0,0,1,9,10.75"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M9,11.25A.25.25,0,0,1,8.75,11"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M9.25,11a.25.25,0,0,1-.25.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M15,10.75a.25.25,0,0,0-.25.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M15.25,11a.25.25,0,0,0-.25-.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M15,11.25a.25.25,0,0,0,.25-.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M14.75,11a.25.25,0,0,0,.25.25"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
export const AppPieChartIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<path d="M23.5,7V20a2,2,0,0,1-2,2H2.5a2,2,0,0,1-2-2V7Z" fill="#00e6ca" />
|
||||
<path d="M2.5,22h2l15-15H.5V20A2,2,0,0,0,2.5,22Z" fill="#c4f0eb" />
|
||||
<path
|
||||
d="M13,14.5a4.993,4.993,0,0,0-2.178-4.128L8,14.5l3.205,3.837A4.988,4.988,0,0,0,13,14.5Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path d="M10.822,10.372A5,5,0,0,0,3,14.5H8Z" fill="#c4f0eb" />
|
||||
<path d="M3,14.5a5,5,0,0,0,8.205,3.837L8,14.5Z" fill="#00e6ca" />
|
||||
<path d="M23.5,6.5H.5v-3a2,2,0,0,1,2-2h19a2,2,0,0,1,2,2Z" fill="#f8fafc" />
|
||||
<rect
|
||||
x={0.5}
|
||||
y={1.504}
|
||||
width={23}
|
||||
height={21}
|
||||
rx={2}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={0.5}
|
||||
y1={6.504}
|
||||
x2={23.5}
|
||||
y2={6.504}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4,3.754A.25.25,0,1,1,3.75,4,.25.25,0,0,1,4,3.754"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7,3.754A.25.25,0,1,1,6.75,4,.25.25,0,0,1,7,3.754"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M10,3.754A.25.25,0,1,1,9.75,4a.25.25,0,0,1,.25-.25"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle
|
||||
cx={8}
|
||||
cy={14.504}
|
||||
r={5}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="10.821 10.376 8 14.504 11.205 18.341"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={8}
|
||||
y1={14.504}
|
||||
x2={3}
|
||||
y2={14.504}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={15}
|
||||
y1={10.504}
|
||||
x2={21}
|
||||
y2={10.504}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={15}
|
||||
y1={13.504}
|
||||
x2={21}
|
||||
y2={13.504}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={15}
|
||||
y1={16.504}
|
||||
x2={21}
|
||||
y2={16.504}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
export const ArchiveIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M23.0625 3.9375H3.9375C3.31618 3.9375 2.8125 4.44118 2.8125 5.0625V16.0695C2.8125 16.6908 3.31618 17.1945 3.9375 17.1945H23.0625C23.6838 17.1945 24.1875 16.6908 24.1875 16.0695V5.0625C24.1875 4.44118 23.6838 3.9375 23.0625 3.9375Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M19.6875 14.0625C19.3891 14.0625 19.103 14.181 18.892 14.392C18.681 14.603 18.5625 14.8891 18.5625 15.1875C18.5625 15.4859 18.444 15.772 18.233 15.983C18.022 16.194 17.7359 16.3125 17.4375 16.3125H9.5625C9.26413 16.3125 8.97798 16.194 8.767 15.983C8.55603 15.772 8.4375 15.4859 8.4375 15.1875C8.4375 14.8891 8.31897 14.603 8.108 14.392C7.89702 14.181 7.61087 14.0625 7.3125 14.0625H1.6875C1.38913 14.0625 1.10298 14.181 0.892005 14.392C0.681026 14.603 0.5625 14.8891 0.5625 15.1875V21.9375C0.5625 22.2359 0.681026 22.522 0.892005 22.733C1.10298 22.944 1.38913 23.0625 1.6875 23.0625H25.3125C25.6109 23.0625 25.897 22.944 26.108 22.733C26.319 22.522 26.4375 22.2359 26.4375 21.9375V15.1875C26.4375 14.8891 26.319 14.603 26.108 14.392C25.897 14.181 25.6109 14.0625 25.3125 14.0625H19.6875Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M25.3125 20.0869H1.6875C1.38913 20.0869 1.10298 19.9684 0.892005 19.7574C0.681026 19.5464 0.5625 19.2603 0.5625 18.9619V21.9375C0.5625 22.2359 0.681026 22.5221 0.892005 22.733C1.10298 22.944 1.38913 23.0625 1.6875 23.0625H25.3125C25.6109 23.0625 25.897 22.944 26.108 22.733C26.319 22.5221 26.4375 22.2359 26.4375 21.9375V18.9619C26.4375 19.2603 26.319 19.5464 26.108 19.7574C25.897 19.9684 25.6109 20.0869 25.3125 20.0869Z"
|
||||
fill="#00C4B8"
|
||||
/>
|
||||
<path
|
||||
d="M10.6875 14.0625H16.3125"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.125"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2.8125 11.8125C2.8125 11.5141 2.93103 11.228 3.142 11.017C3.35298 10.806 3.63913 10.6875 3.9375 10.6875H23.0625C23.3609 10.6875 23.647 10.806 23.858 11.017C24.069 11.228 24.1875 11.5141 24.1875 11.8125"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.125"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2.8125 8.4375C2.8125 8.13913 2.93103 7.85298 3.142 7.64201C3.35298 7.43103 3.63913 7.3125 3.9375 7.3125H23.0625C23.3609 7.3125 23.647 7.43103 23.858 7.64201C24.069 7.85298 24.1875 8.13913 24.1875 8.4375"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.125"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M2.8125 5.0625C2.8125 4.76413 2.93103 4.47798 3.142 4.267C3.35298 4.05603 3.63913 3.9375 3.9375 3.9375H23.0625C23.3609 3.9375 23.647 4.05603 23.858 4.267C24.069 4.47798 24.1875 4.76413 24.1875 5.0625"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.125"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M19.6875 14.0625C19.3891 14.0625 19.103 14.181 18.892 14.392C18.681 14.603 18.5625 14.8891 18.5625 15.1875C18.5625 15.4859 18.444 15.772 18.233 15.983C18.022 16.194 17.7359 16.3125 17.4375 16.3125H9.5625C9.26413 16.3125 8.97798 16.194 8.76701 15.983C8.55603 15.772 8.4375 15.4859 8.4375 15.1875C8.4375 14.8891 8.31897 14.603 8.10799 14.392C7.89702 14.181 7.61087 14.0625 7.3125 14.0625H1.6875C1.38913 14.0625 1.10298 14.181 0.892005 14.392C0.681026 14.603 0.5625 14.8891 0.5625 15.1875V21.9375C0.5625 22.2359 0.681026 22.522 0.892005 22.733C1.10298 22.944 1.38913 23.0625 1.6875 23.0625H25.3125C25.6109 23.0625 25.897 22.944 26.108 22.733C26.319 22.522 26.4375 22.2359 26.4375 21.9375V15.1875C26.4375 14.8891 26.319 14.603 26.108 14.392C25.897 14.181 25.6109 14.0625 25.3125 14.0625H19.6875Z"
|
||||
stroke="#00303E"
|
||||
strokeWidth="1.125"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
export const ArrowRightCircleIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<circle cx={12} cy={12} r={9.5} fill="#00e6ca" />
|
||||
<path
|
||||
d="M1.414,16.5a11.5,11.5,0,1,0,0-9"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="12.5 16 16.5 12 12.5 8"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line
|
||||
x1={16.5}
|
||||
y1={12}
|
||||
x2={0.5}
|
||||
y2={12}
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
export const ArrowUpRightIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<circle cx={12} cy={12} r={10.5} fill="#c4f0eb" />
|
||||
<path
|
||||
d="M1.25,18.25,8.586,10a1.042,1.042,0,0,1,1.432-.107l4.464,3.72a1.038,1.038,0,0,0,1.43-.11L22.75,5.75"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="15.812 5.75 22.75 5.75 22.75 11.729"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
export const BackIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg width={32} height={32} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path
|
||||
d="M16.0001 29.3337C23.3639 29.3337 29.3334 23.3641 29.3334 16.0003C29.3334 8.63653 23.3639 2.66699 16.0001 2.66699C8.63628 2.66699 2.66675 8.63653 2.66675 16.0003C2.66675 23.3641 8.63628 29.3337 16.0001 29.3337Z"
|
||||
fill="#C4F0EB"
|
||||
/>
|
||||
<path
|
||||
d="M0.666748 13.9971H31.3334V23.3304"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.33333"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.33341 20.6644L0.666748 13.9977L7.33341 7.33105"
|
||||
stroke="#0F172A"
|
||||
strokeWidth="1.33333"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
export const BaseballIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<title>{"baseball-bat-ball"}</title>
|
||||
<circle
|
||||
cx={18.25}
|
||||
cy={17.87}
|
||||
r={2.5}
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="#f8fafc"
|
||||
/>
|
||||
<path
|
||||
d="M4.743,21.423,8.6,17.562,21.891,6.549a3.19,3.19,0,1,0-4.5-4.461L6.511,15.409,2.62,19.3Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M22.174,1.826,22.153,1.8a3.19,3.19,0,0,0-4.763.284L6.511,15.409,2.62,19.3,3.66,20.34Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M1.206,19.3a1,1,0,0,1,1.414,0l2.123,2.122a1,1,0,1,1-1.415,1.414L1.206,20.715A1,1,0,0,1,1.206,19.3Z"
|
||||
fill="#00e6ca"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect
|
||||
x={5.084}
|
||||
y={16.209}
|
||||
width={2.5}
|
||||
height={3.001}
|
||||
transform="translate(-10.667 9.666) rotate(-45.001)"
|
||||
fill="#00e6ca"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4.743,21.423,8.6,17.562,21.891,6.549a3.19,3.19,0,1,0-4.5-4.461L6.511,15.409,2.62,19.3Z"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
export const BellIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}>
|
||||
<defs />
|
||||
<path
|
||||
d="M15,20.5a3,3,0,1,1-6,0Z"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path
|
||||
d="M20.5,17.5V11a8.5,8.5,0,0,0-5.541-7.959,3,3,0,0,0-5.922,0A8.493,8.493,0,0,0,3.5,11v6.5a3,3,0,0,1-3,3h23A3,3,0,0,1,20.5,17.5Z"
|
||||
fill="#00e6ca"
|
||||
/>
|
||||
<path d="M12,.5A3,3,0,0,0,9.037,3.044,8.5,8.5,0,0,0,3.5,11v6.5a3,3,0,0,1-3,3H12Z" fill="#c4f0eb" />
|
||||
<path
|
||||
d="M20.5,17.5V11a8.5,8.5,0,0,0-5.541-7.959,3,3,0,0,0-5.922,0A8.493,8.493,0,0,0,3.5,11v6.5a3,3,0,0,1-3,3h23A3,3,0,0,1,20.5,17.5Z"
|
||||
fill="none"
|
||||
stroke="#0f172a"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user