Compare commits

..

10 Commits

Author SHA1 Message Date
Piotr Gaczkowski
4b3a41272e fix: Remove alias 2025-07-31 15:22:22 +02:00
Piotr Gaczkowski
83a38c242e fix: Satisfy actionlint 2025-07-31 14:47:48 +02:00
Piotr Gaczkowski
c4181a1c9c chore(CI): Don't run container tests on infra code changes 2025-07-31 14:13:04 +02:00
Jakob Schott
14de2eab42 feat: 733 warn users when switching survey type (#6336)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-31 08:30:06 +00:00
Piyush Gupta
ad1f80331a fix: Low severity vulnerability in on-headers@1.0.2 (#6319) 2025-07-31 06:42:03 +00:00
Piyush Gupta
3527ac337b feat: adds response status select in filters (#6325) 2025-07-31 06:33:11 +00:00
Victor Hugo dos Santos
23c2d3dce9 feat: Add Regex No Code Action Page Filter (#6305)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-31 05:48:12 +00:00
Anshuman Pandey
da652bd860 fix: adds proxy agent to next-auth (#6326) 2025-07-31 05:08:33 +00:00
Harsh Bhat
6f88dde1a0 chore: SUS template (#6328)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-30 05:27:58 -07:00
Jakob Schott
3b90223101 style: scroll indicator update (#6310) 2025-07-30 05:27:15 -07:00
80 changed files with 5852 additions and 3439 deletions

View File

@@ -90,7 +90,7 @@ When testing hooks that use React Context:
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: {
filter: [],
onlyComplete: false,
responseStatus: "all",
},
setSelectedFilter: vi.fn(),
selectedOptions: {

View File

@@ -4,9 +4,15 @@ on:
pull_request:
branches:
- main
paths-ignore:
- helm-chart/**
- infra/**
merge_group:
branches:
- main
paths-ignore:
- helm-chart/**
- infra/**
workflow_dispatch:
permissions:
@@ -59,18 +65,32 @@ jobs:
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Verify PostgreSQL Connection
- name: Verify and Initialize PostgreSQL
run: |
echo "Verifying PostgreSQL connection..."
# Install PostgreSQL client to test connection
sudo apt-get update && sudo apt-get install -y postgresql-client
# Test connection using psql
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
# Test connection using psql with timeout and proper error handling
echo "Testing PostgreSQL connection with 30 second timeout..."
if timeout 30 bash -c 'until PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" >/dev/null 2>&1; do
echo "Waiting for PostgreSQL to be ready..."
sleep 2
done'; then
echo "✅ PostgreSQL connection successful"
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "SELECT version();"
# Enable necessary extensions that might be required by migrations
echo "Enabling required PostgreSQL extensions..."
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "CREATE EXTENSION IF NOT EXISTS vector;" || echo "Vector extension already exists or not available"
else
echo "❌ PostgreSQL connection failed after 30 seconds"
exit 1
fi
# Show network configuration
echo "Network configuration:"
ip addr show
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
- name: Test Docker Image with Health Check
@@ -89,26 +109,9 @@ jobs:
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
-d formbricks-test:${{ github.sha }}
# Give it more time to start up
echo "Waiting 45 seconds for application to start..."
sleep 45
# Check if the container is running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
echo "❌ Container failed to start properly!"
docker logs formbricks-test
exit 1
else
echo "✅ Container started successfully!"
fi
# Try connecting to PostgreSQL from inside the container
echo "Testing PostgreSQL connection from inside container..."
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
# Try to access the health endpoint
echo "🏥 Testing /health endpoint..."
MAX_RETRIES=10
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..."
MAX_RETRIES=60 # 60 attempts × 5 seconds = 5 minutes
RETRY_COUNT=0
HEALTH_CHECK_SUCCESS=false
@@ -116,38 +119,32 @@ jobs:
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
# Show container logs before each attempt to help debugging
if [ $RETRY_COUNT -gt 1 ]; then
echo "📋 Current container logs:"
docker logs --tail 20 formbricks-test
# Check if container is still running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test 2>/dev/null)" != "true" ]; then
echo "❌ Container stopped running after $((RETRY_COUNT * 5)) seconds!"
echo "📋 Container logs:"
docker logs formbricks-test
exit 1
fi
# Get detailed curl output for debugging
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
CURL_EXIT_CODE=$?
echo "Curl exit code: $CURL_EXIT_CODE"
echo "Curl output: $HTTP_OUTPUT"
if [ $CURL_EXIT_CODE -eq 0 ]; then
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
echo "Status code detected: $STATUS_CODE"
if [ "$STATUS_CODE" = "200" ]; then
echo "✅ Health check successful!"
HEALTH_CHECK_SUCCESS=true
break
else
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
fi
else
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
# Show progress and diagnostic info every 12 attempts (1 minute intervals)
if [ $((RETRY_COUNT % 12)) -eq 0 ] || [ $RETRY_COUNT -eq 1 ]; then
echo "Health check attempt $RETRY_COUNT of $MAX_RETRIES ($(($RETRY_COUNT * 5)) seconds elapsed)..."
echo "📋 Recent container logs:"
docker logs --tail 10 formbricks-test
fi
echo "Waiting 15 seconds before next attempt..."
sleep 15
# Try health endpoint with shorter timeout for faster polling
# Use -f flag to make curl fail on HTTP error status codes (4xx, 5xx)
if curl -f -s -m 10 http://localhost:3000/health >/dev/null 2>&1; then
echo "✅ Health check successful after $((RETRY_COUNT * 5)) seconds!"
HEALTH_CHECK_SUCCESS=true
break
fi
# Wait 5 seconds before next attempt
sleep 5
done
# Show full container logs for debugging
@@ -160,7 +157,7 @@ jobs:
# Exit with failure if health check did not succeed
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
echo "❌ Health check failed after $MAX_RETRIES attempts"
echo "❌ Health check failed after $((MAX_RETRIES * 5)) seconds (5 minutes)"
exit 1
fi

View File

@@ -24,14 +24,17 @@ export const ActionClassesTable = ({
otherEnvActionClasses,
otherEnvironment,
}: ActionClassesTableProps) => {
const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false);
const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false);
const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
const handleOpenActionDetailModalClick = (e, actionClass: TActionClass) => {
const handleOpenActionDetailModalClick = (
e: React.MouseEvent<HTMLButtonElement>,
actionClass: TActionClass
) => {
e.preventDefault();
setActiveActionClass(actionClass);
setActionDetailModalOpen(true);
setIsActionDetailModalOpen(true);
};
return (
@@ -42,7 +45,7 @@ export const ActionClassesTable = ({
{actionClasses.length > 0 ? (
actionClasses.map((actionClass, index) => (
<button
onClick={(e) => {
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
handleOpenActionDetailModalClick(e, actionClass);
}}
className="w-full"
@@ -63,7 +66,7 @@ export const ActionClassesTable = ({
environmentId={environmentId}
environment={environment}
open={isActionDetailModalOpen}
setOpen={setActionDetailModalOpen}
setOpen={setIsActionDetailModalOpen}
actionClasses={actionClasses}
actionClass={activeActionClass}
isReadOnly={isReadOnly}

View File

@@ -70,15 +70,13 @@ export const ActionDetailModal = ({
};
return (
<>
<ModalWithTabs
open={open}
setOpen={setOpen}
tabs={tabs}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
label={actionClass.name}
description={typeDescription()}
/>
</>
<ModalWithTabs
open={open}
setOpen={setOpen}
tabs={tabs}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
label={actionClass.name}
description={typeDescription()}
/>
);
};

View File

@@ -11,6 +11,21 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
updateActionClassAction: vi.fn(),
}));
// Mock action utils
vi.mock("@/modules/survey/editor/lib/action-utils", () => ({
useActionClassKeys: vi.fn(() => ["existing-key"]),
createActionClassZodResolver: vi.fn(() => vi.fn()),
validatePermissions: vi.fn(),
}));
// Mock action builder
vi.mock("@/modules/survey/editor/lib/action-builder", () => ({
buildActionObject: vi.fn((data, environmentId, t) => ({
...data,
environmentId,
})),
}));
// Mock utils
vi.mock("@/app/lib/actionClass/actionClass", () => ({
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
@@ -24,6 +39,7 @@ vi.mock("@/modules/ui/components/button", () => ({
</button>
),
}));
vi.mock("@/modules/ui/components/code-action-form", () => ({
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="code-action-form" data-readonly={isReadOnly}>
@@ -31,6 +47,7 @@ vi.mock("@/modules/ui/components/code-action-form", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
open ? (
@@ -43,6 +60,26 @@ vi.mock("@/modules/ui/components/delete-dialog", () => ({
</div>
) : null,
}));
vi.mock("@/modules/ui/components/action-name-description-fields", () => ({
ActionNameDescriptionFields: ({ isReadOnly, nameInputId, descriptionInputId }: any) => (
<div data-testid="action-name-description-fields">
<input
data-testid={`name-input-${nameInputId}`}
placeholder="environments.actions.eg_clicked_download"
disabled={isReadOnly}
defaultValue="Test Action"
/>
<input
data-testid={`description-input-${descriptionInputId}`}
placeholder="environments.actions.user_clicked_download_button"
disabled={isReadOnly}
defaultValue="Test Description"
/>
</div>
),
}));
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
@@ -56,6 +93,23 @@ vi.mock("lucide-react", () => ({
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
}));
// Mock react-hook-form
const mockHandleSubmit = vi.fn();
const mockForm = {
handleSubmit: mockHandleSubmit,
control: {},
formState: { errors: {} },
};
vi.mock("react-hook-form", async () => {
const actual = await vi.importActual("react-hook-form");
return {
...actual,
useForm: vi.fn(() => mockForm),
FormProvider: ({ children }: any) => <div>{children}</div>,
};
});
const mockSetOpen = vi.fn();
const mockActionClasses: TActionClass[] = [
{
@@ -88,6 +142,7 @@ const createMockActionClass = (id: string, type: TActionClassType, name: string)
describe("ActionSettingsTab", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHandleSubmit.mockImplementation((fn) => fn);
});
afterEach(() => {
@@ -105,13 +160,9 @@ describe("ActionSettingsTab", () => {
/>
);
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
actionClass.name
);
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
actionClass.description
);
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
expect(
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
@@ -131,18 +182,104 @@ describe("ActionSettingsTab", () => {
/>
);
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
actionClass.name
);
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
actionClass.description
);
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
});
test("renders correctly for other action types (fallback)", () => {
const actionClass = {
...createMockActionClass("auto1", "noCode", "Auto Action"),
type: "automatic" as any,
};
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
expect(
screen.getByText(
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
)
).toBeInTheDocument();
});
test("calls utility functions on initialization", async () => {
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
});
test("handles successful form submission", async () => {
const { updateActionClassAction } = await import(
"@/app/(app)/environments/[environmentId]/actions/actions"
);
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
vi.mocked(updateActionClassAction).mockResolvedValue({ data: {} } as any);
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
// Check that utility functions were called during component initialization
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
});
test("handles permission validation error", async () => {
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
vi.mocked(actionUtilsMock.validatePermissions).mockImplementation(() => {
throw new Error("Not authorized");
});
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
const submitButton = screen.getByRole("button", { name: "common.save_changes" });
mockHandleSubmit.mockImplementation((fn) => (e) => {
e.preventDefault();
return fn({ name: "Test", type: "noCode" });
});
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Not authorized");
});
});
test("handles successful deletion", async () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
const { deleteActionClassAction } = await import(
@@ -209,17 +346,16 @@ describe("ActionSettingsTab", () => {
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={true} // Set to read-only
isReadOnly={true}
/>
);
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toBeDisabled();
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toBeDisabled();
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeDisabled();
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeDisabled();
expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true");
expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); // Docs link still visible
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument();
});
test("prevents delete when read-only", async () => {
@@ -228,7 +364,6 @@ describe("ActionSettingsTab", () => {
"@/app/(app)/environments/[environmentId]/actions/actions"
);
// Render with isReadOnly=true, but simulate a delete attempt
render(
<ActionSettingsTab
actionClass={actionClass}
@@ -238,12 +373,6 @@ describe("ActionSettingsTab", () => {
/>
);
// Try to open and confirm delete dialog (buttons won't exist, so we simulate the flow)
// This test primarily checks the logic within handleDeleteAction if it were called.
// A better approach might be to export handleDeleteAction for direct testing,
// but for now, we assume the UI prevents calling it.
// We can assert that the delete button isn't there to prevent the flow
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
expect(deleteActionClassAction).not.toHaveBeenCalled();
});
@@ -262,4 +391,19 @@ describe("ActionSettingsTab", () => {
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
expect(docsLink).toHaveAttribute("target", "_blank");
});
test("uses correct input IDs for ActionNameDescriptionFields", () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
});
});

View File

@@ -4,14 +4,17 @@ import {
deleteActionClassAction,
updateActionClassAction,
} from "@/app/(app)/environments/[environmentId]/actions/actions";
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
import { buildActionObject } from "@/modules/survey/editor/lib/action-builder";
import {
createActionClassZodResolver,
useActionClassKeys,
validatePermissions,
} from "@/modules/survey/editor/lib/action-utils";
import { ActionNameDescriptionFields } from "@/modules/ui/components/action-name-description-fields";
import { Button } from "@/modules/ui/components/button";
import { CodeActionForm } from "@/modules/ui/components/code-action-form";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { TrashIcon } from "lucide-react";
import Link from "next/link";
@@ -19,8 +22,7 @@ import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { z } from "zod";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
interface ActionSettingsTabProps {
actionClass: TActionClass;
@@ -48,63 +50,51 @@ export const ActionSettingsTab = ({
[actionClass.id, actionClasses]
);
const actionClassKeys = useActionClassKeys(actionClasses);
const form = useForm<TActionClassInput>({
defaultValues: {
...restActionClass,
},
resolver: zodResolver(
ZActionClassInput.superRefine((data, ctx) => {
if (data.name && actionClassNames.includes(data.name)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: t("environments.actions.action_with_name_already_exists", { name: data.name }),
});
}
})
),
resolver: createActionClassZodResolver(actionClassNames, actionClassKeys, t),
mode: "onChange",
});
const { handleSubmit, control } = form;
const renderActionForm = () => {
if (actionClass.type === "code") {
return (
<>
<CodeActionForm form={form} isReadOnly={true} />
<p className="text-sm text-slate-600">
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
</p>
</>
);
}
if (actionClass.type === "noCode") {
return <NoCodeActionForm form={form} isReadOnly={isReadOnly} />;
}
return (
<p className="text-sm text-slate-600">
{t("environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it")}
</p>
);
};
const onSubmit = async (data: TActionClassInput) => {
try {
if (isReadOnly) {
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
}
setIsUpdatingAction(true);
validatePermissions(isReadOnly, t);
const updatedAction = buildActionObject(data, actionClass.environmentId, t);
if (data.name && actionClassNames.includes(data.name)) {
throw new Error(t("environments.actions.action_with_name_already_exists", { name: data.name }));
}
if (
data.type === "noCode" &&
data.noCodeConfig?.type === "click" &&
data.noCodeConfig.elementSelector.cssSelector &&
!isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector)
) {
throw new Error(t("environments.actions.invalid_css_selector"));
}
const updatedData: TActionClassInput = {
...data,
...(data.type === "noCode" &&
data.noCodeConfig?.type === "click" && {
noCodeConfig: {
...data.noCodeConfig,
elementSelector: {
cssSelector: data.noCodeConfig.elementSelector.cssSelector,
innerHtml: data.noCodeConfig.elementSelector.innerHtml,
},
},
}),
};
await updateActionClassAction({
actionClassId: actionClass.id,
updatedAction: updatedData,
updatedAction: updatedAction,
});
setOpen(false);
router.refresh();
@@ -123,7 +113,7 @@ export const ActionSettingsTab = ({
router.refresh();
toast.success(t("environments.actions.action_deleted_successfully"));
setOpen(false);
} catch (error) {
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsDeletingAction(false);
@@ -135,79 +125,14 @@ export const ActionSettingsTab = ({
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<FormField
control={control}
name="name"
disabled={isReadOnly}
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel htmlFor="actionNameSettingsInput">
{actionClass.type === "noCode"
? t("environments.actions.what_did_your_user_do")
: t("environments.actions.display_name")}
</FormLabel>
<ActionNameDescriptionFields
control={control}
isReadOnly={isReadOnly}
nameInputId="actionNameSettingsInput"
descriptionInputId="actionDescriptionSettingsInput"
/>
<FormControl>
<Input
type="text"
id="actionNameSettingsInput"
{...field}
placeholder={t("environments.actions.eg_clicked_download")}
isInvalid={!!error?.message}
disabled={isReadOnly}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
<div className="col-span-1">
<FormField
control={control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="actionDescriptionSettingsInput">
{t("common.description")}
</FormLabel>
<FormControl>
<Input
type="text"
id="actionDescriptionSettingsInput"
{...field}
placeholder={t("environments.actions.user_clicked_download_button")}
value={field.value ?? ""}
disabled={isReadOnly}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
{actionClass.type === "code" ? (
<>
<CodeActionForm form={form} isReadOnly={true} />
<p className="text-sm text-slate-600">
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
</p>
</>
) : actionClass.type === "noCode" ? (
<NoCodeActionForm form={form} isReadOnly={isReadOnly} />
) : (
<p className="text-sm text-slate-600">
{t(
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
)}
</p>
)}
{renderActionForm()}
</div>
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">

View File

@@ -28,7 +28,7 @@ const TestComponent = () => {
return (
<div>
<div data-testid="onlyComplete">{selectedFilter.onlyComplete.toString()}</div>
<div data-testid="responseStatus">{selectedFilter.responseStatus}</div>
<div data-testid="filterLength">{selectedFilter.filter.length}</div>
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
@@ -44,7 +44,7 @@ const TestComponent = () => {
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
},
],
onlyComplete: true,
responseStatus: "complete",
})
}>
Update Filter
@@ -81,7 +81,7 @@ describe("ResponseFilterContext", () => {
</ResponseFilterProvider>
);
expect(screen.getByTestId("onlyComplete").textContent).toBe("false");
expect(screen.getByTestId("responseStatus").textContent).toBe("all");
expect(screen.getByTestId("filterLength").textContent).toBe("0");
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
@@ -99,7 +99,7 @@ describe("ResponseFilterContext", () => {
const updateButton = screen.getByText("Update Filter");
await userEvent.click(updateButton);
expect(screen.getByTestId("onlyComplete").textContent).toBe("true");
expect(screen.getByTestId("responseStatus").textContent).toBe("complete");
expect(screen.getByTestId("filterLength").textContent).toBe("1");
});

View File

@@ -16,9 +16,11 @@ export interface FilterValue {
};
}
export type TResponseStatus = "all" | "complete" | "partial";
export interface SelectedFilterValue {
filter: FilterValue[];
onlyComplete: boolean;
responseStatus: TResponseStatus;
}
interface SelectedFilterOptions {
@@ -47,7 +49,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
// state holds the filter selected value
const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({
filter: [],
onlyComplete: false,
responseStatus: "all",
});
// state holds all the options of the responses fetched
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
@@ -67,7 +69,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
});
setSelectedFilter({
filter: [],
onlyComplete: false,
responseStatus: "all",
});
}, []);

View File

@@ -191,7 +191,7 @@ const mockSurvey = {
variables: [],
} as unknown as TSurvey;
const mockSelectedFilter = { filter: [], onlyComplete: false };
const mockSelectedFilter = { filter: [], responseStatus: "all" };
const mockSetSelectedFilter = vi.fn();
const defaultProps = {
@@ -309,17 +309,13 @@ describe("SummaryList", () => {
test("renders EmptySpaceFiller when responseCount is 0 and summary is not empty (no responses match filter)", () => {
const summaryWithItem = [createMockQuestionSummary("q1", TSurveyQuestionTypeEnum.OpenText)];
render(
<SummaryList {...defaultProps} summary={summaryWithItem} responseCount={0} totalResponseCount={10} />
);
render(<SummaryList {...defaultProps} summary={summaryWithItem} responseCount={0} />);
expect(screen.getByText("Mocked EmptySpaceFiller")).toBeInTheDocument();
});
test("renders EmptySpaceFiller when responseCount is 0 and totalResponseCount is 0 (no responses at all)", () => {
const summaryWithItem = [createMockQuestionSummary("q1", TSurveyQuestionTypeEnum.OpenText)];
render(
<SummaryList {...defaultProps} summary={summaryWithItem} responseCount={0} totalResponseCount={0} />
);
render(<SummaryList {...defaultProps} summary={summaryWithItem} responseCount={0} />);
expect(screen.getByText("Mocked EmptySpaceFiller")).toBeInTheDocument();
});
@@ -397,7 +393,7 @@ describe("SummaryList", () => {
},
},
],
onlyComplete: false,
responseStatus: "all",
});
// Ensure vi.mocked(toast.success) refers to the spy from the named export
expect(vi.mocked(toast).success).toHaveBeenCalledWith("Custom add message", { duration: 5000 });
@@ -425,7 +421,7 @@ describe("SummaryList", () => {
},
};
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: { filter: [existingFilter], onlyComplete: false },
selectedFilter: { filter: [existingFilter], responseStatus: "all" },
setSelectedFilter: mockSetSelectedFilter,
resetFilter: vi.fn(),
} as any);
@@ -454,7 +450,7 @@ describe("SummaryList", () => {
},
},
],
onlyComplete: false,
responseStatus: "all",
});
expect(vi.mocked(toast.success)).toHaveBeenCalledWith(
"environments.surveys.summary.filter_updated_successfully",

View File

@@ -92,7 +92,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
setSelectedFilter({
filter: [...filterObject.filter],
onlyComplete: filterObject.onlyComplete,
responseStatus: filterObject.responseStatus,
});
};

View File

@@ -197,7 +197,7 @@ export const QuestionFilterComboBox = ({
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<div className="p-2">
<Input

View File

@@ -188,7 +188,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
</button>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<div className="animate-in absolute top-0 z-50 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
{options?.map((data) => (

View File

@@ -30,6 +30,45 @@ vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [[vi.fn()]],
}));
// Mock the Select components
const mockOnValueChange = vi.fn();
vi.mock("@/modules/ui/components/select", () => ({
Select: ({ children, onValueChange, defaultValue }) => {
// Store the onValueChange callback for testing
mockOnValueChange.mockImplementation(onValueChange);
return (
<div data-testid="select-root" data-default-value={defaultValue}>
{children}
</div>
);
},
SelectTrigger: ({ children, className }) => (
<div
role="combobox"
className={className}
data-testid="select-trigger"
tabIndex={0}
aria-expanded="false"
aria-haspopup="listbox">
{children}
</div>
),
SelectValue: () => <span>environments.surveys.filter.complete_and_partial_responses</span>,
SelectContent: ({ children }) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ value, children, ...props }) => (
<div
data-testid={`select-item-${value}`}
data-value={value}
onClick={() => mockOnValueChange(value)}
onKeyDown={(e) => e.key === "Enter" && mockOnValueChange(value)}
role="option"
tabIndex={0}
{...props}>
{children}
</div>
),
}));
vi.mock("./QuestionsComboBox", () => ({
QuestionsComboBox: ({ onChangeValue }) => (
<div data-testid="questions-combo-box">
@@ -67,7 +106,7 @@ describe("ResponseFilter", () => {
const mockSelectedFilter = {
filter: [],
onlyComplete: false,
responseStatus: "all",
};
const mockSelectedOptions = {
@@ -145,7 +184,7 @@ describe("ResponseFilter", () => {
expect(
screen.getByText("environments.surveys.summary.show_all_responses_that_match")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.only_completed")).toBeInTheDocument();
expect(screen.getByTestId("select-trigger")).toBeInTheDocument();
});
test("fetches filter data when opened", async () => {
@@ -160,7 +199,7 @@ describe("ResponseFilter", () => {
test("handles adding new filter", async () => {
// Start with an empty filter
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: { filter: [], onlyComplete: false },
selectedFilter: { filter: [], responseStatus: "all" },
setSelectedFilter: mockSetSelectedFilter,
selectedOptions: mockSelectedOptions,
setSelectedOptions: mockSetSelectedOptions,
@@ -178,14 +217,38 @@ describe("ResponseFilter", () => {
expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument();
});
test("handles only complete checkbox toggle", async () => {
test("handles response status filter change to complete", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
await userEvent.click(screen.getByRole("checkbox"));
// Simulate selecting "complete" by calling the mock function
mockOnValueChange("complete");
await userEvent.click(screen.getByText("common.apply_filters"));
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: true });
expect(mockSetSelectedFilter).toHaveBeenCalledWith(
expect.objectContaining({
responseStatus: "complete",
})
);
});
test("handles response status filter change to partial", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
// Simulate selecting "partial" by calling the mock function
mockOnValueChange("partial");
await userEvent.click(screen.getByText("common.apply_filters"));
expect(mockSetSelectedFilter).toHaveBeenCalledWith(
expect.objectContaining({
responseStatus: "partial",
})
);
});
test("handles selecting question and filter options", async () => {
@@ -199,7 +262,7 @@ describe("ResponseFilter", () => {
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
},
],
onlyComplete: false,
responseStatus: "all",
},
setSelectedFilter: setSelectedFilterMock,
selectedOptions: mockSelectedOptions,
@@ -228,6 +291,6 @@ describe("ResponseFilter", () => {
await userEvent.click(screen.getByText("Filter"));
await userEvent.click(screen.getByText("common.clear_all"));
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: false });
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], responseStatus: "all" });
});
});

View File

@@ -2,17 +2,23 @@
import {
SelectedFilterValue,
TResponseStatus,
useResponseFilter,
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useTranslate } from "@tolgee/react";
import clsx from "clsx";
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
@@ -72,7 +78,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
)?.filterOptions[0],
},
};
setFilterValue({ filter: [...filterValue.filter], onlyComplete: filterValue.onlyComplete });
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
} else {
// Update the existing value at the specified index
filterValue.filter[index].questionType = value;
@@ -93,7 +99,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
// keep the filter if questionType is selected and filterComboBoxValue is selected
return s.questionType.hasOwnProperty("label") && s.filterType.filterComboBoxValue?.length;
}),
onlyComplete: filterValue.onlyComplete,
responseStatus: filterValue.responseStatus,
});
};
@@ -120,8 +126,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
};
const handleClearAllFilters = () => {
setFilterValue((filterValue) => ({ ...filterValue, filter: [] }));
setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [] }));
setFilterValue((filterValue) => ({ ...filterValue, filter: [], responseStatus: "all" }));
setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [], responseStatus: "all" }));
setIsOpen(false);
};
@@ -158,8 +164,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
setFilterValue({ ...filterValue });
};
const handleCheckOnlyComplete = (checked: boolean) => {
setFilterValue({ ...filterValue, onlyComplete: checked });
const handleResponseStatusChange = (responseStatus: TResponseStatus) => {
setFilterValue({ ...filterValue, responseStatus });
};
// remove the filter which has already been selected
@@ -203,8 +209,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]">
<div className="mb-8 flex flex-wrap items-start justify-between">
className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
onOpenAutoFocus={(event) => event.preventDefault()}>
<div className="mb-8 flex flex-wrap items-start justify-between gap-2">
<p className="text-slate800 hidden text-lg font-semibold sm:block">
{t("environments.surveys.summary.show_all_responses_that_match")}
</p>
@@ -212,16 +219,24 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
{t("environments.surveys.summary.show_all_responses_where")}
</p>
<div className="flex items-center space-x-2">
<label className="text-sm font-normal text-slate-600">
{t("environments.surveys.summary.only_completed")}
</label>
<Checkbox
className={clsx("rounded-md", filterValue.onlyComplete && "bg-black text-white")}
checked={filterValue.onlyComplete}
onCheckedChange={(checked) => {
typeof checked === "boolean" && handleCheckOnlyComplete(checked);
<Select
onValueChange={(val) => {
handleResponseStatusChange(val as TResponseStatus);
}}
/>
defaultValue={filterValue.responseStatus}>
<SelectTrigger className="w-full bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">
{t("environments.surveys.filter.complete_and_partial_responses")}
</SelectItem>
<SelectItem value="complete">
{t("environments.surveys.filter.complete_responses")}
</SelectItem>
<SelectItem value="partial">{t("environments.surveys.filter.partial_responses")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>

View File

@@ -320,7 +320,7 @@ describe("surveys", () => {
test("should return empty filters when no selections", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [],
};
@@ -331,7 +331,7 @@ describe("surveys", () => {
test("should filter by completed responses", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: true,
responseStatus: "complete",
filter: [],
};
@@ -342,7 +342,7 @@ describe("surveys", () => {
test("should filter by date range", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [],
};
@@ -355,7 +355,7 @@ describe("surveys", () => {
test("should filter by tags", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: { type: "Tags", label: "Tag 1", id: "tag1" },
@@ -376,7 +376,7 @@ describe("surveys", () => {
test("should filter by open text questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: {
@@ -397,7 +397,7 @@ describe("surveys", () => {
test("should filter by address questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: {
@@ -418,7 +418,7 @@ describe("surveys", () => {
test("should filter by contact info questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: {
@@ -439,7 +439,7 @@ describe("surveys", () => {
test("should filter by ranking questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: {
@@ -460,7 +460,7 @@ describe("surveys", () => {
test("should filter by multiple choice single questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: {
@@ -481,7 +481,7 @@ describe("surveys", () => {
test("should filter by multiple choice multi questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: {
@@ -502,7 +502,7 @@ describe("surveys", () => {
test("should filter by NPS questions with different operations", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: {
@@ -523,7 +523,7 @@ describe("surveys", () => {
test("should filter by rating questions with less than operation", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: {
@@ -544,7 +544,7 @@ describe("surveys", () => {
test("should filter by CTA questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: {
@@ -565,7 +565,7 @@ describe("surveys", () => {
test("should filter by consent questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: {
@@ -586,7 +586,7 @@ describe("surveys", () => {
test("should filter by picture selection questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: {
@@ -607,7 +607,7 @@ describe("surveys", () => {
test("should filter by matrix questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: {
@@ -628,7 +628,7 @@ describe("surveys", () => {
test("should filter by hidden fields", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: { type: "Hidden Fields", label: "plan", id: "plan" },
@@ -644,7 +644,7 @@ describe("surveys", () => {
test("should filter by attributes", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: { type: "Attributes", label: "role", id: "role" },
@@ -660,7 +660,7 @@ describe("surveys", () => {
test("should filter by other filters", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: { type: "Other Filters", label: "Language", id: "language" },
@@ -676,7 +676,7 @@ describe("surveys", () => {
test("should filter by meta fields", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
responseStatus: "all",
filter: [
{
questionType: { type: "Meta", label: "source", id: "source" },
@@ -692,7 +692,7 @@ describe("surveys", () => {
test("should handle multiple filters together", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: true,
responseStatus: "complete",
filter: [
{
questionType: {

View File

@@ -242,8 +242,10 @@ export const getFormattedFilters = (
});
// for completed responses
if (selectedFilter.onlyComplete) {
if (selectedFilter.responseStatus === "complete") {
filters["finished"] = true;
} else if (selectedFilter.responseStatus === "partial") {
filters["finished"] = false;
}
// for date range responses

View File

@@ -521,6 +521,121 @@ const earnedAdvocacyScore = (t: TFnType): TTemplate => {
);
};
const usabilityScoreRatingSurvey = (t: TFnType): TTemplate => {
return buildSurvey(
{
name: t("templates.usability_score_name"),
role: "customerSuccess",
industries: ["saas"],
channels: ["app", "link"],
description: t("templates.usability_rating_description"),
questions: [
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_1_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_2_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_3_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_4_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_5_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_6_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_7_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_8_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_9_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.usability_question_10_headline"),
required: true,
lowerLabel: t("templates.strongly_disagree"),
upperLabel: t("templates.strongly_agree"),
isColorCodingEnabled: false,
t,
}),
],
},
t
);
};
const improveTrialConversion = (t: TFnType): TTemplate => {
const reusableQuestionIds = [createId(), createId(), createId(), createId(), createId(), createId()];
const reusableOptionIds = [
@@ -3428,6 +3543,7 @@ export const templates = (t: TFnType): TTemplate[] => [
onboardingSegmentation(t),
churnSurvey(t),
earnedAdvocacyScore(t),
usabilityScoreRatingSurvey(t),
improveTrialConversion(t),
reviewPrompt(t),
interviewPrompt(t),

View File

@@ -8,23 +8,67 @@ afterEach(() => {
});
describe("testURLmatch", () => {
const testCases: [string, string, TActionClassPageUrlRule, string][] = [
["https://example.com", "https://example.com", "exactMatch", "yes"],
["https://example.com", "https://example.com/page", "contains", "no"],
["https://example.com/page", "https://example.com", "startsWith", "yes"],
["https://example.com/page", "page", "endsWith", "yes"],
["https://example.com", "https://other.com", "notMatch", "yes"],
["https://example.com", "other", "notContains", "yes"],
// Mock translation function
const mockT = (key: string): string => {
const translations: Record<string, string> = {
"environments.actions.invalid_regex": "Please use a valid regular expression.",
"environments.actions.invalid_match_type": "The option selected is not available.",
};
return translations[key] || key;
};
const testCases: [string, string, TActionClassPageUrlRule, boolean][] = [
["https://example.com", "https://example.com", "exactMatch", true],
["https://example.com", "https://different.com", "exactMatch", false],
["https://example.com/page", "example.com", "contains", true],
["https://example.com", "different.com", "contains", false],
["https://example.com/page", "https://example.com", "startsWith", true],
["https://example.com", "https://different.com", "startsWith", false],
["https://example.com/page", "page", "endsWith", true],
["https://example.com/page", "different", "endsWith", false],
["https://example.com", "https://different.com", "notMatch", true],
["https://example.com", "https://example.com", "notMatch", false],
["https://example.com", "different", "notContains", true],
["https://example.com", "example", "notContains", false],
];
test.each(testCases)("returns %s for %s with rule %s", (testUrl, pageUrlValue, pageUrlRule, expected) => {
expect(testURLmatch(testUrl, pageUrlValue, pageUrlRule)).toBe(expected);
expect(testURLmatch(testUrl, pageUrlValue, pageUrlRule, mockT)).toBe(expected);
});
describe("matchesRegex rule", () => {
test("returns true when URL matches regex pattern", () => {
expect(testURLmatch("https://example.com/user/123", "user/\\d+", "matchesRegex", mockT)).toBe(true);
expect(testURLmatch("https://example.com/dashboard", "dashboard$", "matchesRegex", mockT)).toBe(true);
expect(testURLmatch("https://app.example.com", "^https://app", "matchesRegex", mockT)).toBe(true);
});
test("returns false when URL does not match regex pattern", () => {
expect(testURLmatch("https://example.com/user/abc", "user/\\d+", "matchesRegex", mockT)).toBe(false);
expect(testURLmatch("https://example.com/settings", "dashboard$", "matchesRegex", mockT)).toBe(false);
expect(testURLmatch("https://api.example.com", "^https://app", "matchesRegex", mockT)).toBe(false);
});
test("throws error for invalid regex pattern", () => {
expect(() => testURLmatch("https://example.com", "[invalid-regex", "matchesRegex", mockT)).toThrow(
"Please use a valid regular expression."
);
expect(() => testURLmatch("https://example.com", "*invalid", "matchesRegex", mockT)).toThrow(
"Please use a valid regular expression."
);
});
});
test("throws an error for invalid match type", () => {
expect(() =>
testURLmatch("https://example.com", "https://example.com", "invalidRule" as TActionClassPageUrlRule)
).toThrow("Invalid match type");
testURLmatch(
"https://example.com",
"https://example.com",
"invalidRule" as TActionClassPageUrlRule,
mockT
)
).toThrow("The option selected is not available.");
});
});

View File

@@ -3,23 +3,34 @@ import { TActionClassPageUrlRule } from "@formbricks/types/action-classes";
export const testURLmatch = (
testUrl: string,
pageUrlValue: string,
pageUrlRule: TActionClassPageUrlRule
): string => {
pageUrlRule: TActionClassPageUrlRule,
t: (key: string) => string
): boolean => {
let regex: RegExp;
switch (pageUrlRule) {
case "exactMatch":
return testUrl === pageUrlValue ? "yes" : "no";
return testUrl === pageUrlValue;
case "contains":
return testUrl.includes(pageUrlValue) ? "yes" : "no";
return testUrl.includes(pageUrlValue);
case "startsWith":
return testUrl.startsWith(pageUrlValue) ? "yes" : "no";
return testUrl.startsWith(pageUrlValue);
case "endsWith":
return testUrl.endsWith(pageUrlValue) ? "yes" : "no";
return testUrl.endsWith(pageUrlValue);
case "notMatch":
return testUrl !== pageUrlValue ? "yes" : "no";
return testUrl !== pageUrlValue;
case "notContains":
return !testUrl.includes(pageUrlValue) ? "yes" : "no";
return !testUrl.includes(pageUrlValue);
case "matchesRegex":
try {
regex = new RegExp(pageUrlValue);
} catch {
throw new Error(t("environments.actions.invalid_regex"));
}
return regex.test(testUrl);
default:
throw new Error("Invalid match type");
throw new Error(t("environments.actions.invalid_match_type"));
}
};

View File

@@ -503,21 +503,21 @@
"action_with_key_already_exists": "Aktion mit dem Schlüssel {key} existiert bereits",
"action_with_name_already_exists": "Aktion mit dem Namen {name} existiert bereits",
"add_css_class_or_id": "CSS-Klasse oder ID hinzufügen",
"add_regular_expression_here": "Fügen Sie hier einen regulären Ausdruck hinzu",
"add_url": "URL hinzufügen",
"click": "Klicken",
"contains": "enthält",
"create_action": "Aktion erstellen",
"css_selector": "CSS-Selektor",
"delete_action_text": "Bist Du sicher, dass Du diese Aktion löschen möchtest? Dadurch wird diese Aktion auch als Auslöser aus all deinen Umfragen entfernt.",
"display_name": "Anzeigename",
"does_not_contain": "Enthält nicht",
"does_not_exactly_match": "Stimmt nicht genau überein",
"eg_clicked_download": "z.B. 'Herunterladen' geklickt",
"eg_download_cta_click_on_home": "z.B. Download-CTA-Klick auf der Startseite",
"eg_install_app": "z.B. App installieren",
"eg_user_clicked_download_button": "z.B. Benutzer hat auf 'Herunterladen' geklickt",
"ends_with": "endet mit",
"enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Teste eine URL, um zu sehen, ob der Nutzer deine Umfrage sehen würde.",
"enter_url": "z.B. https://app.com/dashboard",
"exactly_matches": "Stimmt exakt überein",
"exit_intent": "Will Seite verlassen",
"fifty_percent_scroll": "50% Scroll",
@@ -526,9 +526,14 @@
"if_a_user_clicks_a_button_with_a_specific_text": "Wenn ein Benutzer auf einen Button mit einem bestimmten Text klickt",
"in_your_code_read_more_in_our": "in deinem Code. Lies mehr in unserem",
"inner_text": "Innerer Text",
"invalid_action_type_code": "Ungültiger Aktionstyp für Code-Aktion",
"invalid_action_type_no_code": "Ungültiger Aktionstyp für NoCode-Aktion",
"invalid_css_selector": "Ungültiger CSS-Selektor",
"invalid_match_type": "Die ausgewählte Option ist nicht verfügbar.",
"invalid_regex": "Bitte verwenden Sie einen gültigen regulären Ausdruck.",
"limit_the_pages_on_which_this_action_gets_captured": "Begrenze die Seiten, auf denen diese Aktion erfasst wird",
"limit_to_specific_pages": "Auf bestimmte Seiten beschränken",
"matches_regex": "Entspricht Regex",
"on_all_pages": "Auf allen Seiten",
"page_filter": "Seitenfilter",
"page_view": "Seitenansicht",
@@ -548,7 +553,9 @@
"user_clicked_download_button": "Benutzer hat auf 'Herunterladen' geklickt",
"what_did_your_user_do": "Was hat dein Nutzer gemacht?",
"what_is_the_user_doing": "Was macht der Nutzer?",
"you_can_track_code_action_anywhere_in_your_app_using": "Du kannst Code-Aktionen überall in deiner App tracken mit"
"you_can_track_code_action_anywhere_in_your_app_using": "Du kannst Code-Aktionen überall in deiner App tracken mit",
"your_survey_would_be_shown_on_this_url": "Ihre Umfrage wäre unter dieser URL angezeigt.",
"your_survey_would_not_be_shown": "Ihre Umfrage wäre nicht angezeigt."
},
"connect": {
"congrats": "Glückwunsch!",
@@ -1279,6 +1286,7 @@
"change_anyway": "Trotzdem ändern",
"change_background": "Hintergrund ändern",
"change_question_type": "Fragetyp ändern",
"change_survey_type": "Die Änderung des Umfragetypen kann vorhandenen Zugriff beeinträchtigen",
"change_the_background_color_of_the_card": "Hintergrundfarbe der Karte ändern.",
"change_the_background_color_of_the_input_fields": "Hintergrundfarbe der Eingabefelder ändern.",
"change_the_background_to_a_color_image_or_animation": "Hintergrund zu einer Farbe, einem Bild oder einer Animation ändern.",
@@ -1289,6 +1297,7 @@
"change_the_placement_of_this_survey": "Platzierung dieser Umfrage ändern.",
"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",
@@ -1607,6 +1616,11 @@
"zip": "Postleitzahl"
},
"error_deleting_survey": "Beim Löschen der Umfrage ist ein Fehler aufgetreten",
"filter": {
"complete_and_partial_responses": "Vollständige und Teilantworten",
"complete_responses": "Vollständige Antworten",
"partial_responses": "Teilantworten"
},
"new_survey": "Neue Umfrage",
"no_surveys_created_yet": "Noch keine Umfragen erstellt",
"open_options": "Optionen öffnen",
@@ -2771,6 +2785,8 @@
"star_rating_survey_question_3_placeholder": "Schreib hier deine Antwort...",
"star_rating_survey_question_3_subheader": "Hilf uns, deine Erfahrung zu verbessern.",
"statement_call_to_action": "Aussage (Call-to-Action)",
"strongly_agree": "Stimme voll und ganz zu",
"strongly_disagree": "Stimme überhaupt nicht zu",
"supportive_work_culture_survey_description": "Bewerte die Wahrnehmung der Mitarbeiter bezüglich Führungsunterstützung, Kommunikation und des gesamten Arbeitsumfelds.",
"supportive_work_culture_survey_name": "Unterstützende Arbeitskultur",
"supportive_work_culture_survey_question_1_headline": "Mein Vorgesetzter bietet mir die Unterstützung, die ich zur Erledigung meiner Arbeit benötige.",
@@ -2826,6 +2842,18 @@
"understand_purchase_intention_question_2_headline": "Verstanden. Was ist dein Hauptgrund für den heutigen Besuch?",
"understand_purchase_intention_question_2_placeholder": "Tippe deine Antwort hier...",
"understand_purchase_intention_question_3_headline": "Was, wenn überhaupt, hält Dich heute davon ab, einen Kauf zu tätigen?",
"understand_purchase_intention_question_3_placeholder": "Tippe deine Antwort hier..."
"understand_purchase_intention_question_3_placeholder": "Tippe deine Antwort hier...",
"usability_question_10_headline": "Ich musste viel lernen, bevor ich das System richtig benutzen konnte.",
"usability_question_1_headline": "Ich würde dieses System wahrscheinlich häufig verwenden.",
"usability_question_2_headline": "Das System wirkte komplizierter als nötig.",
"usability_question_3_headline": "Das System war leicht zu verstehen.",
"usability_question_4_headline": "Ich glaube, ich bräuchte Unterstützung von einem Technik-Experten, um dieses System zu nutzen.",
"usability_question_5_headline": "Alles im System schien gut zusammenzuarbeiten.",
"usability_question_6_headline": "Das System fühlte sich inkonsistent an, wie die Dinge funktionierten.",
"usability_question_7_headline": "Ich glaube, die meisten Menschen könnten schnell lernen, dieses System zu benutzen.",
"usability_question_8_headline": "Die Nutzung des Systems fühlte sich wie eine Belastung an.",
"usability_question_9_headline": "Ich fühlte mich beim Benutzen des Systems sicher.",
"usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.",
"usability_score_name": "System Usability Score Survey (SUS)"
}
}

View File

@@ -503,21 +503,21 @@
"action_with_key_already_exists": "Action with key {key} already exists",
"action_with_name_already_exists": "Action with name {name} already exists",
"add_css_class_or_id": "Add CSS class or id",
"add_regular_expression_here": "Add a regular expression here",
"add_url": "Add URL",
"click": "Click",
"contains": "Contains",
"create_action": "Create action",
"css_selector": "CSS Selector",
"delete_action_text": "Are you sure you want to delete this action? This also removes this action as a trigger from all your surveys.",
"display_name": "Display name",
"does_not_contain": "Does not contain",
"does_not_exactly_match": "Does not exactly match",
"eg_clicked_download": "E.g. Clicked Download",
"eg_download_cta_click_on_home": "e.g. download_cta_click_on_home",
"eg_install_app": "E.g. Install App",
"eg_user_clicked_download_button": "E.g. User clicked Download Button",
"ends_with": "Ends with",
"enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Enter a URL to see if a user visiting it would be tracked.",
"enter_url": "e.g. https://app.com/dashboard",
"exactly_matches": "Exactly matches",
"exit_intent": "Exit Intent",
"fifty_percent_scroll": "50% Scroll",
@@ -526,9 +526,14 @@
"if_a_user_clicks_a_button_with_a_specific_text": "If a user clicks a button with a specific text",
"in_your_code_read_more_in_our": "in your code. Read more in our",
"inner_text": "Inner Text",
"invalid_action_type_code": "Invalid action type for code action.",
"invalid_action_type_no_code": "Invalid action type for noCode action.",
"invalid_css_selector": "Invalid CSS Selector",
"invalid_match_type": "The option selected is not available.",
"invalid_regex": "Please use a valid regular expression.",
"limit_the_pages_on_which_this_action_gets_captured": "Limit the pages on which this action gets captured",
"limit_to_specific_pages": "Limit to specific pages",
"matches_regex": "Matches regex",
"on_all_pages": "On all pages",
"page_filter": "Page filter",
"page_view": "Page View",
@@ -548,7 +553,9 @@
"user_clicked_download_button": "User clicked Download Button",
"what_did_your_user_do": "What did your user do?",
"what_is_the_user_doing": "What is the user doing?",
"you_can_track_code_action_anywhere_in_your_app_using": "You can track code action anywhere in your app using"
"you_can_track_code_action_anywhere_in_your_app_using": "You can track code action anywhere in your app using",
"your_survey_would_be_shown_on_this_url": "Your survey would be shown on this URL.",
"your_survey_would_not_be_shown": "Your survey would not be shown."
},
"connect": {
"congrats": "Congrats!",
@@ -1279,6 +1286,7 @@
"change_anyway": "Change anyway",
"change_background": "Change background",
"change_question_type": "Change question type",
"change_survey_type": "Switching survey type affects existing access",
"change_the_background_color_of_the_card": "Change the background color of the card.",
"change_the_background_color_of_the_input_fields": "Change the background color of the input fields.",
"change_the_background_to_a_color_image_or_animation": "Change the background to a color, image or animation.",
@@ -1289,6 +1297,7 @@
"change_the_placement_of_this_survey": "Change the placement of this survey.",
"change_the_question_color_of_the_survey": "Change the question color of the survey.",
"changes_saved": "Changes saved.",
"changing_survey_type_will_remove_existing_distribution_channels": "Changing the survey type will affect how it can be shared. If respondents already have access links for the current type, they may lose access after the switch.",
"character_limit_toggle_description": "Limit how short or long an answer can be.",
"character_limit_toggle_title": "Add character limits",
"checkbox_label": "Checkbox Label",
@@ -1607,6 +1616,11 @@
"zip": "Zip"
},
"error_deleting_survey": "An error occured while deleting survey",
"filter": {
"complete_and_partial_responses": "Complete and partial responses",
"complete_responses": "Complete responses",
"partial_responses": "Partial responses"
},
"new_survey": "New Survey",
"no_surveys_created_yet": "No surveys created yet",
"open_options": "Open options",
@@ -2771,6 +2785,8 @@
"star_rating_survey_question_3_placeholder": "Type your answer here...",
"star_rating_survey_question_3_subheader": "Help us improve your experience.",
"statement_call_to_action": "Statement (Call to Action)",
"strongly_agree": "Strongly Agree",
"strongly_disagree": "Strongly Disagree",
"supportive_work_culture_survey_description": "Assess employee perceptions of leadership support, communication, and the overall work environment.",
"supportive_work_culture_survey_name": "Supportive Work Culture",
"supportive_work_culture_survey_question_1_headline": "My manager provides me with the support I need to complete my work.",
@@ -2826,6 +2842,18 @@
"understand_purchase_intention_question_2_headline": "Got it. What's your primary reason for visiting today?",
"understand_purchase_intention_question_2_placeholder": "Type your answer here...",
"understand_purchase_intention_question_3_headline": "What, if anything, is holding you back from making a purchase today?",
"understand_purchase_intention_question_3_placeholder": "Type your answer here..."
"understand_purchase_intention_question_3_placeholder": "Type your answer here...",
"usability_question_10_headline": " I had to learn a lot before I could start using the system properly.",
"usability_question_1_headline": "Id probably use this system often.",
"usability_question_2_headline": "The system felt more complicated than it needed to be.",
"usability_question_3_headline": "The system was easy to figure out.",
"usability_question_4_headline": "I think Id need help from a tech expert to use this system.",
"usability_question_5_headline": "Everything in the system seemed to work well together.",
"usability_question_6_headline": "The system felt inconsistent in how things worked.",
"usability_question_7_headline": "I think most people could learn to use this system quickly.",
"usability_question_8_headline": "Using the system felt like a hassle.",
"usability_question_9_headline": "I felt confident while using the system.",
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)"
}
}

View File

@@ -503,21 +503,21 @@
"action_with_key_already_exists": "L'action avec la clé '{'key'}' existe déjà",
"action_with_name_already_exists": "L'action avec le nom '{'name'}' existe déjà",
"add_css_class_or_id": "Ajouter une classe ou un identifiant CSS",
"add_regular_expression_here": "Ajoutez une expression régulière ici",
"add_url": "Ajouter une URL",
"click": "Cliquez",
"contains": "Contient",
"create_action": "Créer une action",
"css_selector": "Sélecteur CSS",
"delete_action_text": "Êtes-vous sûr de vouloir supprimer cette action ? Cela supprime également cette action en tant que déclencheur de toutes vos enquêtes.",
"display_name": "Nom d'affichage",
"does_not_contain": "Ne contient pas",
"does_not_exactly_match": "Ne correspond pas exactement",
"eg_clicked_download": "Par exemple, cliqué sur Télécharger",
"eg_download_cta_click_on_home": "Par exemple, cliquez sur le CTA de téléchargement sur la page d'accueil",
"eg_install_app": "Par exemple, installer l'application",
"eg_user_clicked_download_button": "Par exemple, l'utilisateur a cliqué sur le bouton de téléchargement.",
"ends_with": "Se termine par",
"enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Saisissez une URL pour voir si un utilisateur la visitant serait suivi.",
"enter_url": "par exemple https://app.com/dashboard",
"exactly_matches": "Correspondance exacte",
"exit_intent": "Intention de sortie",
"fifty_percent_scroll": "50% Défilement",
@@ -526,9 +526,14 @@
"if_a_user_clicks_a_button_with_a_specific_text": "Si un utilisateur clique sur un bouton avec un texte spécifique",
"in_your_code_read_more_in_our": "dans votre code. En savoir plus dans notre",
"inner_text": "Texte interne",
"invalid_action_type_code": "Type d'action invalide pour action code",
"invalid_action_type_no_code": "Type d'action invalide pour action noCode",
"invalid_css_selector": "Sélecteur CSS invalide",
"invalid_match_type": "L'option sélectionnée n'est pas disponible.",
"invalid_regex": "Veuillez utiliser une expression régulière valide.",
"limit_the_pages_on_which_this_action_gets_captured": "Limiter les pages sur lesquelles cette action est capturée",
"limit_to_specific_pages": "Limiter à des pages spécifiques",
"matches_regex": "Correspond à l'expression régulière",
"on_all_pages": "Sur toutes les pages",
"page_filter": "Filtre de page",
"page_view": "Vue de page",
@@ -548,7 +553,9 @@
"user_clicked_download_button": "L'utilisateur a cliqué sur le bouton de téléchargement",
"what_did_your_user_do": "Que fait votre utilisateur ?",
"what_is_the_user_doing": "Que fait l'utilisateur ?",
"you_can_track_code_action_anywhere_in_your_app_using": "Vous pouvez suivre l'action du code partout dans votre application en utilisant"
"you_can_track_code_action_anywhere_in_your_app_using": "Vous pouvez suivre l'action du code partout dans votre application en utilisant",
"your_survey_would_be_shown_on_this_url": "Votre enquête serait affichée sur cette URL.",
"your_survey_would_not_be_shown": "Votre enquête ne serait pas affichée."
},
"connect": {
"congrats": "Félicitations !",
@@ -1279,6 +1286,7 @@
"change_anyway": "Changer de toute façon",
"change_background": "Changer l'arrière-plan",
"change_question_type": "Changer le type de question",
"change_survey_type": "Le changement de type de sondage affecte l'accès existant",
"change_the_background_color_of_the_card": "Changez la couleur de fond de la carte.",
"change_the_background_color_of_the_input_fields": "Changez la couleur de fond des champs de saisie.",
"change_the_background_to_a_color_image_or_animation": "Changez l'arrière-plan en une couleur, une image ou une animation.",
@@ -1289,6 +1297,7 @@
"change_the_placement_of_this_survey": "Changez le placement de cette enquête.",
"change_the_question_color_of_the_survey": "Changez la couleur des questions du sondage.",
"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",
@@ -1607,6 +1616,11 @@
"zip": "Zip"
},
"error_deleting_survey": "Une erreur est survenue lors de la suppression de l'enquête.",
"filter": {
"complete_and_partial_responses": "Réponses complètes et partielles",
"complete_responses": "Réponses complètes",
"partial_responses": "Réponses partielles"
},
"new_survey": "Nouveau Sondage",
"no_surveys_created_yet": "Aucun sondage créé pour le moment",
"open_options": "Ouvrir les options",
@@ -2771,6 +2785,8 @@
"star_rating_survey_question_3_placeholder": "Tapez votre réponse ici...",
"star_rating_survey_question_3_subheader": "Aidez-nous à améliorer votre expérience.",
"statement_call_to_action": "Déclaration (Appel à l'action)",
"strongly_agree": "Tout à fait d'accord",
"strongly_disagree": "Fortement en désaccord",
"supportive_work_culture_survey_description": "Évaluer les perceptions des employés concernant le soutien des dirigeants, la communication et l'environnement de travail global.",
"supportive_work_culture_survey_name": "Culture de travail bienveillante",
"supportive_work_culture_survey_question_1_headline": "Mon manager me fournit le soutien dont j'ai besoin pour accomplir mon travail.",
@@ -2826,6 +2842,18 @@
"understand_purchase_intention_question_2_headline": "Compris. Quelle est votre raison principale de visite aujourd'hui ?",
"understand_purchase_intention_question_2_placeholder": "Entrez votre réponse ici...",
"understand_purchase_intention_question_3_headline": "Qu'est-ce qui vous empêche de faire un achat aujourd'hui, s'il y a quelque chose ?",
"understand_purchase_intention_question_3_placeholder": "Entrez votre réponse ici..."
"understand_purchase_intention_question_3_placeholder": "Entrez votre réponse ici...",
"usability_question_10_headline": "J'ai dû beaucoup apprendre avant de pouvoir utiliser correctement le système.",
"usability_question_1_headline": "Je pourrais probablement utiliser ce système souvent.",
"usability_question_2_headline": "Le système semblait plus compliqué qu'il ne devait l'être.",
"usability_question_3_headline": "Le système était facile à comprendre.",
"usability_question_4_headline": "Je pense que j'aurais besoin de l'aide d'un expert en technologie pour utiliser ce système.",
"usability_question_5_headline": "Tout dans le système semblait bien fonctionner ensemble.",
"usability_question_6_headline": "Le système semblait incohérent dans la façon dont les choses fonctionnaient.",
"usability_question_7_headline": "Je pense que la plupart des gens pourraient apprendre à utiliser ce système rapidement.",
"usability_question_8_headline": "Utiliser le système semblait être une corvée.",
"usability_question_9_headline": "Je me suis senti confiant en utilisant le système.",
"usability_rating_description": "Mesurez la convivialité perçue en demandant aux utilisateurs d'évaluer leur expérience avec votre produit via un sondage standardisé de 10 questions.",
"usability_score_name": "Score d'Utilisabilité du Système (SUS)"
}
}

View File

@@ -503,21 +503,21 @@
"action_with_key_already_exists": "Ação com a chave {key} já existe",
"action_with_name_already_exists": "Ação com o nome {name} já existe",
"add_css_class_or_id": "Adicionar classe ou id CSS",
"add_regular_expression_here": "Adicionar uma expressão regular aqui",
"add_url": "Adicionar URL",
"click": "Clica",
"contains": "contém",
"create_action": "criar ação",
"css_selector": "Seletor CSS",
"delete_action_text": "Tem certeza de que quer deletar essa ação? Isso também vai remover essa ação como gatilho de todas as suas pesquisas.",
"display_name": "Nome de exibição",
"does_not_contain": "não contém",
"does_not_exactly_match": "Não bate exatamente",
"eg_clicked_download": "Por exemplo, clicou em baixar",
"eg_download_cta_click_on_home": "e.g. download_cta_click_on_home",
"eg_install_app": "Ex: Instalar App",
"eg_user_clicked_download_button": "Por exemplo, usuário clicou no botão de download",
"ends_with": "Termina com",
"enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Digite uma URL para ver se um usuário que a visita seria rastreado.",
"enter_url": "ex.: https://app.com/dashboard",
"exactly_matches": "Combina exatamente",
"exit_intent": "Intenção de Saída",
"fifty_percent_scroll": "Rolar 50%",
@@ -526,9 +526,14 @@
"if_a_user_clicks_a_button_with_a_specific_text": "Se um usuário clicar em um botão com um texto específico",
"in_your_code_read_more_in_our": "no seu código. Leia mais em nosso",
"inner_text": "Texto Interno",
"invalid_action_type_code": "Tipo de ação inválido para ação com código",
"invalid_action_type_no_code": "Tipo de ação inválido para ação noCode",
"invalid_css_selector": "Seletor CSS Inválido",
"invalid_match_type": "A opção selecionada não está disponível.",
"invalid_regex": "Por favor, use uma expressão regular válida.",
"limit_the_pages_on_which_this_action_gets_captured": "Limite as páginas nas quais essa ação é capturada",
"limit_to_specific_pages": "Limitar a páginas específicas",
"matches_regex": "Correspondência regex",
"on_all_pages": "Em todas as páginas",
"page_filter": "filtro de página",
"page_view": "Visualização de Página",
@@ -548,7 +553,9 @@
"user_clicked_download_button": "Usuário clicou no botão de download",
"what_did_your_user_do": "O que seu usuário fez?",
"what_is_the_user_doing": "O que o usuário tá fazendo?",
"you_can_track_code_action_anywhere_in_your_app_using": "Você pode rastrear ações de código em qualquer lugar do seu app usando"
"you_can_track_code_action_anywhere_in_your_app_using": "Você pode rastrear ações de código em qualquer lugar do seu app usando",
"your_survey_would_be_shown_on_this_url": "Sua pesquisa seria exibida neste URL.",
"your_survey_would_not_be_shown": "Sua pesquisa não seria exibida."
},
"connect": {
"congrats": "Parabéns!",
@@ -1279,6 +1286,7 @@
"change_anyway": "Mudar mesmo assim",
"change_background": "Mudar fundo",
"change_question_type": "Mudar tipo de pergunta",
"change_survey_type": "Alterar o tipo de pesquisa afeta o acesso existente",
"change_the_background_color_of_the_card": "Muda a cor de fundo do cartão.",
"change_the_background_color_of_the_input_fields": "Mude a cor de fundo dos campos de entrada.",
"change_the_background_to_a_color_image_or_animation": "Mude o fundo para uma cor, imagem ou animação.",
@@ -1289,6 +1297,7 @@
"change_the_placement_of_this_survey": "Muda a posição dessa pesquisa.",
"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",
@@ -1607,6 +1616,11 @@
"zip": "Fecho éclair"
},
"error_deleting_survey": "Ocorreu um erro ao deletar a pesquisa",
"filter": {
"complete_and_partial_responses": "Respostas completas e parciais",
"complete_responses": "Respostas completas",
"partial_responses": "Respostas parciais"
},
"new_survey": "Nova Pesquisa",
"no_surveys_created_yet": "Ainda não foram criadas pesquisas",
"open_options": "Abre opções",
@@ -2771,6 +2785,8 @@
"star_rating_survey_question_3_placeholder": "Digite sua resposta aqui...",
"star_rating_survey_question_3_subheader": "Ajude-nos a melhorar sua experiência.",
"statement_call_to_action": "Declaração (Chamada para Ação)",
"strongly_agree": "Concordo totalmente",
"strongly_disagree": "Discordo totalmente",
"supportive_work_culture_survey_description": "Avalie a percepção dos funcionários sobre o suporte da liderança, comunicação e ambiente geral de trabalho.",
"supportive_work_culture_survey_name": "Cultura de Trabalho de Apoio",
"supportive_work_culture_survey_question_1_headline": "Meu gestor me oferece o suporte necessário para realizar meu trabalho.",
@@ -2826,6 +2842,18 @@
"understand_purchase_intention_question_2_headline": "Entendi. Qual é o principal motivo da sua visita hoje?",
"understand_purchase_intention_question_2_placeholder": "Digite sua resposta aqui...",
"understand_purchase_intention_question_3_headline": "O que, se é que tem algo, está te impedindo de fazer a compra hoje?",
"understand_purchase_intention_question_3_placeholder": "Digite sua resposta aqui..."
"understand_purchase_intention_question_3_placeholder": "Digite sua resposta aqui...",
"usability_question_10_headline": "Tive que aprender muito antes de poder começar a usar o sistema corretamente.",
"usability_question_1_headline": "Provavelmente eu usaria este sistema frequentemente.",
"usability_question_2_headline": "O sistema parecia mais complicado do que precisava ser.",
"usability_question_3_headline": "O sistema foi fácil de entender.",
"usability_question_4_headline": "Acho que precisaria da ajuda de um especialista em tecnologia para usar este sistema.",
"usability_question_5_headline": "Tudo no sistema parecia funcionar bem juntos.",
"usability_question_6_headline": "O sistema parecia inconsistente em como as coisas funcionavam.",
"usability_question_7_headline": "Eu acho que a maioria das pessoas poderia aprender a usar este sistema rapidamente.",
"usability_question_8_headline": "Usar o sistema foi uma dor de cabeça.",
"usability_question_9_headline": "Me senti confiante ao usar o sistema.",
"usability_rating_description": "Meça a usabilidade percebida perguntando aos usuários para avaliar sua experiência com seu produto usando uma pesquisa padronizada de 10 perguntas.",
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
}
}

View File

@@ -503,21 +503,21 @@
"action_with_key_already_exists": "Ação com a chave {key} já existe",
"action_with_name_already_exists": "Ação com o nome {name} já existe",
"add_css_class_or_id": "Adicionar classe ou id CSS",
"add_regular_expression_here": "Adicione uma expressão regular aqui",
"add_url": "Adicionar URL",
"click": "Clique",
"contains": "Contém",
"create_action": "Criar ação",
"css_selector": "Seletor CSS",
"delete_action_text": "Tem a certeza de que deseja eliminar esta ação? Isto também remove esta ação como um gatilho de todos os seus inquéritos.",
"display_name": "Nome de exibição",
"does_not_contain": "Não contém",
"does_not_exactly_match": "Não corresponde exatamente",
"eg_clicked_download": "Por exemplo, Clicou em Descarregar",
"eg_download_cta_click_on_home": "por exemplo, descarregar_cta_clicar_em_home",
"eg_install_app": "Ex. Instalar App",
"eg_user_clicked_download_button": "Por exemplo, Utilizador clicou no Botão Descarregar",
"ends_with": "Termina com",
"enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "Introduza um URL para ver se um utilizador que o visita seria rastreado.",
"enter_url": "por exemplo, https://app.com/dashboard",
"exactly_matches": "Corresponde exatamente",
"exit_intent": "Intenção de Saída",
"fifty_percent_scroll": "Rolar 50%",
@@ -526,9 +526,14 @@
"if_a_user_clicks_a_button_with_a_specific_text": "Se um utilizador clicar num botão com um texto específico",
"in_your_code_read_more_in_our": "no seu código. Leia mais no nosso",
"inner_text": "Texto Interno",
"invalid_action_type_code": "Tipo de ação inválido para ação de código",
"invalid_action_type_no_code": "Tipo de ação inválido para ação noCode",
"invalid_css_selector": "Seletor CSS inválido",
"invalid_match_type": "A opção selecionada não está disponível.",
"invalid_regex": "Por favor, utilize uma expressão regular válida.",
"limit_the_pages_on_which_this_action_gets_captured": "Limitar as páginas nas quais esta ação é capturada",
"limit_to_specific_pages": "Limitar a páginas específicas",
"matches_regex": "Coincide com regex",
"on_all_pages": "Em todas as páginas",
"page_filter": "Filtro de página",
"page_view": "Visualização de Página",
@@ -548,7 +553,9 @@
"user_clicked_download_button": "Utilizador clicou no Botão Descarregar",
"what_did_your_user_do": "O que fez o seu utilizador?",
"what_is_the_user_doing": "O que está o utilizador a fazer?",
"you_can_track_code_action_anywhere_in_your_app_using": "Pode rastrear a ação do código em qualquer lugar na sua aplicação usando"
"you_can_track_code_action_anywhere_in_your_app_using": "Pode rastrear a ação do código em qualquer lugar na sua aplicação usando",
"your_survey_would_be_shown_on_this_url": "O seu inquérito seria mostrado neste URL.",
"your_survey_would_not_be_shown": "O seu inquérito não seria mostrado."
},
"connect": {
"congrats": "Parabéns!",
@@ -1279,6 +1286,7 @@
"change_anyway": "Alterar mesmo assim",
"change_background": "Alterar fundo",
"change_question_type": "Alterar tipo de pergunta",
"change_survey_type": "Alterar o tipo de inquérito afeta o acesso existente",
"change_the_background_color_of_the_card": "Alterar a cor de fundo do cartão",
"change_the_background_color_of_the_input_fields": "Alterar a cor de fundo dos campos de entrada",
"change_the_background_to_a_color_image_or_animation": "Altere o fundo para uma cor, imagem ou animação",
@@ -1289,6 +1297,7 @@
"change_the_placement_of_this_survey": "Alterar a colocação deste inquérito.",
"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",
@@ -1607,6 +1616,11 @@
"zip": "Comprimir"
},
"error_deleting_survey": "Ocorreu um erro ao eliminar o questionário",
"filter": {
"complete_and_partial_responses": "Respostas completas e parciais",
"complete_responses": "Respostas completas",
"partial_responses": "Respostas parciais"
},
"new_survey": "Novo inquérito",
"no_surveys_created_yet": "Ainda não foram criados questionários",
"open_options": "Abrir opções",
@@ -2771,6 +2785,8 @@
"star_rating_survey_question_3_placeholder": "Escreva a sua resposta aqui...",
"star_rating_survey_question_3_subheader": "Ajude-nos a melhorar a sua experiência.",
"statement_call_to_action": "Declaração (Chamada para Ação)",
"strongly_agree": "Concordo totalmente",
"strongly_disagree": "Discordo totalmente",
"supportive_work_culture_survey_description": "Avaliar as perceções dos funcionários sobre o apoio da liderança, comunicação e o ambiente de trabalho geral.",
"supportive_work_culture_survey_name": "Cultura de Trabalho de Apoio",
"supportive_work_culture_survey_question_1_headline": "O meu gestor fornece-me o apoio de que preciso para concluir o meu trabalho.",
@@ -2826,6 +2842,18 @@
"understand_purchase_intention_question_2_headline": "Entendido. Qual é a sua principal razão para visitar hoje?",
"understand_purchase_intention_question_2_placeholder": "Escreva a sua resposta aqui...",
"understand_purchase_intention_question_3_headline": "O que, se alguma coisa, o está a impedir de fazer uma compra hoje?",
"understand_purchase_intention_question_3_placeholder": "Escreva a sua resposta aqui..."
"understand_purchase_intention_question_3_placeholder": "Escreva a sua resposta aqui...",
"usability_question_10_headline": "Tive que aprender muito antes de poder começar a usar o sistema corretamente.",
"usability_question_1_headline": "Provavelmente usaria este sistema com frequência.",
"usability_question_2_headline": "O sistema parecia mais complicado do que precisava ser.",
"usability_question_3_headline": "O sistema foi fácil de entender.",
"usability_question_4_headline": "Acho que precisaria de ajuda de um especialista em tecnologia para utilizar este sistema.",
"usability_question_5_headline": "Tudo no sistema parecia funcionar bem em conjunto.",
"usability_question_6_headline": "O sistema parecia inconsistente na forma como as coisas funcionavam.",
"usability_question_7_headline": "Acho que a maioria das pessoas poderia aprender a usar este sistema rapidamente.",
"usability_question_8_headline": "Usar o sistema pareceu complicado.",
"usability_question_9_headline": "Eu senti-me confiante ao usar o sistema.",
"usability_rating_description": "Meça a usabilidade percebida ao solicitar que os utilizadores avaliem a sua experiência com o seu produto usando um questionário padronizado de 10 perguntas.",
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
}
}

View File

@@ -503,21 +503,21 @@
"action_with_key_already_exists": "金鑰為 '{'key'}' 的操作已存在",
"action_with_name_already_exists": "名稱為 '{'name'}' 的操作已存在",
"add_css_class_or_id": "新增 CSS 類別或 ID",
"add_regular_expression_here": "新增正則表達式在此",
"add_url": "新增網址",
"click": "點擊",
"contains": "包含",
"create_action": "建立操作",
"css_selector": "CSS 選取器",
"delete_action_text": "您確定要刪除此操作嗎?這也會從您的所有問卷中移除此操作作為觸發器。",
"display_name": "顯示名稱",
"does_not_contain": "不包含",
"does_not_exactly_match": "不完全相符",
"eg_clicked_download": "例如,點擊下載",
"eg_download_cta_click_on_home": "例如download_cta_click_on_home",
"eg_install_app": "例如,安裝應用程式",
"eg_user_clicked_download_button": "例如,使用者點擊了下載按鈕",
"ends_with": "結尾為",
"enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked": "輸入網址以查看造訪該網址的使用者是否會被追蹤。",
"enter_url": "例如 https://app.com/dashboard",
"exactly_matches": "完全相符",
"exit_intent": "離開意圖",
"fifty_percent_scroll": "50% 捲動",
@@ -526,9 +526,14 @@
"if_a_user_clicks_a_button_with_a_specific_text": "如果使用者點擊具有特定文字的按鈕",
"in_your_code_read_more_in_our": "在您的程式碼中。在我們的文件中閱讀更多內容",
"inner_text": "內部文字",
"invalid_action_type_code": "對程式碼操作的操作類型無效",
"invalid_action_type_no_code": "使用無程式碼操作的操作類型無效",
"invalid_css_selector": "無效的 CSS 選取器",
"invalid_match_type": "所選擇的選項不適用。",
"invalid_regex": "請使用有效的正規表示式。",
"limit_the_pages_on_which_this_action_gets_captured": "限制擷取此操作的頁面",
"limit_to_specific_pages": "限制為特定頁面",
"matches_regex": "符合 正則 表達式",
"on_all_pages": "在所有頁面上",
"page_filter": "頁面篩選器",
"page_view": "頁面檢視",
@@ -548,7 +553,9 @@
"user_clicked_download_button": "使用者點擊了下載按鈕",
"what_did_your_user_do": "您的使用者做了什麼?",
"what_is_the_user_doing": "使用者正在做什麼?",
"you_can_track_code_action_anywhere_in_your_app_using": "您可以使用以下方式在您的應用程式中的任何位置追蹤程式碼操作"
"you_can_track_code_action_anywhere_in_your_app_using": "您可以使用以下方式在您的應用程式中的任何位置追蹤程式碼操作",
"your_survey_would_be_shown_on_this_url": "您的問卷將顯示在此網址。",
"your_survey_would_not_be_shown": "您的問卷將不會顯示。"
},
"connect": {
"congrats": "恭喜!",
@@ -1279,6 +1286,7 @@
"change_anyway": "仍然變更",
"change_background": "變更背景",
"change_question_type": "變更問題類型",
"change_survey_type": "切換問卷類型會影響現有訪問",
"change_the_background_color_of_the_card": "變更卡片的背景顏色。",
"change_the_background_color_of_the_input_fields": "變更輸入欄位的背景顏色。",
"change_the_background_to_a_color_image_or_animation": "將背景變更為顏色、圖片或動畫。",
@@ -1289,6 +1297,7 @@
"change_the_placement_of_this_survey": "變更此問卷的位置。",
"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": "核取方塊標籤",
@@ -1607,6 +1616,11 @@
"zip": "郵遞區號"
},
"error_deleting_survey": "刪除問卷時發生錯誤",
"filter": {
"complete_and_partial_responses": "完整 和 部分 回應",
"complete_responses": "完整回應",
"partial_responses": "部分回應"
},
"new_survey": "新增問卷",
"no_surveys_created_yet": "尚未建立任何問卷",
"open_options": "開啟選項",
@@ -2771,6 +2785,8 @@
"star_rating_survey_question_3_placeholder": "在此輸入您的答案...",
"star_rating_survey_question_3_subheader": "協助我們改善您的體驗。",
"statement_call_to_action": "陳述(行動呼籲)",
"strongly_agree": "非常同意",
"strongly_disagree": "非常不同意",
"supportive_work_culture_survey_description": "評估員工對領導層支援、溝通和整體工作環境的看法。",
"supportive_work_culture_survey_name": "支援性工作文化",
"supportive_work_culture_survey_question_1_headline": "我的經理為我提供了完成工作所需的支援。",
@@ -2826,6 +2842,18 @@
"understand_purchase_intention_question_2_headline": "瞭解了。您今天來訪的主要原因是什麼?",
"understand_purchase_intention_question_2_placeholder": "在此輸入您的答案...",
"understand_purchase_intention_question_3_headline": "有什麼阻礙您今天進行購買嗎?",
"understand_purchase_intention_question_3_placeholder": "在此輸入您的答案..."
"understand_purchase_intention_question_3_placeholder": "在此輸入您的答案...",
"usability_question_10_headline": "我 必須 學習 很多 東西 才能 正確 使用 該 系統。",
"usability_question_1_headline": "我可能會經常使用這個系統。",
"usability_question_2_headline": "系統感覺起來比實際需要的更複雜。",
"usability_question_3_headline": "系統很容易理解。",
"usability_question_4_headline": "我 認為 我 需要 技術 專家 的 幫助 才能 使用 這個 系統。",
"usability_question_5_headline": "系統中 的 所有 元素 看起來 都能 很好 地 運作。",
"usability_question_6_headline": "系統在運作上給人不一致的感覺。",
"usability_question_7_headline": "我認為大多數人可以快速 學會 使用 這個 系統。",
"usability_question_8_headline": "使用系統 感覺 令人 困擾。",
"usability_question_9_headline": "使用 系統 時,我 感到 有 信心。",
"usability_rating_description": "透過使用標準化的 十個問題 問卷,要求使用者評估他們對 您 產品的使用體驗,來衡量感知的 可用性。",
"usability_score_name": "系統 可用性 分數 (SUS)"
}
}

View File

@@ -1,16 +1,22 @@
import { ActionClass } from "@prisma/client";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useRouter } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createActionClassAction } from "../actions";
import { CreateNewActionTab } from "./create-new-action-tab";
// Mock the NoCodeActionForm and CodeActionForm components
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
NoCodeActionForm: () => <div data-testid="no-code-action-form">NoCodeActionForm</div>,
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@/modules/ui/components/code-action-form", () => ({
CodeActionForm: () => <div data-testid="code-action-form">CodeActionForm</div>,
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
// Mock constants
@@ -18,41 +24,291 @@ vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
}));
// Mock CSS selector validation
vi.mock("@/app/lib/actionClass/actionClass", () => ({
isValidCssSelector: vi.fn(() => true),
}));
// Mock the createActionClassAction function
vi.mock("../actions", () => ({
createActionClassAction: vi.fn(),
}));
// Mock action-utils functions
vi.mock("../lib/action-utils", () => ({
useActionClassKeys: vi.fn(() => []),
createActionClassZodResolver: vi.fn(() => () => ({ errors: {}, isValid: true })),
validatePermissions: vi.fn(),
}));
// Mock action-builder functions
vi.mock("../lib/action-builder", () => ({
buildActionObject: vi.fn((data) => data),
}));
// Mock ActionNameDescriptionFields component
vi.mock("@/modules/ui/components/action-name-description-fields", () => ({
ActionNameDescriptionFields: vi.fn(({ nameInputId, descriptionInputId }) => (
<div data-testid="action-name-description-fields">
<label htmlFor={nameInputId}>What did your user do?</label>
<input id={nameInputId} name="name" data-testid="name-input" />
<label htmlFor={descriptionInputId}>Description</label>
<input id={descriptionInputId} name="description" data-testid="description-input" />
</div>
)),
}));
// Mock useTranslate hook
const mockT = vi.fn((key: string, params?: any) => {
const translations: Record<string, string> = {
"environments.actions.new_action": "New Action",
"common.no_code": "No Code",
"common.code": "Code",
"environments.actions.action_type": "Action Type",
"environments.actions.what_did_your_user_do": "What did your user do?",
"common.description": "Description",
"environments.actions.create_action": "Create Action",
"common.key": "Key",
"common.cancel": "Cancel",
"environments.actions.action_created_successfully": "Action created successfully",
"environments.actions.action_with_name_already_exists": `Action with name "{{name}}" already exists`,
"environments.actions.action_with_key_already_exists": `Action with key "{{key}}" already exists`,
"environments.actions.invalid_css_selector": "Invalid CSS selector",
"environments.actions.invalid_regex": "Invalid regex pattern",
"common.you_are_not_authorised_to_perform_this_action": "You are not authorized to perform this action",
};
let translation = translations[key] || key;
if (params) {
Object.keys(params).forEach((param) => {
translation = translation.replace(`{{${param}}}`, params[param]);
});
}
return translation;
});
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: mockT }),
}));
describe("CreateNewActionTab", () => {
const mockPush = vi.fn();
const mockRefresh = vi.fn();
const mockCreateActionClassAction = vi.mocked(createActionClassAction);
const defaultProps = {
actionClasses: [] as ActionClass[],
setActionClasses: vi.fn(),
setOpen: vi.fn(),
isReadOnly: false,
setLocalSurvey: vi.fn(),
environmentId: "test-env-id",
};
beforeEach(async () => {
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
refresh: mockRefresh,
} as any);
mockCreateActionClassAction.mockResolvedValue({
data: {
id: "new-action-id",
name: "Test Action",
type: "noCode",
environmentId: "test-env-id",
description: null,
key: null,
noCodeConfig: {},
createdAt: new Date(),
updatedAt: new Date(),
} as ActionClass,
});
// Import and setup the CSS selector mock
const cssModule = (await vi.importMock("@/app/lib/actionClass/actionClass")) as any;
cssModule.isValidCssSelector.mockReturnValue(true);
// Setup action-utils mocks
const actionUtilsModule = (await vi.importMock("../lib/action-utils")) as any;
actionUtilsModule.useActionClassKeys.mockReturnValue([]);
actionUtilsModule.createActionClassZodResolver.mockReturnValue(() => ({ errors: {}, isValid: true }));
actionUtilsModule.validatePermissions.mockImplementation(() => {});
// Setup action-builder mock
const actionBuilderModule = (await vi.importMock("../lib/action-builder")) as any;
actionBuilderModule.buildActionObject.mockImplementation((data) => data);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders all expected fields and UI elements when provided with valid props", () => {
const actionClasses: ActionClass[] = [];
const setActionClasses = vi.fn();
const setOpen = vi.fn();
const isReadOnly = false;
const setLocalSurvey = vi.fn();
const environmentId = "test-env-id";
// Basic rendering tests
test("renders all expected fields and UI elements", () => {
render(<CreateNewActionTab {...defaultProps} />);
render(
<CreateNewActionTab
actionClasses={actionClasses}
setActionClasses={setActionClasses}
setOpen={setOpen}
isReadOnly={isReadOnly}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
/>
expect(screen.getByText("Action Type")).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "No Code" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Code" })).toBeInTheDocument();
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Create Action" })).toBeInTheDocument();
});
test("switches between action forms correctly", async () => {
render(<CreateNewActionTab {...defaultProps} />);
// Initially shows no-code form
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
expect(screen.queryByTestId("code-action-form")).not.toBeInTheDocument();
// Switch to code tab
const codeTab = screen.getByRole("radio", { name: "Code" });
await act(async () => {
fireEvent.click(codeTab);
});
// Should now show code form
await waitFor(() => {
expect(screen.queryByTestId("no-code-action-form")).not.toBeInTheDocument();
expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
});
});
test("renders readonly state correctly", () => {
render(<CreateNewActionTab {...defaultProps} isReadOnly={true} />);
// Form should still render but components should receive isReadOnly prop
expect(screen.getByText("Action Type")).toBeInTheDocument();
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
});
test("renders with existing action classes", () => {
const existingActionClasses: ActionClass[] = [
{
id: "existing-action",
name: "Existing Action",
environmentId: "test-env-id",
type: "noCode",
description: "Existing description",
key: null,
noCodeConfig: {},
createdAt: new Date(),
updatedAt: new Date(),
},
];
render(<CreateNewActionTab {...defaultProps} actionClasses={existingActionClasses} />);
// Form should render normally regardless of existing actions
expect(screen.getByText("Action Type")).toBeInTheDocument();
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
});
test("calls useActionClassKeys with correct arguments", async () => {
const actionClasses = [
{
id: "test-action",
name: "Test Action",
environmentId: "test-env-id",
type: "code",
key: "test-key",
description: null,
noCodeConfig: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ActionClass,
];
const actionUtilsModule = (await vi.importMock("../lib/action-utils")) as any;
render(<CreateNewActionTab {...defaultProps} actionClasses={actionClasses} />);
expect(actionUtilsModule.useActionClassKeys).toHaveBeenCalledWith(actionClasses);
});
test("renders form with correct resolver configuration", async () => {
const actionUtilsModule = (await vi.importMock("../lib/action-utils")) as any;
render(<CreateNewActionTab {...defaultProps} />);
// Verify that the resolver is configured correctly
expect(actionUtilsModule.createActionClassZodResolver).toHaveBeenCalledWith(
[], // actionClassNames
[], // actionClassKeys
mockT
);
});
// Check for the presence of key UI elements
expect(screen.getByText("environments.actions.action_type")).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "common.no_code" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "common.code" })).toBeInTheDocument();
expect(screen.getByLabelText("environments.actions.what_did_your_user_do")).toBeInTheDocument();
expect(screen.getByLabelText("common.description")).toBeInTheDocument();
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument(); // Ensure NoCodeActionForm is rendered by default
test("handles validation errors correctly", async () => {
// Mock form validation to fail
const actionUtilsModule = (await vi.importMock("../lib/action-utils")) as any;
actionUtilsModule.createActionClassZodResolver.mockReturnValue(() => ({
errors: { name: { message: "Name is required" } },
isValid: false,
}));
render(<CreateNewActionTab {...defaultProps} />);
const submitButton = screen.getByRole("button", { name: "Create Action" });
await act(async () => {
fireEvent.click(submitButton);
});
// Since validation fails, buildActionObject should not be called
const { buildActionObject } = await import("../lib/action-builder");
expect(buildActionObject).not.toHaveBeenCalled();
});
test("handles readonly permissions correctly", async () => {
const { validatePermissions } = await import("../lib/action-utils");
const toast = await import("react-hot-toast");
// Make validatePermissions throw for readonly
const actionUtilsModule = (await vi.importMock("../lib/action-utils")) as any;
actionUtilsModule.validatePermissions.mockImplementation(() => {
throw new Error("You are not authorized to perform this action");
});
render(<CreateNewActionTab {...defaultProps} isReadOnly={true} />);
const submitButton = screen.getByRole("button", { name: "Create Action" });
await act(async () => {
fireEvent.click(submitButton);
});
expect(validatePermissions).toHaveBeenCalledWith(true, mockT);
expect(toast.default.error).toHaveBeenCalledWith("You are not authorized to perform this action");
});
test("uses correct action class names and keys for validation", async () => {
const actionClasses = [
{
id: "action1",
name: "Existing Action",
environmentId: "test-env-id",
type: "code",
key: "existing-key",
description: null,
noCodeConfig: null,
createdAt: new Date(),
updatedAt: new Date(),
} as ActionClass,
];
const actionUtilsModule = (await vi.importMock("../lib/action-utils")) as any;
actionUtilsModule.useActionClassKeys.mockReturnValue(["existing-key"]);
render(<CreateNewActionTab {...defaultProps} actionClasses={actionClasses} />);
// Verify that the resolver is configured with existing action names and keys
expect(actionUtilsModule.createActionClassZodResolver).toHaveBeenCalledWith(
["Existing Action"], // actionClassNames
["existing-key"], // actionClassKeys
mockT
);
});
});

View File

@@ -1,28 +1,23 @@
"use client";
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
import { ActionNameDescriptionFields } from "@/modules/ui/components/action-name-description-fields";
import { Button } from "@/modules/ui/components/button";
import { CodeActionForm } from "@/modules/ui/components/code-action-form";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { FormField } from "@/modules/ui/components/form";
import { Label } from "@/modules/ui/components/label";
import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form";
import { TabToggle } from "@/modules/ui/components/tab-toggle";
import { zodResolver } from "@hookform/resolvers/zod";
import { ActionClass } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { FormProvider, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import {
TActionClassInput,
TActionClassInputCode,
ZActionClassInput,
} from "@formbricks/types/action-classes";
import { TActionClassInput } from "@formbricks/types/action-classes";
import { TSurvey } from "@formbricks/types/surveys/types";
import { createActionClassAction } from "../actions";
import { buildActionObject } from "../lib/action-builder";
import { createActionClassZodResolver, useActionClassKeys, validatePermissions } from "../lib/action-utils";
interface CreateNewActionTabProps {
actionClasses: ActionClass[];
@@ -48,6 +43,8 @@ export const CreateNewActionTab = ({
[actionClasses]
);
const actionClassKeys = useActionClassKeys(actionClasses);
const form = useForm<TActionClassInput>({
defaultValues: {
name: "",
@@ -63,112 +60,49 @@ export const CreateNewActionTab = ({
urlFilters: [],
},
},
resolver: zodResolver(
ZActionClassInput.superRefine((data, ctx) => {
if (data.name && actionClassNames.includes(data.name)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: t("environments.actions.action_with_name_already_exists", { name: data.name }),
});
}
})
),
resolver: createActionClassZodResolver(actionClassNames, actionClassKeys, t),
mode: "onChange",
});
const { control, handleSubmit, watch, reset } = form;
const { isSubmitting } = form.formState;
const actionClassKeys = useMemo(() => {
const codeActionClasses: TActionClassInputCode[] = actionClasses.filter(
(actionClass) => actionClass.type === "code"
) as TActionClassInputCode[];
return codeActionClasses.map((actionClass) => actionClass.key);
}, [actionClasses]);
const submitHandler = async (data: TActionClassInput) => {
const { type } = data;
try {
if (isReadOnly) {
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
}
if (data.name && actionClassNames.includes(data.name)) {
throw new Error(t("environments.actions.action_with_name_already_exists", { name: data.name }));
}
if (type === "code" && data.key && actionClassKeys.includes(data.key)) {
throw new Error(t("environments.actions.action_with_key_already_exists", { key: data.key }));
}
if (
data.type === "noCode" &&
data.noCodeConfig?.type === "click" &&
data.noCodeConfig.elementSelector.cssSelector &&
!isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector)
) {
throw new Error("Invalid CSS Selector");
}
let updatedAction = {};
if (type === "noCode") {
updatedAction = {
name: data.name.trim(),
description: data.description,
environmentId,
type: "noCode",
noCodeConfig: {
...data.noCodeConfig,
...(data.type === "noCode" &&
data.noCodeConfig?.type === "click" && {
elementSelector: {
cssSelector: data.noCodeConfig.elementSelector.cssSelector,
innerHtml: data.noCodeConfig.elementSelector.innerHtml,
},
}),
},
};
} else if (type === "code") {
updatedAction = {
name: data.name.trim(),
description: data.description,
environmentId,
type: "code",
key: data.key,
};
}
// const newActionClass: TActionClass =
const createActionClassResposne = await createActionClassAction({
action: updatedAction as TActionClassInput,
});
if (!createActionClassResposne?.data) return;
const newActionClass = createActionClassResposne.data;
if (setActionClasses) {
setActionClasses((prevActionClasses: ActionClass[]) => [...prevActionClasses, newActionClass]);
}
if (setLocalSurvey) {
setLocalSurvey((prev) => ({
...prev,
triggers: prev.triggers.concat({ actionClass: newActionClass }),
}));
}
reset();
resetAllStates();
router.refresh();
toast.success(t("environments.actions.action_created_successfully"));
validatePermissions(isReadOnly, t);
const updatedAction = buildActionObject(data, environmentId, t);
await createAndHandleAction(updatedAction);
} catch (e: any) {
toast.error(e.message);
}
};
const createAndHandleAction = async (updatedAction: TActionClassInput) => {
const createActionClassResposne = await createActionClassAction({
action: updatedAction,
});
if (!createActionClassResposne?.data) return;
const newActionClass = createActionClassResposne.data;
if (setActionClasses) {
setActionClasses((prevActionClasses: ActionClass[]) => [...prevActionClasses, newActionClass]);
}
if (setLocalSurvey) {
setLocalSurvey((prev) => ({
...prev,
triggers: prev.triggers.concat({ actionClass: newActionClass }),
}));
}
reset();
resetAllStates();
router.refresh();
toast.success(t("environments.actions.action_created_successfully"));
};
const resetAllStates = () => {
reset();
setOpen(false);
@@ -177,7 +111,7 @@ export const CreateNewActionTab = ({
return (
<div>
<FormProvider {...form}>
<form onSubmit={handleSubmit(submitHandler)}>
<form onSubmit={handleSubmit(submitHandler)} aria-label="create-action-form">
<div className="w-full space-y-4">
<div className="w-3/5">
<FormField
@@ -200,56 +134,12 @@ export const CreateNewActionTab = ({
/>
</div>
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<FormField
control={control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel htmlFor="actionNameInput">
{t("environments.actions.what_did_your_user_do")}
</FormLabel>
<FormControl>
<Input
type="text"
id="actionNameInput"
{...field}
placeholder={t("environments.actions.eg_clicked_download")}
isInvalid={!!error?.message}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
<div className="col-span-1">
<FormField
control={control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="actionDescriptionInput">{t("common.description")}</FormLabel>
<FormControl>
<Input
type="text"
id="actionDescriptionInput"
{...field}
placeholder={t("environments.actions.eg_user_clicked_download_button")}
value={field.value ?? ""}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<hr className="border-slate-200" />
<ActionNameDescriptionFields
control={control}
isReadOnly={isReadOnly}
nameInputId="actionNameInput"
descriptionInputId="actionDescriptionInput"
/>
{watch("type") === "code" ? (
<CodeActionForm form={form} isReadOnly={isReadOnly} />

View File

@@ -122,7 +122,17 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="flex flex-col" ref={parent}>
<hr className="py-1 text-slate-600" />
<div className="p-3">
<div className="space-y-3 p-3">
{localSurvey.status === "inProgress" && (
<Alert variant="warning" className="mb-3">
<AlertTitle>{t("environments.surveys.edit.change_survey_type")}</AlertTitle>
<AlertDescription>
{t(
"environments.surveys.edit.changing_survey_type_will_remove_existing_distribution_channels"
)}
</AlertDescription>
</Alert>
)}
<RadioGroup
defaultValue="app"
value={localSurvey.type}

View File

@@ -20,6 +20,13 @@ describe("SavedActionsTab", () => {
description: "Description for No Code Action",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "click",
urlFilters: [{ rule: "exactMatch", value: "https://example.com" }],
elementSelector: {
cssSelector: ".button",
},
},
} as unknown as ActionClass,
{
id: "2",
@@ -29,6 +36,7 @@ describe("SavedActionsTab", () => {
description: "Description for Code Action",
type: "code",
environmentId: "env1",
key: "code-action-key",
} as unknown as ActionClass,
];
@@ -74,6 +82,13 @@ describe("SavedActionsTab", () => {
description: "Description for Action One",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "click",
urlFilters: [{ rule: "contains", value: "/dashboard" }],
elementSelector: {
cssSelector: ".button",
},
},
} as unknown as ActionClass,
];
@@ -121,6 +136,74 @@ describe("SavedActionsTab", () => {
expect(setOpen).toHaveBeenCalledWith(false);
});
test("displays action classes with regex URL filters correctly", () => {
const actionClasses: ActionClass[] = [
{
id: "1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Regex Action",
description: "Action with regex URL filter",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "pageView",
urlFilters: [{ rule: "matchesRegex", value: "user/\\d+" }],
},
} as unknown as ActionClass,
{
id: "2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Multiple Filters Action",
description: "Action with multiple URL filters including regex",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "click",
urlFilters: [
{ rule: "startsWith", value: "https://app.example.com" },
{ rule: "matchesRegex", value: "dashboard.*\\?tab=\\w+" },
],
elementSelector: {
cssSelector: ".nav-button",
},
},
} as unknown as ActionClass,
];
const localSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
questions: [],
triggers: [],
environmentId: "env1",
status: "draft",
} as any;
const setLocalSurvey = vi.fn();
const setOpen = vi.fn();
render(
<SavedActionsTab
actionClasses={actionClasses}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setOpen={setOpen}
/>
);
// Check if actions with regex filters are displayed
expect(screen.getByText("Regex Action")).toBeInTheDocument();
expect(screen.getByText("Multiple Filters Action")).toBeInTheDocument();
// Verify the ActionClassInfo component displays the URL filters
expect(screen.getByText("Action with regex URL filter")).toBeInTheDocument();
expect(screen.getByText("Action with multiple URL filters including regex")).toBeInTheDocument();
});
test("displays 'No saved actions found' message when no actions are available", () => {
const actionClasses: ActionClass[] = [];
const localSurvey: TSurvey = {
@@ -149,6 +232,68 @@ describe("SavedActionsTab", () => {
expect(noActionsMessage).toBeInTheDocument();
});
test("excludes actions that are already used as triggers in the survey", () => {
const actionClasses: ActionClass[] = [
{
id: "1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Available Action",
description: "This action is available",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "click",
urlFilters: [{ rule: "exactMatch", value: "https://example.com" }],
elementSelector: {
cssSelector: ".button",
},
},
} as unknown as ActionClass,
{
id: "2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Used Action",
description: "This action is already used",
type: "code",
environmentId: "env1",
key: "used-action-key",
} as unknown as ActionClass,
];
const localSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
questions: [],
triggers: [
{ actionClass: actionClasses[1] }, // "Used Action" is already a trigger
],
environmentId: "env1",
status: "draft",
} as any;
const setLocalSurvey = vi.fn();
const setOpen = vi.fn();
render(
<SavedActionsTab
actionClasses={actionClasses}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setOpen={setOpen}
/>
);
// Available action should be displayed
expect(screen.getByText("Available Action")).toBeInTheDocument();
// Used action should not be displayed
expect(screen.queryByText("Used Action")).not.toBeInTheDocument();
});
test("filters actionClasses correctly with special characters, diacritics, and non-Latin scripts", async () => {
const user = userEvent.setup();
const actionClasses: ActionClass[] = [
@@ -160,6 +305,13 @@ describe("SavedActionsTab", () => {
description: "Description for Action One",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "click",
urlFilters: [{ rule: "contains", value: "special" }],
elementSelector: {
cssSelector: ".special",
},
},
} as unknown as ActionClass,
{
id: "2",
@@ -169,6 +321,7 @@ describe("SavedActionsTab", () => {
description: "Description for Action Two",
type: "code",
environmentId: "env1",
key: "cyrillic-action",
} as unknown as ActionClass,
{
id: "3",
@@ -178,6 +331,10 @@ describe("SavedActionsTab", () => {
description: "Description for Another Action",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "pageView",
urlFilters: [{ rule: "matchesRegex", value: "special.*symbols" }],
},
} as unknown as ActionClass,
];
@@ -228,6 +385,13 @@ describe("SavedActionsTab", () => {
description: "Description for Action One",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "click",
urlFilters: [{ rule: "exactMatch", value: "https://example.com" }],
elementSelector: {
cssSelector: ".button-one",
},
},
} as unknown as ActionClass,
{
id: "2",
@@ -237,6 +401,7 @@ describe("SavedActionsTab", () => {
description: "Description for Action Two",
type: "code",
environmentId: "env1",
key: "action-two-key",
} as unknown as ActionClass,
{
id: "3",
@@ -246,6 +411,10 @@ describe("SavedActionsTab", () => {
description: "Description for Another Action",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "pageView",
urlFilters: [{ rule: "matchesRegex", value: "another.*page" }],
},
} as unknown as ActionClass,
];
@@ -310,4 +479,69 @@ describe("SavedActionsTab", () => {
expect(screen.queryByText("Action One")).toBeNull();
expect(screen.queryByText("Action Two")).toBeNull();
});
test("handles action classes with mixed URL filter rule types including regex", async () => {
const user = userEvent.setup();
const actionClasses: ActionClass[] = [
{
id: "1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Mixed Rules Action",
description: "Action with multiple rule types",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "click",
urlFilters: [
{ rule: "startsWith", value: "https://app" },
{ rule: "contains", value: "/dashboard" },
{ rule: "matchesRegex", value: "\\?section=\\w+" },
{ rule: "endsWith", value: "#main" },
],
elementSelector: {
cssSelector: ".complex-button",
},
},
} as unknown as ActionClass,
];
const localSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
questions: [],
triggers: [],
environmentId: "env1",
status: "draft",
} as any;
const setLocalSurvey = vi.fn();
const setOpen = vi.fn();
render(
<SavedActionsTab
actionClasses={actionClasses}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setOpen={setOpen}
/>
);
// Verify the action is displayed
expect(screen.getByText("Mixed Rules Action")).toBeInTheDocument();
expect(screen.getByText("Action with multiple rule types")).toBeInTheDocument();
// Click on the action to add it as a trigger
const actionElement = screen.getByText("Mixed Rules Action");
await user.click(actionElement);
// Verify the action was added to triggers
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
const updateFunction = setLocalSurvey.mock.calls[0][0];
const result = updateFunction(localSurvey);
expect(result.triggers).toHaveLength(1);
expect(result.triggers[0].actionClass).toEqual(actionClasses[0]);
});
});

View File

@@ -1,6 +1,7 @@
"use client";
import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils";
import { ActionClassInfo } from "@/modules/ui/components/action-class-info";
import { Input } from "@/modules/ui/components/input";
import { ActionClass } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
@@ -79,7 +80,7 @@ export const SavedActionsTab = ({
</div>
<h4 className="text-sm font-semibold text-slate-600">{action.name}</h4>
</div>
<p className="mt-1 text-xs text-slate-500">{action.description}</p>
<ActionClassInfo actionClass={action} />
</button>
))}
</div>

View File

@@ -3,7 +3,6 @@ import { updateSurveyAction } from "@/modules/survey/editor/actions";
import { SurveyMenuBar } from "@/modules/survey/editor/components/survey-menu-bar";
import { isSurveyValid } from "@/modules/survey/editor/lib/validation";
import { Project } from "@prisma/client";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -72,23 +71,6 @@ vi.mock("@formbricks/i18n-utils/src/utils", () => ({
getLanguageLabel: vi.fn((code) => `Lang(${code})`),
}));
// Mock Zod schemas to always validate successfully
vi.mock("@formbricks/types/surveys/types", async () => {
const actual = await vi.importActual("@formbricks/types/surveys/types");
return {
...actual,
ZSurvey: {
safeParse: vi.fn(() => ({ success: true })),
},
ZSurveyEndScreenCard: {
parse: vi.fn((ending) => ending),
},
ZSurveyRedirectUrlCard: {
parse: vi.fn((ending) => ending),
},
};
});
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
@@ -106,43 +88,15 @@ vi.mock("lucide-react", async () => {
};
});
// Mock next/navigation
const mockRouter = {
back: vi.fn(),
push: vi.fn(),
refresh: vi.fn(),
};
vi.mock("next/navigation", () => ({
useRouter: () => mockRouter,
}));
const mockSetLocalSurvey = vi.fn();
const mockSetActiveId = vi.fn();
const mockSetInvalidQuestions = vi.fn();
const mockSetIsCautionDialogOpen = vi.fn();
// Mock window.history
const mockHistoryPushState = vi.fn();
Object.defineProperty(window, "history", {
value: {
pushState: mockHistoryPushState,
},
writable: true,
});
// Mock window event listeners
const mockAddEventListener = vi.fn();
const mockRemoveEventListener = vi.fn();
Object.defineProperty(window, "addEventListener", {
value: mockAddEventListener,
writable: true,
});
Object.defineProperty(window, "removeEventListener", {
value: mockRemoveEventListener,
writable: true,
});
const baseSurvey = {
id: "survey-1",
createdAt: new Date(),
@@ -209,10 +163,6 @@ const defaultProps = {
};
describe("SurveyMenuBar", () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
vi.mocked(updateSurveyAction).mockResolvedValue({ data: { ...baseSurvey, updatedAt: new Date() } }); // Mock successful update
vi.mocked(isSurveyValid).mockReturnValue(true);
@@ -221,9 +171,10 @@ describe("SurveyMenuBar", () => {
} as any);
localStorage.clear();
vi.clearAllMocks();
mockHistoryPushState.mockClear();
mockAddEventListener.mockClear();
mockRemoveEventListener.mockClear();
});
afterEach(() => {
cleanup();
});
test("renders correctly with default props", () => {
@@ -235,133 +186,6 @@ describe("SurveyMenuBar", () => {
expect(screen.getByText("environments.surveys.edit.publish")).toBeInTheDocument();
});
test("sets up browser history state and event listeners on mount", () => {
render(<SurveyMenuBar {...defaultProps} />);
// Check that history state is pushed with inSurveyEditor flag
expect(mockHistoryPushState).toHaveBeenCalledWith({ inSurveyEditor: true }, "");
// Check that event listeners are added
expect(mockAddEventListener).toHaveBeenCalledWith("popstate", expect.any(Function));
expect(mockAddEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
test("removes event listeners on unmount", () => {
const { unmount } = render(<SurveyMenuBar {...defaultProps} />);
// Clear the mock to focus on cleanup calls
mockRemoveEventListener.mockClear();
unmount();
// Check that event listeners are removed
expect(mockRemoveEventListener).toHaveBeenCalledWith("popstate", expect.any(Function));
expect(mockRemoveEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
});
test("handles popstate event by calling handleBack", () => {
render(<SurveyMenuBar {...defaultProps} />);
// Get the popstate handler from the addEventListener call
const popstateHandler = mockAddEventListener.mock.calls.find((call) => call[0] === "popstate")?.[1];
expect(popstateHandler).toBeDefined();
// Simulate popstate event
popstateHandler?.(new PopStateEvent("popstate"));
// Should navigate to home since surveys are equal (no changes)
expect(mockRouter.push).toHaveBeenCalledWith("/");
});
test("handles keyboard shortcut (Alt + ArrowLeft) by calling handleBack", () => {
render(<SurveyMenuBar {...defaultProps} />);
// Get the keydown handler from the addEventListener call
const keydownHandler = mockAddEventListener.mock.calls.find((call) => call[0] === "keydown")?.[1];
expect(keydownHandler).toBeDefined();
// Simulate Alt + ArrowLeft keydown event
const keyEvent = new KeyboardEvent("keydown", {
altKey: true,
key: "ArrowLeft",
});
keydownHandler?.(keyEvent);
// Should navigate to home since surveys are equal (no changes)
expect(mockRouter.push).toHaveBeenCalledWith("/");
});
test("handles keyboard shortcut (Cmd + ArrowLeft) by calling handleBack", () => {
render(<SurveyMenuBar {...defaultProps} />);
// Get the keydown handler from the addEventListener call
const keydownHandler = mockAddEventListener.mock.calls.find((call) => call[0] === "keydown")?.[1];
expect(keydownHandler).toBeDefined();
// Simulate Cmd + ArrowLeft keydown event
const keyEvent = new KeyboardEvent("keydown", {
metaKey: true,
key: "ArrowLeft",
});
keydownHandler?.(keyEvent);
// Should navigate to home since surveys are equal (no changes)
expect(mockRouter.push).toHaveBeenCalledWith("/");
});
test("ignores keyboard events without proper modifier keys", () => {
render(<SurveyMenuBar {...defaultProps} />);
// Get the keydown handler from the addEventListener call
const keydownHandler = mockAddEventListener.mock.calls.find((call) => call[0] === "keydown")?.[1];
expect(keydownHandler).toBeDefined();
// Simulate ArrowLeft without modifier keys
const keyEvent = new KeyboardEvent("keydown", {
key: "ArrowLeft",
});
keydownHandler?.(keyEvent);
// Should not navigate
expect(mockRouter.push).not.toHaveBeenCalled();
});
test("ignores keyboard events with wrong key", () => {
render(<SurveyMenuBar {...defaultProps} />);
// Get the keydown handler from the addEventListener call
const keydownHandler = mockAddEventListener.mock.calls.find((call) => call[0] === "keydown")?.[1];
expect(keydownHandler).toBeDefined();
// Simulate Alt + ArrowRight (wrong key)
const keyEvent = new KeyboardEvent("keydown", {
altKey: true,
key: "ArrowRight",
});
keydownHandler?.(keyEvent);
// Should not navigate
expect(mockRouter.push).not.toHaveBeenCalled();
});
test("navigates to home page instead of using router.back when handleBack is called with no changes", async () => {
render(<SurveyMenuBar {...defaultProps} />);
const backButton = screen.getByText("common.back").closest("button");
await userEvent.click(backButton!);
expect(mockRouter.push).toHaveBeenCalledWith("/");
expect(mockRouter.back).not.toHaveBeenCalled();
});
test("updates survey name on input change", async () => {
render(<SurveyMenuBar {...defaultProps} />);
const input = screen.getByTestId("survey-name-input");
@@ -374,49 +198,11 @@ describe("SurveyMenuBar", () => {
render(<SurveyMenuBar {...defaultProps} localSurvey={changedSurvey} />);
const backButton = screen.getByText("common.back").closest("button");
await userEvent.click(backButton!);
expect(mockRouter.push).not.toHaveBeenCalled();
expect(mockRouter.back).not.toHaveBeenCalled();
expect(screen.getByTestId("alert-dialog")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.confirm_survey_changes")).toBeInTheDocument();
});
test("navigates to home page when declining unsaved changes in dialog", async () => {
const changedSurvey = { ...baseSurvey, name: "Changed Name" };
render(<SurveyMenuBar {...defaultProps} localSurvey={changedSurvey} />);
const backButton = screen.getByText("common.back").closest("button");
await userEvent.click(backButton!);
const declineButton = screen.getByText("common.discard");
await userEvent.click(declineButton);
expect(mockRouter.push).toHaveBeenCalledWith("/");
});
test("saves and navigates to home page when confirming unsaved changes in dialog", async () => {
const changedSurvey = { ...baseSurvey, name: "Changed Name" };
// Mock successful save response
vi.mocked(updateSurveyAction).mockResolvedValueOnce({
data: { ...changedSurvey, updatedAt: new Date() },
});
render(<SurveyMenuBar {...defaultProps} localSurvey={changedSurvey} />);
const backButton = screen.getByText("common.back").closest("button");
await userEvent.click(backButton!);
// Get the save button specifically from within the alert dialog
const dialog = screen.getByTestId("alert-dialog");
const confirmButton = dialog.querySelector("button:first-of-type")!;
await userEvent.click(confirmButton);
// Wait for the async operation to complete
await vi.waitFor(
() => {
expect(mockRouter.push).toHaveBeenCalledWith("/");
},
{ timeout: 3000 }
);
});
test("shows caution alert when responseCount > 0", () => {
render(<SurveyMenuBar {...defaultProps} responseCount={5} />);
expect(screen.getByText("environments.surveys.edit.caution_text")).toBeInTheDocument();

View File

@@ -91,29 +91,6 @@ export const SurveyMenuBar = ({
};
}, [localSurvey, survey, t]);
useEffect(() => {
window.history.pushState({ inSurveyEditor: true }, "");
const handlePopstate = (_: PopStateEvent) => {
handleBack();
};
const handleKeyDown = (e: KeyboardEvent) => {
const isBackShortcut = (e.altKey || e.metaKey) && e.key === "ArrowLeft";
if (isBackShortcut) {
handleBack();
}
};
window.addEventListener("popstate", handlePopstate);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("popstate", handlePopstate);
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
const clearSurveyLocalStorage = () => {
if (typeof localStorage !== "undefined") {
localStorage.removeItem(`${localSurvey.id}-columnOrder`);
@@ -144,7 +121,7 @@ export const SurveyMenuBar = ({
if (!isEqual(localSurveyRest, surveyRest)) {
setConfirmDialogOpen(true);
} else {
router.push("/");
router.back();
}
};
@@ -270,6 +247,7 @@ export const SurveyMenuBar = ({
if (updatedSurveyResponse?.data) {
setLocalSurvey(updatedSurveyResponse.data);
toast.success(t("environments.surveys.edit.changes_saved"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updatedSurveyResponse);
toast.error(errorMessage);
@@ -288,8 +266,7 @@ export const SurveyMenuBar = ({
const handleSaveAndGoBack = async () => {
const isSurveySaved = await handleSurveySave();
if (isSurveySaved) {
setConfirmDialogOpen(false);
router.push("/");
router.back();
}
};
@@ -418,7 +395,7 @@ export const SurveyMenuBar = ({
declineBtnVariant="destructive"
onDecline={() => {
setConfirmDialogOpen(false);
router.push("/");
router.back();
}}
onConfirm={handleSaveAndGoBack}
/>

View File

@@ -136,6 +136,42 @@ const mockActionClasses: ActionClass[] = [
urlFilters: [{ rule: "exactMatch", value: "http://example.com" }],
},
},
{
id: "action3",
createdAt: new Date(),
updatedAt: new Date(),
name: "Regex URL Action",
description: "A no-code action with regex URL filter",
type: "noCode",
environmentId: "env1",
key: null,
noCodeConfig: {
type: "pageView",
urlFilters: [
{ rule: "matchesRegex", value: "^https://app\\.example\\.com/user/\\d+$" },
{ rule: "contains", value: "/dashboard" },
],
},
},
{
id: "action4",
createdAt: new Date(),
updatedAt: new Date(),
name: "Multiple Filter Action",
description: "Action with multiple URL filter types including regex",
type: "noCode",
environmentId: "env1",
key: null,
noCodeConfig: {
type: "click",
elementSelector: { cssSelector: ".submit-btn" },
urlFilters: [
{ rule: "startsWith", value: "https://secure" },
{ rule: "matchesRegex", value: "/checkout/\\w+/complete" },
{ rule: "notContains", value: "test" },
],
},
},
];
describe("WhenToSendCard Component Tests", () => {
@@ -276,6 +312,124 @@ describe("WhenToSendCard Component Tests", () => {
expect(setLocalSurvey).toHaveBeenCalledWith(expect.objectContaining({ triggers: [] }));
});
test("displays action with regex URL filters correctly", () => {
const regexActionClass = mockActionClasses[2]; // "Regex URL Action"
localSurvey.triggers = [{ actionClass: regexActionClass }];
render(
<WhenToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId="env1"
propActionClasses={mockActionClasses}
membershipRole={OrganizationRole.owner}
projectPermission={null}
/>
);
// Verify the action name is displayed
expect(screen.getByText("Regex URL Action")).toBeInTheDocument();
// Verify the action description is displayed
expect(screen.getByText("A no-code action with regex URL filter")).toBeInTheDocument();
});
test("displays action with multiple URL filter types including regex", () => {
const multipleFilterAction = mockActionClasses[3]; // "Multiple Filter Action"
localSurvey.triggers = [{ actionClass: multipleFilterAction }];
render(
<WhenToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId="env1"
propActionClasses={mockActionClasses}
membershipRole={OrganizationRole.owner}
projectPermission={null}
/>
);
// Verify the action name is displayed
expect(screen.getByText("Multiple Filter Action")).toBeInTheDocument();
// Verify the action description is displayed
expect(screen.getByText("Action with multiple URL filter types including regex")).toBeInTheDocument();
});
test("displays multiple triggers with mixed URL filter types", () => {
const standardAction = mockActionClasses[1]; // "No Code Action" with exactMatch
const regexAction = mockActionClasses[2]; // "Regex URL Action" with matchesRegex
localSurvey.triggers = [{ actionClass: standardAction }, { actionClass: regexAction }];
render(
<WhenToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId="env1"
propActionClasses={mockActionClasses}
membershipRole={OrganizationRole.owner}
projectPermission={null}
/>
);
// Verify both actions are displayed
expect(screen.getByText("No Code Action")).toBeInTheDocument();
expect(screen.getByText("Regex URL Action")).toBeInTheDocument();
// Verify the "or" separator is shown between triggers
expect(screen.getByText("or")).toBeInTheDocument();
});
test("removes regex action trigger correctly", async () => {
const regexAction = mockActionClasses[2]; // "Regex URL Action"
localSurvey.triggers = [{ actionClass: regexAction }];
const { container } = render(
<WhenToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId="env1"
propActionClasses={mockActionClasses}
membershipRole={OrganizationRole.owner}
projectPermission={null}
/>
);
expect(screen.getByText("Regex URL Action")).toBeInTheDocument();
const trashIcon = container.querySelector("svg.lucide-trash2");
if (!trashIcon)
throw new Error(
"Trash icon not found using selector 'svg.lucide-trash2'. Check component's class names."
);
await userEvent.click(trashIcon);
expect(setLocalSurvey).toHaveBeenCalledWith(expect.objectContaining({ triggers: [] }));
});
test("handles empty triggers array correctly", () => {
localSurvey.triggers = [];
render(
<WhenToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId="env1"
propActionClasses={mockActionClasses}
membershipRole={OrganizationRole.owner}
projectPermission={null}
/>
);
// Should show amber circle indicating empty triggers
const amberIndicator = document.querySelector(".border-amber-500.bg-amber-50");
expect(amberIndicator).toBeInTheDocument();
// Should still show the "Add Action" button
expect(screen.getByText("common.add_action")).toBeInTheDocument();
});
describe("Delay functionality", () => {
test("toggles delay and updates survey", async () => {
const surveyStep0 = { ...localSurvey, delay: 0 }; // Start with delay 0

View File

@@ -5,6 +5,7 @@ import { getAccessFlags } from "@/lib/membership/utils";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { AddActionModal } from "@/modules/survey/editor/components/add-action-modal";
import { ActionClassInfo } from "@/modules/ui/components/action-class-info";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
@@ -199,48 +200,7 @@ export const WhenToSendCard = ({
<h4 className="text-sm font-semibold text-slate-600">{trigger.actionClass.name}</h4>
</div>
<div className="mt-1 text-xs text-slate-500">
{trigger.actionClass.description && (
<span className="mr-1">{trigger.actionClass.description}</span>
)}
{trigger.actionClass.type === "code" && (
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
{t("environments.surveys.edit.key")}: <b>{trigger.actionClass.key}</b>
</span>
)}
{trigger.actionClass.type === "noCode" &&
trigger.actionClass.noCodeConfig?.type === "click" &&
trigger.actionClass.noCodeConfig?.elementSelector.cssSelector && (
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
{t("environments.surveys.edit.css_selector")}:{" "}
<b>{trigger.actionClass.noCodeConfig?.elementSelector.cssSelector}</b>
</span>
)}
{trigger.actionClass.type === "noCode" &&
trigger.actionClass.noCodeConfig?.type === "click" &&
trigger.actionClass.noCodeConfig?.elementSelector.innerHtml && (
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
{t("environments.surveys.edit.inner_text")}:{" "}
<b>{trigger.actionClass.noCodeConfig?.elementSelector.innerHtml}</b>
</span>
)}
{trigger.actionClass.type === "noCode" &&
trigger.actionClass.noCodeConfig?.urlFilters &&
trigger.actionClass.noCodeConfig.urlFilters.length > 0 ? (
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
{t("environments.surveys.edit.url_filters")}:{" "}
{trigger.actionClass.noCodeConfig.urlFilters.map((urlFilter, index) => (
<span key={index}>
{urlFilter.rule} <b>{urlFilter.value}</b>
{trigger.actionClass.type === "noCode" &&
index !==
(trigger.actionClass.noCodeConfig?.urlFilters?.length || 0) - 1 &&
", "}
</span>
))}
</span>
) : null}
</div>
<ActionClassInfo actionClass={trigger.actionClass} />
</div>
</div>
<Trash2Icon

View File

@@ -0,0 +1,236 @@
import { TFnType } from "@tolgee/react";
import { describe, expect, test, vi } from "vitest";
import { TActionClassInput } from "@formbricks/types/action-classes";
import { buildActionObject, buildCodeAction, buildNoCodeAction } from "./action-builder";
const mockT = vi.fn((key: string) => {
const translations: Record<string, string> = {
"environments.actions.invalid_action_type_no_code": "Invalid action type for noCode action",
"environments.actions.invalid_action_type_code": "Invalid action type for code action",
};
return translations[key] || key;
}) as unknown as TFnType;
describe("action-builder", () => {
describe("buildActionObject", () => {
test("builds noCode action when type is noCode", () => {
const data: TActionClassInput = {
name: "Click Button",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "click",
urlFilters: [],
elementSelector: {
cssSelector: ".button",
innerHtml: "Click me",
},
},
};
const result = buildActionObject(data, "env1", mockT);
expect(result).toEqual({
name: "Click Button",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "click",
urlFilters: [],
elementSelector: {
cssSelector: ".button",
innerHtml: "Click me",
},
},
});
});
test("builds code action when type is code", () => {
const data: TActionClassInput = {
name: "Track Event",
type: "code",
key: "track_event",
environmentId: "env1",
};
const result = buildActionObject(data, "env1", mockT);
expect(result).toEqual({
name: "Track Event",
type: "code",
key: "track_event",
environmentId: "env1",
});
});
});
describe("buildNoCodeAction", () => {
test("builds noCode action with click config", () => {
const data: TActionClassInput = {
name: "Click Button",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "click",
urlFilters: [{ rule: "exactMatch", value: "https://example.com" }],
elementSelector: {
cssSelector: ".button",
innerHtml: "Click me",
},
},
};
const result = buildNoCodeAction(data, "env1", mockT);
expect(result).toEqual({
name: "Click Button",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "click",
urlFilters: [{ rule: "exactMatch", value: "https://example.com" }],
elementSelector: {
cssSelector: ".button",
innerHtml: "Click me",
},
},
});
});
test("builds noCode action with pageView config", () => {
const data: TActionClassInput = {
name: "Page Visit",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "pageView",
urlFilters: [{ rule: "contains", value: "/dashboard" }],
},
};
const result = buildNoCodeAction(data, "env1", mockT);
expect(result).toEqual({
name: "Page Visit",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "pageView",
urlFilters: [{ rule: "contains", value: "/dashboard" }],
},
});
});
test("throws error for invalid action type", () => {
const data = {
name: "Invalid Action",
type: "code",
environmentId: "env1",
} as any;
expect(() => buildNoCodeAction(data, "env1", mockT)).toThrow("Invalid action type for noCode action");
});
test("includes optional fields when provided", () => {
const data: TActionClassInput = {
name: "Click Button",
description: "Click the submit button",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "click",
urlFilters: [],
elementSelector: {
cssSelector: ".button",
innerHtml: "Submit",
},
},
};
const result = buildNoCodeAction(data, "env1", mockT);
expect(result).toEqual({
name: "Click Button",
description: "Click the submit button",
type: "noCode",
environmentId: "env1",
noCodeConfig: {
type: "click",
urlFilters: [],
elementSelector: {
cssSelector: ".button",
innerHtml: "Submit",
},
},
});
});
});
describe("buildCodeAction", () => {
test("builds code action with required fields", () => {
const data: TActionClassInput = {
name: "Track Event",
type: "code",
key: "track_event",
environmentId: "env1",
};
const result = buildCodeAction(data, "env1", mockT);
expect(result).toEqual({
name: "Track Event",
type: "code",
key: "track_event",
environmentId: "env1",
});
});
test("builds code action with optional description", () => {
const data: TActionClassInput = {
name: "Track Purchase",
description: "Track when user makes a purchase",
type: "code",
key: "track_purchase",
environmentId: "env1",
};
const result = buildCodeAction(data, "env1", mockT);
expect(result).toEqual({
name: "Track Purchase",
description: "Track when user makes a purchase",
type: "code",
key: "track_purchase",
environmentId: "env1",
});
});
test("throws error for invalid action type", () => {
const data = {
name: "Invalid Action",
type: "noCode",
environmentId: "env1",
} as any;
expect(() => buildCodeAction(data, "env1", mockT)).toThrow("Invalid action type for code action");
});
test("handles null key", () => {
const data: TActionClassInput = {
name: "Track Event",
type: "code",
key: null,
environmentId: "env1",
};
const result = buildCodeAction(data, "env1", mockT);
expect(result).toEqual({
name: "Track Event",
type: "code",
key: null,
environmentId: "env1",
});
});
});
});

View File

@@ -0,0 +1,52 @@
import { TFnType } from "@tolgee/react";
import { TActionClassInput } from "@formbricks/types/action-classes";
export const buildActionObject = (data: TActionClassInput, environmentId: string, t: TFnType) => {
if (data.type === "noCode") {
return buildNoCodeAction(data, environmentId, t);
}
return buildCodeAction(data, environmentId, t);
};
export const buildNoCodeAction = (data: TActionClassInput, environmentId: string, t: TFnType) => {
if (data.type !== "noCode") {
throw new Error(t("environments.actions.invalid_action_type_no_code"));
}
const baseAction = {
name: data.name.trim(),
description: data.description,
environmentId,
type: "noCode" as const,
noCodeConfig: data.noCodeConfig,
};
if (data.noCodeConfig?.type === "click") {
return {
...baseAction,
noCodeConfig: {
...data.noCodeConfig,
elementSelector: {
cssSelector: data.noCodeConfig.elementSelector.cssSelector,
innerHtml: data.noCodeConfig.elementSelector.innerHtml,
},
},
};
}
return baseAction;
};
export const buildCodeAction = (data: TActionClassInput, environmentId: string, t: TFnType) => {
if (data.type !== "code") {
throw new Error(t("environments.actions.invalid_action_type_code"));
}
return {
name: data.name.trim(),
description: data.description,
environmentId,
type: "code" as const,
key: data.key,
};
};

View File

@@ -0,0 +1,406 @@
/**
* @vitest-environment jsdom
*/
import "@testing-library/jest-dom/vitest";
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { TActionClass } from "@formbricks/types/action-classes";
import {
createActionClassZodResolver,
useActionClassKeys,
validateActionKeyUniqueness,
validateActionNameUniqueness,
validateCssSelector,
validatePermissions,
validateUrlFilterRegex,
} from "./action-utils";
// Mock the CSS selector validation function
vi.mock("@/app/lib/actionClass/actionClass", () => ({
isValidCssSelector: vi.fn(),
}));
const { isValidCssSelector } = await import("@/app/lib/actionClass/actionClass");
// Mock translation function
const mockT = vi.fn((key: string, params?: any) => {
if (key === "environments.actions.action_with_name_already_exists") {
return `Action with name "${params?.name}" already exists`;
}
if (key === "environments.actions.action_with_key_already_exists") {
return `Action with key "${params?.key}" already exists`;
}
if (key === "environments.actions.invalid_css_selector") {
return "Invalid CSS selector";
}
if (key === "environments.actions.invalid_regex") {
return "Invalid regex pattern";
}
if (key === "common.you_are_not_authorised_to_perform_this_action") {
return "You are not authorised to perform this action";
}
return key;
}) as any;
// Helper to create mock context
const createMockContext = () => {
const issues: z.ZodIssue[] = [];
return {
addIssue: vi.fn((issue: z.ZodIssue) => issues.push(issue)),
issues,
} as any;
};
describe("action-utils", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("useActionClassKeys", () => {
test("should extract keys from code-type action classes", () => {
const actionClasses: TActionClass[] = [
{
id: "1",
name: "Code Action 1",
description: null,
type: "code",
key: "key1",
noCodeConfig: null,
environmentId: "env1",
createdAt: new Date(),
updatedAt: new Date(),
} as TActionClass,
{
id: "2",
name: "NoCode Action",
description: null,
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
elementSelector: { cssSelector: "button", innerHtml: undefined },
urlFilters: [],
},
environmentId: "env1",
createdAt: new Date(),
updatedAt: new Date(),
} as TActionClass,
{
id: "3",
name: "Code Action 2",
description: null,
type: "code",
key: "key2",
noCodeConfig: null,
environmentId: "env1",
createdAt: new Date(),
updatedAt: new Date(),
} as TActionClass,
];
const { result } = renderHook(() => useActionClassKeys(actionClasses));
expect(result.current).toEqual(["key1", "key2"]);
});
test("should filter out null keys", () => {
const actionClasses: TActionClass[] = [
{
id: "1",
name: "Code Action 1",
description: null,
type: "code",
key: "key1",
noCodeConfig: null,
environmentId: "env1",
createdAt: new Date(),
updatedAt: new Date(),
} as TActionClass,
{
id: "2",
name: "Code Action 2",
description: null,
type: "code",
key: null,
noCodeConfig: null,
environmentId: "env1",
createdAt: new Date(),
updatedAt: new Date(),
} as TActionClass,
];
const { result } = renderHook(() => useActionClassKeys(actionClasses));
expect(result.current).toEqual(["key1"]);
});
test("should return empty array when no code actions exist", () => {
const actionClasses: TActionClass[] = [
{
id: "1",
name: "NoCode Action",
description: null,
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
elementSelector: { cssSelector: "button", innerHtml: undefined },
urlFilters: [],
},
environmentId: "env1",
createdAt: new Date(),
updatedAt: new Date(),
} as TActionClass,
];
const { result } = renderHook(() => useActionClassKeys(actionClasses));
expect(result.current).toEqual([]);
});
});
describe("validateActionNameUniqueness", () => {
test("should add error when action name already exists", () => {
const ctx = createMockContext();
const data = { name: "existingAction" };
validateActionNameUniqueness(data, ["existingAction"], ctx, mockT);
expect(ctx.addIssue).toHaveBeenCalledWith({
code: z.ZodIssueCode.custom,
path: ["name"],
message: 'Action with name "existingAction" already exists',
});
});
test("should not add error when action name is unique", () => {
const ctx = createMockContext();
const data = { name: "uniqueAction" };
validateActionNameUniqueness(data, ["existingAction"], ctx, mockT);
expect(ctx.addIssue).not.toHaveBeenCalled();
});
test("should not add error when name is undefined", () => {
const ctx = createMockContext();
const data = { name: undefined };
validateActionNameUniqueness(data, ["existingAction"], ctx, mockT);
expect(ctx.addIssue).not.toHaveBeenCalled();
});
});
describe("validateActionKeyUniqueness", () => {
test("should add error when code action key already exists", () => {
const ctx = createMockContext();
const data = { type: "code", key: "existingKey" };
validateActionKeyUniqueness(data, ["existingKey"], ctx, mockT);
expect(ctx.addIssue).toHaveBeenCalledWith({
code: z.ZodIssueCode.custom,
path: ["key"],
message: 'Action with key "existingKey" already exists',
});
});
test("should not add error when code action key is unique", () => {
const ctx = createMockContext();
const data = { type: "code", key: "uniqueKey" };
validateActionKeyUniqueness(data, ["existingKey"], ctx, mockT);
expect(ctx.addIssue).not.toHaveBeenCalled();
});
test("should not validate key for non-code actions", () => {
const ctx = createMockContext();
const data = { type: "noCode", key: "existingKey" };
validateActionKeyUniqueness(data, ["existingKey"], ctx, mockT);
expect(ctx.addIssue).not.toHaveBeenCalled();
});
test("should not add error when key is undefined", () => {
const ctx = createMockContext();
const data = { type: "code", key: undefined };
validateActionKeyUniqueness(data, ["existingKey"], ctx, mockT);
expect(ctx.addIssue).not.toHaveBeenCalled();
});
});
describe("validateCssSelector", () => {
test("should add error when CSS selector is invalid", () => {
const ctx = createMockContext();
const data = {
type: "noCode",
noCodeConfig: {
type: "click",
elementSelector: { cssSelector: "invalid-selector" },
},
};
vi.mocked(isValidCssSelector).mockReturnValue(false);
validateCssSelector(data, ctx, mockT);
expect(ctx.addIssue).toHaveBeenCalledWith({
code: z.ZodIssueCode.custom,
path: ["noCodeConfig", "elementSelector", "cssSelector"],
message: "Invalid CSS selector",
});
});
test("should not add error when CSS selector is valid", () => {
const ctx = createMockContext();
const data = {
type: "noCode",
noCodeConfig: {
type: "click",
elementSelector: { cssSelector: "valid-selector" },
},
};
vi.mocked(isValidCssSelector).mockReturnValue(true);
validateCssSelector(data, ctx, mockT);
expect(ctx.addIssue).not.toHaveBeenCalled();
});
test("should not validate CSS selector for non-click noCode actions", () => {
const ctx = createMockContext();
const data = {
type: "noCode",
noCodeConfig: { type: "pageView" },
};
validateCssSelector(data, ctx, mockT);
expect(ctx.addIssue).not.toHaveBeenCalled();
});
test("should not validate CSS selector for code actions", () => {
const ctx = createMockContext();
const data = { type: "code" };
validateCssSelector(data, ctx, mockT);
expect(ctx.addIssue).not.toHaveBeenCalled();
});
});
describe("validateUrlFilterRegex", () => {
test("should add error when regex pattern is invalid", () => {
const ctx = createMockContext();
const data = {
type: "noCode",
noCodeConfig: {
urlFilters: [{ rule: "matchesRegex", value: "[invalid-regex" }],
},
};
validateUrlFilterRegex(data, ctx, mockT);
expect(ctx.addIssue).toHaveBeenCalledWith({
code: z.ZodIssueCode.custom,
path: ["noCodeConfig", "urlFilters", 0, "value"],
message: "Invalid regex pattern",
});
});
test("should not add error when regex pattern is valid", () => {
const ctx = createMockContext();
const data = {
type: "noCode",
noCodeConfig: {
urlFilters: [{ rule: "matchesRegex", value: "^https://.*" }],
},
};
validateUrlFilterRegex(data, ctx, mockT);
expect(ctx.addIssue).not.toHaveBeenCalled();
});
test("should not validate regex for non-regex URL filter rules", () => {
const ctx = createMockContext();
const data = {
type: "noCode",
noCodeConfig: {
urlFilters: [{ rule: "exactMatch", value: "some-value" }],
},
};
validateUrlFilterRegex(data, ctx, mockT);
expect(ctx.addIssue).not.toHaveBeenCalled();
});
test("should not validate for code actions", () => {
const ctx = createMockContext();
const data = { type: "code" };
validateUrlFilterRegex(data, ctx, mockT);
expect(ctx.addIssue).not.toHaveBeenCalled();
});
test("should validate multiple URL filters", () => {
const ctx = createMockContext();
const data = {
type: "noCode",
noCodeConfig: {
urlFilters: [
{ rule: "matchesRegex", value: "^https://.*" },
{ rule: "matchesRegex", value: "[invalid-regex" },
],
},
};
validateUrlFilterRegex(data, ctx, mockT);
expect(ctx.addIssue).toHaveBeenCalledTimes(1);
expect(ctx.addIssue).toHaveBeenCalledWith({
code: z.ZodIssueCode.custom,
path: ["noCodeConfig", "urlFilters", 1, "value"],
message: "Invalid regex pattern",
});
});
});
describe("createActionClassZodResolver", () => {
test("should return a zodResolver function", () => {
const resolver = createActionClassZodResolver([], [], mockT);
expect(typeof resolver).toBe("function");
});
test("should create resolver with correct parameters", () => {
const testResolver = createActionClassZodResolver(["testAction"], ["testKey"], mockT);
expect(testResolver).toBeDefined();
expect(typeof testResolver).toBe("function");
});
test("should handle empty arrays", () => {
const emptyResolver = createActionClassZodResolver([], [], mockT);
expect(emptyResolver).toBeDefined();
expect(typeof emptyResolver).toBe("function");
});
});
describe("validatePermissions", () => {
test("should throw error when user is read-only", () => {
expect(() => validatePermissions(true, mockT)).toThrow("You are not authorised to perform this action");
});
test("should not throw error when user has write permissions", () => {
expect(() => validatePermissions(false, mockT)).not.toThrow();
});
});
});

View File

@@ -0,0 +1,129 @@
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
import { zodResolver } from "@hookform/resolvers/zod";
import { TFnType } from "@tolgee/react";
import { useMemo } from "react";
import { z } from "zod";
import {
TActionClass,
TActionClassInput,
TActionClassInputCode,
ZActionClassInput,
} from "@formbricks/types/action-classes";
/**
* Extract action class keys from code-type action classes
*/
export const useActionClassKeys = (actionClasses: TActionClass[]) => {
return useMemo(() => {
const codeActionClasses: TActionClassInputCode[] = actionClasses.filter(
(actionClass) => actionClass.type === "code"
) as TActionClassInputCode[];
return codeActionClasses
.map((actionClass) => actionClass.key)
.filter((key): key is string => key !== null);
}, [actionClasses]);
};
/**
* Validate action name uniqueness
*/
export const validateActionNameUniqueness = (
data: TActionClassInput,
actionClassNames: string[],
ctx: z.RefinementCtx,
t: TFnType
) => {
if (data.name && actionClassNames.includes(data.name)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: t("environments.actions.action_with_name_already_exists", { name: data.name }),
});
}
};
/**
* Validate action key uniqueness for code actions
*/
export const validateActionKeyUniqueness = (
data: TActionClassInput,
actionClassKeys: string[],
ctx: z.RefinementCtx,
t: TFnType
) => {
if (data.type === "code" && data.key && actionClassKeys.includes(data.key)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["key"],
message: t("environments.actions.action_with_key_already_exists", { key: data.key }),
});
}
};
/**
* Validate CSS selector for noCode click actions
*/
export const validateCssSelector = (data: TActionClassInput, ctx: z.RefinementCtx, t: TFnType) => {
if (
data.type === "noCode" &&
data.noCodeConfig?.type === "click" &&
data.noCodeConfig.elementSelector.cssSelector &&
!isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["noCodeConfig", "elementSelector", "cssSelector"],
message: t("environments.actions.invalid_css_selector"),
});
}
};
/**
* Validate regex patterns in URL filters
*/
export const validateUrlFilterRegex = (data: TActionClassInput, ctx: z.RefinementCtx, t: TFnType) => {
if (data.type === "noCode" && data.noCodeConfig?.urlFilters) {
for (let i = 0; i < data.noCodeConfig.urlFilters.length; i++) {
const urlFilter = data.noCodeConfig.urlFilters[i];
if (urlFilter.rule === "matchesRegex") {
try {
new RegExp(urlFilter.value);
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["noCodeConfig", "urlFilters", i, "value"],
message: t("environments.actions.invalid_regex"),
});
}
}
}
}
};
/**
* Create a zodResolver with comprehensive validation for action class forms
*/
export const createActionClassZodResolver = (
actionClassNames: string[],
actionClassKeys: string[],
t: TFnType
) => {
return zodResolver(
ZActionClassInput.superRefine((data, ctx) => {
validateActionNameUniqueness(data, actionClassNames, ctx, t);
validateActionKeyUniqueness(data, actionClassKeys, ctx, t);
validateCssSelector(data, ctx, t);
validateUrlFilterRegex(data, ctx, t);
})
);
};
/**
* Validate permissions for action class forms
*/
export const validatePermissions = (isReadOnly: boolean, t: TFnType) => {
if (isReadOnly) {
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
}
};

