mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-23 05:17:49 -05:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ce48b2ee6 | |||
| f250bc7e88 | |||
| c7faa29437 |
+6
-4
@@ -64,15 +64,17 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
||||
|
||||
const ZResetSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
organizationId: ZId,
|
||||
projectId: ZId,
|
||||
});
|
||||
|
||||
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
|
||||
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -81,12 +83,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: parsedInput.projectId,
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = null;
|
||||
|
||||
|
||||
+1
-2
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const { organizationId, project } = useEnvironment();
|
||||
const { project } = useEnvironment();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||
|
||||
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
@@ -128,7 +128,6 @@ export const SurveyAnalysisCTA = ({
|
||||
setIsResetting(true);
|
||||
const result = await resetSurveyAction({
|
||||
surveyId: survey.id,
|
||||
organizationId: organizationId,
|
||||
projectId: project.id,
|
||||
});
|
||||
if (result?.data) {
|
||||
|
||||
@@ -217,7 +217,7 @@ describe("utils", () => {
|
||||
});
|
||||
|
||||
describe("logApiError", () => {
|
||||
test("logs API error details", () => {
|
||||
test("logs API error details with method and path", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
@@ -228,7 +228,7 @@ describe("utils", () => {
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/test");
|
||||
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "POST" });
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
@@ -238,9 +238,11 @@ describe("utils", () => {
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify withContext was called with the expected context
|
||||
// Verify withContext was called with the expected context including method and path
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
correlationId: "123",
|
||||
method: "POST",
|
||||
path: "/api/v2/management/surveys",
|
||||
error,
|
||||
});
|
||||
|
||||
@@ -275,6 +277,8 @@ describe("utils", () => {
|
||||
// Verify withContext was called with the expected context
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
correlationId: "",
|
||||
method: "GET",
|
||||
path: "/api/test",
|
||||
error,
|
||||
});
|
||||
|
||||
@@ -285,7 +289,7 @@ describe("utils", () => {
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
|
||||
test("log API error details with SENTRY_DSN set", () => {
|
||||
test("log API error details with SENTRY_DSN set includes method and path tags", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
@@ -295,11 +299,23 @@ describe("utils", () => {
|
||||
// Mock Sentry's captureException method
|
||||
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
|
||||
|
||||
// Capture the scope mock for tag verification
|
||||
const scopeSetTagMock = vi.fn();
|
||||
vi.mocked(Sentry.withScope).mockImplementation((callback: (scope: any) => void) => {
|
||||
const mockScope = {
|
||||
setTag: scopeSetTagMock,
|
||||
setContext: vi.fn(),
|
||||
setLevel: vi.fn(),
|
||||
setExtra: vi.fn(),
|
||||
};
|
||||
callback(mockScope);
|
||||
});
|
||||
|
||||
// Replace the original withContext with our mock
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/test");
|
||||
const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "DELETE" });
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
@@ -309,20 +325,60 @@ describe("utils", () => {
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify withContext was called with the expected context
|
||||
// Verify withContext was called with the expected context including method and path
|
||||
expect(withContextMock).toHaveBeenCalledWith({
|
||||
correlationId: "123",
|
||||
method: "DELETE",
|
||||
path: "/api/v2/management/surveys",
|
||||
error,
|
||||
});
|
||||
|
||||
// Verify error was called on the child logger
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Verify Sentry scope tags include method and path
|
||||
expect(scopeSetTagMock).toHaveBeenCalledWith("correlationId", "123");
|
||||
expect(scopeSetTagMock).toHaveBeenCalledWith("method", "DELETE");
|
||||
expect(scopeSetTagMock).toHaveBeenCalledWith("path", "/api/v2/management/surveys");
|
||||
|
||||
// Verify Sentry.captureException was called
|
||||
expect(Sentry.captureException).toHaveBeenCalled();
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
|
||||
test("does not send to Sentry for non-internal_server_error types", () => {
|
||||
// Mock the withContext method and its returned error method
|
||||
const errorMock = vi.fn();
|
||||
const withContextMock = vi.fn().mockReturnValue({
|
||||
error: errorMock,
|
||||
});
|
||||
|
||||
vi.mocked(Sentry.captureException).mockClear();
|
||||
|
||||
// Replace the original withContext with our mock
|
||||
const originalWithContext = logger.withContext;
|
||||
logger.withContext = withContextMock;
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/v2/management/surveys");
|
||||
mockRequest.headers.set("x-request-id", "456");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
type: "not_found",
|
||||
details: [{ field: "survey", issue: "not found" }],
|
||||
};
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
// Verify Sentry.captureException was NOT called for non-500 errors
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
|
||||
// But structured logging should still happen
|
||||
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
|
||||
|
||||
// Restore the original method
|
||||
logger.withContext = originalWithContext;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,13 +6,18 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
|
||||
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
|
||||
const correlationId = request.headers.get("x-request-id") ?? "";
|
||||
const method = request.method;
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
|
||||
// This is useful for tracking down issues without overloading Sentry with errors
|
||||
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
|
||||
// Use Sentry scope to add correlation ID as a tag for easy filtering
|
||||
// Use Sentry scope to add correlation ID and request context as tags for easy filtering
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("correlationId", correlationId);
|
||||
scope.setTag("method", method);
|
||||
scope.setTag("path", path);
|
||||
scope.setLevel("error");
|
||||
|
||||
scope.setExtra("originalError", error);
|
||||
@@ -24,6 +29,8 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
|
||||
logger
|
||||
.withContext({
|
||||
correlationId,
|
||||
method,
|
||||
path,
|
||||
error,
|
||||
})
|
||||
.error("API V2 Error Details");
|
||||
|
||||
@@ -97,14 +97,13 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
|
||||
);
|
||||
|
||||
const ZUpdateSegmentAction = z.object({
|
||||
environmentId: ZId,
|
||||
segmentId: ZId,
|
||||
data: ZSegmentUpdateInput,
|
||||
});
|
||||
|
||||
export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdateSegmentAction).action(
|
||||
withAuditLogging("updated", "segment", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
const organizationId = await getOrganizationIdFromSegmentId(parsedInput.segmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
|
||||
@@ -75,7 +75,6 @@ export function SegmentSettings({
|
||||
try {
|
||||
setIsUpdatingSegment(true);
|
||||
const data = await updateSegmentAction({
|
||||
environmentId,
|
||||
segmentId: segment.id,
|
||||
data: {
|
||||
title: segment.title,
|
||||
|
||||
@@ -124,7 +124,7 @@ export function TargetingCard({
|
||||
};
|
||||
|
||||
const handleSaveAsNewSegmentUpdate = async (segmentId: string, data: TSegmentUpdateInput) => {
|
||||
const updatedSegment = await updateSegmentAction({ segmentId, environmentId, data });
|
||||
const updatedSegment = await updateSegmentAction({ segmentId, data });
|
||||
return updatedSegment?.data as TSegment;
|
||||
};
|
||||
|
||||
@@ -136,7 +136,7 @@ export function TargetingCard({
|
||||
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
|
||||
try {
|
||||
if (!segment) throw new Error(t("environments.segments.invalid_segment"));
|
||||
const result = await updateSegmentAction({ segmentId: segment.id, environmentId, data });
|
||||
const result = await updateSegmentAction({ segmentId: segment.id, data });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
|
||||
@@ -21,7 +21,6 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
|
||||
const ZDeleteQuotaAction = z.object({
|
||||
quotaId: ZId,
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
const checkQuotasEnabled = async (organizationId: string) => {
|
||||
@@ -37,7 +36,7 @@ const checkQuotasEnabled = async (organizationId: string) => {
|
||||
|
||||
export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQuotaAction).action(
|
||||
withAuditLogging("deleted", "quota", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
|
||||
await checkQuotasEnabled(organizationId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -49,7 +48,7 @@ export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQu
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
@@ -72,7 +71,7 @@ const ZUpdateQuotaAction = z.object({
|
||||
|
||||
export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQuotaAction).action(
|
||||
withAuditLogging("updated", "quota", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.quota.surveyId);
|
||||
const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
|
||||
await checkQuotasEnabled(organizationId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -84,7 +83,7 @@ export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQu
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.quota.surveyId),
|
||||
projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -85,7 +85,6 @@ export const QuotasCard = ({
|
||||
setIsDeletingQuota(true);
|
||||
const deleteQuotaActionResult = await deleteQuotaAction({
|
||||
quotaId: quotaId,
|
||||
surveyId: localSurvey.id,
|
||||
});
|
||||
if (deleteQuotaActionResult?.data) {
|
||||
toast.success(t("environments.surveys.edit.quotas.quota_deleted_successfull_toast"));
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getUserManagementAccess } from "@/lib/membership/utils";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromInviteId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { updateInvite } from "@/modules/ee/role-management/lib/invite";
|
||||
@@ -31,7 +32,6 @@ export const checkRoleManagementPermission = async (organizationId: string) => {
|
||||
|
||||
const ZUpdateInviteAction = z.object({
|
||||
inviteId: ZUuid,
|
||||
organizationId: ZId,
|
||||
data: ZInviteUpdateInput,
|
||||
});
|
||||
|
||||
@@ -39,17 +39,16 @@ export type TUpdateInviteAction = z.infer<typeof ZUpdateInviteAction>;
|
||||
|
||||
export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateInviteAction).action(
|
||||
withAuditLogging("updated", "invite", async ({ ctx, parsedInput }) => {
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(
|
||||
ctx.user.id,
|
||||
parsedInput.organizationId
|
||||
);
|
||||
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(ctx.user.id, organizationId);
|
||||
if (!currentUserMembership) {
|
||||
throw new AuthenticationError("User not a member of this organization");
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
@@ -68,9 +67,9 @@ export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateI
|
||||
throw new OperationNotAllowedError("Managers can only invite members");
|
||||
}
|
||||
|
||||
await checkRoleManagementPermission(parsedInput.organizationId);
|
||||
await checkRoleManagementPermission(organizationId);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
|
||||
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function EditMembershipRole({
|
||||
}
|
||||
|
||||
if (inviteId) {
|
||||
await updateInviteAction({ inviteId: inviteId, organizationId, data: { role } });
|
||||
await updateInviteAction({ inviteId: inviteId, data: { role } });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
|
||||
@@ -27,14 +27,15 @@ import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInv
|
||||
|
||||
const ZDeleteInviteAction = z.object({
|
||||
inviteId: ZUuid,
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteInviteAction).action(
|
||||
withAuditLogging("deleted", "invite", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -42,7 +43,7 @@ export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteI
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
|
||||
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
|
||||
return await deleteInvite(parsedInput.inviteId);
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
|
||||
if (!member && invite) {
|
||||
// This is an invite
|
||||
|
||||
const result = await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id });
|
||||
const result = await deleteInviteAction({ inviteId: invite?.id });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setIsDeleting(false);
|
||||
|
||||
@@ -43,6 +43,17 @@ vi.mock("@/lib/common/utils", () => ({
|
||||
handleHiddenFields: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUpdateQueue = {
|
||||
hasPendingWork: vi.fn().mockReturnValue(false),
|
||||
waitForPendingWork: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
vi.mock("@/lib/user/update-queue", () => ({
|
||||
UpdateQueue: {
|
||||
getInstance: vi.fn(() => mockUpdateQueue),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("widget-file", () => {
|
||||
let getInstanceConfigMock: MockInstance<() => Config>;
|
||||
let getInstanceLoggerMock: MockInstance<() => Logger>;
|
||||
@@ -249,4 +260,265 @@ describe("widget-file", () => {
|
||||
widget.removeWidgetContainer();
|
||||
expect(document.getElementById("formbricks-container")).toBeFalsy();
|
||||
});
|
||||
|
||||
test("renderWidget waits for pending identification before rendering", async () => {
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
|
||||
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
environment: {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
userId: "user_abc",
|
||||
contactId: "contact_abc",
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
language: "en",
|
||||
},
|
||||
},
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
expect(mockUpdateQueue.hasPendingWork).toHaveBeenCalled();
|
||||
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactId: "contact_abc",
|
||||
})
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("renderWidget does not wait when no identification is pending", async () => {
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(false);
|
||||
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
environment: {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
userId: "user_abc",
|
||||
contactId: "contact_abc",
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
language: "en",
|
||||
},
|
||||
},
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
expect(mockUpdateQueue.hasPendingWork).toHaveBeenCalled();
|
||||
expect(mockUpdateQueue.waitForPendingWork).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("renderWidget reads contactId after identification wait completes", async () => {
|
||||
let callCount = 0;
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockImplementation(() => {
|
||||
callCount++;
|
||||
return {
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
environment: {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
// Simulate contactId becoming available after identification
|
||||
userId: "user_abc",
|
||||
contactId: callCount > 2 ? "contact_after_identification" : undefined,
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
language: "en",
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
// The contactId passed to renderSurvey should be read after the wait
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactId: "contact_after_identification",
|
||||
})
|
||||
);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("renderWidget skips survey when identification fails and survey has segment filters", async () => {
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
|
||||
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
segment: { id: "seg_1", filters: [{ type: "attribute", value: "plan" }] },
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
expect(mockUpdateQueue.waitForPendingWork).toHaveBeenCalled();
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
"User identification failed. Skipping survey with segment filters."
|
||||
);
|
||||
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renderWidget proceeds when identification fails but survey has no segment filters", async () => {
|
||||
mockUpdateQueue.hasPendingWork.mockReturnValue(true);
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(false);
|
||||
|
||||
const mockConfigValue = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
appUrl: "https://fake.app",
|
||||
environmentId: "env_123",
|
||||
environment: {
|
||||
data: {
|
||||
project: {
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
placement: "bottomRight",
|
||||
inAppSurveyBranding: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
data: {
|
||||
userId: null,
|
||||
contactId: null,
|
||||
displays: [],
|
||||
responses: [],
|
||||
lastDisplayAt: null,
|
||||
language: "en",
|
||||
},
|
||||
},
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
delay: 0,
|
||||
segment: undefined,
|
||||
} as unknown as TEnvironmentStateSurvey);
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
"User identification failed but survey has no segment filters. Proceeding."
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
handleHiddenFields,
|
||||
shouldDisplayBasedOnPercentage,
|
||||
} from "@/lib/common/utils";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { type TEnvironmentStateSurvey, type TUserState } from "@/types/config";
|
||||
import { type TTrackProperties } from "@/types/survey";
|
||||
|
||||
@@ -60,6 +61,24 @@ export const renderWidget = async (
|
||||
|
||||
setIsSurveyRunning(true);
|
||||
|
||||
// Wait for pending user identification to complete before rendering
|
||||
const updateQueue = UpdateQueue.getInstance();
|
||||
if (updateQueue.hasPendingWork()) {
|
||||
logger.debug("Waiting for pending user identification before rendering survey");
|
||||
const identificationSucceeded = await updateQueue.waitForPendingWork();
|
||||
if (!identificationSucceeded) {
|
||||
const hasSegmentFilters = Array.isArray(survey.segment?.filters) && survey.segment.filters.length > 0;
|
||||
|
||||
if (hasSegmentFilters) {
|
||||
logger.debug("User identification failed. Skipping survey with segment filters.");
|
||||
setIsSurveyRunning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("User identification failed but survey has no segment filters. Proceeding.");
|
||||
}
|
||||
}
|
||||
|
||||
if (survey.delay) {
|
||||
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay.toString()} seconds.`);
|
||||
}
|
||||
|
||||
@@ -169,4 +169,104 @@ describe("UpdateQueue", () => {
|
||||
"Formbricks can't set attributes without a userId! Please set a userId first with the setUserId function"
|
||||
);
|
||||
});
|
||||
|
||||
test("hasPendingWork returns false when no updates and no flush in flight", () => {
|
||||
expect(updateQueue.hasPendingWork()).toBe(false);
|
||||
});
|
||||
|
||||
test("hasPendingWork returns true when updates are queued", () => {
|
||||
updateQueue.updateUserId(mockUserId1);
|
||||
expect(updateQueue.hasPendingWork()).toBe(true);
|
||||
});
|
||||
|
||||
test("hasPendingWork returns true while processUpdates flush is in flight", () => {
|
||||
(sendUpdates as Mock).mockReturnValue({
|
||||
ok: true,
|
||||
data: { hasWarnings: false },
|
||||
});
|
||||
|
||||
updateQueue.updateUserId(mockUserId1);
|
||||
// Start processing but don't await — the debounce means the flush is in-flight
|
||||
void updateQueue.processUpdates();
|
||||
|
||||
expect(updateQueue.hasPendingWork()).toBe(true);
|
||||
});
|
||||
|
||||
test("waitForPendingWork returns true immediately when no pending work", async () => {
|
||||
const result = await updateQueue.waitForPendingWork();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("waitForPendingWork returns true when processUpdates succeeds", async () => {
|
||||
(sendUpdates as Mock).mockReturnValue({
|
||||
ok: true,
|
||||
data: { hasWarnings: false },
|
||||
});
|
||||
|
||||
updateQueue.updateUserId(mockUserId1);
|
||||
void updateQueue.processUpdates();
|
||||
|
||||
const result = await updateQueue.waitForPendingWork();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(updateQueue.hasPendingWork()).toBe(false);
|
||||
expect(sendUpdates).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("waitForPendingWork returns false when processUpdates rejects", async () => {
|
||||
loggerMock.mockReturnValue(mockLogger as unknown as Logger);
|
||||
(sendUpdates as Mock).mockRejectedValue(new Error("network error"));
|
||||
|
||||
updateQueue.updateUserId(mockUserId1);
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally swallowing rejection to avoid unhandled promise
|
||||
const processPromise = updateQueue.processUpdates().catch(() => {});
|
||||
|
||||
const result = await updateQueue.waitForPendingWork();
|
||||
expect(result).toBe(false);
|
||||
await processPromise;
|
||||
});
|
||||
|
||||
test("waitForPendingWork returns false when flush hangs past timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// sendUpdates returns a promise that never resolves, simulating a network hang
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentionally never-resolving promise
|
||||
(sendUpdates as Mock).mockReturnValue(new Promise(() => {}));
|
||||
|
||||
updateQueue.updateUserId(mockUserId1);
|
||||
void updateQueue.processUpdates();
|
||||
|
||||
const resultPromise = updateQueue.waitForPendingWork();
|
||||
|
||||
// Advance past the debounce delay (500ms) so the handler fires and hangs on sendUpdates
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
// Advance past the pending work timeout (5000ms)
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result).toBe(false);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("processUpdates reuses pending flush instead of creating orphaned promises", async () => {
|
||||
(sendUpdates as Mock).mockReturnValue({
|
||||
ok: true,
|
||||
data: { hasWarnings: false },
|
||||
});
|
||||
|
||||
updateQueue.updateUserId(mockUserId1);
|
||||
|
||||
// First call creates the flush promise
|
||||
const firstPromise = updateQueue.processUpdates();
|
||||
|
||||
// Second call while first is still pending should not create a new flush
|
||||
updateQueue.updateAttributes({ name: mockAttributes.name });
|
||||
const secondPromise = updateQueue.processUpdates();
|
||||
|
||||
// Both promises should resolve (second is not orphaned)
|
||||
await Promise.all([firstPromise, secondPromise]);
|
||||
|
||||
expect(updateQueue.hasPendingWork()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,9 @@ export class UpdateQueue {
|
||||
private static instance: UpdateQueue | null = null;
|
||||
private updates: TUpdates | null = null;
|
||||
private debounceTimeout: NodeJS.Timeout | null = null;
|
||||
private pendingFlush: Promise<void> | null = null;
|
||||
private readonly DEBOUNCE_DELAY = 500;
|
||||
private readonly PENDING_WORK_TIMEOUT = 5000;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
@@ -63,17 +65,45 @@ export class UpdateQueue {
|
||||
return !this.updates;
|
||||
}
|
||||
|
||||
public hasPendingWork(): boolean {
|
||||
return this.updates !== null || this.pendingFlush !== null;
|
||||
}
|
||||
|
||||
public async waitForPendingWork(): Promise<boolean> {
|
||||
if (!this.hasPendingWork()) return true;
|
||||
|
||||
const flush = this.pendingFlush ?? this.processUpdates();
|
||||
try {
|
||||
const succeeded = await Promise.race([
|
||||
flush.then(() => true as const),
|
||||
new Promise<false>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(false);
|
||||
}, this.PENDING_WORK_TIMEOUT);
|
||||
}),
|
||||
]);
|
||||
return succeeded;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async processUpdates(): Promise<void> {
|
||||
const logger = Logger.getInstance();
|
||||
if (!this.updates) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a flush is already in flight, reuse it instead of creating a new promise
|
||||
if (this.pendingFlush) {
|
||||
return this.pendingFlush;
|
||||
}
|
||||
|
||||
if (this.debounceTimeout) {
|
||||
clearTimeout(this.debounceTimeout);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const flushPromise = new Promise<void>((resolve, reject) => {
|
||||
const handler = async (): Promise<void> => {
|
||||
try {
|
||||
let currentUpdates = { ...this.updates };
|
||||
@@ -147,8 +177,10 @@ export class UpdateQueue {
|
||||
}
|
||||
|
||||
this.clearUpdates();
|
||||
this.pendingFlush = null;
|
||||
resolve();
|
||||
} catch (error: unknown) {
|
||||
this.pendingFlush = null;
|
||||
logger.error(
|
||||
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
);
|
||||
@@ -158,5 +190,8 @@ export class UpdateQueue {
|
||||
|
||||
this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY);
|
||||
});
|
||||
|
||||
this.pendingFlush = flushPromise;
|
||||
return flushPromise;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"source": "en",
|
||||
"targets": [
|
||||
"ar",
|
||||
"cs",
|
||||
"da",
|
||||
"de",
|
||||
"es",
|
||||
@@ -18,9 +19,12 @@
|
||||
"it",
|
||||
"ja",
|
||||
"nl",
|
||||
"pl",
|
||||
"pt",
|
||||
"ro",
|
||||
"ru",
|
||||
"sk",
|
||||
"sr",
|
||||
"sv",
|
||||
"uz",
|
||||
"zh-Hans"
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"common": {
|
||||
"and": "a",
|
||||
"apply": "použít",
|
||||
"auto_close_wrapper": "Automaticky uzavřít obal",
|
||||
"back": "Zpět",
|
||||
"close_survey": "Zavřít dotazník",
|
||||
"company_logo": "Logo společnosti",
|
||||
"finish": "Dokončit",
|
||||
"language_switch": "Přepínač jazyka",
|
||||
"next": "Další",
|
||||
"open_in_new_tab": "Otevřít na nové kartě",
|
||||
"people_responded": "{count, plural, one {Odpověděla 1 osoba} few {Odpověděly {count} osoby} many {Odpovědělo {count} osoby} other {Odpovědělo {count} osob}}",
|
||||
"please_retry_now_or_try_again_later": "Zkus to prosím znovu teď nebo to zkus později.",
|
||||
"powered_by": "Používá technologii",
|
||||
"privacy_policy": "Zásady ochrany osobních údajů",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "Chráněno reCAPTCHA a Google",
|
||||
"question": "Otázka",
|
||||
"question_video": "Video otázky",
|
||||
"required": "Povinné",
|
||||
"respondents_will_not_see_this_card": "Respondenti tuto kartu neuvidí",
|
||||
"retry": "Zkusit znovu",
|
||||
"retrying": "Opakuji pokus…",
|
||||
"select_option": "Vyber možnost",
|
||||
"select_options": "Vyber možnosti",
|
||||
"sending_responses": "Odesílám odpovědi…",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Zabere méně než 1 minutu} few {Zabere méně než {count} minuty} many {Zabere méně než {count} minuty} other {Zabere méně než {count} minut}}",
|
||||
"takes_x_minutes": "{count, plural, one {Zabere 1 minutu} few {Zabere {count} minuty} many {Zabere {count} minuty} other {Zabere {count} minut}}",
|
||||
"takes_x_plus_minutes": "Zabere {count}+ minut",
|
||||
"terms_of_service": "Smluvní podmínky",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Servery momentálně nelze kontaktovat.",
|
||||
"they_will_be_redirected_immediately": "Budou okamžitě přesměrováni",
|
||||
"your_feedback_is_stuck": "Vaše zpětná vazba uvízla :("
|
||||
},
|
||||
"errors": {
|
||||
"all_options_must_be_ranked": "Seřaďte prosím všechny možnosti",
|
||||
"all_rows_must_be_answered": "Odpovězte prosím na všechny řádky",
|
||||
"file_extension_must_be": "Přípona souboru musí být {extension}",
|
||||
"file_extension_must_not_be": "Přípona souboru nesmí být {extension}",
|
||||
"file_input": {
|
||||
"duplicate_files": "Následující soubory jsou již nahrány: {duplicateNames}. Duplicitní soubory nejsou povoleny.",
|
||||
"file_size_exceeded": "Následující soubory překračují maximální velikost {maxSizeInMB} MB a byly odstraněny: {fileNames}",
|
||||
"file_size_exceeded_alert": "Soubor by měl být menší než {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "Nebyly vybrány žádné platné typy souborů. Vyberte prosím platný typ souboru.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Najednou lze nahrát pouze jeden soubor.",
|
||||
"placeholder_text": "Klikněte nebo přetáhněte soubory k nahrání",
|
||||
"upload_failed": "Nahrávání selhalo! Zkuste to prosím znovu.",
|
||||
"uploading": "Nahrávám...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Můžete nahrát maximálně {FILE_LIMIT} souborů."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
"message": "Pokud chcete pokračovat v používání tohoto zařízení, zakažte prosím ochranu proti spamu v nastavení průzkumu.",
|
||||
"title": "Toto zařízení nepodporuje ochranu proti spamu."
|
||||
},
|
||||
"invalid_format": "Zadejte prosím platný formát",
|
||||
"is_between": "Vyberte prosím datum mezi {startDate} a {endDate}",
|
||||
"is_earlier_than": "Vyberte prosím datum dřívější než {date}",
|
||||
"is_greater_than": "Zadejte prosím hodnotu větší než {min}",
|
||||
"is_later_than": "Prosím vyber datum pozdější než {date}",
|
||||
"is_less_than": "Prosím zadej hodnotu menší než {max}",
|
||||
"is_not_between": "Prosím vyber datum, které není mezi {startDate} a {endDate}",
|
||||
"max_length": "Prosím zadej maximálně {max} znaků",
|
||||
"max_selections": "Prosím vyber maximálně {max} možností",
|
||||
"max_value": "Prosím zadej hodnotu nejvýše {max}",
|
||||
"min_length": "Prosím zadej alespoň {min} znaků",
|
||||
"min_selections": "Prosím vyber alespoň {min} možností",
|
||||
"min_value": "Prosím zadej hodnotu alespoň {min}",
|
||||
"minimum_options_ranked": "Prosím seřaď alespoň {min} možností",
|
||||
"minimum_rows_answered": "Prosím odpověz alespoň na {min} řádků",
|
||||
"please_enter_a_valid_email_address": "Prosím zadej platnou e-mailovou adresu",
|
||||
"please_enter_a_valid_phone_number": "Prosím zadej platné telefonní číslo",
|
||||
"please_enter_a_valid_url": "Prosím zadej platnou URL adresu",
|
||||
"please_fill_out_this_field": "Prosím vyplň toto pole",
|
||||
"recaptcha_error": {
|
||||
"message": "Tvou odpověď se nepodařilo odeslat, protože byla označena jako automatizovaná aktivita. Pokud dýcháš, zkus to prosím znovu.",
|
||||
"title": "Nepodařilo se nám ověřit, že jsi člověk."
|
||||
},
|
||||
"value_must_contain": "Hodnota musí obsahovat {value}",
|
||||
"value_must_equal": "Hodnota musí být {value}",
|
||||
"value_must_not_contain": "Hodnota nesmí obsahovat {value}",
|
||||
"value_must_not_equal": "Hodnota nesmí být {value}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"common": {
|
||||
"and": "i",
|
||||
"apply": "zastosuj",
|
||||
"auto_close_wrapper": "Automatyczne zamknięcie okna",
|
||||
"back": "Wstecz",
|
||||
"close_survey": "Zamknij ankietę",
|
||||
"company_logo": "Logo firmy",
|
||||
"finish": "Zakończ",
|
||||
"language_switch": "Przełącznik języka",
|
||||
"next": "Dalej",
|
||||
"open_in_new_tab": "Otwórz w nowej karcie",
|
||||
"people_responded": "{count, plural, one {1 osoba odpowiedziała} few {{count} osoby odpowiedziały} many {{count} osób odpowiedziało} other {{count} osób odpowiedziało}}",
|
||||
"please_retry_now_or_try_again_later": "Spróbuj ponownie teraz lub spróbuj później.",
|
||||
"powered_by": "Powered by",
|
||||
"privacy_policy": "Polityka prywatności",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "Chronione przez reCAPTCHA i Google",
|
||||
"question": "Pytanie",
|
||||
"question_video": "Wideo z pytaniem",
|
||||
"required": "Wymagane",
|
||||
"respondents_will_not_see_this_card": "Respondenci nie zobaczą tej karty",
|
||||
"retry": "Spróbuj ponownie",
|
||||
"retrying": "Ponowna próba…",
|
||||
"select_option": "Wybierz opcję",
|
||||
"select_options": "Wybierz opcje",
|
||||
"sending_responses": "Wysyłanie odpowiedzi…",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Zajmie mniej niż 1 minutę} few {Zajmie mniej niż {count} minuty} many {Zajmie mniej niż {count} minut} other {Zajmie mniej niż {count} minut}}",
|
||||
"takes_x_minutes": "{count, plural, one {Zajmuje 1 minutę} few {Zajmuje {count} minuty} many {Zajmuje {count} minut} other {Zajmuje {count} minuty}}",
|
||||
"takes_x_plus_minutes": "Zajmuje {count}+ minut",
|
||||
"terms_of_service": "Regulamin świadczenia usług",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Serwery są obecnie niedostępne.",
|
||||
"they_will_be_redirected_immediately": "Zostaną przekierowani natychmiast",
|
||||
"your_feedback_is_stuck": "Twoja opinia utknęła :("
|
||||
},
|
||||
"errors": {
|
||||
"all_options_must_be_ranked": "Proszę uszeregować wszystkie opcje",
|
||||
"all_rows_must_be_answered": "Proszę odpowiedzieć na wszystkie wiersze",
|
||||
"file_extension_must_be": "Rozszerzenie pliku musi być {extension}",
|
||||
"file_extension_must_not_be": "Rozszerzenie pliku nie może być {extension}",
|
||||
"file_input": {
|
||||
"duplicate_files": "Następujące pliki są już przesłane: {duplicateNames}. Duplikaty plików nie są dozwolone.",
|
||||
"file_size_exceeded": "Następujące pliki przekraczają maksymalny rozmiar {maxSizeInMB} MB i zostały usunięte: {fileNames}",
|
||||
"file_size_exceeded_alert": "Plik powinien mieć mniej niż {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "Nie wybrano prawidłowych typów plików. Proszę wybrać prawidłowy typ pliku.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Można przesłać tylko jeden plik na raz.",
|
||||
"placeholder_text": "Kliknij lub przeciągnij, aby przesłać pliki",
|
||||
"upload_failed": "Przesyłanie nie powiodło się! Spróbuj ponownie.",
|
||||
"uploading": "Przesyłanie...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Możesz przesłać maksymalnie {FILE_LIMIT} plików."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
"message": "Proszę wyłączyć ochronę przed spamem w ustawieniach ankiety, aby kontynuować korzystanie z tego urządzenia.",
|
||||
"title": "To urządzenie nie obsługuje ochrony przed spamem."
|
||||
},
|
||||
"invalid_format": "Proszę wprowadzić prawidłowy format",
|
||||
"is_between": "Proszę wybrać datę między {startDate} a {endDate}",
|
||||
"is_earlier_than": "Proszę wybrać datę wcześniejszą niż {date}",
|
||||
"is_greater_than": "Proszę wprowadzić wartość większą niż {min}",
|
||||
"is_later_than": "Wybierz datę późniejszą niż {date}",
|
||||
"is_less_than": "Wprowadź wartość mniejszą niż {max}",
|
||||
"is_not_between": "Wybierz datę spoza przedziału od {startDate} do {endDate}",
|
||||
"max_length": "Wprowadź maksymalnie {max} znaków",
|
||||
"max_selections": "Wybierz maksymalnie {max} opcji",
|
||||
"max_value": "Wprowadź wartość nie większą niż {max}",
|
||||
"min_length": "Wprowadź co najmniej {min} znaków",
|
||||
"min_selections": "Wybierz co najmniej {min} opcji",
|
||||
"min_value": "Wprowadź wartość co najmniej {min}",
|
||||
"minimum_options_ranked": "Uszereguj co najmniej {min} opcji",
|
||||
"minimum_rows_answered": "Odpowiedz na co najmniej {min} wierszy",
|
||||
"please_enter_a_valid_email_address": "Wprowadź poprawny adres e-mail",
|
||||
"please_enter_a_valid_phone_number": "Wprowadź poprawny numer telefonu",
|
||||
"please_enter_a_valid_url": "Wprowadź poprawny adres URL",
|
||||
"please_fill_out_this_field": "Wypełnij to pole",
|
||||
"recaptcha_error": {
|
||||
"message": "Nie udało się przesłać odpowiedzi, ponieważ została oznaczona jako aktywność automatyczna. Jeśli oddychasz, spróbuj ponownie.",
|
||||
"title": "Nie mogliśmy zweryfikować, że jesteś człowiekiem."
|
||||
},
|
||||
"value_must_contain": "Wartość musi zawierać {value}",
|
||||
"value_must_equal": "Wartość musi być równa {value}",
|
||||
"value_must_not_contain": "Wartość nie może zawierać {value}",
|
||||
"value_must_not_equal": "Wartość nie może być równa {value}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"common": {
|
||||
"and": "a",
|
||||
"apply": "použiť",
|
||||
"auto_close_wrapper": "Automatické zatvorenie obálky",
|
||||
"back": "Späť",
|
||||
"close_survey": "Zatvoriť prieskum",
|
||||
"company_logo": "Logo spoločnosti",
|
||||
"finish": "Dokončiť",
|
||||
"language_switch": "Prepínač jazyka",
|
||||
"next": "Ďalej",
|
||||
"open_in_new_tab": "Otvoriť na novej karte",
|
||||
"people_responded": "{count, plural, one {1 osoba odpovedala} few {{count} osoby odpovedali} many {{count} osoby odpovedalo} other {{count} osôb odpovedalo}}",
|
||||
"please_retry_now_or_try_again_later": "Skús to prosím znova teraz alebo neskôr.",
|
||||
"powered_by": "Poháňané",
|
||||
"privacy_policy": "Zásady ochrany osobných údajov",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "Chránené službou reCAPTCHA a Google",
|
||||
"question": "Otázka",
|
||||
"question_video": "Video otázka",
|
||||
"required": "Povinné",
|
||||
"respondents_will_not_see_this_card": "Respondenti neuvidia túto kartu",
|
||||
"retry": "Skúsiť znova",
|
||||
"retrying": "Opakujem pokus…",
|
||||
"select_option": "Vyber možnosť",
|
||||
"select_options": "Vyber možnosti",
|
||||
"sending_responses": "Odosielam odpovede…",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Trvá menej ako 1 minútu} few {Trvá menej ako {count} minúty} many {Trvá menej ako {count} minúty} other {Trvá menej ako {count} minút}}",
|
||||
"takes_x_minutes": "{count, plural, one {Trvá 1 minútu} few {Trvá {count} minúty} many {Trvá {count} minúty} other {Trvá {count} minút}}",
|
||||
"takes_x_plus_minutes": "Trvá {count}+ minút",
|
||||
"terms_of_service": "Podmienky používania",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Servery momentálne nie sú dostupné.",
|
||||
"they_will_be_redirected_immediately": "Budú okamžite presmerovaní",
|
||||
"your_feedback_is_stuck": "Tvoja spätná väzba uviazla :("
|
||||
},
|
||||
"errors": {
|
||||
"all_options_must_be_ranked": "Prosím, zoraď všetky možnosti",
|
||||
"all_rows_must_be_answered": "Prosím, odpovedz na všetky riadky",
|
||||
"file_extension_must_be": "Prípona súboru musí byť {extension}",
|
||||
"file_extension_must_not_be": "Prípona súboru nesmie byť {extension}",
|
||||
"file_input": {
|
||||
"duplicate_files": "Nasledujúce súbory sú už nahrané: {duplicateNames}. Duplicitné súbory nie sú povolené.",
|
||||
"file_size_exceeded": "Nasledujúce súbory prekračujú maximálnu veľkosť {maxSizeInMB} MB a boli odstránené: {fileNames}",
|
||||
"file_size_exceeded_alert": "Súbor by mal byť menší ako {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "Neboli vybrané žiadne platné typy súborov. Prosím, vyber platný typ súboru.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Naraz je možné nahrať len jeden súbor.",
|
||||
"placeholder_text": "Klikni alebo presuň súbory sem",
|
||||
"upload_failed": "Nahrávanie zlyhalo! Prosím, skús to znova.",
|
||||
"uploading": "Nahráva sa...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Môžeš nahrať maximálne {FILE_LIMIT} súborov."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
"message": "Prosím, deaktivuj ochranu proti spamu v nastaveniach prieskumu, aby si mohol/mohla na tomto zariadení pokračovať.",
|
||||
"title": "Toto zariadenie nepodporuje ochranu proti spamu."
|
||||
},
|
||||
"invalid_format": "Prosím, zadaj platný formát",
|
||||
"is_between": "Prosím, vyber dátum medzi {startDate} a {endDate}",
|
||||
"is_earlier_than": "Prosím, vyber dátum skôr ako {date}",
|
||||
"is_greater_than": "Prosím, zadaj hodnotu väčšiu ako {min}",
|
||||
"is_later_than": "Prosím, vyberte dátum neskorší ako {date}",
|
||||
"is_less_than": "Prosím, zadajte hodnotu menšiu ako {max}",
|
||||
"is_not_between": "Prosím, vyberte dátum mimo rozsahu medzi {startDate} a {endDate}",
|
||||
"max_length": "Prosím, zadajte maximálne {max} znakov",
|
||||
"max_selections": "Prosím, vyberte maximálne {max} možností",
|
||||
"max_value": "Prosím, zadajte hodnotu najviac {max}",
|
||||
"min_length": "Prosím, zadajte aspoň {min} znakov",
|
||||
"min_selections": "Prosím, vyberte aspoň {min} možností",
|
||||
"min_value": "Prosím, zadajte hodnotu aspoň {min}",
|
||||
"minimum_options_ranked": "Prosím, zoraďte aspoň {min} možností",
|
||||
"minimum_rows_answered": "Prosím, odpovedzte aspoň na {min} riadkov",
|
||||
"please_enter_a_valid_email_address": "Prosím, zadajte platnú emailovú adresu",
|
||||
"please_enter_a_valid_phone_number": "Prosím, zadajte platné telefónne číslo",
|
||||
"please_enter_a_valid_url": "Prosím, zadajte platnú URL adresu",
|
||||
"please_fill_out_this_field": "Prosím, vyplňte toto pole",
|
||||
"recaptcha_error": {
|
||||
"message": "Tvoju odpoveď sa nepodarilo odoslať, pretože bola označená ako automatizovaná aktivita. Ak dýchaš, skús to prosím znova.",
|
||||
"title": "Nepodarilo sa nám overiť, že si človek."
|
||||
},
|
||||
"value_must_contain": "Hodnota musí obsahovať {value}",
|
||||
"value_must_equal": "Hodnota sa musí rovnať {value}",
|
||||
"value_must_not_contain": "Hodnota nesmie obsahovať {value}",
|
||||
"value_must_not_equal": "Hodnota sa nesmie rovnať {value}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"common": {
|
||||
"and": "i",
|
||||
"apply": "primeni",
|
||||
"auto_close_wrapper": "Automatski zatvori omot",
|
||||
"back": "Nazad",
|
||||
"close_survey": "Zatvori anketu",
|
||||
"company_logo": "Logo kompanije",
|
||||
"finish": "Završi",
|
||||
"language_switch": "Promena jezika",
|
||||
"next": "Sledeće",
|
||||
"open_in_new_tab": "Otvori u novoj kartici",
|
||||
"people_responded": "{count, plural, one {1 osoba je odgovorila} few {{count} osobe su odgovorile} other {{count} osoba je odgovorilo}}",
|
||||
"please_retry_now_or_try_again_later": "Pokušaj ponovo sada ili pokušaj kasnije.",
|
||||
"powered_by": "Pokreće",
|
||||
"privacy_policy": "Politika privatnosti",
|
||||
"protected_by_reCAPTCHA_and_the_Google": "Zaštićeno pomoću reCAPTCHA i Google",
|
||||
"question": "Pitanje",
|
||||
"question_video": "Video pitanje",
|
||||
"required": "Obavezno",
|
||||
"respondents_will_not_see_this_card": "Ispitanici neće videti ovu karticu",
|
||||
"retry": "Pokušaj ponovo",
|
||||
"retrying": "Pokušavam ponovo…",
|
||||
"select_option": "Izaberi opciju",
|
||||
"select_options": "Izaberi opcije",
|
||||
"sending_responses": "Šaljem odgovore…",
|
||||
"takes_less_than_x_minutes": "{count, plural, one {Traje manje od 1 minuta} few {Traje manje od {count} minuta} other {Traje manje od {count} minuta}}",
|
||||
"takes_x_minutes": "{count, plural, one {Traje 1 minut} few {Traje {count} minuta} other {Traje {count} minuta}}",
|
||||
"takes_x_plus_minutes": "Traje {count}+ minuta",
|
||||
"terms_of_service": "Uslovi korišćenja",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Serveri trenutno nisu dostupni.",
|
||||
"they_will_be_redirected_immediately": "Biće odmah preusmereni",
|
||||
"your_feedback_is_stuck": "Tvoj komentar je zapeo :("
|
||||
},
|
||||
"errors": {
|
||||
"all_options_must_be_ranked": "Rangiraj sve opcije",
|
||||
"all_rows_must_be_answered": "Odgovori na sva pitanja",
|
||||
"file_extension_must_be": "Ekstenzija fajla mora biti {extension}",
|
||||
"file_extension_must_not_be": "Ekstenzija fajla ne sme biti {extension}",
|
||||
"file_input": {
|
||||
"duplicate_files": "Sledeći fajlovi su već otpremljeni: {duplicateNames}. Duplikati nisu dozvoljeni.",
|
||||
"file_size_exceeded": "Sledeći fajl(ovi) prelaze maksimalnu veličinu od {maxSizeInMB} MB i uklonjeni su: {fileNames}",
|
||||
"file_size_exceeded_alert": "Fajl mora biti manji od {maxSizeInMB} MB",
|
||||
"no_valid_file_types_selected": "Nijedan važeći tip fajla nije izabran. Izaberi važeći tip fajla.",
|
||||
"only_one_file_can_be_uploaded_at_a_time": "Samo jedan fajl može biti otpremljen odjednom.",
|
||||
"placeholder_text": "Klikni ili prevuci da otpremiš fajlove",
|
||||
"upload_failed": "Otpremanje nije uspelo! Pokušaj ponovo.",
|
||||
"uploading": "Otpremanje...",
|
||||
"you_can_only_upload_a_maximum_of_files": "Možeš otpremiti maksimalno {FILE_LIMIT} fajlova."
|
||||
},
|
||||
"invalid_device_error": {
|
||||
"message": "Isključi zaštitu od neželjenih poruka u podešavanjima ankete da bi nastavio da koristiš ovaj uređaj.",
|
||||
"title": "Ovaj uređaj ne podržava zaštitu od neželjenih poruka."
|
||||
},
|
||||
"invalid_format": "Unesi važeći format",
|
||||
"is_between": "Izaberi datum između {startDate} i {endDate}",
|
||||
"is_earlier_than": "Izaberi datum pre {date}",
|
||||
"is_greater_than": "Unesi vrednost veću od {min}",
|
||||
"is_later_than": "Molimo izaberite datum posle {date}",
|
||||
"is_less_than": "Molimo unesite vrednost manju od {max}",
|
||||
"is_not_between": "Molimo izaberite datum koji nije između {startDate} i {endDate}",
|
||||
"max_length": "Molimo unesite najviše {max} karaktera",
|
||||
"max_selections": "Molimo izaberite najviše {max} opcija",
|
||||
"max_value": "Molimo unesite vrednost ne veću od {max}",
|
||||
"min_length": "Molimo unesite najmanje {min} karaktera",
|
||||
"min_selections": "Molimo izaberite najmanje {min} opcija",
|
||||
"min_value": "Molimo unesite vrednost od najmanje {min}",
|
||||
"minimum_options_ranked": "Molimo rangirajte najmanje {min} opcija",
|
||||
"minimum_rows_answered": "Molimo odgovorite na najmanje {min} redova",
|
||||
"please_enter_a_valid_email_address": "Molimo unesite validnu email adresu",
|
||||
"please_enter_a_valid_phone_number": "Molimo unesite validan broj telefona",
|
||||
"please_enter_a_valid_url": "Molimo unesite validan URL",
|
||||
"please_fill_out_this_field": "Molimo popunite ovo polje",
|
||||
"recaptcha_error": {
|
||||
"message": "Vaš odgovor nije mogao biti poslat jer je označen kao automatska aktivnost. Ako dišete, molimo pokušajte ponovo.",
|
||||
"title": "Nismo mogli da potvrdimo da ste čovek."
|
||||
},
|
||||
"value_must_contain": "Vrednost mora da sadrži {value}",
|
||||
"value_must_equal": "Vrednost mora biti jednaka {value}",
|
||||
"value_must_not_contain": "Vrednost ne sme da sadrži {value}",
|
||||
"value_must_not_equal": "Vrednost ne sme biti jednaka {value}"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user