mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-28 09:20:49 -06:00
Compare commits
10 Commits
fix-back-b
...
chore/dont
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b3a41272e | ||
|
|
83a38c242e | ||
|
|
c4181a1c9c | ||
|
|
14de2eab42 | ||
|
|
ad1f80331a | ||
|
|
3527ac337b | ||
|
|
23c2d3dce9 | ||
|
|
da652bd860 | ||
|
|
6f88dde1a0 | ||
|
|
3b90223101 |
@@ -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: {
|
||||
|
||||
107
.github/workflows/docker-build-validation.yml
vendored
107
.github/workflows/docker-build-validation.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -92,7 +92,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
|
||||
setSelectedFilter({
|
||||
filter: [...filterObject.filter],
|
||||
onlyComplete: filterObject.onlyComplete,
|
||||
responseStatus: filterObject.responseStatus,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "I’d 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 I’d 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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
236
apps/web/modules/survey/editor/lib/action-builder.test.ts
Normal file
236
apps/web/modules/survey/editor/lib/action-builder.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
52
apps/web/modules/survey/editor/lib/action-builder.ts
Normal file
52
apps/web/modules/survey/editor/lib/action-builder.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
406
apps/web/modules/survey/editor/lib/action-utils.test.ts
Normal file
406
apps/web/modules/survey/editor/lib/action-utils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
129
apps/web/modules/survey/editor/lib/action-utils.ts
Normal file
129
apps/web/modules/survey/editor/lib/action-utils.ts
Normal 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"));
|
||||
}
|
||||
};
|
||||
368
apps/web/modules/ui/components/action-class-info/index.test.tsx
Normal file
368
apps/web/modules/ui/components/action-class-info/index.test.tsx
Normal 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("\\[\\{\\(.*\\)\\}\\]");
|
||||
});
|
||||
});
|
||||
66
apps/web/modules/ui/components/action-class-info/index.tsx
Normal file
66
apps/web/modules/ui/components/action-class-info/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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", "");
|
||||
});
|
||||
});
|
||||
@@ -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" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
60
apps/web/scripts/docker/next-start.sh
Normal file → Executable 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -71,5 +71,10 @@
|
||||
"budgetPercentIncreaseRed": 20,
|
||||
"minimumChangeThreshold": 0,
|
||||
"showDetails": true
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.11": "patches/next-auth@4.24.11.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ export type TActionClassPageUrlRule =
|
||||
| "startsWith"
|
||||
| "endsWith"
|
||||
| "notMatch"
|
||||
| "notContains";
|
||||
| "notContains"
|
||||
| "matchesRegex";
|
||||
|
||||
export type TActionClassNoCodeConfig =
|
||||
| {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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{" "}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
23
patches/next-auth@4.24.11.patch
Normal file
23
patches/next-auth@4.24.11.patch
Normal 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
1564
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user