View File

@@ -0,0 +1,368 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { ActionClassInfo } from "./index";
describe("ActionClassInfo", () => {
afterEach(() => {
cleanup();
});
const mockCodeActionClass: TActionClass = {
id: "action-1",
name: "Code Action",
description: "Test code action description",
type: "code",
key: "test-key",
noCodeConfig: null,
environmentId: "env-1",
createdAt: new Date(),
updatedAt: new Date(),
};
const mockNoCodeClickActionClass: TActionClass = {
id: "action-2",
name: "NoCode Click Action",
description: "Test nocode click action description",
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
urlFilters: [
{ rule: "exactMatch", value: "https://example.com" },
{ rule: "contains", value: "/dashboard" },
],
elementSelector: {
cssSelector: ".button-class",
innerHtml: "Click me",
},
},
environmentId: "env-1",
createdAt: new Date(),
updatedAt: new Date(),
};
const mockNoCodePageViewActionClass: TActionClass = {
id: "action-3",
name: "NoCode PageView Action",
description: "Test nocode pageview action description",
type: "noCode",
key: null,
noCodeConfig: {
type: "pageView",
urlFilters: [{ rule: "startsWith", value: "https://app.example.com" }],
},
environmentId: "env-1",
createdAt: new Date(),
updatedAt: new Date(),
};
const mockActionClassWithoutDescription: TActionClass = {
id: "action-4",
name: "Action Without Description",
description: null,
type: "code",
key: "no-desc-key",
noCodeConfig: null,
environmentId: "env-1",
createdAt: new Date(),
updatedAt: new Date(),
};
const mockRegexActionClass: TActionClass = {
id: "action-5",
name: "Regex Action",
description: "Test regex action description",
type: "noCode",
key: null,
noCodeConfig: {
type: "pageView",
urlFilters: [
{ rule: "matchesRegex", value: "^https://app\\.example\\.com/user/\\d+$" },
{ rule: "contains", value: "/dashboard" },
],
},
environmentId: "env-1",
createdAt: new Date(),
updatedAt: new Date(),
};
const mockMixedFilterActionClass: TActionClass = {
id: "action-6",
name: "Mixed Filter Action",
description: "Test action with mixed filter types",
type: "noCode",
key: null,
noCodeConfig: {
type: "click",
urlFilters: [
{ rule: "startsWith", value: "https://secure" },
{ rule: "matchesRegex", value: "/checkout/\\w+/complete" },
{ rule: "notContains", value: "test" },
],
elementSelector: {
cssSelector: ".submit-btn",
innerHtml: "Submit",
},
},
environmentId: "env-1",
createdAt: new Date(),
updatedAt: new Date(),
};
test("renders description when present", () => {
render(<ActionClassInfo actionClass={mockCodeActionClass} />);
expect(screen.getByText("Test code action description")).toBeInTheDocument();
});
test("does not render description when null", () => {
render(<ActionClassInfo actionClass={mockActionClassWithoutDescription} />);
expect(screen.queryByText("Test code action description")).not.toBeInTheDocument();
});
test("renders code action key", () => {
render(<ActionClassInfo actionClass={mockCodeActionClass} />);
expect(
screen.getByText((content) => content.includes("environments.surveys.edit.key"))
).toBeInTheDocument();
expect(screen.getByText("test-key")).toBeInTheDocument();
});
test("renders noCode click action with CSS selector", () => {
render(<ActionClassInfo actionClass={mockNoCodeClickActionClass} />);
expect(
screen.getByText((content) => content.includes("environments.surveys.edit.css_selector"))
).toBeInTheDocument();
expect(screen.getByText(".button-class")).toBeInTheDocument();
});
test("renders noCode click action with inner HTML", () => {
render(<ActionClassInfo actionClass={mockNoCodeClickActionClass} />);
expect(
screen.getByText((content) => content.includes("environments.surveys.edit.inner_text"))
).toBeInTheDocument();
expect(screen.getByText("Click me")).toBeInTheDocument();
});
test("renders URL filters for noCode actions", () => {
const { container } = render(<ActionClassInfo actionClass={mockNoCodeClickActionClass} />);
expect(
screen.getByText((content) => content.includes("environments.surveys.edit.url_filters"))
).toBeInTheDocument();
expect(container).toHaveTextContent("exactMatch");
expect(container).toHaveTextContent("https://example.com");
expect(container).toHaveTextContent("contains");
expect(container).toHaveTextContent("/dashboard");
});
test("renders URL filters with comma separation", () => {
const { container } = render(<ActionClassInfo actionClass={mockNoCodeClickActionClass} />);
expect(container).toHaveTextContent("exactMatch");
expect(container).toHaveTextContent("https://example.com");
expect(container).toHaveTextContent("contains");
expect(container).toHaveTextContent("/dashboard");
});
test("renders noCode pageView action without element selector", () => {
render(<ActionClassInfo actionClass={mockNoCodePageViewActionClass} />);
expect(screen.getByText("Test nocode pageview action description")).toBeInTheDocument();
expect(
screen.getByText((content) => content.includes("environments.surveys.edit.url_filters"))
).toBeInTheDocument();
expect(screen.getByText("startsWith")).toBeInTheDocument();
expect(screen.getByText("https://app.example.com")).toBeInTheDocument();
// Should not render CSS selector or inner HTML for pageView
expect(
screen.queryByText((content) => content.includes("environments.surveys.edit.css_selector"))
).not.toBeInTheDocument();
expect(
screen.queryByText((content) => content.includes("environments.surveys.edit.inner_text"))
).not.toBeInTheDocument();
});
test("does not render URL filters when empty", () => {
const actionWithoutUrlFilters: TActionClass = {
...mockNoCodeClickActionClass,
noCodeConfig: {
type: "click",
urlFilters: [],
elementSelector: {
cssSelector: ".button-class",
innerHtml: "Click me",
},
},
};
render(<ActionClassInfo actionClass={actionWithoutUrlFilters} />);
expect(screen.queryByText("environments.surveys.edit.url_filters")).not.toBeInTheDocument();
});
test("does not render CSS selector when not present", () => {
const actionWithoutCssSelector: TActionClass = {
...mockNoCodeClickActionClass,
noCodeConfig: {
type: "click",
urlFilters: [{ rule: "exactMatch", value: "https://example.com" }],
elementSelector: {
innerHtml: "Click me",
},
},
};
render(<ActionClassInfo actionClass={actionWithoutCssSelector} />);
expect(
screen.queryByText((content) => content.includes("environments.surveys.edit.css_selector"))
).not.toBeInTheDocument();
expect(
screen.getByText((content) => content.includes("environments.surveys.edit.inner_text"))
).toBeInTheDocument();
});
test("does not render inner HTML when not present", () => {
const actionWithoutInnerHtml: TActionClass = {
...mockNoCodeClickActionClass,
noCodeConfig: {
type: "click",
urlFilters: [{ rule: "exactMatch", value: "https://example.com" }],
elementSelector: {
cssSelector: ".button-class",
},
},
};
render(<ActionClassInfo actionClass={actionWithoutInnerHtml} />);
expect(
screen.getByText((content) => content.includes("environments.surveys.edit.css_selector"))
).toBeInTheDocument();
expect(
screen.queryByText((content) => content.includes("environments.surveys.edit.inner_text"))
).not.toBeInTheDocument();
});
test("applies custom className", () => {
const { container } = render(
<ActionClassInfo actionClass={mockCodeActionClass} className="custom-class" />
);
const div = container.querySelector("div");
expect(div).toHaveClass("custom-class");
});
test("has correct default styling", () => {
const { container } = render(<ActionClassInfo actionClass={mockCodeActionClass} />);
const div = container.querySelector("div");
expect(div).toHaveClass("mt-1", "text-xs", "text-slate-500");
});
test("renders single URL filter without comma", () => {
const actionWithSingleFilter: TActionClass = {
...mockNoCodeClickActionClass,
noCodeConfig: {
type: "click",
urlFilters: [{ rule: "exactMatch", value: "https://example.com" }],
elementSelector: {
cssSelector: ".button-class",
innerHtml: "Click me",
},
},
};
const { container } = render(<ActionClassInfo actionClass={actionWithSingleFilter} />);
expect(container).toHaveTextContent("exactMatch");
expect(container).toHaveTextContent("https://example.com");
expect(container).not.toHaveTextContent(",");
});
test("renders URL filters with regex rule", () => {
const { container } = render(<ActionClassInfo actionClass={mockRegexActionClass} />);
expect(
screen.getByText((content) => content.includes("environments.surveys.edit.url_filters"))
).toBeInTheDocument();
expect(container).toHaveTextContent("matchesRegex");
expect(container).toHaveTextContent("^https://app\\.example\\.com/user/\\d+$");
expect(container).toHaveTextContent("contains");
expect(container).toHaveTextContent("/dashboard");
});
test("renders mixed URL filter types including regex", () => {
const { container } = render(<ActionClassInfo actionClass={mockMixedFilterActionClass} />);
expect(
screen.getByText((content) => content.includes("environments.surveys.edit.url_filters"))
).toBeInTheDocument();
// Check all filter types are displayed
expect(container).toHaveTextContent("startsWith");
expect(container).toHaveTextContent("https://secure");
expect(container).toHaveTextContent("matchesRegex");
expect(container).toHaveTextContent("/checkout/\\w+/complete");
expect(container).toHaveTextContent("notContains");
expect(container).toHaveTextContent("test");
// Check element selector is also displayed
expect(
screen.getByText((content) => content.includes("environments.surveys.edit.css_selector"))
).toBeInTheDocument();
expect(screen.getByText(".submit-btn")).toBeInTheDocument();
expect(
screen.getByText((content) => content.includes("environments.surveys.edit.inner_text"))
).toBeInTheDocument();
expect(screen.getByText("Submit")).toBeInTheDocument();
});
test("renders complex regex patterns correctly", () => {
const complexRegexAction: TActionClass = {
...mockRegexActionClass,
noCodeConfig: {
type: "pageView",
urlFilters: [
{
rule: "matchesRegex",
value: "^https://(app|admin)\\.example\\.com/(?:user|profile)/\\d+(?:/edit)?$",
},
],
},
};
const { container } = render(<ActionClassInfo actionClass={complexRegexAction} />);
expect(container).toHaveTextContent("matchesRegex");
expect(container).toHaveTextContent(
"^https://(app|admin)\\.example\\.com/(?:user|profile)/\\d+(?:/edit)?$"
);
});
test("handles regex with special characters in display", () => {
const specialCharsRegexAction: TActionClass = {
...mockRegexActionClass,
noCodeConfig: {
type: "click",
urlFilters: [{ rule: "matchesRegex", value: "\\[\\{\\(.*\\)\\}\\]" }],
elementSelector: {
cssSelector: ".btn",
},
},
};
const { container } = render(<ActionClassInfo actionClass={specialCharsRegexAction} />);
expect(container).toHaveTextContent("matchesRegex");
expect(container).toHaveTextContent("\\[\\{\\(.*\\)\\}\\]");
});
});

View File

@@ -0,0 +1,66 @@
"use client";
import { useTranslate } from "@tolgee/react";
import { TActionClass } from "@formbricks/types/action-classes";
interface ActionClassInfoProps {
actionClass: TActionClass;
className?: string;
}
const InfoItem = ({ children }: { children: React.ReactNode }) => (
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">{children}</span>
);
export const ActionClassInfo = ({ actionClass, className = "" }: ActionClassInfoProps) => {
const { t } = useTranslate();
const renderUrlFilters = () => {
const urlFilters = actionClass.noCodeConfig?.urlFilters;
if (!urlFilters?.length) return null;
return (
<InfoItem>
{t("environments.surveys.edit.url_filters")}:{" "}
{urlFilters.map((urlFilter, index) => (
<span key={urlFilter.rule + index}>
{urlFilter.rule} <b>{urlFilter.value}</b>
{index !== urlFilters.length - 1 && ", "}
</span>
))}
</InfoItem>
);
};
const isNoCodeClick = actionClass.type === "noCode" && actionClass.noCodeConfig?.type === "click";
const clickConfig = isNoCodeClick
? (actionClass.noCodeConfig as Extract<typeof actionClass.noCodeConfig, { type: "click" }>)
: null;
return (
<div className={`mt-1 text-xs text-slate-500 ${className}`}>
{actionClass.description && <span className="mr-1">{actionClass.description}</span>}
{actionClass.type === "code" && (
<InfoItem>
{t("environments.surveys.edit.key")}: <b>{actionClass.key}</b>
</InfoItem>
)}
{clickConfig?.elementSelector.cssSelector && (
<InfoItem>
{t("environments.surveys.edit.css_selector")}: <b>{clickConfig.elementSelector.cssSelector}</b>
</InfoItem>
)}
{clickConfig?.elementSelector.innerHtml && (
<InfoItem>
{t("environments.surveys.edit.inner_text")}: <b>{clickConfig.elementSelector.innerHtml}</b>
</InfoItem>
)}
{renderUrlFilters()}
</div>
);
};

View File

@@ -0,0 +1,211 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useForm } from "react-hook-form";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClassInput } from "@formbricks/types/action-classes";
import { ActionNameDescriptionFields } from "./index";
// Mock the form components
vi.mock("@/modules/ui/components/form", () => ({
FormControl: ({ children }: { children: React.ReactNode }) => (
<div data-testid="form-control">{children}</div>
),
FormField: ({ name, render }: any) => {
const field = {
value: "",
onChange: vi.fn(),
onBlur: vi.fn(),
name: name,
ref: vi.fn(),
};
const fieldState = { error: null };
return render({ field, fieldState });
},
FormItem: ({ children }: { children: React.ReactNode }) => <div data-testid="form-item">{children}</div>,
FormLabel: ({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) => (
<label data-testid="form-label" htmlFor={htmlFor}>
{children}
</label>
),
FormError: () => <div data-testid="form-error">Form Error</div>,
}));
// Mock the Input component
vi.mock("@/modules/ui/components/input", () => ({
Input: ({ type, id, placeholder, disabled, isInvalid, ...props }: any) => (
<input
data-testid={`input-${id}`}
type={type}
id={id}
placeholder={placeholder}
disabled={disabled}
data-invalid={isInvalid}
{...props}
/>
),
}));
// Test wrapper component
const TestWrapper = ({
isReadOnly = false,
nameInputId = "actionNameInput",
descriptionInputId = "actionDescriptionInput",
showSeparator = false,
}: {
isReadOnly?: boolean;
nameInputId?: string;
descriptionInputId?: string;
showSeparator?: boolean;
}) => {
const { control } = useForm<TActionClassInput>({
defaultValues: {
name: "",
description: "",
},
});
return (
<ActionNameDescriptionFields
control={control}
isReadOnly={isReadOnly}
nameInputId={nameInputId}
descriptionInputId={descriptionInputId}
/>
);
};
// Test wrapper with default props
const TestWrapperDefault = () => {
const { control } = useForm<TActionClassInput>({
defaultValues: {
name: "",
description: "",
},
});
return <ActionNameDescriptionFields control={control} />;
};
describe("ActionNameDescriptionFields", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders name and description fields correctly", () => {
render(<TestWrapper />);
expect(screen.getByTestId("input-actionNameInput")).toBeInTheDocument();
expect(screen.getByTestId("input-actionDescriptionInput")).toBeInTheDocument();
expect(screen.getByText("environments.actions.what_did_your_user_do")).toBeInTheDocument();
expect(screen.getByText("common.description")).toBeInTheDocument();
});
test("displays correct placeholders using translation keys", () => {
render(<TestWrapper />);
const nameInput = screen.getByTestId("input-actionNameInput");
const descriptionInput = screen.getByTestId("input-actionDescriptionInput");
expect(nameInput).toHaveAttribute("placeholder", "environments.actions.eg_clicked_download");
expect(descriptionInput).toHaveAttribute(
"placeholder",
"environments.actions.user_clicked_download_button"
);
});
test("renders with custom input IDs", () => {
render(<TestWrapper nameInputId="customNameId" descriptionInputId="customDescriptionId" />);
expect(screen.getByTestId("input-customNameId")).toBeInTheDocument();
expect(screen.getByTestId("input-customDescriptionId")).toBeInTheDocument();
});
test("renders inputs as disabled when isReadOnly is true", () => {
render(<TestWrapper isReadOnly={true} />);
const nameInput = screen.getByTestId("input-actionNameInput");
const descriptionInput = screen.getByTestId("input-actionDescriptionInput");
expect(nameInput).toBeDisabled();
expect(descriptionInput).toBeDisabled();
});
test("renders inputs as enabled when isReadOnly is false", () => {
render(<TestWrapper isReadOnly={false} />);
const nameInput = screen.getByTestId("input-actionNameInput");
const descriptionInput = screen.getByTestId("input-actionDescriptionInput");
expect(nameInput).not.toBeDisabled();
expect(descriptionInput).not.toBeDisabled();
});
test("shows separator when showSeparator is true", () => {
render(<TestWrapper showSeparator={true} />);
const separator = screen.getByRole("separator");
expect(separator).toBeInTheDocument();
expect(separator).toHaveClass("border-slate-200");
});
test("renders form structure correctly with two columns", () => {
render(<TestWrapper />);
const nameFormItem = screen.getAllByTestId("form-item")[0];
const descriptionFormItem = screen.getAllByTestId("form-item")[1];
expect(nameFormItem).toBeInTheDocument();
expect(descriptionFormItem).toBeInTheDocument();
});
test("renders form labels correctly", () => {
render(<TestWrapper />);
expect(screen.getAllByTestId("form-label")).toHaveLength(2);
expect(screen.getByText("environments.actions.what_did_your_user_do")).toBeInTheDocument();
expect(screen.getByText("common.description")).toBeInTheDocument();
});
test("renders form controls and items correctly", () => {
render(<TestWrapper />);
expect(screen.getAllByTestId("form-control")).toHaveLength(2);
expect(screen.getAllByTestId("form-item")).toHaveLength(2);
});
test("renders with default prop values", () => {
render(<TestWrapperDefault />);
expect(screen.getByTestId("input-actionNameInput")).toBeInTheDocument();
expect(screen.getByTestId("input-actionDescriptionInput")).toBeInTheDocument();
});
test("handles user interactions with name field", async () => {
const user = userEvent.setup();
render(<TestWrapper />);
const nameInput = screen.getByTestId("input-actionNameInput");
await user.click(nameInput);
expect(nameInput).toBeInTheDocument();
});
test("handles user interactions with description field", async () => {
const user = userEvent.setup();
render(<TestWrapper />);
const descriptionInput = screen.getByTestId("input-actionDescriptionInput");
await user.click(descriptionInput);
expect(descriptionInput).toBeInTheDocument();
});
test("description field handles empty value correctly", () => {
render(<TestWrapper />);
const descriptionInput = screen.getByTestId("input-actionDescriptionInput");
expect(descriptionInput).toHaveAttribute("value", "");
});
});

View File

@@ -0,0 +1,78 @@
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react";
import { Control } from "react-hook-form";
import { TActionClassInput } from "@formbricks/types/action-classes";
interface ActionNameDescriptionFieldsProps {
control: Control<TActionClassInput>;
isReadOnly?: boolean;
nameInputId?: string;
descriptionInputId?: string;
}
export const ActionNameDescriptionFields = ({
control,
isReadOnly = false,
nameInputId = "actionNameInput",
descriptionInputId = "actionDescriptionInput",
}: ActionNameDescriptionFieldsProps) => {
const { t } = useTranslate();
return (
<>
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<FormField
control={control}
name="name"
disabled={isReadOnly}
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel htmlFor={nameInputId}>{t("environments.actions.what_did_your_user_do")}</FormLabel>
<FormControl>
<Input
type="text"
id={nameInputId}
{...field}
placeholder={t("environments.actions.eg_clicked_download")}
isInvalid={!!error?.message}
disabled={isReadOnly}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
<div className="col-span-1">
<FormField
control={control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor={descriptionInputId}>{t("common.description")}</FormLabel>
<FormControl>
<Input
type="text"
id={descriptionInputId}
{...field}
placeholder={t("environments.actions.user_clicked_download_button")}
value={field.value ?? ""}
disabled={isReadOnly}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<hr className="border-slate-200" />
</>
);
};

View File

@@ -35,7 +35,7 @@ describe("Button", () => {
});
test("applies correct size classes", () => {
const { rerender } = render(<Button size="default">Default Size</Button>);
const { rerender } = render(<Button>Default</Button>);
expect(screen.getByRole("button")).toHaveClass("h-9", "px-4", "py-2");
rerender(<Button size="sm">Small</Button>);
@@ -46,6 +46,9 @@ describe("Button", () => {
rerender(<Button size="icon">Icon</Button>);
expect(screen.getByRole("button")).toHaveClass("h-9", "w-9");
rerender(<Button size="tall">Tall</Button>);
expect(screen.getByRole("button")).toHaveClass("h-10", "px-3", "text-xs");
});
test("renders as a different element when asChild is true", () => {

View File

@@ -21,6 +21,7 @@ const buttonVariants = cva(
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
tall: "h-10 rounded-md px-3 text-xs",
},
loading: {
true: "cursor-not-allowed opacity-50",

View File

@@ -69,7 +69,7 @@ const meta: Meta<typeof Button> = {
},
size: {
control: "select",
options: ["default", "sm", "lg", "icon"],
options: ["default", "sm", "lg", "icon", "tall"],
description: "Size of the button",
table: {
category: "Appearance",
@@ -208,6 +208,20 @@ export const Icon: Story = {
},
};
export const Tall: Story = {
args: {
children: "Tall Button",
size: "tall",
},
parameters: {
docs: {
description: {
story: "Use for buttons that need more height while maintaining compact padding.",
},
},
},
};
export const Loading: Story = {
args: {
children: "Loading...",

View File

@@ -31,6 +31,7 @@ vi.mock("@/modules/ui/components/form", () => ({
},
FormItem: ({ children }: { children: React.ReactNode }) => <div data-testid="form-item">{children}</div>,
FormLabel: ({ children }: { children: React.ReactNode }) => <div data-testid="form-label">{children}</div>,
FormError: () => <div data-testid="form-error">Form Error</div>,
}));
vi.mock("@/modules/ui/components/input", () => ({

View File

@@ -1,7 +1,7 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { FormControl, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react";
import { Terminal } from "lucide-react";
@@ -15,7 +15,7 @@ export const CodeActionForm = ({ form, isReadOnly }: CodeActionFormProps) => {
const { control, watch } = form;
const { t } = useTranslate();
return (
<>
<div data-testid="code-action-form" className="space-y-4">
<div className="col-span-1">
<FormField
control={control}
@@ -36,6 +36,7 @@ export const CodeActionForm = ({ form, isReadOnly }: CodeActionFormProps) => {
disabled={isReadOnly}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
@@ -52,9 +53,9 @@ export const CodeActionForm = ({ form, isReadOnly }: CodeActionFormProps) => {
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline">
{t("common.docs")}
</a>
.
{"."}
</AlertDescription>
</Alert>
</>
</div>
);
};

View File

@@ -60,7 +60,7 @@ function CommandInput({
...props
}: React.ComponentProps<typeof CommandPrimitive.Input> & { hidden?: boolean }) {
return (
<div data-slot="command-input-wrapper" className={cn("flex h-11 items-center")}>
<div data-slot="command-input-wrapper" className={cn("flex items-center")}>
<SearchIcon className="h-4 w-4 shrink-0 text-slate-500" />
<CommandPrimitive.Input
data-slot="command-input"

View File

@@ -1,22 +1,32 @@
import { Select, SelectContent, SelectItem } from "@/modules/ui/components/select";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { useForm } from "react-hook-form";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClassInput } from "@formbricks/types/action-classes";
import { ACTION_CLASS_PAGE_URL_RULES, TActionClassInput } from "@formbricks/types/action-classes";
import { PageUrlSelector } from "./page-url-selector";
// Mock testURLmatch function
vi.mock("@/lib/utils/url", () => ({
testURLmatch: vi.fn((testUrl, value, rule) => {
// Simple mock implementation
if (rule === "exactMatch" && testUrl === value) return "yes";
if (rule === "contains" && testUrl.includes(value)) return "yes";
if (rule === "startsWith" && testUrl.startsWith(value)) return "yes";
if (rule === "endsWith" && testUrl.endsWith(value)) return "yes";
if (rule === "notMatch" && testUrl !== value) return "yes";
if (rule === "notContains" && !testUrl.includes(value)) return "yes";
return "no";
testURLmatch: vi.fn((testUrl, value, rule, t) => {
// Updated mock implementation to match new function signature
if (rule === "exactMatch") return testUrl === value;
if (rule === "contains") return testUrl.includes(value);
if (rule === "startsWith") return testUrl.startsWith(value);
if (rule === "endsWith") return testUrl.endsWith(value);
if (rule === "notMatch") return testUrl !== value;
if (rule === "notContains") return !testUrl.includes(value);
if (rule === "matchesRegex") {
try {
const regex = new RegExp(value);
return regex.test(testUrl);
} catch {
throw new Error(t("environments.actions.invalid_regex"));
}
}
throw new Error(t("environments.actions.invalid_match_type"));
}),
}));
@@ -66,7 +76,7 @@ vi.mock("@/modules/ui/components/input", () => ({
placeholder={placeholder}
disabled={disabled}
value={value || ""}
onChange={(e) => onChange && onChange(e)}
onChange={(e) => onChange?.(e)}
data-invalid={isInvalid}
autoComplete={autoComplete}
{...rest}
@@ -95,25 +105,43 @@ vi.mock("@/modules/ui/components/button", () => ({
}));
// Mock the Select component
vi.mock("@/modules/ui/components/select", () => ({
Select: ({ children, onValueChange, value, name, disabled }: any) => (
<div data-testid={`select-${name}`} data-value={value} data-disabled={disabled}>
{children}
</div>
),
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ children, value }: any) => (
<div data-testid={`select-item-${value}`} data-value={value}>
{children}
</div>
),
SelectTrigger: ({ children, className }: any) => (
<div data-testid="select-trigger" className={className}>
{children}
</div>
),
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
}));
vi.mock("@/modules/ui/components/select", async () => {
const React = await import("react");
const SelectContext = React.createContext<{ onValueChange?: (value: string) => void }>({});
return {
Select: ({ children, value, name, disabled, onValueChange }: any) => {
const contextValue = React.useMemo(() => ({ onValueChange }), [onValueChange]);
return (
<SelectContext.Provider value={contextValue}>
<div data-testid={`select-${name}`} data-value={value} data-disabled={disabled}>
{children}
</div>
</SelectContext.Provider>
);
},
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ children, value }: any) => {
const context = React.useContext(SelectContext);
return (
<button // NOSONAR // This is a mocked component to test the logic
type="button"
data-testid={`select-item-${value}`}
data-value={value}
onClick={() => context.onValueChange?.(value)}
style={{ cursor: "pointer" }}>
{children}
</button>
);
},
SelectTrigger: ({ children, className }: any) => (
<div data-testid="select-trigger" className={className}>
{children}
</div>
),
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
};
});
// Mock the Label component
vi.mock("@/modules/ui/components/label", () => ({
@@ -154,6 +182,7 @@ vi.mock("@/modules/ui/components/form", () => ({
fieldState: { error: null },
}),
FormItem: ({ children, className }: any) => <div className={className}>{children}</div>,
FormError: () => <div>Form Error</div>,
}));
// Mock the tolgee translation
@@ -166,7 +195,7 @@ vi.mock("@tolgee/react", () => ({
// Helper component for the form
const TestWrapper = ({
urlFilters = [] as {
rule: "startsWith" | "exactMatch" | "contains" | "endsWith" | "notMatch" | "notContains";
rule: "startsWith" | "exactMatch" | "contains" | "endsWith" | "notMatch" | "notContains" | "matchesRegex";
value: string;
}[],
isReadOnly = false,
@@ -188,6 +217,7 @@ const TestWrapper = ({
describe("PageUrlSelector", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders with default values and 'all' filter type", () => {
@@ -233,21 +263,267 @@ describe("PageUrlSelector", () => {
expect(trashIcons.length).toBe(2);
});
test("test URL match functionality", async () => {
test("test URL match functionality - successful match", async () => {
const testUrl = "https://example.com/pricing";
const urlFilters = [{ rule: "contains" as const, value: "pricing" }];
render(<TestWrapper urlFilters={urlFilters} />);
const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl");
// Updated testId to match the actual button's testId from our mock
const testButton = screen.getByTestId("button-environments.actions.test_match");
await userEvent.type(testInput, testUrl);
await userEvent.click(testButton);
// Toast should be called to show match result
// Toast should be called to show successful match
const toast = await import("react-hot-toast");
expect(toast.default.success).toHaveBeenCalled();
expect(toast.default.success).toHaveBeenCalledWith(
"environments.actions.your_survey_would_be_shown_on_this_url"
);
});
test("test URL match functionality - no match", async () => {
const testUrl = "https://example.com/dashboard";
const urlFilters = [{ rule: "contains" as const, value: "pricing" }];
render(<TestWrapper urlFilters={urlFilters} />);
const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl");
const testButton = screen.getByTestId("button-environments.actions.test_match");
await userEvent.type(testInput, testUrl);
await userEvent.click(testButton);
// Toast should be called to show no match
const toast = await import("react-hot-toast");
expect(toast.default.error).toHaveBeenCalledWith("environments.actions.your_survey_would_not_be_shown");
});
test("test URL match functionality with regex - valid regex", async () => {
const testUrl = "https://example.com/user/123";
const urlFilters = [{ rule: "matchesRegex" as const, value: "/user/\\d+" }];
render(<TestWrapper urlFilters={urlFilters} />);
const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl");
const testButton = screen.getByTestId("button-environments.actions.test_match");
await userEvent.type(testInput, testUrl);
await userEvent.click(testButton);
// Toast should be called to show successful match
const toast = await import("react-hot-toast");
expect(toast.default.success).toHaveBeenCalledWith(
"environments.actions.your_survey_would_be_shown_on_this_url"
);
});
test("test URL match functionality with regex - invalid regex", async () => {
const testUrl = "https://example.com/user/123";
const urlFilters = [{ rule: "matchesRegex" as const, value: "[invalid-regex" }];
render(<TestWrapper urlFilters={urlFilters} />);
const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl");
const testButton = screen.getByTestId("button-environments.actions.test_match");
await userEvent.type(testInput, testUrl);
await userEvent.click(testButton);
// Toast should be called to show error
const toast = await import("react-hot-toast");
expect(toast.default.error).toHaveBeenCalledWith("environments.actions.invalid_regex");
});
test("test URL match functionality with regex - no match", async () => {
const testUrl = "https://example.com/user/abc";
const urlFilters = [{ rule: "matchesRegex" as const, value: "/user/\\d+" }];
render(<TestWrapper urlFilters={urlFilters} />);
const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl");
const testButton = screen.getByTestId("button-environments.actions.test_match");
await userEvent.type(testInput, testUrl);
await userEvent.click(testButton);
// Toast should be called to show no match
const toast = await import("react-hot-toast");
expect(toast.default.error).toHaveBeenCalledWith("environments.actions.your_survey_would_not_be_shown");
});
test("handles multiple URL filters with OR logic", async () => {
const testUrl = "https://example.com/pricing";
const urlFilters = [
{ rule: "contains" as const, value: "dashboard" },
{ rule: "contains" as const, value: "pricing" },
];
render(<TestWrapper urlFilters={urlFilters} />);
const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl");
const testButton = screen.getByTestId("button-environments.actions.test_match");
await userEvent.type(testInput, testUrl);
await userEvent.click(testButton);
// Should match because one of the filters matches (OR logic)
const toast = await import("react-hot-toast");
expect(toast.default.success).toHaveBeenCalledWith(
"environments.actions.your_survey_would_be_shown_on_this_url"
);
});
test("shows correct placeholder for regex input", () => {
const urlFilters = [{ rule: "matchesRegex" as const, value: "" }];
render(<TestWrapper urlFilters={urlFilters} />);
const input = screen.getByTestId("input-noCodeConfig.urlFilters.0.value");
expect(input).toHaveAttribute("placeholder", "environments.actions.add_regular_expression_here");
});
test("shows correct placeholder for non-regex input", () => {
const urlFilters = [{ rule: "exactMatch" as const, value: "" }];
render(<TestWrapper urlFilters={urlFilters} />);
const input = screen.getByTestId("input-noCodeConfig.urlFilters.0.value");
expect(input).toHaveAttribute("placeholder", "environments.actions.enter_url");
});
test("renders all available rule options from ACTION_CLASS_PAGE_URL_RULES", () => {
render(<TestWrapper urlFilters={[{ rule: "exactMatch", value: "https://example.com" }]} />);
// Check that all rule options are rendered
ACTION_CLASS_PAGE_URL_RULES.forEach((rule) => {
expect(screen.getByTestId(`select-item-${rule}`)).toBeInTheDocument();
});
});
test("displays correct translated labels for each rule type", () => {
render(<TestWrapper urlFilters={[{ rule: "exactMatch", value: "https://example.com" }]} />);
// Test that each rule has the correct translated label
expect(screen.getByTestId("select-item-exactMatch")).toHaveTextContent(
"environments.actions.exactly_matches"
);
expect(screen.getByTestId("select-item-contains")).toHaveTextContent("environments.actions.contains");
expect(screen.getByTestId("select-item-startsWith")).toHaveTextContent(
"environments.actions.starts_with"
);
expect(screen.getByTestId("select-item-endsWith")).toHaveTextContent("environments.actions.ends_with");
expect(screen.getByTestId("select-item-notMatch")).toHaveTextContent(
"environments.actions.does_not_exactly_match"
);
expect(screen.getByTestId("select-item-notContains")).toHaveTextContent(
"environments.actions.does_not_contain"
);
expect(screen.getByTestId("select-item-matchesRegex")).toHaveTextContent(
"environments.actions.matches_regex"
);
});
test("test input styling changes based on match result", async () => {
const testUrl = "https://example.com/pricing";
const urlFilters = [{ rule: "contains" as const, value: "pricing" }];
render(<TestWrapper urlFilters={urlFilters} />);
const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl");
const testButton = screen.getByTestId("button-environments.actions.test_match");
// Test URL that should match
await userEvent.type(testInput, testUrl);
await userEvent.click(testButton);
// The input should have success styling (this tests the useMemo matchClass logic)
expect(testInput).toHaveClass("border-green-500", "bg-green-50");
});
test("test input styling for no match", async () => {
const testUrl = "https://example.com/dashboard";
const urlFilters = [{ rule: "contains" as const, value: "pricing" }];
render(<TestWrapper urlFilters={urlFilters} />);
const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl");
const testButton = screen.getByTestId("button-environments.actions.test_match");
await userEvent.type(testInput, testUrl);
await userEvent.click(testButton);
// The input should have error styling
expect(testInput).toHaveClass("border-red-200", "bg-red-50");
});
test("test input has default styling before any test", () => {
const urlFilters = [{ rule: "contains" as const, value: "pricing" }];
render(<TestWrapper urlFilters={urlFilters} />);
const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl");
// The input should have default styling
expect(testInput).toHaveClass("border-slate-200");
});
test("resets match state when test URL is changed", async () => {
const urlFilters = [{ rule: "contains" as const, value: "pricing" }];
render(<TestWrapper urlFilters={urlFilters} />);
const testInput = screen.getByTestId("input-noCodeConfig.urlFilters.testUrl");
const testButton = screen.getByTestId("button-environments.actions.test_match");
// First, perform a test that matches
await userEvent.type(testInput, "https://example.com/pricing");
await userEvent.click(testButton);
// Verify the input has success styling
expect(testInput).toHaveClass("border-green-500", "bg-green-50");
// Clear and type new URL
await userEvent.clear(testInput);
await userEvent.type(testInput, "https://example.com/dashboard");
// The styling should reset to default while typing
expect(testInput).toHaveClass("border-slate-200");
});
test("Select mock properly handles different selection values", async () => {
const mockOnValueChange = vi.fn();
render(
<div>
<Select name="test-select" onValueChange={mockOnValueChange}>
<SelectContent>
<SelectItem value="exactMatch">Exact Match</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="startsWith">Starts With</SelectItem>
</SelectContent>
</Select>
</div>
);
// Test clicking different select items
const exactMatchItem = screen.getByTestId("select-item-exactMatch");
const containsItem = screen.getByTestId("select-item-contains");
const startsWithItem = screen.getByTestId("select-item-startsWith");
// Click exactMatch
await userEvent.click(exactMatchItem);
expect(mockOnValueChange).toHaveBeenCalledWith("exactMatch");
// Click contains
await userEvent.click(containsItem);
expect(mockOnValueChange).toHaveBeenCalledWith("contains");
// Click startsWith
await userEvent.click(startsWithItem);
expect(mockOnValueChange).toHaveBeenCalledWith("startsWith");
// Verify each call was made with the correct value
expect(mockOnValueChange).toHaveBeenCalledTimes(3);
});
});

View File

@@ -3,7 +3,7 @@
import { cn } from "@/lib/cn";
import { testURLmatch } from "@/lib/utils/url";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormField, FormItem } from "@/modules/ui/components/form";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
@@ -14,18 +14,44 @@ import {
SelectValue,
} from "@/modules/ui/components/select";
import { TabToggle } from "@/modules/ui/components/tab-toggle";
import { useTranslate } from "@tolgee/react";
import { TFnType, useTranslate } from "@tolgee/react";
import { PlusIcon, TrashIcon } from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import {
Control,
FieldArrayWithId,
UseFieldArrayRemove,
UseFormReturn,
useFieldArray,
useWatch,
} from "react-hook-form";
import toast from "react-hot-toast";
import { TActionClassInput, TActionClassPageUrlRule } from "@formbricks/types/action-classes";
import {
ACTION_CLASS_PAGE_URL_RULES,
TActionClassInput,
TActionClassPageUrlRule,
} from "@formbricks/types/action-classes";
const getRuleLabel = (rule: TActionClassPageUrlRule, t: TFnType): string => {
switch (rule) {
case "exactMatch":
return t("environments.actions.exactly_matches");
case "contains":
return t("environments.actions.contains");
case "startsWith":
return t("environments.actions.starts_with");
case "endsWith":
return t("environments.actions.ends_with");
case "notMatch":
return t("environments.actions.does_not_exactly_match");
case "notContains":
return t("environments.actions.does_not_contain");
case "matchesRegex":
return t("environments.actions.matches_regex");
default:
return rule;
}
};
interface PageUrlSelectorProps {
form: UseFormReturn<TActionClassInput>;
@@ -34,29 +60,35 @@ interface PageUrlSelectorProps {
export const PageUrlSelector = ({ form, isReadOnly }: PageUrlSelectorProps) => {
const [testUrl, setTestUrl] = useState("");
const [isMatch, setIsMatch] = useState("");
const [isMatch, setIsMatch] = useState<boolean | null>(null);
const { t } = useTranslate();
const filterType = form.watch("noCodeConfig.urlFilters")?.length ? "specific" : "all";
const urlFilters = form.watch("noCodeConfig.urlFilters");
const filterType = urlFilters?.length ? "specific" : "all";
const setFilterType = (value: string) => {
form.setValue("noCodeConfig.urlFilters", value === "all" ? [] : [{ rule: "exactMatch", value: "" }]);
};
const handleMatchClick = () => {
const match =
form.watch("noCodeConfig.urlFilters")?.some((urlFilter) => {
const res =
testURLmatch(testUrl, urlFilter.value, urlFilter.rule as TActionClassPageUrlRule) === "yes";
return res;
}) || false;
try {
const match =
urlFilters?.some((urlFilter) => {
return testURLmatch(testUrl, urlFilter.value, urlFilter.rule, t);
}) || false;
const isMatch = match ? "yes" : "no";
setIsMatch(isMatch);
if (isMatch === "yes") toast.success("Your survey would be shown on this URL.");
if (isMatch === "no") toast.error("Your survey would not be shown.");
setIsMatch(match);
if (match) toast.success(t("environments.actions.your_survey_would_be_shown_on_this_url"));
if (!match) toast.error(t("environments.actions.your_survey_would_not_be_shown"));
} catch (error) {
toast.error(error.message);
}
};
const matchClass = useMemo(() => {
if (isMatch === null) return "border-slate-200";
return isMatch ? "border-green-500 bg-green-50" : "border-red-200 bg-red-50";
}, [isMatch]);
const {
fields,
append: appendUrlRule,
@@ -112,11 +144,11 @@ export const PageUrlSelector = ({ form, isReadOnly }: PageUrlSelectorProps) => {
{t("environments.actions.add_url")}
</Button>
<div className="mt-4">
<div className="text-sm text-slate-900">{t("environments.actions.test_your_url")}</div>
<div className="text-xs text-slate-400">
<Label className="font-semibold">{t("environments.actions.test_your_url")}</Label>
<p className="text-sm font-normal text-slate-500">
{t("environments.actions.enter_a_url_to_see_if_a_user_visiting_it_would_be_tracked")}
</div>
<div className="rounded bg-slate-50">
</p>
<div className="rounded">
<div className="mt-1 flex items-end">
<Input
type="text"
@@ -124,17 +156,9 @@ export const PageUrlSelector = ({ form, isReadOnly }: PageUrlSelectorProps) => {
name="noCodeConfig.urlFilters.testUrl"
onChange={(e) => {
setTestUrl(e.target.value);
setIsMatch("default");
setIsMatch(null);
}}
className={cn(
isMatch === "yes"
? "border-green-500 bg-green-50"
: isMatch === "no"
? "border-red-200 bg-red-50"
: isMatch === "default"
? "border-slate-200"
: "bg-white"
)}
className={cn(matchClass)}
placeholder="e.g. https://app.com/dashboard"
/>
<Button
@@ -167,10 +191,16 @@ const UrlInput = ({
disabled: boolean;
}) => {
const { t } = useTranslate();
// Watch all rule values to determine placeholders
const ruleValues = useWatch({
control,
name: "noCodeConfig.urlFilters",
});
return (
<div className="flex w-full flex-col gap-2">
{fields.map((field, index) => (
<div key={field.id} className="flex items-center space-x-2">
<div key={field.id} className="ml-1 flex items-start space-x-2">
{index !== 0 && <p className="ml-1 text-sm font-bold text-slate-700">or</p>}
<FormField
name={`noCodeConfig.urlFilters.${index}.rule`}
@@ -179,20 +209,15 @@ const UrlInput = ({
<FormItem>
<FormControl>
<Select onValueChange={onChange} value={value} name={name} disabled={disabled}>
<SelectTrigger className="w-[250px] bg-white">
<SelectTrigger className="h-[40px] w-[250px] bg-white">
<SelectValue placeholder={t("environments.actions.select_match_type")} />
</SelectTrigger>
<SelectContent className="bg-white">
<SelectItem value="exactMatch">{t("environments.actions.exactly_matches")}</SelectItem>
<SelectItem value="contains">{t("environments.actions.contains")}</SelectItem>
<SelectItem value="startsWith">{t("environments.actions.starts_with")}</SelectItem>
<SelectItem value="endsWith">{t("environments.actions.ends_with")}</SelectItem>
<SelectItem value="notMatch">
{t("environments.actions.does_not_exactly_match")}
</SelectItem>
<SelectItem value="notContains">
{t("environments.actions.does_not_contain")}
</SelectItem>
{ACTION_CLASS_PAGE_URL_RULES.map((rule) => (
<SelectItem key={rule} value={rule}>
{getRuleLabel(rule, t)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
@@ -202,27 +227,37 @@ const UrlInput = ({
<FormField
control={control}
name={`noCodeConfig.urlFilters.${index}.value`}
render={({ field, fieldState: { error } }) => (
<FormItem className="flex-1">
<FormControl>
<Input
type="text"
className="bg-white"
disabled={disabled}
{...field}
placeholder="e.g. https://app.com/dashboard"
autoComplete="off"
isInvalid={!!error?.message}
/>
</FormControl>
</FormItem>
)}
render={({ field, fieldState: { error } }) => {
const ruleValue = ruleValues[index]?.rule;
return (
<FormItem className="flex-1">
<FormControl>
<Input
type="text"
className="bg-white"
disabled={disabled}
{...field}
placeholder={
ruleValue === "matchesRegex"
? t("environments.actions.add_regular_expression_here")
: t("environments.actions.enter_url")
}
autoComplete="off"
isInvalid={!!error?.message}
/>
</FormControl>
<FormError />
</FormItem>
);
}}
/>
{fields.length > 1 && (
<Button
variant="secondary"
size="sm"
size="tall"
type="button"
onClick={() => {
removeUrlRule(index);

View File

@@ -21,7 +21,7 @@ export const NoCodeActionForm = ({ form, isReadOnly }: NoCodeActionFormProps) =>
const { control, watch } = form;
const { t } = useTranslate();
return (
<>
<div data-testid="no-code-action-form">
<FormField
name={`noCodeConfig.type`}
control={control}
@@ -97,6 +97,6 @@ export const NoCodeActionForm = ({ form, isReadOnly }: NoCodeActionFormProps) =>
)}
<PageUrlSelector form={form} isReadOnly={isReadOnly} />
</div>
</>
</div>
);
};

60
apps/web/scripts/docker/next-start.sh Normal file → Executable file
View File

@@ -2,12 +2,64 @@
set -eu
export NODE_ENV=production
# Function to run command with timeout if available, or without timeout as fallback
run_with_timeout() {
_timeout_duration="$1"
_timeout_name="$2"
shift 2
if command -v timeout >/dev/null 2>&1; then
# timeout command is available, use it
echo "Using timeout ($_timeout_duration seconds) for $_timeout_name"
if ! timeout "$_timeout_duration" "$@"; then
echo "$_timeout_name timed out after $_timeout_duration seconds"
echo "📋 This might indicate database connectivity issues"
exit 1
fi
else
# timeout not available, try to install it or run without timeout
echo "⚠️ timeout command not found, attempting to install..."
if command -v apk >/dev/null 2>&1; then
apk add --no-cache coreutils >/dev/null 2>&1 || {
echo "⚠️ Could not install timeout, running $_timeout_name without timeout protection"
echo "📋 Note: Process may hang indefinitely if there are connectivity issues"
}
fi
# Run the command (either with newly installed timeout or without timeout)
if command -v timeout >/dev/null 2>&1; then
echo "✅ timeout installed, using timeout ($_timeout_duration seconds) for $_timeout_name"
if ! timeout "$_timeout_duration" "$@"; then
echo "$_timeout_name timed out after $_timeout_duration seconds"
echo "📋 This might indicate database connectivity issues"
exit 1
fi
else
echo "Running $_timeout_name without timeout protection..."
if ! "$@"; then
echo "$_timeout_name failed"
echo "📋 This might indicate database connectivity issues"
exit 1
fi
fi
fi
}
# Start cron jobs if enabled
if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then
echo "Starting cron jobs...";
supercronic -quiet /app/docker/cronjobs &
else
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0";
fi;
(cd packages/database && npm run db:migrate:deploy) &&
(cd packages/database && npm run db:create-saml-database:deploy) &&
exec node apps/web/server.js
fi
echo "🗃️ Running database migrations..."
run_with_timeout 300 "database migration" sh -c '(cd packages/database && npm run db:migrate:deploy)'
echo "🗃️ Running SAML database setup..."
run_with_timeout 60 "SAML database setup" sh -c '(cd packages/database && npm run db:create-saml-database:deploy)'
echo "✅ Database setup completed"
echo "🚀 Starting Next.js server..."
exec node apps/web/server.js

View File

@@ -4,10 +4,6 @@ description: "Personal Links enable you to generate unique survey links for indi
icon: "user"
---
<Note>
Personal Links are currently in beta and not yet available for all users.
</Note>
<Note>
Personal Links are part of the [Enterprise Edition](/self-hosting/advanced/license).
</Note>

View File

@@ -121,6 +121,8 @@ You can limit action tracking to specific subpages of your website or web app by
- **notContains**: Activates when the URL does not contain the specified substring.
- **matchesRegex**: Activates when the URL matches the pattern from the specified string.
## **Setting Up Code Actions**
For more granular control, you can implement actions directly in your code:

View File

@@ -71,5 +71,10 @@
"budgetPercentIncreaseRed": 20,
"minimumChangeThreshold": 0,
"showDetails": true
},
"pnpm": {
"patchedDependencies": {
"next-auth@4.24.11": "patches/next-auth@4.24.11.patch"
}
}
}

View File

@@ -205,6 +205,8 @@ export const checkUrlMatch = (
pageUrlValue: string,
pageUrlRule: TActionClassPageUrlRule
): boolean => {
let regex: RegExp;
switch (pageUrlRule) {
case "exactMatch":
return url === pageUrlValue;
@@ -218,6 +220,15 @@ export const checkUrlMatch = (
return url !== pageUrlValue;
case "notContains":
return !url.includes(pageUrlValue);
case "matchesRegex":
try {
regex = new RegExp(pageUrlValue);
} catch {
// edge case: fail closed if the regex expression is invalid
return false;
}
return regex.test(url);
default:
return false;
}

View File

@@ -40,7 +40,8 @@ export type TActionClassPageUrlRule =
| "startsWith"
| "endsWith"
| "notMatch"
| "notContains";
| "notContains"
| "matchesRegex";
export type TActionClassNoCodeConfig =
| {

View File

@@ -27,7 +27,6 @@
},
"scripts": {
"dev": "vite build --watch --mode dev",
"serve": "serve dist -p 3003",
"build": "tsc && vite build",
"build:dev": "tsc && vite build --mode dev",
"go": "vite build --watch --mode dev",
@@ -42,8 +41,8 @@
"@formkit/auto-animate": "0.8.2",
"isomorphic-dompurify": "2.24.0",
"preact": "10.26.6",
"react-date-picker": "11.0.0",
"react-calendar": "5.1.0"
"react-calendar": "5.1.0",
"react-date-picker": "11.0.0"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
@@ -56,7 +55,6 @@
"autoprefixer": "10.4.21",
"concurrently": "9.1.2",
"postcss": "8.5.3",
"serve": "14.2.4",
"tailwindcss": "3.4.17",
"terser": "5.39.1",
"vite": "6.3.5",

View File

@@ -4,7 +4,7 @@ export function FormbricksBranding() {
href="https://formbricks.com?utm_source=survey_branding"
target="_blank"
tabIndex={-1}
className="fb-my-2 fb-flex fb-justify-center"
className="fb-flex fb-justify-center"
rel="noopener">
<p className="fb-text-signature fb-text-xs">
Powered by{" "}

View File

@@ -765,8 +765,8 @@ export function Survey({
)}>
{content()}
</div>
<div className="fb-space-y-4">
<div className="fb-px-4 space-y-2">
<div className="fb-gap-y-2 fb-min-h-8 fb-flex fb-flex-col fb-justify-end">
<div className="fb-px-4 fb-space-y-2">
{isBrandingEnabled ? <FormbricksBranding /> : null}
{isSpamProtectionEnabled ? <RecaptchaBranding /> : null}
</div>

View File

@@ -133,73 +133,67 @@ export function WelcomeCard({
}, [isCurrent]);
return (
<div>
<ScrollableContainer>
<div>
{fileUrl ? (
<img
src={fileUrl}
className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-rounded-lg fb-object-contain"
alt="Company Logo"
/>
) : null}
<ScrollableContainer>
<div>
{fileUrl ? (
<img
src={fileUrl}
className="fb-mb-8 fb-max-h-96 fb-w-1/4 fb-rounded-lg fb-object-contain"
alt="Company Logo"
/>
) : null}
<Headline
headline={replaceRecallInfo(
getLocalizedValue(headline, languageCode),
responseData,
variablesData
)}
questionId="welcomeCard"
/>
<HtmlBody
htmlString={replaceRecallInfo(getLocalizedValue(html, languageCode), responseData, variablesData)}
questionId="welcomeCard"
/>
</div>
</ScrollableContainer>
<div className="fb-mx-6 fb-mt-4 fb-flex fb-gap-4 fb-py-4">
<SubmitButton
buttonLabel={getLocalizedValue(buttonLabel, languageCode)}
isLastQuestion={false}
focus={isCurrent ? autoFocusEnabled : false}
tabIndex={isCurrent ? 0 : -1}
onClick={handleSubmit}
type="button"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
<Headline
headline={replaceRecallInfo(getLocalizedValue(headline, languageCode), responseData, variablesData)}
questionId="welcomeCard"
/>
<HtmlBody
htmlString={replaceRecallInfo(getLocalizedValue(html, languageCode), responseData, variablesData)}
questionId="welcomeCard"
/>
<div className="fb-mt-4 fb-flex fb-gap-4 fb-pt-4">
<SubmitButton
buttonLabel={getLocalizedValue(buttonLabel, languageCode)}
isLastQuestion={false}
focus={isCurrent ? autoFocusEnabled : false}
tabIndex={isCurrent ? 0 : -1}
onClick={handleSubmit}
type="button"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
}
}}
/>
</div>
{timeToFinish && !showResponseCount ? (
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
<TimerIcon />
<p className="fb-pt-1 fb-text-xs">
<span> Takes {calculateTimeToComplete()} </span>
</p>
</div>
) : null}
{showResponseCount && !timeToFinish && responseCount && responseCount > 3 ? (
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
<UsersIcon />
<p className="fb-pt-1 fb-text-xs">
<span>{`${responseCount.toString()} people responded`}</span>
</p>
</div>
) : null}
{timeToFinish && showResponseCount ? (
<div className="fb-items-center fb-text-subheading fb-my-4 fb-flex">
<TimerIcon />
<p className="fb-pt-1 fb-text-xs">
<span> Takes {calculateTimeToComplete()} </span>
<span>
{responseCount && responseCount > 3 ? `${responseCount.toString()} people responded` : ""}
</span>
</p>
</div>
) : null}
</div>
{timeToFinish && !showResponseCount ? (
<div className="fb-items-center fb-text-subheading fb-my-4 fb-ml-6 fb-flex">
<TimerIcon />
<p className="fb-pt-1 fb-text-xs">
<span> Takes {calculateTimeToComplete()} </span>
</p>
</div>
) : null}
{showResponseCount && !timeToFinish && responseCount && responseCount > 3 ? (
<div className="fb-items-center fb-text-subheading fb-my-4 fb-ml-6 fb-flex">
<UsersIcon />
<p className="fb-pt-1 fb-text-xs">
<span>{`${responseCount.toString()} people responded`}</span>
</p>
</div>
) : null}
{timeToFinish && showResponseCount ? (
<div className="fb-items-center fb-text-subheading fb-my-4 fb-ml-6 fb-flex">
<TimerIcon />
<p className="fb-pt-1 fb-text-xs">
<span> Takes {calculateTimeToComplete()} </span>
<span>
{responseCount && responseCount > 3 ? `${responseCount.toString()} people responded` : ""}
</span>
</p>
</div>
) : null}
</div>
</ScrollableContainer>
);
}

View File

@@ -120,8 +120,8 @@ export function AddressQuestion({
);
return (
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full" ref={formRef}>
<ScrollableContainer>
<ScrollableContainer>
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full" ref={formRef}>
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
@@ -176,27 +176,27 @@ export function AddressQuestion({
);
})}
</div>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
<div />
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);
onBack();
}}
/>
)}
</div>
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
<div />
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);
onBack();
}}
/>
)}
</div>
</form>
</form>
</ScrollableContainer>
);
}

View File

@@ -58,29 +58,29 @@ export function CalQuestion({
}, [onChange, onSubmit, question.id, setTtc, startTime, ttc]);
return (
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
if (question.required && !value) {
setErrorMessage("Please book an appointment");
// Scroll to bottom to show the error message
setTimeout(() => {
if (scrollableRef.current?.scrollToBottom) {
scrollableRef.current.scrollToBottom();
}
}, 100);
return;
}
<ScrollableContainer ref={scrollableRef}>
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
if (question.required && !value) {
setErrorMessage("Please book an appointment");
// Scroll to bottom to show the error message
setTimeout(() => {
if (scrollableRef.current?.scrollToBottom) {
scrollableRef.current.scrollToBottom();
}
}, 100);
return;
}
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);
onChange({ [question.id]: value });
onSubmit({ [question.id]: value }, updatedttc);
}}
className="fb-w-full">
<ScrollableContainer ref={scrollableRef}>
onChange({ [question.id]: value });
onSubmit({ [question.id]: value }, updatedttc);
}}
className="fb-w-full">
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
@@ -97,25 +97,25 @@ export function CalQuestion({
<CalEmbed key={question.id} question={question} onSuccessfulBooking={onSuccessfulBooking} />
{errorMessage ? <span className="fb-text-red-500">{errorMessage}</span> : null}
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
<SubmitButton
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
tabIndex={isCurrent ? 0 : -1}
/>
<div />
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
onBack();
}}
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
tabIndex={isCurrent ? 0 : -1}
/>
)}
</div>
</form>
<div />
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
onBack();
}}
tabIndex={isCurrent ? 0 : -1}
/>
)}
</div>
</form>
</ScrollableContainer>
);
}

View File

@@ -58,88 +58,81 @@ export function ConsentQuestion({
);
return (
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: value }, updatedTtcObj);
}}>
<ScrollableContainer>
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
<ScrollableContainer>
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: value }, updatedTtcObj);
}}>
{isMediaAvailable ? <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} /> : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<HtmlBody
htmlString={getLocalizedValue(question.html, languageCode) || ""}
questionId={question.id}
/>
<label
ref={consentRef}
dir="auto"
tabIndex={isCurrent ? 0 : -1}
id={`${question.id}-label`}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(question.id)?.click();
document.getElementById(`${question.id}-label`)?.focus();
}
}}
className="fb-border-border fb-bg-input-bg fb-text-heading hover:fb-bg-input-bg-selected focus:fb-bg-input-bg-selected focus:fb-ring-brand fb-rounded-custom fb-relative fb-z-10 fb-my-2 fb-flex fb-w-full fb-cursor-pointer fb-items-center fb-border fb-p-4 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2">
<input
tabIndex={-1}
type="checkbox"
id={question.id}
name={question.id}
value={getLocalizedValue(question.label, languageCode)}
onChange={(e) => {
if (e.target instanceof HTMLInputElement && e.target.checked) {
onChange({ [question.id]: "accepted" });
} else {
onChange({ [question.id]: "" });
}
}}
checked={value === "accepted"}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${question.id}-label`}
required={question.required}
/>
<HtmlBody
htmlString={getLocalizedValue(question.html, languageCode) || ""}
questionId={question.id}
/>
<div className="fb-bg-survey-bg fb-sticky -fb-bottom-2 fb-z-10 fb-w-full fb-px-1 fb-py-1">
<label
ref={consentRef}
dir="auto"
tabIndex={isCurrent ? 0 : -1}
id={`${question.id}-label`}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(question.id)?.click();
document.getElementById(`${question.id}-label`)?.focus();
}
}}
className="fb-border-border fb-bg-input-bg fb-text-heading hover:fb-bg-input-bg-selected focus:fb-bg-input-bg-selected focus:fb-ring-brand fb-rounded-custom fb-relative fb-z-10 fb-my-2 fb-flex fb-w-full fb-cursor-pointer fb-items-center fb-border fb-p-4 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2">
<input
tabIndex={-1}
type="checkbox"
id={question.id}
name={question.id}
value={getLocalizedValue(question.label, languageCode)}
onChange={(e) => {
if (e.target instanceof HTMLInputElement && e.target.checked) {
onChange({ [question.id]: "accepted" });
} else {
onChange({ [question.id]: "" });
}
}}
checked={value === "accepted"}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${question.id}-label`}
required={question.required}
/>
<span id={`${question.id}-label`} className="fb-ml-3 fb-mr-3 fb-font-medium">
{getLocalizedValue(question.label, languageCode)}
</span>
</label>
</div>
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
<div />
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
<span id={`${question.id}-label`} className="fb-ml-3 fb-mr-3 fb-font-medium">
{getLocalizedValue(question.label, languageCode)}
</span>
</label>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
)}
</div>
</form>
<div />
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
/>
)}
</div>
</form>
</ScrollableContainer>
);
}

View File

@@ -115,90 +115,85 @@ export function ContactInfoQuestion({
);
return (
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full" ref={formRef}>
<ScrollableContainer>
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<ScrollableContainer>
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full" ref={formRef}>
{isMediaAvailable ? <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} /> : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full">
{fields.map((field, index) => {
const isFieldRequired = () => {
if (field.required) {
return true;
}
// if all fields are optional and the question is required, then the fields should be required
if (
fields.filter((currField) => currField.show).every((currField) => !currField.required) &&
question.required
) {
return true;
}
return false;
};
let inputType = "text";
if (field.id === "email") {
inputType = "email";
} else if (field.id === "phone") {
inputType = "number";
<div className="fb-flex fb-flex-col fb-space-y-2 fb-mt-4 fb-w-full">
{fields.map((field, index) => {
const isFieldRequired = () => {
if (field.required) {
return true;
}
return (
field.show && (
<div className="fb-space-y-1">
<Label htmlForId={field.id} text={isFieldRequired() ? `${field.label}*` : field.label} />
<Input
id={field.id}
ref={index === 0 ? contactInfoRef : null}
key={field.id}
required={isFieldRequired()}
value={safeValue[index] || ""}
type={inputType}
onChange={(e) => {
handleChange(field.id, e.currentTarget.value);
}}
tabIndex={isCurrent ? 0 : -1}
aria-label={field.label}
/>
</div>
)
);
})}
</div>
</div>
</ScrollableContainer>
// if all fields are optional and the question is required, then the fields should be required
if (
fields.filter((currField) => currField.show).every((currField) => !currField.required) &&
question.required
) {
return true;
}
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
return false;
};
let inputType = "text";
if (field.id === "email") {
inputType = "email";
} else if (field.id === "phone") {
inputType = "number";
}
return (
field.show && (
<div className="fb-space-y-1">
<Label htmlForId={field.id} text={isFieldRequired() ? `${field.label}*` : field.label} />
<Input
id={field.id}
ref={index === 0 ? contactInfoRef : null}
key={field.id}
required={isFieldRequired()}
value={safeValue[index] || ""}
type={inputType}
onChange={(e) => {
handleChange(field.id, e.currentTarget.value);
}}
tabIndex={isCurrent ? 0 : -1}
aria-label={field.label}
/>
</div>
)
);
})}
</div>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);
onBack();
}}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
)}
</div>
</form>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);
onBack();
}}
/>
)}
</div>
</form>
</ScrollableContainer>
);
}

View File

@@ -60,58 +60,58 @@ export function CTAQuestion({
required={question.required}
/>
<HtmlBody htmlString={getLocalizedValue(question.html, languageCode)} questionId={question.id} />
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-start">
<SubmitButton
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
focus={isCurrent ? autoFocusEnabled : false}
tabIndex={isCurrent ? 0 : -1}
onClick={() => {
if (question.buttonExternal && question.buttonUrl) {
if (onOpenExternalURL) {
onOpenExternalURL(question.buttonUrl);
} else {
window.open(question.buttonUrl, "_blank")?.focus();
}
}
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: "clicked" }, updatedTtcObj);
onChange({ [question.id]: "clicked" });
}}
type="button"
/>
{!question.required && (
<button
dir="auto"
type="button"
tabIndex={isCurrent ? 0 : -1}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: "" }, updatedTtcObj);
onChange({ [question.id]: "" });
}}
className="fb-text-heading focus:fb-ring-focus fb-mr-4 fb-flex fb-items-center fb-rounded-md fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2">
{getLocalizedValue(question.dismissButtonLabel, languageCode) || "Skip"}
</button>
)}
</div>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
/>
)}
</div>
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-start">
<SubmitButton
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
focus={isCurrent ? autoFocusEnabled : false}
tabIndex={isCurrent ? 0 : -1}
onClick={() => {
if (question.buttonExternal && question.buttonUrl) {
if (onOpenExternalURL) {
onOpenExternalURL(question.buttonUrl);
} else {
window.open(question.buttonUrl, "_blank")?.focus();
}
}
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: "clicked" }, updatedTtcObj);
onChange({ [question.id]: "clicked" });
}}
type="button"
/>
{!question.required && (
<button
dir="auto"
type="button"
tabIndex={isCurrent ? 0 : -1}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: "" }, updatedTtcObj);
onChange({ [question.id]: "" });
}}
className="fb-text-heading focus:fb-ring-focus fb-mr-4 fb-flex fb-items-center fb-rounded-md fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2">
{getLocalizedValue(question.dismissButtonLabel, languageCode) || "Skip"}
</button>
)}
</div>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
/>
)}
</div>
</div>
);
}

View File

@@ -134,159 +134,154 @@ export function DateQuestion({
}, [selectedDate]);
return (
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
if (question.required && !value) {
setErrorMessage("Please select a date.");
return;
}
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: value }, updatedTtcObj);
}}
className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div id="error-message" className="fb-text-red-600" aria-live="assertive">
<span>{errorMessage}</span>
</div>
<div
className={cn("fb-mt-4 fb-w-full", errorMessage && "fb-rounded-lg fb-border-2 fb-border-red-500")}
id="date-picker-root">
<div className="fb-relative">
{!datePickerOpen && (
<button
onClick={() => {
setDatePickerOpen(true);
}}
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
if (e.key === " ") setDatePickerOpen(true);
}}
aria-label={selectedDate ? `You have selected ${formattedDate}` : "Select a date"}
aria-describedby={errorMessage ? "error-message" : undefined}
className="focus:fb-outline-brand fb-bg-input-bg hover:fb-bg-input-bg-selected fb-border-border fb-text-heading fb-rounded-custom fb-relative fb-flex fb-h-[12dvh] fb-w-full fb-cursor-pointer fb-appearance-none fb-items-center fb-justify-center fb-border fb-text-left fb-text-base fb-font-normal">
<div className="fb-flex fb-items-center fb-gap-2">
{selectedDate ? (
<div className="fb-flex fb-items-center fb-gap-2">
<CalendarCheckIcon /> <span>{formattedDate}</span>
</div>
) : (
<div className="fb-flex fb-items-center fb-gap-2">
<CalendarIcon /> <span>Select a date</span>
</div>
)}
</div>
</button>
)}
<DatePicker
key={datePickerOpen}
value={selectedDate}
isOpen={datePickerOpen}
onChange={(value) => {
const date = value as Date;
setSelectedDate(date);
// Get the timezone offset in minutes and convert it to milliseconds
const timezoneOffset = date.getTimezoneOffset() * 60000;
// Adjust the date by subtracting the timezone offset
const adjustedDate = new Date(date.getTime() - timezoneOffset);
// Format the date as YYYY-MM-DD
const dateString = adjustedDate.toISOString().split("T")[0];
onChange({ [question.id]: dateString });
}}
minDate={
new Date(new Date().getFullYear() - 100, new Date().getMonth(), new Date().getDate())
}
maxDate={new Date("3000-12-31")}
dayPlaceholder="DD"
monthPlaceholder="MM"
yearPlaceholder="YYYY"
format={question.format ?? "M-d-y"}
className={`dp-input-root fb-rounded-custom wrapper-hide ${!datePickerOpen ? "" : "fb-h-[46dvh] sm:fb-h-[34dvh]"} ${hideInvalid ? "hide-invalid" : ""} `}
calendarProps={{
className:
"calendar-root !fb-text-heading !fb-bg-input-bg fb-border fb-border-border fb-rounded-custom fb-p-3 fb-h-[46dvh] sm:fb-h-[33dvh] fb-overflow-auto",
tileClassName: ({ date }: { date: Date }) => {
const baseClass =
"hover:fb-bg-input-bg-selected fb-rounded-custom fb-h-9 fb-p-0 fb-mt-1 fb-font-normal aria-selected:fb-opacity-100 focus:fb-ring-2 focus:fb-bg-slate-200";
// active date class (check first to take precedence over today's date)
if (
selectedDate &&
date.getDate() === selectedDate?.getDate() &&
date.getMonth() === selectedDate.getMonth() &&
date.getFullYear() === selectedDate.getFullYear()
) {
return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-calendar-tile`;
}
// today's date class
if (
date.getDate() === new Date().getDate() &&
date.getMonth() === new Date().getMonth() &&
date.getFullYear() === new Date().getFullYear()
) {
return `${baseClass} !fb-bg-brand !fb-opacity-50 !fb-border-border-highlight !fb-text-calendar-tile focus:fb-ring-2 focus:fb-bg-slate-200`;
}
return `${baseClass} !fb-text-heading`;
},
formatShortWeekday: (_: any, date: Date) => {
return date.toLocaleDateString("en-US", { weekday: "short" }).slice(0, 2);
},
showNeighboringMonth: false,
}}
clearIcon={null}
onCalendarOpen={() => {
<ScrollableContainer>
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
if (question.required && !value) {
setErrorMessage("Please select a date.");
return;
}
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: value }, updatedTtcObj);
}}
className="fb-w-full">
{isMediaAvailable ? <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} /> : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div id="error-message" className="fb-text-red-600" aria-live="assertive">
<span>{errorMessage}</span>
</div>
<div
className={cn("fb-mt-4 fb-w-full", errorMessage && "fb-rounded-lg fb-border-2 fb-border-red-500")}
id="date-picker-root">
<div className="fb-relative">
{!datePickerOpen && (
<button
onClick={() => {
setDatePickerOpen(true);
}}
onCalendarClose={() => {
// reset state
setDatePickerOpen(false);
setSelectedDate(selectedDate);
tabIndex={isCurrent ? 0 : -1}
type="button"
onKeyDown={(e) => {
if (e.key === " ") setDatePickerOpen(true);
}}
calendarIcon={(<CalendarIcon />) as DatePickerProps["calendarIcon"]}
showLeadingZeros={false}
/>
</div>
aria-label={selectedDate ? `You have selected ${formattedDate}` : "Select a date"}
aria-describedby={errorMessage ? "error-message" : undefined}
className="focus:fb-outline-brand fb-bg-input-bg hover:fb-bg-input-bg-selected fb-border-border fb-text-heading fb-rounded-custom fb-relative fb-flex fb-h-[12dvh] fb-w-full fb-cursor-pointer fb-appearance-none fb-items-center fb-justify-center fb-border fb-text-left fb-text-base fb-font-normal">
<div className="fb-flex fb-items-center fb-gap-2">
{selectedDate ? (
<div className="fb-flex fb-items-center fb-gap-2">
<CalendarCheckIcon /> <span>{formattedDate}</span>
</div>
) : (
<div className="fb-flex fb-items-center fb-gap-2">
<CalendarIcon /> <span>Select a date</span>
</div>
)}
</div>
</button>
)}
<DatePicker
key={datePickerOpen}
value={selectedDate}
isOpen={datePickerOpen}
onChange={(value) => {
const date = value as Date;
setSelectedDate(date);
// Get the timezone offset in minutes and convert it to milliseconds
const timezoneOffset = date.getTimezoneOffset() * 60000;
// Adjust the date by subtracting the timezone offset
const adjustedDate = new Date(date.getTime() - timezoneOffset);
// Format the date as YYYY-MM-DD
const dateString = adjustedDate.toISOString().split("T")[0];
onChange({ [question.id]: dateString });
}}
minDate={new Date(new Date().getFullYear() - 100, new Date().getMonth(), new Date().getDate())}
maxDate={new Date("3000-12-31")}
dayPlaceholder="DD"
monthPlaceholder="MM"
yearPlaceholder="YYYY"
format={question.format ?? "M-d-y"}
className={`dp-input-root fb-rounded-custom wrapper-hide ${!datePickerOpen ? "" : "fb-h-[46dvh] sm:fb-h-[34dvh]"} ${hideInvalid ? "hide-invalid" : ""} `}
calendarProps={{
className:
"calendar-root !fb-text-heading !fb-bg-input-bg fb-border fb-border-border fb-rounded-custom fb-p-3 fb-h-[46dvh] sm:fb-h-[33dvh] fb-overflow-auto",
tileClassName: ({ date }: { date: Date }) => {
const baseClass =
"hover:fb-bg-input-bg-selected fb-rounded-custom fb-h-9 fb-p-0 fb-mt-1 fb-font-normal aria-selected:fb-opacity-100 focus:fb-ring-2 focus:fb-bg-slate-200";
// active date class (check first to take precedence over today's date)
if (
selectedDate &&
date.getDate() === selectedDate?.getDate() &&
date.getMonth() === selectedDate.getMonth() &&
date.getFullYear() === selectedDate.getFullYear()
) {
return `${baseClass} !fb-bg-brand !fb-border-border-highlight !fb-text-calendar-tile`;
}
// today's date class
if (
date.getDate() === new Date().getDate() &&
date.getMonth() === new Date().getMonth() &&
date.getFullYear() === new Date().getFullYear()
) {
return `${baseClass} !fb-bg-brand !fb-opacity-50 !fb-border-border-highlight !fb-text-calendar-tile focus:fb-ring-2 focus:fb-bg-slate-200`;
}
return `${baseClass} !fb-text-heading`;
},
formatShortWeekday: (_: any, date: Date) => {
return date.toLocaleDateString("en-US", { weekday: "short" }).slice(0, 2);
},
showNeighboringMonth: false,
}}
clearIcon={null}
onCalendarOpen={() => {
setDatePickerOpen(true);
}}
onCalendarClose={() => {
// reset state
setDatePickerOpen(false);
setSelectedDate(selectedDate);
}}
calendarIcon={(<CalendarIcon />) as DatePickerProps["calendarIcon"]}
showLeadingZeros={false}
/>
</div>
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
isLastQuestion={isLastQuestion}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
isLastQuestion={isLastQuestion}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
/>
)}
</div>
</form>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
/>
)}
</div>
</form>
</ScrollableContainer>
);
}

View File

@@ -53,75 +53,71 @@ export function FileUploadQuestion({
const isCurrent = question.id === currentQuestionId;
return (
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
if (question.required) {
if (value && value.length > 0) {
<ScrollableContainer>
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
if (question.required) {
if (value && value.length > 0) {
onSubmit({ [question.id]: value }, updatedTtcObj);
} else {
alert("Please upload a file");
}
} else if (value) {
onSubmit({ [question.id]: value }, updatedTtcObj);
} else {
alert("Please upload a file");
onSubmit({ [question.id]: "skipped" }, updatedTtcObj);
}
} else if (value) {
onSubmit({ [question.id]: value }, updatedTtcObj);
} else {
onSubmit({ [question.id]: "skipped" }, updatedTtcObj);
}
}}
className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<FileInput
htmlFor={question.id}
surveyId={surveyId}
onFileUpload={onFileUpload}
onUploadCallback={(urls: string[]) => {
if (urls) {
onChange({ [question.id]: urls });
} else {
onChange({ [question.id]: "skipped" });
}
}}
fileUrls={value}
allowMultipleFiles={question.allowMultipleFiles}
{...(question.allowedFileExtensions
? { allowedFileExtensions: question.allowedFileExtensions }
: {})}
{...(question.maxSizeInMB ? { maxSizeInMB: question.maxSizeInMB } : {})}
/>
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
}}
className="fb-w-full">
{isMediaAvailable ? <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} /> : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<FileInput
htmlFor={question.id}
surveyId={surveyId}
onFileUpload={onFileUpload}
onUploadCallback={(urls: string[]) => {
if (urls) {
onChange({ [question.id]: urls });
} else {
onChange({ [question.id]: "skipped" });
}
}}
fileUrls={value}
allowMultipleFiles={question.allowMultipleFiles}
{...(question.allowedFileExtensions
? { allowedFileExtensions: question.allowedFileExtensions }
: {})}
{...(question.maxSizeInMB ? { maxSizeInMB: question.maxSizeInMB } : {})}
/>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
onBack();
}}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
)}
</div>
</form>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
onBack();
}}
/>
)}
</div>
</form>
</ScrollableContainer>
);
}

View File

@@ -119,109 +119,100 @@ export function MatrixQuestion({
);
return (
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={getLocalizedValue(question.subheader, languageCode)}
questionId={question.id}
/>
<div className="fb-overflow-x-auto fb-py-4">
<table className="fb-no-scrollbar fb-min-w-full fb-table-auto fb-border-collapse fb-text-sm">
<thead>
<tr>
<th className="fb-px-4 fb-py-2" />
{columnsHeaders}
</tr>
</thead>
<tbody>
{questionRows.map((row, rowIndex) => (
<tr
key={`row-${rowIndex.toString()}`}
className={rowIndex % 2 === 0 ? "fb-bg-input-bg" : ""}>
<th
scope="row"
className="fb-text-heading fb-rounded-l-custom fb-max-w-40 fb-break-words fb-pr-4 fb-pl-2 fb-py-2 fb-text-left fb-min-w-[20%] fb-font-semibold"
dir="auto">
{getLocalizedValue(row, languageCode)}
</th>
{question.columns.map((column, columnIndex) => (
<td
key={`column-${columnIndex.toString()}`}
tabIndex={isCurrent ? 0 : -1}
className={`fb-outline-brand fb-px-4 fb-py-2 fb-text-slate-800 ${columnIndex === question.columns.length - 1 ? "fb-rounded-r-custom" : ""}`}
onClick={() => {
<ScrollableContainer>
<form key={question.id} onSubmit={handleSubmit} className="fb-w-full">
{isMediaAvailable ? <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} /> : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader subheader={getLocalizedValue(question.subheader, languageCode)} questionId={question.id} />
<div className="fb-overflow-x-auto fb-py-4">
<table className="fb-no-scrollbar fb-min-w-full fb-table-auto fb-border-collapse fb-text-sm">
<thead>
<tr>
<th className="fb-px-4 fb-py-2" />
{columnsHeaders}
</tr>
</thead>
<tbody>
{questionRows.map((row, rowIndex) => (
<tr key={`row-${rowIndex.toString()}`} className={rowIndex % 2 === 0 ? "fb-bg-input-bg" : ""}>
<th
scope="row"
className="fb-text-heading fb-rounded-l-custom fb-max-w-40 fb-break-words fb-pr-4 fb-pl-2 fb-py-2 fb-text-left fb-min-w-[20%] fb-font-semibold"
dir="auto">
{getLocalizedValue(row, languageCode)}
</th>
{question.columns.map((column, columnIndex) => (
<td
key={`column-${columnIndex.toString()}`}
tabIndex={isCurrent ? 0 : -1}
className={`fb-outline-brand fb-px-4 fb-py-2 fb-text-slate-800 ${columnIndex === question.columns.length - 1 ? "fb-rounded-r-custom" : ""}`}
onClick={() => {
handleSelect(
getLocalizedValue(column, languageCode),
getLocalizedValue(row, languageCode)
);
}}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();
handleSelect(
getLocalizedValue(column, languageCode),
getLocalizedValue(row, languageCode)
);
}}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();
handleSelect(
getLocalizedValue(column, languageCode),
getLocalizedValue(row, languageCode)
);
}
}}
dir="auto">
<div className="fb-flex fb-items-center fb-justify-center fb-p-2">
<input
dir="auto"
type="radio"
tabIndex={-1}
required={question.required}
id={`row${rowIndex.toString()}-column${columnIndex.toString()}`}
name={getLocalizedValue(row, languageCode)}
value={getLocalizedValue(column, languageCode)}
checked={
typeof value === "object" && !Array.isArray(value)
? value[getLocalizedValue(row, languageCode)] ===
getLocalizedValue(column, languageCode)
: false
}
}}
dir="auto">
<div className="fb-flex fb-items-center fb-justify-center fb-p-2">
<input
dir="auto"
type="radio"
tabIndex={-1}
required={question.required}
id={`row${rowIndex.toString()}-column${columnIndex.toString()}`}
name={getLocalizedValue(row, languageCode)}
value={getLocalizedValue(column, languageCode)}
checked={
typeof value === "object" && !Array.isArray(value)
? value[getLocalizedValue(row, languageCode)] ===
getLocalizedValue(column, languageCode)
: false
}
aria-label={`${getLocalizedValue(
question.headline,
languageCode
)}: ${getLocalizedValue(row, languageCode)} ${getLocalizedValue(
column,
languageCode
)}`}
className="fb-border-brand fb-text-brand fb-h-5 fb-w-5 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
/>
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
aria-label={`${getLocalizedValue(
question.headline,
languageCode
)}: ${getLocalizedValue(row, languageCode)} ${getLocalizedValue(
column,
languageCode
)}`}
className="fb-border-brand fb-text-brand fb-h-5 fb-w-5 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
/>
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
<SubmitButton
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
tabIndex={isCurrent ? 0 : -1}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={handleBackButtonClick}
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
tabIndex={isCurrent ? 0 : -1}
/>
)}
</div>
</form>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={handleBackButtonClick}
tabIndex={isCurrent ? 0 : -1}
/>
)}
</div>
</form>
</ScrollableContainer>
);
}

View File

@@ -147,187 +147,182 @@ export function MultipleChoiceMultiQuestion({
}, [languageCode, question.otherOptionPlaceholder, otherValue]);
return (
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const newValue = value.filter((item) => {
return getChoicesWithoutOtherLabels().includes(item) || item === otherValue;
}); // filter out all those values which are either in getChoicesWithoutOtherLabels() (i.e. selected by checkbox) or the latest entered otherValue
if (otherValue && otherSelected && !newValue.includes(otherValue)) newValue.push(otherValue);
onChange({ [question.id]: newValue });
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: newValue }, updatedTtcObj);
}}
className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Options</legend>
<div className="fb-bg-survey-bg fb-relative fb-space-y-2" ref={choicesContainerRef}>
{questionChoices.map((choice, idx) => {
if (!choice || choice.id === "other") return;
return (
<label
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
className={cn(
value.includes(getLocalizedValue(choice.label, languageCode))
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border fb-bg-input-bg",
"fb-text-heading focus-within:fb-border-brand hover:fb-bg-input-bg-selected focus:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(choice.id)?.click();
document.getElementById(choice.id)?.focus();
}
}}
autoFocus={idx === 0 && autoFocusEnabled}>
<span className="fb-flex fb-items-center fb-text-sm" dir="auto">
<input
type="checkbox"
id={choice.id}
name={question.id}
tabIndex={-1}
value={getLocalizedValue(choice.label, languageCode)}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
if ((e.target as HTMLInputElement).checked) {
addItem(getLocalizedValue(choice.label, languageCode));
} else {
removeItem(getLocalizedValue(choice.label, languageCode));
}
}}
checked={
Array.isArray(value) &&
value.includes(getLocalizedValue(choice.label, languageCode))
}
required={getIsRequired()}
/>
<span id={`${choice.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
{getLocalizedValue(choice.label, languageCode)}
</span>
</span>
</label>
);
})}
{otherOption ? (
<ScrollableContainer>
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const newValue = value.filter((item) => {
return getChoicesWithoutOtherLabels().includes(item) || item === otherValue;
}); // filter out all those values which are either in getChoicesWithoutOtherLabels() (i.e. selected by checkbox) or the latest entered otherValue
if (otherValue && otherSelected && !newValue.includes(otherValue)) newValue.push(otherValue);
onChange({ [question.id]: newValue });
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: newValue }, updatedTtcObj);
}}
className="fb-w-full">
{isMediaAvailable ? <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} /> : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Options</legend>
<div className="fb-bg-survey-bg fb-relative fb-space-y-2" ref={choicesContainerRef}>
{questionChoices.map((choice, idx) => {
if (!choice || choice.id === "other") return;
return (
<label
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
className={cn(
otherSelected ? "fb-border-brand fb-bg-input-bg-selected fb-z-10" : "fb-border-border",
"fb-text-heading focus-within:fb-border-brand fb-bg-input-bg focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
value.includes(getLocalizedValue(choice.label, languageCode))
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border fb-bg-input-bg",
"fb-text-heading focus-within:fb-border-brand hover:fb-bg-input-bg-selected focus:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
if (otherSelected) return;
document.getElementById(otherOption.id)?.click();
document.getElementById(otherOption.id)?.focus();
e.preventDefault();
document.getElementById(choice.id)?.click();
document.getElementById(choice.id)?.focus();
}
}}>
}}
autoFocus={idx === 0 && autoFocusEnabled}>
<span className="fb-flex fb-items-center fb-text-sm" dir="auto">
<input
type="checkbox"
tabIndex={-1}
id={otherOption.id}
id={choice.id}
name={question.id}
value={getLocalizedValue(otherOption.label, languageCode)}
tabIndex={-1}
value={getLocalizedValue(choice.label, languageCode)}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
onChange={() => {
if (otherSelected) {
setOtherValue("");
onChange({
[question.id]: value.filter((item) => {
return getChoicesWithoutOtherLabels().includes(item);
}),
});
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
if ((e.target as HTMLInputElement).checked) {
addItem(getLocalizedValue(choice.label, languageCode));
} else {
removeItem(getLocalizedValue(choice.label, languageCode));
}
setOtherSelected(!otherSelected);
}}
checked={otherSelected}
checked={
Array.isArray(value) &&
value.includes(getLocalizedValue(choice.label, languageCode))
}
required={getIsRequired()}
/>
<span id={`${otherOption.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
{getLocalizedValue(otherOption.label, languageCode)}
<span id={`${choice.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
{getLocalizedValue(choice.label, languageCode)}
</span>
</span>
{otherSelected ? (
<input
ref={otherSpecify}
dir={otherOptionDir}
id={`${otherOption.id}-label`}
maxLength={250}
name={question.id}
tabIndex={isCurrent ? 0 : -1}
value={otherValue}
pattern=".*\S+.*"
onChange={(e) => {
setOtherValue(e.currentTarget.value);
}}
className="placeholder:fb-text-placeholder fb-border-border fb-bg-survey-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-mt-3 fb-flex fb-h-10 fb-w-full fb-border fb-px-3 fb-py-2 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50"
placeholder={
getLocalizedValue(question.otherOptionPlaceholder, languageCode).length > 0
? getLocalizedValue(question.otherOptionPlaceholder, languageCode)
: "Please specify"
}
required={question.required}
aria-labelledby={`${otherOption.id}-label`}
onBlur={() => {
const newValue = value.filter((item) => {
return getChoicesWithoutOtherLabels().includes(item);
});
if (otherValue && otherSelected) {
newValue.push(otherValue);
onChange({ [question.id]: newValue });
}
}}
/>
) : null}
</label>
) : null}
</div>
</fieldset>
</div>
);
})}
{otherOption ? (
<label
tabIndex={isCurrent ? 0 : -1}
className={cn(
otherSelected ? "fb-border-brand fb-bg-input-bg-selected fb-z-10" : "fb-border-border",
"fb-text-heading focus-within:fb-border-brand fb-bg-input-bg focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
if (otherSelected) return;
document.getElementById(otherOption.id)?.click();
document.getElementById(otherOption.id)?.focus();
}
}}>
<span className="fb-flex fb-items-center fb-text-sm" dir="auto">
<input
type="checkbox"
tabIndex={-1}
id={otherOption.id}
name={question.id}
value={getLocalizedValue(otherOption.label, languageCode)}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
onChange={() => {
if (otherSelected) {
setOtherValue("");
onChange({
[question.id]: value.filter((item) => {
return getChoicesWithoutOtherLabels().includes(item);
}),
});
}
setOtherSelected(!otherSelected);
}}
checked={otherSelected}
/>
<span id={`${otherOption.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
{getLocalizedValue(otherOption.label, languageCode)}
</span>
</span>
{otherSelected ? (
<input
ref={otherSpecify}
dir={otherOptionDir}
id={`${otherOption.id}-label`}
maxLength={250}
name={question.id}
tabIndex={isCurrent ? 0 : -1}
value={otherValue}
pattern=".*\S+.*"
onChange={(e) => {
setOtherValue(e.currentTarget.value);
}}
className="placeholder:fb-text-placeholder fb-border-border fb-bg-survey-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-mt-3 fb-flex fb-h-10 fb-w-full fb-border fb-px-3 fb-py-2 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50"
placeholder={
getLocalizedValue(question.otherOptionPlaceholder, languageCode).length > 0
? getLocalizedValue(question.otherOptionPlaceholder, languageCode)
: "Please specify"
}
required={question.required}
aria-labelledby={`${otherOption.id}-label`}
onBlur={() => {
const newValue = value.filter((item) => {
return getChoicesWithoutOtherLabels().includes(item);
});
if (otherValue && otherSelected) {
newValue.push(otherValue);
onChange({ [question.id]: newValue });
}
}}
/>
) : null}
</label>
) : null}
</div>
</fieldset>
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
)}
</div>
</form>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
/>
)}
</div>
</form>
</ScrollableContainer>
);
}

View File

@@ -107,168 +107,164 @@ export function MultipleChoiceSingleQuestion({
}, [languageCode, question.otherOptionPlaceholder, value]);
return (
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: value ?? "" }, updatedTtcObj);
}}
className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Options</legend>
<ScrollableContainer>
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: value ?? "" }, updatedTtcObj);
}}
className="fb-w-full">
{isMediaAvailable ? <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} /> : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Options</legend>
<div
className="fb-bg-survey-bg fb-relative fb-space-y-2"
role="radiogroup"
ref={choicesContainerRef}>
{questionChoices.map((choice, idx) => {
if (!choice || choice.id === "other") return;
return (
<label
dir="auto"
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
className={cn(
value === getLocalizedValue(choice.label, languageCode)
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border",
"fb-text-heading fb-bg-input-bg focus-within:fb-border-brand focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(choice.id)?.click();
document.getElementById(choice.id)?.focus();
}
}}
autoFocus={idx === 0 && autoFocusEnabled}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
type="radio"
id={choice.id}
name={question.id}
value={getLocalizedValue(choice.label, languageCode)}
dir="auto"
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={() => {
setOtherSelected(false);
onChange({ [question.id]: getLocalizedValue(choice.label, languageCode) });
}}
checked={value === getLocalizedValue(choice.label, languageCode)}
required={question.required ? idx === 0 : undefined}
/>
<span id={`${choice.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
{getLocalizedValue(choice.label, languageCode)}
</span>
</span>
</label>
);
})}
{otherOption ? (
<div
className="fb-bg-survey-bg fb-relative fb-space-y-2"
role="radiogroup"
ref={choicesContainerRef}>
{questionChoices.map((choice, idx) => {
if (!choice || choice.id === "other") return;
return (
<label
dir="auto"
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
className={cn(
value === getLocalizedValue(otherOption.label, languageCode)
value === getLocalizedValue(choice.label, languageCode)
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border",
"fb-text-heading focus-within:fb-border-brand fb-bg-input-bg focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
"fb-text-heading fb-bg-input-bg focus-within:fb-border-brand focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
if (otherSelected) return;
document.getElementById(otherOption.id)?.click();
document.getElementById(otherOption.id)?.focus();
e.preventDefault();
document.getElementById(choice.id)?.click();
document.getElementById(choice.id)?.focus();
}
}}>
<span className="fb-flex fb-items-center fb-text-sm" dir="auto">
}}
autoFocus={idx === 0 && autoFocusEnabled}>
<span className="fb-flex fb-items-center fb-text-sm">
<input
tabIndex={-1}
dir="auto"
type="radio"
id={otherOption.id}
id={choice.id}
name={question.id}
value={getLocalizedValue(otherOption.label, languageCode)}
value={getLocalizedValue(choice.label, languageCode)}
dir="auto"
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
aria-labelledby={`${choice.id}-label`}
onChange={() => {
setOtherSelected(!otherSelected);
onChange({ [question.id]: "" });
setOtherSelected(false);
onChange({ [question.id]: getLocalizedValue(choice.label, languageCode) });
}}
checked={otherSelected}
checked={value === getLocalizedValue(choice.label, languageCode)}
required={question.required ? idx === 0 : undefined}
/>
<span id={`${otherOption.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
{getLocalizedValue(otherOption.label, languageCode)}
<span id={`${choice.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
{getLocalizedValue(choice.label, languageCode)}
</span>
</span>
{otherSelected ? (
<input
ref={otherSpecify}
id={`${otherOption.id}-label`}
dir={otherOptionDir}
name={question.id}
pattern=".*\S+.*"
value={value}
onChange={(e) => {
onChange({ [question.id]: e.currentTarget.value });
}}
className="placeholder:fb-text-placeholder fb-border-border fb-bg-survey-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-mt-3 fb-flex fb-h-10 fb-w-full fb-border fb-px-3 fb-py-2 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50"
placeholder={
getLocalizedValue(question.otherOptionPlaceholder, languageCode).length > 0
? getLocalizedValue(question.otherOptionPlaceholder, languageCode)
: "Please specify"
}
required={question.required}
aria-labelledby={`${otherOption.id}-label`}
maxLength={250}
/>
) : null}
</label>
) : null}
</div>
</fieldset>
</div>
);
})}
{otherOption ? (
<label
dir="auto"
tabIndex={isCurrent ? 0 : -1}
className={cn(
value === getLocalizedValue(otherOption.label, languageCode)
? "fb-border-brand fb-bg-input-bg-selected fb-z-10"
: "fb-border-border",
"fb-text-heading focus-within:fb-border-brand fb-bg-input-bg focus-within:fb-bg-input-bg-selected hover:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-flex fb-cursor-pointer fb-flex-col fb-border fb-p-4 focus:fb-outline-none"
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
if (otherSelected) return;
document.getElementById(otherOption.id)?.click();
document.getElementById(otherOption.id)?.focus();
}
}}>
<span className="fb-flex fb-items-center fb-text-sm" dir="auto">
<input
tabIndex={-1}
dir="auto"
type="radio"
id={otherOption.id}
name={question.id}
value={getLocalizedValue(otherOption.label, languageCode)}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
onChange={() => {
setOtherSelected(!otherSelected);
onChange({ [question.id]: "" });
}}
checked={otherSelected}
/>
<span id={`${otherOption.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
{getLocalizedValue(otherOption.label, languageCode)}
</span>
</span>
{otherSelected ? (
<input
ref={otherSpecify}
id={`${otherOption.id}-label`}
dir={otherOptionDir}
name={question.id}
pattern=".*\S+.*"
value={value}
onChange={(e) => {
onChange({ [question.id]: e.currentTarget.value });
}}
className="placeholder:fb-text-placeholder fb-border-border fb-bg-survey-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-mt-3 fb-flex fb-h-10 fb-w-full fb-border fb-px-3 fb-py-2 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50"
placeholder={
getLocalizedValue(question.otherOptionPlaceholder, languageCode).length > 0
? getLocalizedValue(question.otherOptionPlaceholder, languageCode)
: "Please specify"
}
required={question.required}
aria-labelledby={`${otherOption.id}-label`}
maxLength={250}
/>
) : null}
</label>
) : null}
</div>
</fieldset>
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
)}
</div>
</form>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
tabIndex={isCurrent ? 0 : -1}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
/>
)}
</div>
</form>
</ScrollableContainer>
);
}

View File

@@ -68,114 +68,114 @@ export function NPSQuestion({
};
return (
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: value ?? "" }, updatedTtcObj);
}}>
<ScrollableContainer>
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-my-4">
<fieldset>
<legend className="fb-sr-only">Options</legend>
<div className="fb-flex">
{Array.from({ length: 11 }, (_, i) => i).map((number, idx) => {
return (
<label
key={number}
tabIndex={isCurrent ? 0 : -1}
onMouseOver={() => {
setHoveredNumber(number);
}}
onMouseLeave={() => {
setHoveredNumber(-1);
}}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(number.toString())?.click();
document.getElementById(number.toString())?.focus();
}
}}
className={cn(
value === number
? "fb-border-border-highlight fb-bg-accent-selected-bg fb-z-10 fb-border"
: "fb-border-border",
"fb-text-heading first:fb-rounded-l-custom last:fb-rounded-r-custom focus:fb-border-brand fb-relative fb-h-10 fb-flex-1 fb-cursor-pointer fb-overflow-hidden fb-border-b fb-border-l fb-border-t fb-text-center fb-text-sm last:fb-border-r focus:fb-border-2 focus:fb-outline-none",
question.isColorCodingEnabled
? "fb-h-[46px] fb-leading-[3.5em]"
: "fb-h fb-leading-10",
hoveredNumber === number ? "fb-bg-accent-bg" : ""
)}>
{question.isColorCodingEnabled ? (
<div
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getNPSOptionColor(idx)}`}
/>
) : null}
<input
type="radio"
id={number.toString()}
name="nps"
value={number}
checked={value === number}
className="fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => {
handleClick(number);
}}
required={question.required}
tabIndex={-1}
<ScrollableContainer>
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: value ?? "" }, updatedTtcObj);
}}>
{isMediaAvailable ? <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} /> : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-my-4">
<fieldset>
<legend className="fb-sr-only">Options</legend>
<div className="fb-flex">
{Array.from({ length: 11 }, (_, i) => i).map((number, idx) => {
return (
<label
key={number}
tabIndex={isCurrent ? 0 : -1}
onMouseOver={() => {
setHoveredNumber(number);
}}
onMouseLeave={() => {
setHoveredNumber(-1);
}}
onFocus={() => {
setHoveredNumber(number);
}}
onBlur={() => {
setHoveredNumber(-1);
}}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(number.toString())?.click();
document.getElementById(number.toString())?.focus();
}
}}
className={cn(
value === number
? "fb-border-border-highlight fb-bg-accent-selected-bg fb-z-10 fb-border"
: "fb-border-border",
"fb-text-heading first:fb-rounded-l-custom last:fb-rounded-r-custom focus:fb-border-brand fb-relative fb-h-10 fb-flex-1 fb-cursor-pointer fb-overflow-hidden fb-border-b fb-border-l fb-border-t fb-text-center fb-text-sm last:fb-border-r focus:fb-border-2 focus:fb-outline-none",
question.isColorCodingEnabled ? "fb-h-[46px] fb-leading-[3.5em]" : "fb-h fb-leading-10",
hoveredNumber === number ? "fb-bg-accent-bg" : ""
)}>
{question.isColorCodingEnabled ? (
<div
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getNPSOptionColor(idx)}`}
/>
{number}
</label>
);
})}
</div>
<div className="fb-text-subheading fb-mt-2 fb-flex fb-justify-between fb-px-1.5 fb-text-xs fb-leading-6 fb-space-x-8">
<p dir="auto">{getLocalizedValue(question.lowerLabel, languageCode)}</p>
<p dir="auto">{getLocalizedValue(question.upperLabel, languageCode)}</p>
</div>
</fieldset>
</div>
) : null}
<input
type="radio"
id={number.toString()}
name="nps"
value={number}
checked={value === number}
className="fb-absolute fb-left-0 fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => {
handleClick(number);
}}
required={question.required}
tabIndex={-1}
/>
{number}
</label>
);
})}
</div>
<div className="fb-text-subheading fb-mt-2 fb-flex fb-justify-between fb-px-1.5 fb-text-xs fb-leading-6 fb-space-x-8">
<p dir="auto">{getLocalizedValue(question.lowerLabel, languageCode)}</p>
<p dir="auto">{getLocalizedValue(question.upperLabel, languageCode)}</p>
</div>
</fieldset>
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
{question.required ? (
<div></div>
) : (
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
)}
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
/>
)}
</div>
</form>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
{question.required ? (
<div></div>
) : (
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
)}
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
/>
)}
</div>
</form>
</ScrollableContainer>
);
}

View File

@@ -88,100 +88,96 @@ export function OpenTextQuestion({
}, [value, languageCode, question.placeholder]);
return (
<form key={question.id} onSubmit={handleOnSubmit} className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mt-4">
{question.longAnswer === false ? (
<input
ref={inputRef as RefObject<HTMLInputElement>}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
tabIndex={isCurrent ? 0 : -1}
name={question.id}
id={question.id}
placeholder={getLocalizedValue(question.placeholder, languageCode)}
dir={dir}
step="any"
required={question.required}
value={value ? value : ""}
type={question.inputType}
onInput={(e) => {
handleInputChange(e.currentTarget.value);
}}
className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0 sm:fb-text-sm"
pattern={question.inputType === "phone" ? "^[0-9+][0-9+\\- ]*[0-9]$" : ".*"}
title={question.inputType === "phone" ? "Enter a valid phone number" : undefined}
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
maxLength={
question.inputType === "text"
? question.charLimit?.max
: question.inputType === "phone"
? 30
: undefined
}
/>
) : (
<textarea
ref={inputRef as RefObject<HTMLTextAreaElement>}
rows={3}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
name={question.id}
tabIndex={isCurrent ? 0 : -1}
aria-label="textarea"
id={question.id}
placeholder={getLocalizedValue(question.placeholder, languageCode)}
dir={dir}
required={question.required}
value={value}
onInput={(e) => {
handleInputChange(e.currentTarget.value);
}}
className="fb-border-border placeholder:fb-text-placeholder fb-bg-input-bg fb-text-subheading focus:fb-border-brand fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm"
title={question.inputType === "phone" ? "Please enter a valid phone number" : undefined}
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
maxLength={question.inputType === "text" ? question.charLimit?.max : undefined}
/>
)}
{question.inputType === "text" && question.charLimit?.max !== undefined && (
<span
className={`fb-text-xs ${currentLength >= question.charLimit?.max ? "fb-text-red-500 font-semibold" : "text-neutral-400"}`}>
{currentLength}/{question.charLimit?.max}
</span>
)}
</div>
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
onClick={() => {}}
<ScrollableContainer>
<form key={question.id} onSubmit={handleOnSubmit} className="fb-w-full">
{isMediaAvailable ? <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} /> : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mt-4">
{question.longAnswer === false ? (
<input
ref={inputRef as RefObject<HTMLInputElement>}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
tabIndex={isCurrent ? 0 : -1}
name={question.id}
id={question.id}
placeholder={getLocalizedValue(question.placeholder, languageCode)}
dir={dir}
step="any"
required={question.required}
value={value ? value : ""}
type={question.inputType}
onInput={(e) => {
handleInputChange(e.currentTarget.value);
}}
className="fb-border-border placeholder:fb-text-placeholder fb-text-subheading focus:fb-border-brand fb-bg-input-bg fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-outline-none focus:fb-ring-0 sm:fb-text-sm"
pattern={question.inputType === "phone" ? "^[0-9+][0-9+\\- ]*[0-9]$" : ".*"}
title={question.inputType === "phone" ? "Enter a valid phone number" : undefined}
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
maxLength={
question.inputType === "text"
? question.charLimit?.max
: question.inputType === "phone"
? 30
: undefined
}
/>
) : (
<textarea
ref={inputRef as RefObject<HTMLTextAreaElement>}
rows={3}
autoFocus={isCurrent ? autoFocusEnabled : undefined}
name={question.id}
tabIndex={isCurrent ? 0 : -1}
aria-label="textarea"
id={question.id}
placeholder={getLocalizedValue(question.placeholder, languageCode)}
dir={dir}
required={question.required}
value={value}
onInput={(e) => {
handleInputChange(e.currentTarget.value);
}}
className="fb-border-border placeholder:fb-text-placeholder fb-bg-input-bg fb-text-subheading focus:fb-border-brand fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm"
title={question.inputType === "phone" ? "Please enter a valid phone number" : undefined}
minLength={question.inputType === "text" ? question.charLimit?.min : undefined}
maxLength={question.inputType === "text" ? question.charLimit?.max : undefined}
/>
)}
{question.inputType === "text" && question.charLimit?.max !== undefined && (
<span
className={`fb-text-xs ${currentLength >= question.charLimit?.max ? "fb-text-red-500 font-semibold" : "text-neutral-400"}`}>
{currentLength}/{question.charLimit?.max}
</span>
)}
</div>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);
onBack();
}}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
onClick={() => {}}
/>
)}
</div>
</form>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);
onBack();
}}
/>
)}
</div>
</form>
</ScrollableContainer>
);
}

View File

@@ -96,153 +96,151 @@ export function PictureSelectionQuestion({
const questionChoices = question.choices;
return (
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: value }, updatedTtcObj);
}}
className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Options</legend>
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-1 sm:fb-grid-cols-2 fb-gap-4">
{questionChoices.map((choice) => (
<div className="fb-relative" key={choice.id}>
<button
type="button"
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
e.currentTarget.click();
e.currentTarget.focus();
}
}}
onClick={() => {
handleChange(choice.id);
}}
className={cn(
"fb-relative fb-w-full fb-cursor-pointer fb-overflow-hidden fb-border fb-rounded-custom focus-visible:fb-outline-none focus-visible:fb-ring-2 focus-visible:fb-ring-brand focus-visible:fb-ring-offset-2 fb-aspect-[4/3] fb-min-h-[7rem] fb-max-h-[50vh] group/image",
Array.isArray(value) && value.includes(choice.id)
? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm"
: ""
)}>
{loadingImages[choice.id] && (
<div className="fb-absolute fb-inset-0 fb-flex fb-h-full fb-w-full fb-animate-pulse fb-items-center fb-justify-center fb-rounded-md fb-bg-slate-200" />
)}
<img
src={choice.imageUrl}
id={choice.id}
alt={getOriginalFileNameFromUrl(choice.imageUrl)}
className={cn(
"fb-h-full fb-w-full fb-object-cover",
loadingImages[choice.id] ? "fb-opacity-0" : ""
)}
onLoad={() => {
setLoadingImages((prev) => ({ ...prev, [choice.id]: false }));
}}
onError={() => {
setLoadingImages((prev) => ({ ...prev, [choice.id]: false }));
}}
/>
{question.allowMulti ? (
<input
id={`${choice.id}-checked`}
name={`${choice.id}-checkbox`}
type="checkbox"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length ? false : question.required}
/>
) : (
<input
id={`${choice.id}-radio`}
name={`${question.id}`}
type="radio"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length ? false : question.required}
/>
)}
</button>
<a
tabIndex={-1}
href={choice.imageUrl}
target="_blank"
title="Open in new tab"
rel="noreferrer"
onClick={(e) => {
e.stopPropagation();
}}
className="fb-absolute fb-bottom-4 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100 fb-z-20">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-image-down-icon lucide-image-down">
<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21" />
<path d="m14 19 3 3v-5.5" />
<path d="m17 22 3-3" />
<circle cx="9" cy="9" r="2" />
</svg>
</a>
</div>
))}
</div>
</fieldset>
</div>
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
<ScrollableContainer>
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: value }, updatedTtcObj);
}}
className="fb-w-full">
{isMediaAvailable ? <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} /> : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Options</legend>
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-1 sm:fb-grid-cols-2 fb-gap-4">
{questionChoices.map((choice) => (
<div className="fb-relative" key={choice.id}>
<button
type="button"
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
e.currentTarget.click();
e.currentTarget.focus();
}
}}
onClick={() => {
handleChange(choice.id);
}}
className={cn(
"fb-relative fb-w-full fb-cursor-pointer fb-overflow-hidden fb-border fb-rounded-custom focus-visible:fb-outline-none focus-visible:fb-ring-2 focus-visible:fb-ring-brand focus-visible:fb-ring-offset-2 fb-aspect-[4/3] fb-min-h-[7rem] fb-max-h-[50vh] group/image",
Array.isArray(value) && value.includes(choice.id)
? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm"
: ""
)}>
{loadingImages[choice.id] && (
<div className="fb-absolute fb-inset-0 fb-flex fb-h-full fb-w-full fb-animate-pulse fb-items-center fb-justify-center fb-rounded-md fb-bg-slate-200" />
)}
<img
src={choice.imageUrl}
id={choice.id}
alt={getOriginalFileNameFromUrl(choice.imageUrl)}
className={cn(
"fb-h-full fb-w-full fb-object-cover",
loadingImages[choice.id] ? "fb-opacity-0" : ""
)}
onLoad={() => {
setLoadingImages((prev) => ({ ...prev, [choice.id]: false }));
}}
onError={() => {
setLoadingImages((prev) => ({ ...prev, [choice.id]: false }));
}}
/>
{question.allowMulti ? (
<input
id={`${choice.id}-checked`}
name={`${choice.id}-checkbox`}
type="checkbox"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length === 0}
/>
) : (
<input
id={`${choice.id}-radio`}
name={`${question.id}`}
type="radio"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length ? false : question.required}
/>
)}
</button>
<a
tabIndex={-1}
href={choice.imageUrl}
target="_blank"
title="Open in new tab"
rel="noreferrer"
onClick={(e) => {
e.stopPropagation();
}}
className="fb-absolute fb-bottom-4 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100 fb-z-20">
<span className="fb-sr-only">Open in new tab</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="lucide lucide-image-down-icon lucide-image-down">
<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21" />
<path d="m14 19 3 3v-5.5" />
<path d="m17 22 3-3" />
<circle cx="9" cy="9" r="2" />
</svg>
</a>
</div>
))}
</div>
</fieldset>
</div>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
)}
</div>
</form>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
/>
)}
</div>
</form>
</ScrollableContainer>
);
}

View File

@@ -154,153 +154,148 @@ export function RankingQuestion({
};
return (
<form onSubmit={handleSubmit} className="fb-w-full">
<ScrollableContainer ref={scrollableRef}>
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Ranking Items</legend>
<div className="fb-relative" ref={parent}>
{[...sortedItems, ...unsortedItems].map((item, idx) => {
if (!item) return null;
const isSorted = sortedItems.includes(item);
const isFirst = isSorted && idx === 0;
const isLast = isSorted && idx === sortedItems.length - 1;
<ScrollableContainer ref={scrollableRef}>
<form onSubmit={handleSubmit} className="fb-w-full">
{isMediaAvailable ? <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} /> : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Ranking Items</legend>
<div className="fb-relative" ref={parent}>
{[...sortedItems, ...unsortedItems].map((item, idx) => {
if (!item) return null;
const isSorted = sortedItems.includes(item);
const isFirst = isSorted && idx === 0;
const isLast = isSorted && idx === sortedItems.length - 1;
return (
<div
key={item.id}
className={cn(
"fb-flex fb-h-12 fb-items-center fb-mb-2 fb-border fb-border-border fb-transition-all fb-text-heading hover:fb-bg-input-bg-selected focus-within:fb-border-brand focus-within:fb-shadow-outline focus-within:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-cursor-pointer w-full focus:outline-none",
isSorted ? "fb-bg-input-bg-selected" : "fb-bg-input-bg"
)}>
<button
autoFocus={idx === 0 && autoFocusEnabled}
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();
handleItemClick(item);
}
}}
onClick={(e) => {
return (
<div
key={item.id}
className={cn(
"fb-flex fb-h-12 fb-items-center fb-mb-2 fb-border fb-border-border fb-transition-all fb-text-heading hover:fb-bg-input-bg-selected focus-within:fb-border-brand focus-within:fb-shadow-outline focus-within:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-cursor-pointer w-full focus:outline-none",
isSorted ? "fb-bg-input-bg-selected" : "fb-bg-input-bg"
)}>
<button
autoFocus={idx === 0 && autoFocusEnabled}
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();
handleItemClick(item);
}}
type="button"
aria-label={`Select ${getLocalizedValue(item.label, languageCode)} for ranking`}
className="fb-flex fb-gap-x-4 fb-px-4 fb-items-center fb-grow fb-h-full group text-left focus:outline-none">
<span
}
}}
onClick={(e) => {
e.preventDefault();
handleItemClick(item);
}}
type="button"
aria-label={`Select ${getLocalizedValue(item.label, languageCode)} for ranking`}
className="fb-flex fb-gap-x-4 fb-px-4 fb-items-center fb-grow fb-h-full group text-left focus:outline-none">
<span
className={cn(
"fb-w-6 fb-grow-0 fb-h-6 fb-flex fb-items-center fb-justify-center fb-rounded-full fb-text-xs fb-font-semibold fb-border-brand fb-border",
isSorted
? "fb-bg-brand fb-text-white fb-border"
: "fb-border-dashed group-hover:fb-bg-white fb-text-transparent group-hover:fb-text-heading"
)}>
{(idx + 1).toString()}
</span>
<div className="fb-grow fb-shrink fb-font-medium fb-text-sm fb-text-start" dir="auto">
{getLocalizedValue(item.label, languageCode)}
</div>
</button>
{isSorted ? (
<div className="fb-flex fb-flex-col fb-h-full fb-grow-0 fb-border-l fb-border-border">
<button
tabIndex={isFirst ? -1 : 0}
type="button"
onClick={(e) => {
e.preventDefault();
handleMove(item.id, "up");
}}
aria-label={`Move ${getLocalizedValue(item.label, languageCode)} up`}
className={cn(
"fb-w-6 fb-grow-0 fb-h-6 fb-flex fb-items-center fb-justify-center fb-rounded-full fb-text-xs fb-font-semibold fb-border-brand fb-border",
isSorted
? "fb-bg-brand fb-text-white fb-border"
: "fb-border-dashed group-hover:fb-bg-white fb-text-transparent group-hover:fb-text-heading"
)}>
{(idx + 1).toString()}
</span>
<div className="fb-grow fb-shrink fb-font-medium fb-text-sm fb-text-start" dir="auto">
{getLocalizedValue(item.label, languageCode)}
</div>
</button>
{isSorted ? (
<div className="fb-flex fb-flex-col fb-h-full fb-grow-0 fb-border-l fb-border-border">
<button
tabIndex={isFirst ? -1 : 0}
type="button"
onClick={(e) => {
e.preventDefault();
handleMove(item.id, "up");
}}
aria-label={`Move ${getLocalizedValue(item.label, languageCode)} up`}
className={cn(
"fb-px-2 fb-flex fb-flex-1 fb-items-center fb-justify-center",
isFirst
? "fb-opacity-30 fb-cursor-not-allowed"
: "hover:fb-bg-black/5 fb-rounded-tr-custom fb-transition-colors"
)}
disabled={isFirst}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-chevron-up">
<path d="m18 15-6-6-6 6" />
</svg>
</button>
<button
tabIndex={isLast ? -1 : 0}
type="button"
onClick={(e) => {
e.preventDefault();
handleMove(item.id, "down");
}}
className={cn(
"fb-px-2 fb-flex-1 fb-border-t fb-border-border fb-flex fb-items-center fb-justify-center",
isLast
? "fb-opacity-30 fb-cursor-not-allowed"
: "hover:fb-bg-black/5 fb-rounded-br-custom fb-transition-colors"
)}
aria-label={`Move ${getLocalizedValue(item.label, languageCode)} down`}
disabled={isLast}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-chevron-down">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</div>
) : null}
</div>
);
})}
</div>
</fieldset>
</div>
{error ? <div className="fb-text-red-500 fb-mt-2 fb-text-sm">{error}</div> : null}
"fb-px-2 fb-flex fb-flex-1 fb-items-center fb-justify-center",
isFirst
? "fb-opacity-30 fb-cursor-not-allowed"
: "hover:fb-bg-black/5 fb-rounded-tr-custom fb-transition-colors"
)}
disabled={isFirst}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-chevron-up">
<path d="m18 15-6-6-6 6" />
</svg>
</button>
<button
tabIndex={isLast ? -1 : 0}
type="button"
onClick={(e) => {
e.preventDefault();
handleMove(item.id, "down");
}}
className={cn(
"fb-px-2 fb-flex-1 fb-border-t fb-border-border fb-flex fb-items-center fb-justify-center",
isLast
? "fb-opacity-30 fb-cursor-not-allowed"
: "hover:fb-bg-black/5 fb-rounded-br-custom fb-transition-colors"
)}
aria-label={`Move ${getLocalizedValue(item.label, languageCode)} down`}
disabled={isLast}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-chevron-down">
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</div>
) : null}
</div>
);
})}
</div>
</fieldset>
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
{error ? <div className="fb-text-red-500 fb-mt-2 fb-text-sm">{error}</div> : null}
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
onClick={handleBack}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
)}
</div>
</form>
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
tabIndex={isCurrent ? 0 : -1}
onClick={handleBack}
/>
)}
</div>
</form>
</ScrollableContainer>
);
}

View File

@@ -111,179 +111,175 @@ export function RatingQuestion({
};
return (
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: value ?? "" }, updatedTtcObj);
}}
className="fb-w-full">
<ScrollableContainer>
<div>
{isMediaAvailable ? (
<QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} />
) : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mb-4 fb-mt-6 fb-flex fb-items-center fb-justify-center">
<fieldset className="fb-w-full">
<legend className="fb-sr-only">Choices</legend>
<div className="fb-flex fb-w-full">
{Array.from({ length: question.range }, (_, i) => i + 1).map((number, i, a) => (
<span
key={number}
onMouseOver={() => {
setHoveredNumber(number);
}}
onMouseLeave={() => {
setHoveredNumber(0);
}}
className="fb-bg-survey-bg fb-flex-1 fb-text-center fb-text-sm">
{question.scale === "number" ? (
<label
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(number.toString())?.click();
document.getElementById(number.toString())?.focus();
}
}}
className={cn(
value === number
? "fb-bg-accent-selected-bg fb-border-border-highlight fb-z-10 fb-border"
: "fb-border-border",
a.length === number ? "fb-rounded-r-custom fb-border-r" : "",
number === 1 ? "fb-rounded-l-custom" : "",
hoveredNumber === number ? "fb-bg-accent-bg" : "",
question.isColorCodingEnabled ? "fb-min-h-[47px]" : "fb-min-h-[41px]",
"fb-text-heading focus:fb-border-brand fb-relative fb-flex fb-w-full fb-cursor-pointer fb-items-center fb-justify-center fb-overflow-hidden fb-border-b fb-border-l fb-border-t focus:fb-border-2 focus:fb-outline-none"
)}>
{question.isColorCodingEnabled ? (
<div
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getRatingNumberOptionColor(question.range, number)}`}
<ScrollableContainer>
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: value ?? "" }, updatedTtcObj);
}}
className="fb-w-full">
{isMediaAvailable ? <QuestionMedia imgUrl={question.imageUrl} videoUrl={question.videoUrl} /> : null}
<Headline
headline={getLocalizedValue(question.headline, languageCode)}
questionId={question.id}
required={question.required}
/>
<Subheader
subheader={question.subheader ? getLocalizedValue(question.subheader, languageCode) : ""}
questionId={question.id}
/>
<div className="fb-mb-4 fb-mt-6 fb-flex fb-items-center fb-justify-center">
<fieldset className="fb-w-full">
<legend className="fb-sr-only">Choices</legend>
<div className="fb-flex fb-w-full">
{Array.from({ length: question.range }, (_, i) => i + 1).map((number, i, a) => (
<span
key={number}
onMouseOver={() => {
setHoveredNumber(number);
}}
onMouseLeave={() => {
setHoveredNumber(0);
}}
className="fb-bg-survey-bg fb-flex-1 fb-text-center fb-text-sm">
{question.scale === "number" ? (
<label
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(number.toString())?.click();
document.getElementById(number.toString())?.focus();
}
}}
className={cn(
value === number
? "fb-bg-accent-selected-bg fb-border-border-highlight fb-z-10 fb-border"
: "fb-border-border",
a.length === number ? "fb-rounded-r-custom fb-border-r" : "",
number === 1 ? "fb-rounded-l-custom" : "",
hoveredNumber === number ? "fb-bg-accent-bg" : "",
question.isColorCodingEnabled ? "fb-min-h-[47px]" : "fb-min-h-[41px]",
"fb-text-heading focus:fb-border-brand fb-relative fb-flex fb-w-full fb-cursor-pointer fb-items-center fb-justify-center fb-overflow-hidden fb-border-b fb-border-l fb-border-t focus:fb-border-2 focus:fb-outline-none"
)}>
{question.isColorCodingEnabled ? (
<div
className={`fb-absolute fb-left-0 fb-top-0 fb-h-[6px] fb-w-full ${getRatingNumberOptionColor(question.range, number)}`}
/>
) : null}
<HiddenRadioInput number={number} id={number.toString()} />
{number}
</label>
) : question.scale === "star" ? (
<label
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(number.toString())?.click();
document.getElementById(number.toString())?.focus();
}
}}
className={cn(
number <= hoveredNumber || number <= value!
? "fb-text-amber-400"
: "fb-text-[#8696AC]",
hoveredNumber === number ? "fb-text-amber-400" : "",
"fb-relative fb-flex fb-max-h-16 fb-min-h-9 fb-cursor-pointer fb-justify-center focus:fb-outline-none"
)}
onFocus={() => {
setHoveredNumber(number);
}}
onBlur={() => {
setHoveredNumber(0);
}}>
<HiddenRadioInput number={number} id={number.toString()} />
<div className="fb-h-full fb-w-full fb-max-w-[74px] fb-object-contain">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
fillRule="evenodd"
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
/>
) : null}
<HiddenRadioInput number={number} id={number.toString()} />
{number}
</label>
) : question.scale === "star" ? (
<label
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(number.toString())?.click();
document.getElementById(number.toString())?.focus();
}
}}
className={cn(
number <= hoveredNumber || number <= value!
? "fb-text-amber-400"
: "fb-text-[#8696AC]",
hoveredNumber === number ? "fb-text-amber-400" : "",
"fb-relative fb-flex fb-max-h-16 fb-min-h-9 fb-cursor-pointer fb-justify-center focus:fb-outline-none"
)}
onFocus={() => {
setHoveredNumber(number);
}}
onBlur={() => {
setHoveredNumber(0);
}}>
<HiddenRadioInput number={number} id={number.toString()} />
<div className="fb-h-full fb-w-full fb-max-w-[74px] fb-object-contain">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
fillRule="evenodd"
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
/>
</svg>
</div>
</label>
) : (
<label
tabIndex={isCurrent ? 0 : -1}
className={cn(
"fb-relative fb-flex fb-max-h-16 fb-min-h-9 fb-w-full fb-cursor-pointer fb-justify-center",
value === number || hoveredNumber === number
? "fb-stroke-rating-selected fb-text-rating-selected"
: "fb-stroke-heading fb-text-heading focus:fb-border-accent-bg focus:fb-border-2 focus:fb-outline-none"
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(number.toString())?.click();
document.getElementById(number.toString())?.focus();
}
}}
onFocus={() => {
setHoveredNumber(number);
}}
onBlur={() => {
setHoveredNumber(0);
}}>
<HiddenRadioInput number={number} id={number.toString()} />
<div className={cn("fb-h-full fb-w-full fb-max-w-[74px] fb-object-contain")}>
<RatingSmiley
active={value === number || hoveredNumber === number}
idx={i}
range={question.range}
addColors={question.isColorCodingEnabled}
/>
</div>
</label>
)}
</span>
))}
</div>
<div className="fb-text-subheading fb-mt-4 fb-flex fb-justify-between fb-px-1.5 fb-text-xs fb-leading-6 fb-space-x-8">
<p className="fb-w-1/2 fb-text-left" dir="auto">
{getLocalizedValue(question.lowerLabel, languageCode)}
</p>
<p className="fb-w-1/2 fb-text-right" dir="auto">
{getLocalizedValue(question.upperLabel, languageCode)}
</p>
</div>
</fieldset>
</div>
</svg>
</div>
</label>
) : (
<label
tabIndex={isCurrent ? 0 : -1}
className={cn(
"fb-relative fb-flex fb-max-h-16 fb-min-h-9 fb-w-full fb-cursor-pointer fb-justify-center",
value === number || hoveredNumber === number
? "fb-stroke-rating-selected fb-text-rating-selected"
: "fb-stroke-heading fb-text-heading focus:fb-border-accent-bg focus:fb-border-2 focus:fb-outline-none"
)}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(number.toString())?.click();
document.getElementById(number.toString())?.focus();
}
}}
onFocus={() => {
setHoveredNumber(number);
}}
onBlur={() => {
setHoveredNumber(0);
}}>
<HiddenRadioInput number={number} id={number.toString()} />
<div className={cn("fb-h-full fb-w-full fb-max-w-[74px] fb-object-contain")}>
<RatingSmiley
active={value === number || hoveredNumber === number}
idx={i}
range={question.range}
addColors={question.isColorCodingEnabled}
/>
</div>
</label>
)}
</span>
))}
</div>
<div className="fb-text-subheading fb-mt-4 fb-flex fb-justify-between fb-px-1.5 fb-text-xs fb-leading-6 fb-space-x-8">
<p className="fb-w-1/2 fb-text-left" dir="auto">
{getLocalizedValue(question.lowerLabel, languageCode)}
</p>
<p className="fb-w-1/2 fb-text-right" dir="auto">
{getLocalizedValue(question.upperLabel, languageCode)}
</p>
</div>
</fieldset>
</div>
</ScrollableContainer>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-px-6 fb-py-4">
{question.required ? (
<div></div>
) : (
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
)}
<div />
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
/>
)}
</div>
</form>
<div className="fb-flex fb-flex-row-reverse fb-w-full fb-justify-between fb-pt-4">
{question.required ? (
<div></div>
) : (
<SubmitButton
tabIndex={isCurrent ? 0 : -1}
buttonLabel={getLocalizedValue(question.buttonLabel, languageCode)}
isLastQuestion={isLastQuestion}
/>
)}
<div />
{!isFirstQuestion && !isBackButtonHidden && (
<BackButton
tabIndex={isCurrent ? 0 : -1}
backButtonLabel={getLocalizedValue(question.backButtonLabel, languageCode)}
onClick={() => {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onBack();
}}
/>
)}
</div>
</form>
</ScrollableContainer>
);
}

View File

@@ -22,9 +22,14 @@ export const ScrollableContainer = forwardRef<ScrollableContainerHandle, Scrolla
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
setIsAtBottom(Math.round(scrollTop) + clientHeight >= scrollHeight);
// Use a small tolerance to account for zoom-related precision issues
const tolerance = 1;
setIsAtTop(scrollTop === 0);
// Check if at bottom with tolerance
setIsAtBottom(scrollTop + clientHeight >= scrollHeight - tolerance);
// Check if at top with tolerance
setIsAtTop(scrollTop <= tolerance);
};
const scrollToBottom = () => {
@@ -59,7 +64,7 @@ export const ScrollableContainer = forwardRef<ScrollableContainerHandle, Scrolla
return (
<div className="fb-relative">
{!isAtTop && (
<div className="fb-from-survey-bg fb-absolute fb-left-0 fb-right-2 fb-top-0 fb-z-10 fb-h-6 fb-bg-gradient-to-b fb-to-transparent" />
<div className="fb-from-survey-bg fb-absolute fb-left-0 fb-right-2 fb-top-0 fb-z-10 fb-h-4 fb-bg-gradient-to-b fb-to-transparent" />
)}
<div
ref={containerRef}
@@ -67,11 +72,11 @@ export const ScrollableContainer = forwardRef<ScrollableContainerHandle, Scrolla
scrollbarGutter: "stable both-edges",
maxHeight: isSurveyPreview ? "42dvh" : "60dvh",
}}
className={cn("fb-overflow-auto fb-px-4 fb-pb-4 fb-bg-survey-bg")}>
className={cn("fb-overflow-auto fb-px-4 fb-pb-1 fb-bg-survey-bg")}>
{children}
</div>
{!isAtBottom && (
<div className="fb-from-survey-bg fb-absolute -fb-bottom-2 fb-left-0 fb-right-2 fb-h-8 fb-bg-gradient-to-t fb-to-transparent" />
<div className="fb-from-survey-bg fb-absolute fb-bottom-0 fb-left-4 fb-right-4 fb-h-4 fb-bg-gradient-to-t fb-to-transparent" />
)}
</div>
);

View File

@@ -10,14 +10,19 @@ export const ZActionClassMatchType = z.union([
z.literal("notContains"),
]);
export const ZActionClassPageUrlRule = z.union([
z.literal("exactMatch"),
z.literal("contains"),
z.literal("startsWith"),
z.literal("endsWith"),
z.literal("notMatch"),
z.literal("notContains"),
]);
// Define the rule values as a const array to avoid duplication
export const ACTION_CLASS_PAGE_URL_RULES = [
"exactMatch",
"contains",
"startsWith",
"endsWith",
"notMatch",
"notContains",
"matchesRegex",
] as const;
// Create Zod schema from the const array
export const ZActionClassPageUrlRule = z.enum(ACTION_CLASS_PAGE_URL_RULES);
export type TActionClassPageUrlRule = z.infer<typeof ZActionClassPageUrlRule>;

View File

@@ -0,0 +1,23 @@
diff --git a/core/lib/oauth/client.js b/core/lib/oauth/client.js
index 52c51eb6ff422dc0899ccec31baf3fa39e42eeae..d33754cb23f5fb949b367b4ed159e53cb12723fa 100644
--- a/core/lib/oauth/client.js
+++ b/core/lib/oauth/client.js
@@ -5,9 +5,17 @@ Object.defineProperty(exports, "__esModule", {
});
exports.openidClient = openidClient;
var _openidClient = require("openid-client");
+var httpProxyAgent = require("https-proxy-agent");
async function openidClient(options) {
const provider = options.provider;
- if (provider.httpOptions) _openidClient.custom.setHttpOptionsDefaults(provider.httpOptions);
+ let httpOptions = {};
+ if (provider.httpOptions) httpOptions = { ...provider.httpOptions };
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.https_proxy || process.env.http_proxy;
+ if(proxyUrl) {
+ const agent = new httpProxyAgent.HttpsProxyAgent(proxyUrl);
+ httpOptions.agent = agent;
+ }
+ _openidClient.custom.setHttpOptionsDefaults(httpOptions);
let issuer;
if (provider.wellKnown) {
issuer = await _openidClient.Issuer.discover(provider.wellKnown);

1564
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff