Compare commits

...

15 Commits

Author SHA1 Message Date
Matthias Nannt
4a141171c5 chore: introduce ai package 2025-08-06 13:12:46 +02:00
Jonas Höbenreich
0cc2606ec6 fix: Remove rounded-lg Class from Company Logo (#6347) 2025-08-04 01:42:05 -07:00
Dhruwang Jariwala
0fada94b80 chore: Replace entity ids (#6317) 2025-08-04 04:10:41 +00:00
Piyush Gupta
a59ede20c7 fix: one leet security issues (#6303) 2025-08-01 14:35:11 +00:00
Piyush Gupta
84294f9df2 feat: adds debug logs (#6237)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-08-01 11:10:21 +00:00
Johannes
855e7c78ce docs: add quota docs (#6343) 2025-07-31 06:25:34 -07:00
Piotr Gaczkowski
6c506d90c7 fix: Make EKS endpoint private (#6333)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-07-31 13:08:18 +00:00
Piyush Gupta
53f6e02ca1 fix: XLSX security vulnerability | Update XLSX to SheetJS (#6321) 2025-07-31 12:12:17 +00:00
Jakob Schott
14de2eab42 feat: 733 warn users when switching survey type (#6336)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-31 08:30:06 +00:00
Piyush Gupta
ad1f80331a fix: Low severity vulnerability in on-headers@1.0.2 (#6319) 2025-07-31 06:42:03 +00:00
Piyush Gupta
3527ac337b feat: adds response status select in filters (#6325) 2025-07-31 06:33:11 +00:00
Victor Hugo dos Santos
23c2d3dce9 feat: Add Regex No Code Action Page Filter (#6305)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-31 05:48:12 +00:00
Anshuman Pandey
da652bd860 fix: adds proxy agent to next-auth (#6326) 2025-07-31 05:08:33 +00:00
Harsh Bhat
6f88dde1a0 chore: SUS template (#6328)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-30 05:27:58 -07:00
Jakob Schott
3b90223101 style: scroll indicator update (#6310) 2025-07-30 05:27:15 -07:00
134 changed files with 8082 additions and 3495 deletions

View File

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

View File

@@ -62,10 +62,12 @@ runs:
shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
env:
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
shell: bash
- run: |

View File

@@ -1,23 +1,23 @@
name: 'Upload Sentry Sourcemaps'
description: 'Extract sourcemaps from Docker image and upload to Sentry'
name: "Upload Sentry Sourcemaps"
description: "Extract sourcemaps from Docker image and upload to Sentry"
inputs:
docker_image:
description: 'Docker image to extract sourcemaps from'
description: "Docker image to extract sourcemaps from"
required: true
release_version:
description: 'Sentry release version (e.g., v1.2.3)'
description: "Sentry release version (e.g., v1.2.3)"
required: true
sentry_auth_token:
description: 'Sentry authentication token'
description: "Sentry authentication token"
required: true
environment:
description: 'Sentry environment (e.g., production, staging)'
description: "Sentry environment (e.g., production, staging)"
required: false
default: 'staging'
default: "staging"
runs:
using: 'composite'
using: "composite"
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -26,13 +26,12 @@ runs:
- name: Validate Sentry auth token
shell: bash
env:
SENTRY_TOKEN: ${{ inputs.sentry_auth_token }}
run: |
set -euo pipefail
echo "🔐 Validating Sentry authentication token..."
# Assign token to local variable for secure handling
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
# Test the token by making a simple API call to Sentry
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
-H "Authorization: Bearer $SENTRY_TOKEN" \
@@ -57,13 +56,23 @@ runs:
- name: Extract sourcemaps from Docker image
shell: bash
env:
DOCKER_IMAGE: ${{ inputs.docker_image }}
run: |
set -euo pipefail
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
# Validate docker image format (basic validation)
if [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+$ ]] && [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+@sha256:[A-Fa-f0-9]{64}$ ]]; then
echo "❌ Error: Invalid docker image format. Must be in format 'image:tag' or 'image@sha256:hash'"
echo "Provided: $DOCKER_IMAGE"
exit 1
fi
echo "📦 Extracting sourcemaps from Docker image: $DOCKER_IMAGE"
# Create temporary container from the image and capture its ID
echo "Creating temporary container..."
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
CONTAINER_ID=$(docker create "$DOCKER_IMAGE")
echo "Container created with ID: $CONTAINER_ID"
# Set up cleanup function to ensure container is removed on script exit
@@ -82,7 +91,7 @@ runs:
# Exit with the original exit code to preserve script success/failure status
exit $original_exit_code
}
# Register cleanup function to run on script exit (success or failure)
trap cleanup_container EXIT
@@ -113,7 +122,7 @@ runs:
with:
environment: ${{ inputs.environment }}
version: ${{ inputs.release_version }}
sourcemaps: './extracted-next/'
sourcemaps: "./extracted-next/"
- name: Clean up extracted files
shell: bash

View File

@@ -106,15 +106,16 @@ jobs:
env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
run: |
# Set hostname based on environment
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
if [[ "$ENVIRONMENT" == "production" ]]; then
PURGE_HOST="app.formbricks.com"
else
PURGE_HOST="stage.app.formbricks.com"
fi
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: $ENVIRONMENT, zone: $CF_ZONE_ID)"
# Prepare JSON payload for selective cache purge
json_payload=$(cat << EOF

View File

@@ -47,34 +47,53 @@ jobs:
- name: Build Docker Image
uses: docker/build-push-action@v6
env:
GITHUB_SHA: ${{ github.sha }}
with:
context: .
file: ./apps/web/Dockerfile
push: false
load: true
tags: formbricks-test:${{ github.sha }}
tags: formbricks-test:${{ env.GITHUB_SHA }}
cache-from: type=gha
cache-to: type=gha,mode=max
secrets: |
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
shell: bash
env:
GITHUB_SHA: ${{ github.sha }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
run: |
echo "🧪 Testing if the Docker image starts correctly..."
@@ -86,29 +105,12 @@ jobs:
$DOCKER_RUN_ARGS \
-p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
-d formbricks-test:${{ github.sha }}
-e ENCRYPTION_KEY="$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 +118,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 +156,7 @@ jobs:
# Exit with failure if health check did not succeed
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
echo "❌ Health check failed after $MAX_RETRIES attempts"
echo "❌ Health check failed after $((MAX_RETRIES * 5)) seconds (5 minutes)"
exit 1
fi

View File

@@ -44,11 +44,11 @@ jobs:
- name: Generate SemVer version from branch or tag
id: generate_version
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
# Get reference name and type
REF_NAME="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
# Get reference name and type from environment variables
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"

View File

@@ -9,7 +9,7 @@ on:
workflow_call:
inputs:
IS_PRERELEASE:
description: 'Whether this is a prerelease (affects latest tag)'
description: "Whether this is a prerelease (affects latest tag)"
required: false
type: boolean
default: false
@@ -52,9 +52,20 @@ jobs:
id: extract_release_tag
run: |
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
TAG=${{ github.ref }}
TAG="$GITHUB_REF"
TAG=${TAG#refs/tags/v}
# Validate the extracted tag format
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Original ref: $GITHUB_REF"
echo "Extracted tag: $TAG"
exit 1
fi
# Safely add to environment variables
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
echo "Using tag-based version: $TAG"

View File

@@ -26,8 +26,23 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Extract release version
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
- name: Validate input version
env:
INPUT_VERSION: ${{ inputs.VERSION }}
run: |
set -euo pipefail
# Validate input version format (expects clean semver without 'v' prefix)
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid version format. Must be clean semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Expected: clean version without 'v' prefix"
echo "Provided: $INPUT_VERSION"
exit 1
fi
# Store validated version in environment variable
echo "VERSION<<EOF" >> $GITHUB_ENV
echo "$INPUT_VERSION" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
@@ -35,15 +50,18 @@ jobs:
version: latest
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
run: printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version
run: |
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
- name: Package Helm chart
run: |
@@ -51,4 +69,4 @@ jobs:
- name: Push Helm chart to GitHub Container Registry
run: |
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts

View File

@@ -27,10 +27,18 @@ jobs:
- name: Get source branch name
id: branch-name
env:
RAW_BRANCH: ${{ github.head_ref }}
run: |
RAW_BRANCH="${{ github.head_ref }}"
# Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
# Additional validation - ensure branch name is not empty after sanitization
if [[ -z "$SOURCE_BRANCH" ]]; then
echo "❌ Error: Branch name is empty after sanitization"
echo "Original branch: $RAW_BRANCH"
exit 1
fi
# Safely add to environment variables using GitHub's recommended method
# This prevents environment variable injection attacks

View File

@@ -23,7 +23,7 @@ jobs:
upload-sourcemaps:
name: Upload Sourcemaps to Sentry
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
@@ -31,16 +31,13 @@ jobs:
fetch-depth: 0
- name: Set Docker Image
run: |
if [ -n "${{ inputs.tag_version }}" ]; then
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
else
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
fi
run: echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> $GITHUB_ENV
env:
DOCKER_IMAGE: ${{ inputs.docker_image }}:${{ inputs.tag_version != '' && inputs.tag_version || inputs.release_version }}
- name: Upload Sourcemaps to Sentry
uses: ./.github/actions/upload-sentry-sourcemaps
with:
docker_image: ${{ env.DOCKER_IMAGE }}
release_version: ${{ inputs.release_version }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -121,8 +121,9 @@ describe("ProfilePage", () => {
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
expect(screen.getByTestId("delete-account")).toBeInTheDocument();
// Use a regex to match the text content, allowing for variable whitespace
expect(screen.getByText(new RegExp(`common\\.profile\\s*:\\s*${mockUser.id}`))).toBeInTheDocument(); // SettingsId
// Check for IdBadge content
expect(screen.getByText("common.profile_id")).toBeInTheDocument();
expect(screen.getByText(mockUser.id)).toBeInTheDocument();
});
});

View File

@@ -5,9 +5,9 @@ import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/servi
import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
@@ -103,7 +103,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
isMultiOrgEnabled={isMultiOrgEnabled}
/>
</SettingsCard>
<SettingsId title={t("common.profile")} id={user.id}></SettingsId>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
</div>
)}
</PageContentWrapper>

View File

@@ -5,7 +5,7 @@ import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/lice
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { getTranslate } from "@/tolgee/server";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -78,8 +78,8 @@ vi.mock("./components/DeleteOrganization", () => ({
DeleteOrganization: vi.fn(() => <div>DeleteOrganization</div>),
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: vi.fn(() => <div>SettingsId</div>),
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(() => <div>IdBadge</div>),
}));
describe("Page", () => {
@@ -156,10 +156,11 @@ describe("Page", () => {
},
undefined
);
expect(SettingsId).toHaveBeenCalledWith(
expect(IdBadge).toHaveBeenCalledWith(
{
title: "common.organization_id",
id: mockEnvironmentAuth.organization.id,
label: "common.organization_id",
variant: "column",
},
undefined
);

View File

@@ -4,9 +4,9 @@ import { getUser } from "@/lib/user/service";
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
@@ -70,7 +70,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard>
)}
<SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId>
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
</PageContentWrapper>
);
};

View File

@@ -1,10 +1,9 @@
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { cleanup } from "@testing-library/react";
import { AnyActionArg } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
import {
@@ -60,6 +59,7 @@ vi.mock("@/modules/survey/lib/questions", () => ({
getQuestionIconMap: vi.fn(() => ({
[TSurveyQuestionTypeEnum.OpenText]: <span>OT</span>,
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: <span>MCS</span>,
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: <span>MCM</span>,
[TSurveyQuestionTypeEnum.Matrix]: <span>MX</span>,
[TSurveyQuestionTypeEnum.Address]: <span>AD</span>,
[TSurveyQuestionTypeEnum.ContactInfo]: <span>CI</span>,
@@ -104,6 +104,27 @@ vi.mock("lucide-react", () => ({
TagIcon: () => <span>Tag</span>,
}));
// Mock new dependencies
vi.mock("@/lib/response/utils", () => ({
extractChoiceIdsFromResponse: vi.fn((responseValue) => {
// Mock implementation that returns choice IDs based on response value
if (Array.isArray(responseValue)) {
return responseValue.map((_, index) => `choice-${index + 1}`);
} else if (typeof responseValue === "string") {
return [`choice-single`];
}
return [];
}),
}));
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(({ id }) => <div data-testid="id-badge">{id}</div>),
}));
vi.mock("@/modules/ui/lib/utils", () => ({
cn: vi.fn((...classes) => classes.filter(Boolean).join(" ")),
}));
const mockSurvey = {
id: "survey1",
name: "Test Survey",
@@ -136,6 +157,28 @@ const mockSurvey = {
headline: { default: "Contact Info Question" },
required: false,
} as unknown as TSurveyQuestion,
{
id: "q5single",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Single Choice Question" },
required: false,
choices: [
{ id: "choice-1", label: { default: "Option 1" } },
{ id: "choice-2", label: { default: "Option 2" } },
{ id: "choice-3", label: { default: "Option 3" } },
],
} as unknown as TSurveyQuestion,
{
id: "q6multi",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Multi Choice Question" },
required: false,
choices: [
{ id: "choice-a", label: { default: "Choice A" } },
{ id: "choice-b", label: { default: "Choice B" } },
{ id: "choice-c", label: { default: "Choice C" } },
],
} as unknown as TSurveyQuestion,
],
variables: [
{ id: "var1", name: "User Segment", type: "text" } as TSurveyVariable,
@@ -173,6 +216,8 @@ const mockResponseData = {
firstName: "John",
email: "john.doe@example.com",
hf1: "Hidden Field 1 Value",
q5single: "Option 1", // Single choice response
q6multi: ["Choice A", "Choice C"], // Multi choice response
},
variables: {
var1: "Segment A",
@@ -495,3 +540,281 @@ describe("ResponseTableColumns - Column Implementations", () => {
expect(hfColumn).toBeUndefined();
});
});
describe("ResponseTableColumns - Multiple Choice Questions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("generates two columns for multipleChoiceSingle questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q5single");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
expect(optionIdsColumn).toBeDefined();
});
test("generates two columns for multipleChoiceMulti questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q6multi");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
expect(optionIdsColumn).toBeDefined();
});
test("multipleChoiceSingle main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: "default",
},
};
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
// Check that RenderResponse component is returned
expect(cellResult).toBeDefined();
});
test("multipleChoiceMulti main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
// Check that RenderResponse component is returned
expect(cellResult).toBeDefined();
});
});
describe("ResponseTableColumns - Choice ID Columns", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("option IDs column calls extractChoiceIdsFromResponse for string response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: "default",
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
"Option 1",
expect.objectContaining({ id: "q5single", type: "multipleChoiceSingle" }),
"default"
);
});
test("option IDs column calls extractChoiceIdsFromResponse for array response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
["Choice A", "Choice C"],
expect.objectContaining({ id: "q6multi", type: "multipleChoiceMulti" }),
"default"
);
});
test("option IDs column renders IdBadge components for choice IDs", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const mockRow = {
original: {
responseData: { q6multi: ["Choice A", "Choice C"] },
language: "default",
},
};
// Mock extractChoiceIdsFromResponse to return specific choice IDs
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce(["choice-1", "choice-3"]);
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
// Should render something for choice IDs
expect(cellResult).toBeDefined();
// Verify that extractChoiceIdsFromResponse was called
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalled();
});
test("option IDs column returns null for non-string/array response values", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: 123 }, // Invalid type
language: "default",
},
};
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeNull();
expect(vi.mocked(extractChoiceIdsFromResponse)).not.toHaveBeenCalled();
});
test("option IDs column returns null when no choice IDs found", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Non-existent option" },
language: "default",
},
};
// Mock extractChoiceIdsFromResponse to return empty array
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce([]);
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeNull();
});
test("option IDs column handles missing language gracefully", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const mockRow = {
original: {
responseData: { q5single: "Option 1" },
language: null, // No language
},
};
optionIdsColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
"Option 1",
expect.objectContaining({ id: "q5single" }),
undefined
);
});
});
describe("ResponseTableColumns - Helper Functions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("question headers are properly created for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
// Test main column header
const mainHeader = mainColumn?.header?.();
expect(mainHeader).toBeDefined();
expect(mainHeader?.props?.className).toContain("flex items-center justify-between");
// Test option IDs column header
const optionHeader = optionIdsColumn?.header?.();
expect(optionHeader).toBeDefined();
expect(optionHeader?.props?.className).toContain("flex items-center justify-between");
});
test("question headers include proper icons for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const singleChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const multiChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
// Headers should be functions that return JSX
expect(typeof singleChoiceColumn?.header).toBe("function");
expect(typeof multiChoiceColumn?.header).toBe("function");
// Call headers to ensure they don't throw
expect(() => singleChoiceColumn?.header?.()).not.toThrow();
expect(() => multiChoiceColumn?.header?.()).not.toThrow();
});
});
describe("ResponseTableColumns - Integration Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("multiple choice questions work end-to-end with real data", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Find all multiple choice related columns
const singleMainCol = columns.find((col) => (col as any).accessorKey === "q5single");
const singleIdsCol = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const multiMainCol = columns.find((col) => (col as any).accessorKey === "q6multi");
const multiIdsCol = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
expect(singleMainCol).toBeDefined();
expect(singleIdsCol).toBeDefined();
expect(multiMainCol).toBeDefined();
expect(multiIdsCol).toBeDefined();
// Test with actual mock response data
const mockRow = { original: mockResponseData };
// Test single choice main column
const singleMainResult = (singleMainCol?.cell as any)?.({ row: mockRow });
expect(singleMainResult).toBeDefined();
// Test multi choice main column
const multiMainResult = (multiMainCol?.cell as any)?.({ row: mockRow });
expect(multiMainResult).toBeDefined();
// Test that choice ID columns exist and can be called
const singleIdsResult = (singleIdsCol?.cell as any)?.({ row: mockRow });
const multiIdsResult = (multiIdsCol?.cell as any)?.({ row: mockRow });
// Should not error when calling the cell functions
expect(() => singleIdsResult).not.toThrow();
expect(() => multiIdsResult).not.toThrow();
});
});

View File

@@ -1,6 +1,7 @@
"use client";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
@@ -8,8 +9,10 @@ import { recallToHeadline } from "@/lib/utils/recall";
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { cn } from "@/modules/ui/lib/utils";
import { ColumnDef } from "@tanstack/react-table";
import { TFnType } from "@tolgee/react";
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
@@ -61,6 +64,42 @@ const getQuestionColumnsData = (
t: TFnType
): ColumnDef<TResponseTableData>[] => {
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
// Helper function to create consistent column headers
const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => {
const title = suffix ? `${headline} - ${suffix}` : headline;
const QuestionHeader = () => (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[questionType]}</span>
<span className="truncate">{title}</span>
</div>
</div>
);
QuestionHeader.displayName = "QuestionHeader";
return QuestionHeader;
};
// Helper function to get localized question headline
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
return getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default");
};
// Helper function to render choice ID badges
const renderChoiceIdBadges = (choiceIds: string[], isExpanded: boolean) => {
if (choiceIds.length === 0) return null;
const containerClasses = cn("flex gap-x-1 w-full", isExpanded && "flex-wrap gap-y-1");
return (
<div className={containerClasses}>
{choiceIds.map((choiceId, index) => (
<IdBadge key={choiceId || index} id={choiceId} />
))}
</div>
);
};
switch (question.type) {
case "matrix":
return question.rows.map((matrixRow) => {
@@ -137,6 +176,49 @@ const getQuestionColumnsData = (
};
});
case "multipleChoiceMulti":
case "multipleChoiceSingle":
case "ranking":
case "pictureSelection": {
const questionHeadline = getQuestionHeadline(question, survey);
return [
{
accessorKey: question.id,
header: createQuestionHeader(question.type, questionHeadline),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
const language = row.original.language;
return (
<RenderResponse
question={question}
survey={survey}
responseData={responseValue}
language={language}
isExpanded={isExpanded}
/>
);
},
},
{
accessorKey: question.id + "optionIds",
header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
// Type guard to ensure responseValue is the correct type
if (typeof responseValue === "string" || Array.isArray(responseValue)) {
const choiceIds = extractChoiceIdsFromResponse(
responseValue,
question,
row.original.language || undefined
);
return renderChoiceIdBadges(choiceIds, isExpanded);
}
return null;
},
},
];
}
default:
return [
{
@@ -317,7 +399,6 @@ export const generateResponseTableColumns = (
};
// Combine the selection column with the dynamic question columns
const baseColumns = [
personColumn,
dateColumn,

View File

@@ -27,10 +27,10 @@ vi.mock("@/modules/survey/lib/questions", () => ({
],
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: ({ title, id }: { title: string; id: string }) => (
<div data-testid="settings-id">
{title}: {id}
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ label, id }: { label: string; id: string }) => (
<div data-testid="id-badge">
{label}: {id}
</div>
),
}));
@@ -76,7 +76,7 @@ describe("QuestionSummaryHeader", () => {
).toBeInTheDocument();
expect(screen.getByTestId("question-icon")).toBeInTheDocument();
expect(screen.getByTestId("settings-id")).toHaveTextContent("common.question_id: q1");
expect(screen.getByTestId("id-badge")).toHaveTextContent("common.question_id: q1");
expect(screen.queryByText("environments.surveys.edit.optional")).not.toBeInTheDocument();
});

View File

@@ -3,7 +3,7 @@
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
import type { JSX } from "react";
@@ -55,7 +55,7 @@ export const QuestionSummaryHeader = ({
</div>
)}
</div>
<SettingsId title={t("common.question_id")} id={questionSummary.question.id}></SettingsId>
<IdBadge id={questionSummary.question.id} label={t("common.question_id")} />
</div>
);
};

View File

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

View File

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

View File

@@ -98,8 +98,8 @@ vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ children }) => <div data-testid="page-header">{children}</div>),
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: vi.fn(() => <div data-testid="settings-id"></div>),
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: vi.fn(() => <div data-testid="id-badge"></div>),
}));
vi.mock("@/tolgee/server", () => ({
@@ -227,7 +227,7 @@ describe("SurveyPage", () => {
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("survey-analysis-navigation")).toBeInTheDocument();
expect(screen.getByTestId("summary-page")).toBeInTheDocument();
expect(screen.getByTestId("settings-id")).toBeInTheDocument();
expect(screen.getByTestId("id-badge")).toBeInTheDocument();
expect(vi.mocked(getEnvironmentAuth)).toHaveBeenCalledWith(mockEnvironmentId);
expect(vi.mocked(getSurvey)).toHaveBeenCalledWith(mockSurveyId);

View File

@@ -9,9 +9,9 @@ import { getUser } from "@/lib/user/service";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { getTranslate } from "@/tolgee/server";
import { notFound } from "next/navigation";
@@ -74,7 +74,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
initialSurveySummary={initialSurveySummary}
/>
<SettingsId title={t("common.survey_id")} id={surveyId} />
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
</PageContentWrapper>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,17 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
TSurvey,
TSurveyOpenTextQuestion,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import {
buildWhereClause,
calculateTtcTotal,
extracMetadataKeys,
extractChoiceIdsFromResponse,
extractSurveyDetails,
generateAllPermutationsOfSubsets,
getResponseContactAttributes,
@@ -555,3 +561,176 @@ describe("Response Utils", () => {
});
});
});
describe("extractChoiceIdsFromResponse", () => {
const multipleChoiceMultiQuestion: TSurveyQuestion = {
id: "multi-choice-id",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Select multiple options" },
required: false,
choices: [
{
id: "choice-1",
label: { default: "Option 1", es: "Opción 1" },
},
{
id: "choice-2",
label: { default: "Option 2", es: "Opción 2" },
},
{
id: "choice-3",
label: { default: "Option 3", es: "Opción 3" },
},
],
};
const multipleChoiceSingleQuestion: TSurveyQuestion = {
id: "single-choice-id",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Select one option" },
required: false,
choices: [
{
id: "choice-a",
label: { default: "Choice A", fr: "Choix A" },
},
{
id: "choice-b",
label: { default: "Choice B", fr: "Choix B" },
},
],
};
const textQuestion: TSurveyOpenTextQuestion = {
id: "text-id",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "What do you think?" },
required: false,
inputType: "text",
charLimit: { enabled: false, min: 0, max: 0 },
};
describe("multipleChoiceMulti questions", () => {
test("should extract choice IDs from array response with default language", () => {
const responseValue = ["Option 1", "Option 3"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual(["choice-1", "choice-3"]);
});
test("should extract choice IDs from array response with specific language", () => {
const responseValue = ["Opción 1", "Opción 2"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "es");
expect(result).toEqual(["choice-1", "choice-2"]);
});
test("should fall back to checking all language values when exact language match fails", () => {
const responseValue = ["Opción 1", "Option 2"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual(["choice-1", "choice-2"]);
});
test("should render other option when non-matching choice is selected", () => {
const responseValue = ["Option 1", "Non-existent option", "Option 3"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual(["choice-1", "other", "choice-3"]);
});
test("should return empty array for empty response", () => {
const responseValue: string[] = [];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual([]);
});
});
describe("multipleChoiceSingle questions", () => {
test("should extract choice ID from string response with default language", () => {
const responseValue = "Choice A";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
expect(result).toEqual(["choice-a"]);
});
test("should extract choice ID from string response with specific language", () => {
const responseValue = "Choix B";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "fr");
expect(result).toEqual(["choice-b"]);
});
test("should fall back to checking all language values for single choice", () => {
const responseValue = "Choix A";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
expect(result).toEqual(["choice-a"]);
});
test("should return empty array for empty string response", () => {
const responseValue = "";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceSingleQuestion, "default");
expect(result).toEqual([]);
});
});
describe("edge cases", () => {
test("should return empty array for non-multiple choice questions", () => {
const responseValue = "Some text response";
const result = extractChoiceIdsFromResponse(responseValue, textQuestion, "default");
expect(result).toEqual([]);
});
test("should handle missing language parameter by defaulting to 'default'", () => {
const responseValue = "Option 1";
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion);
expect(result).toEqual(["choice-1"]);
});
test("should handle numeric or other types by returning empty array", () => {
const responseValue = 123;
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual([]);
});
test("should handle object responses by returning empty array", () => {
const responseValue = { invalid: "object" };
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "default");
expect(result).toEqual([]);
});
});
describe("language handling", () => {
test("should use provided language parameter", () => {
const responseValue = ["Opción 1"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, "es");
expect(result).toEqual(["choice-1"]);
});
test("should handle null language parameter by defaulting to 'default'", () => {
const responseValue = ["Option 1"];
const result = extractChoiceIdsFromResponse(responseValue, multipleChoiceMultiQuestion, null as any);
expect(result).toEqual(["choice-1"]);
});
test("should handle undefined language parameter by defaulting to 'default'", () => {
const responseValue = ["Option 1"];
const result = extractChoiceIdsFromResponse(
responseValue,
multipleChoiceMultiQuestion,
undefined as any
);
expect(result).toEqual(["choice-1"]);
});
});
});

View File

@@ -1,20 +1,86 @@
import "server-only";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { Prisma } from "@prisma/client";
import {
TResponse,
TResponseDataValue,
TResponseFilterCriteria,
TResponseHiddenFieldsFilter,
TResponseTtc,
TSurveyContactAttributes,
TSurveyMetaFieldFilter,
} from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import { processResponseData } from "../responses";
import { getTodaysDateTimeFormatted } from "../time";
import { getFormattedDateTimeString } from "../utils/datetime";
import { sanitizeString } from "../utils/strings";
/**
* Extracts choice IDs from response values for multiple choice questions
* @param responseValue - The response value (string for single choice, array for multi choice)
* @param question - The survey question containing choices
* @param language - The language to match against (defaults to "default")
* @returns Array of choice IDs
*/
export const extractChoiceIdsFromResponse = (
responseValue: TResponseDataValue,
question: TSurveyQuestion,
language: string = "default"
): string[] => {
// Type guard to ensure the question has choices
if (
question.type !== "multipleChoiceMulti" &&
question.type !== "multipleChoiceSingle" &&
question.type !== "ranking" &&
question.type !== "pictureSelection"
) {
return [];
}
const isPictureSelection = question.type === "pictureSelection";
if (!responseValue) {
return [];
}
// For picture selection questions, the response value is already choice ID(s)
if (isPictureSelection) {
if (Array.isArray(responseValue)) {
// Multi-selection: array of choice IDs
return responseValue.filter((id): id is string => typeof id === "string");
} else if (typeof responseValue === "string") {
// Single selection: single choice ID
return [responseValue];
}
return [];
}
const defaultLanguage = language ?? "default";
// Helper function to find choice by label - eliminates duplication
const findChoiceByLabel = (choiceLabel: string): string | null => {
const targetChoice = question.choices.find((c) => {
// Try exact language match first
if (c.label[defaultLanguage] === choiceLabel) {
return true;
}
// Fall back to checking all language values
return Object.values(c.label).includes(choiceLabel);
});
return targetChoice?.id || "other";
};
if (Array.isArray(responseValue)) {
// Multiple choice case - response is an array of selected choice labels
return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null);
} else if (typeof responseValue === "string") {
// Single choice case - response is a single choice label
const choiceId = findChoiceByLabel(responseValue);
return choiceId ? [choiceId] : [];
}
return [];
};
export const calculateTtcTotal = (ttc: TResponseTtc) => {
const result = { ...ttc };
result._total = Object.values(result).reduce((acc: number, val: number) => acc + val, 0);
@@ -490,10 +556,17 @@ export const extractSurveyDetails = (survey: TSurvey, responses: TResponse[]) =>
return question.rows.map((row) => {
return `${idx + 1}. ${headline} - ${getLocalizedValue(row, "default")}`;
});
} else if (
question.type === "multipleChoiceMulti" ||
question.type === "multipleChoiceSingle" ||
question.type === "ranking"
) {
return [`${idx + 1}. ${headline}`, `${idx + 1}. ${headline} - Option ID`];
} else {
return [`${idx + 1}. ${headline}`];
}
});
const hiddenFields = survey.hiddenFields?.fieldIds || [];
const userAttributes =
survey.type === "app"
@@ -556,6 +629,19 @@ export const getResponsesJson = (
}
}
});
} else if (
question.type === "multipleChoiceMulti" ||
question.type === "multipleChoiceSingle" ||
question.type === "ranking"
) {
// Set the main response value
jsonData[idx][questionHeadline[0]] = processResponseData(answer);
// Set the option IDs using the reusable function
if (questionHeadline[1]) {
const choiceIds = extractChoiceIdsFromResponse(answer, question, response.language || "default");
jsonData[idx][questionHeadline[1]] = choiceIds.join(", ");
}
} else {
jsonData[idx][questionHeadline[0]] = processResponseData(answer);
}

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { deepDiff, redactPII } from "./utils";
import { deepDiff, redactPII, sanitizeUrlForLogging } from "./logger-helpers";
// Patch redis multi before any imports
beforeEach(async () => {
@@ -104,7 +104,7 @@ describe("withAuditLogging", () => {
});
test("logs audit event for successful handler", async () => {
const handler = vi.fn().mockResolvedValue("ok");
const { withAuditLogging } = await import("./handler");
const { withAuditLogging } = await import("../../modules/ee/audit-logs/lib/handler");
const wrapped = withAuditLogging("created", "survey", handler);
const ctx = {
user: {
@@ -143,7 +143,7 @@ describe("withAuditLogging", () => {
});
test("logs audit event for failed handler and throws", async () => {
const handler = vi.fn().mockRejectedValue(new Error("fail"));
const { withAuditLogging } = await import("./handler");
const { withAuditLogging } = await import("../../modules/ee/audit-logs/lib/handler");
const wrapped = withAuditLogging("created", "survey", handler);
const ctx = {
user: {
@@ -181,3 +181,37 @@ describe("withAuditLogging", () => {
expect(handler).toHaveBeenCalled();
});
});
describe("sanitizeUrlForLogging", () => {
test("returns sanitized URL with token", () => {
expect(sanitizeUrlForLogging("https://example.com?token=1234567890")).toBe(
"https://example.com/?token=********"
);
});
test("returns sanitized URL with code", () => {
expect(sanitizeUrlForLogging("https://example.com?code=1234567890")).toBe(
"https://example.com/?code=********"
);
});
test("returns sanitized URL with state", () => {
expect(sanitizeUrlForLogging("https://example.com?state=1234567890")).toBe(
"https://example.com/?state=********"
);
});
test("returns sanitized URL with multiple keys", () => {
expect(
sanitizeUrlForLogging("https://example.com?token=1234567890&code=1234567890&state=1234567890")
).toBe("https://example.com/?token=********&code=********&state=********");
});
test("returns sanitized URL without query params", () => {
expect(sanitizeUrlForLogging("https://example.com")).toBe("https://example.com/");
});
test("returns sanitized URL with invalid URL", () => {
expect(sanitizeUrlForLogging("not-a-valid-url")).toBe("[invalid-url]");
});
});

View File

@@ -1,3 +1,5 @@
import { isStringUrl } from "@/lib/utils/url";
const SENSITIVE_KEYS = [
"email",
"name",
@@ -33,8 +35,11 @@ const SENSITIVE_KEYS = [
"image",
"stripeCustomerId",
"fileName",
"state",
];
const URL_SENSITIVE_KEYS = ["token", "code", "state"];
/**
* Redacts sensitive data from the object by replacing the sensitive keys with "********".
* @param obj - The object to redact.
@@ -45,6 +50,10 @@ export const redactPII = (obj: any, seen: WeakSet<any> = new WeakSet()): any =>
return obj.toISOString();
}
if (typeof obj === "string" && isStringUrl(obj)) {
return sanitizeUrlForLogging(obj);
}
if (obj && typeof obj === "object") {
if (seen.has(obj)) return "[Circular]";
seen.add(obj);
@@ -89,3 +98,24 @@ export const deepDiff = (oldObj: any, newObj: any): any => {
}
return Object.keys(diff).length > 0 ? diff : undefined;
};
/**
* Sanitizes a URL for logging by redacting sensitive parameters.
* @param url - The URL to sanitize.
* @returns The sanitized URL.
*/
export const sanitizeUrlForLogging = (url: string): string => {
try {
const urlObj = new URL(url);
URL_SENSITIVE_KEYS.forEach((key) => {
if (urlObj.searchParams.has(key)) {
urlObj.searchParams.set(key, "********");
}
});
return urlObj.origin + urlObj.pathname + (urlObj.search ? `${urlObj.search}` : "");
} catch {
return "[invalid-url]";
}
};

View File

@@ -1,30 +1,74 @@
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TActionClassPageUrlRule } from "@formbricks/types/action-classes";
import { isValidCallbackUrl, testURLmatch } from "./url";
import { isStringUrl, isValidCallbackUrl, testURLmatch } from "./url";
afterEach(() => {
cleanup();
});
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.");
});
});
@@ -47,3 +91,13 @@ describe("isValidCallbackUrl", () => {
expect(isValidCallbackUrl("not-a-valid-url", WEBAPP_URL)).toBe(false);
});
});
describe("isStringUrl", () => {
test("returns true for valid URL", () => {
expect(isStringUrl("https://example.com")).toBe(true);
});
test("returns false for invalid URL", () => {
expect(isStringUrl("not-a-valid-url")).toBe(false);
});
});

View File

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

View File

@@ -124,6 +124,7 @@
"add_action": "Aktion hinzufügen",
"add_filter": "Filter hinzufügen",
"add_logo": "Logo hinzufügen",
"add_member": "Mitglied hinzufügen",
"add_project": "Projekt hinzufügen",
"add_to_team": "Zum Team hinzufügen",
"all": "Alle",
@@ -279,6 +280,8 @@
"on": "An",
"only_one_file_allowed": "Es ist nur eine Datei erlaubt",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Nur Eigentümer, Manager und Mitglieder mit Zugriff auf das Management können diese Aktion ausführen.",
"option_id": "Option-ID",
"option_ids": "Option-IDs",
"or": "oder",
"organization": "Organisation",
"organization_id": "Organisations-ID",
@@ -305,6 +308,7 @@
"privacy": "Datenschutz",
"product_manager": "Produktmanager",
"profile": "Profil",
"profile_id": "Profil-ID",
"project_configuration": "Projektkonfiguration",
"project_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
"project_id": "Projekt-ID",
@@ -385,6 +389,7 @@
"targeting": "Targeting",
"team": "Team",
"team_access": "Teamzugriff",
"team_id": "Team-ID",
"team_name": "Teamname",
"teams": "Zugriffskontrolle",
"teams_not_found": "Teams nicht gefunden",
@@ -503,21 +508,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 +531,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 +558,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 +1291,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 +1302,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",
@@ -1302,7 +1316,7 @@
"columns": "Spalten",
"company": "Firma",
"company_logo": "Firmenlogo",
"completed_responses": "abgeschlossene Antworten",
"completed_responses": "unvollständige oder vollständige Antworten.",
"concat": "Verketten +",
"conditional_logic": "Bedingte Logik",
"confirm_default_language": "Standardsprache bestätigen",
@@ -1607,6 +1621,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",
@@ -1799,7 +1818,6 @@
"last_quarter": "Letztes Quartal",
"last_year": "Letztes Jahr",
"no_responses_found": "Keine Antworten gefunden",
"only_completed": "Nur vollständige Antworten",
"other_values_found": "Andere Werte gefunden",
"overall": "Insgesamt",
"qr_code": "QR-Code",
@@ -2771,6 +2789,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 +2846,18 @@
"understand_purchase_intention_question_2_headline": "Verstanden. Was ist dein Hauptgrund für den heutigen Besuch?",
"understand_purchase_intention_question_2_placeholder": "Tippe deine Antwort hier...",
"understand_purchase_intention_question_3_headline": "Was, wenn überhaupt, hält Dich heute davon ab, einen Kauf zu tätigen?",
"understand_purchase_intention_question_3_placeholder": "Tippe deine Antwort hier..."
"understand_purchase_intention_question_3_placeholder": "Tippe deine Antwort hier...",
"usability_question_10_headline": "Ich musste viel lernen, bevor ich das System richtig benutzen konnte.",
"usability_question_1_headline": "Ich würde dieses System wahrscheinlich häufig verwenden.",
"usability_question_2_headline": "Das System wirkte komplizierter als nötig.",
"usability_question_3_headline": "Das System war leicht zu verstehen.",
"usability_question_4_headline": "Ich glaube, ich bräuchte Unterstützung von einem Technik-Experten, um dieses System zu nutzen.",
"usability_question_5_headline": "Alles im System schien gut zusammenzuarbeiten.",
"usability_question_6_headline": "Das System fühlte sich inkonsistent an, wie die Dinge funktionierten.",
"usability_question_7_headline": "Ich glaube, die meisten Menschen könnten schnell lernen, dieses System zu benutzen.",
"usability_question_8_headline": "Die Nutzung des Systems fühlte sich wie eine Belastung an.",
"usability_question_9_headline": "Ich fühlte mich beim Benutzen des Systems sicher.",
"usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.",
"usability_score_name": "System Usability Score Survey (SUS)"
}
}

View File

@@ -124,6 +124,7 @@
"add_action": "Add action",
"add_filter": "Add filter",
"add_logo": "Add logo",
"add_member": "Add member",
"add_project": "Add project",
"add_to_team": "Add to team",
"all": "All",
@@ -279,6 +280,8 @@
"on": "On",
"only_one_file_allowed": "Only one file is allowed",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Only owners and managers can perform this action.",
"option_id": "Option ID",
"option_ids": "Option IDs",
"or": "or",
"organization": "Organization",
"organization_id": "Organization ID",
@@ -305,6 +308,7 @@
"privacy": "Privacy Policy",
"product_manager": "Product Manager",
"profile": "Profile",
"profile_id": "Profile ID",
"project_configuration": "Project's Configuration",
"project_creation_description": "Organize surveys in projects for better access control.",
"project_id": "Project ID",
@@ -385,6 +389,7 @@
"targeting": "Targeting",
"team": "Team",
"team_access": "Team Access",
"team_id": "Team ID",
"team_name": "Team name",
"teams": "Access Control",
"teams_not_found": "Teams not found",
@@ -503,21 +508,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 +531,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 +558,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 +1291,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 +1302,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",
@@ -1302,7 +1316,7 @@
"columns": "Columns",
"company": "Company",
"company_logo": "Company logo",
"completed_responses": "completed responses.",
"completed_responses": "partial or completed responses.",
"concat": "Concat +",
"conditional_logic": "Conditional Logic",
"confirm_default_language": "Confirm default language",
@@ -1607,6 +1621,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",
@@ -1799,7 +1818,6 @@
"last_quarter": "Last quarter",
"last_year": "Last year",
"no_responses_found": "No responses found",
"only_completed": "Only completed",
"other_values_found": "Other values found",
"overall": "Overall",
"qr_code": "QR code",
@@ -2771,6 +2789,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 +2846,18 @@
"understand_purchase_intention_question_2_headline": "Got it. What's your primary reason for visiting today?",
"understand_purchase_intention_question_2_placeholder": "Type your answer here...",
"understand_purchase_intention_question_3_headline": "What, if anything, is holding you back from making a purchase today?",
"understand_purchase_intention_question_3_placeholder": "Type your answer here..."
"understand_purchase_intention_question_3_placeholder": "Type your answer here...",
"usability_question_10_headline": " I had to learn a lot before I could start using the system properly.",
"usability_question_1_headline": "Id probably use this system often.",
"usability_question_2_headline": "The system felt more complicated than it needed to be.",
"usability_question_3_headline": "The system was easy to figure out.",
"usability_question_4_headline": "I think Id need help from a tech expert to use this system.",
"usability_question_5_headline": "Everything in the system seemed to work well together.",
"usability_question_6_headline": "The system felt inconsistent in how things worked.",
"usability_question_7_headline": "I think most people could learn to use this system quickly.",
"usability_question_8_headline": "Using the system felt like a hassle.",
"usability_question_9_headline": "I felt confident while using the system.",
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)"
}
}

View File

@@ -124,6 +124,7 @@
"add_action": "Ajouter une action",
"add_filter": "Ajouter un filtre",
"add_logo": "Ajouter un logo",
"add_member": "Ajouter un membre",
"add_project": "Ajouter un projet",
"add_to_team": "Ajouter à l'équipe",
"all": "Tout",
@@ -279,6 +280,8 @@
"on": "Sur",
"only_one_file_allowed": "Un seul fichier est autorisé",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Seules les propriétaires, les gestionnaires et les membres ayant accès à la gestion peuvent effectuer cette action.",
"option_id": "Identifiant de l'option",
"option_ids": "Identifiants des options",
"or": "ou",
"organization": "Organisation",
"organization_id": "ID de l'organisation",
@@ -305,6 +308,7 @@
"privacy": "Politique de confidentialité",
"product_manager": "Chef de produit",
"profile": "Profil",
"profile_id": "Identifiant de profil",
"project_configuration": "Configuration du projet",
"project_creation_description": "Organisez les enquêtes en projets pour un meilleur contrôle d'accès.",
"project_id": "ID de projet",
@@ -385,6 +389,7 @@
"targeting": "Ciblage",
"team": "Équipe",
"team_access": "Accès Équipe",
"team_id": "Équipe ID",
"team_name": "Nom de l'équipe",
"teams": "Contrôle d'accès",
"teams_not_found": "Équipes non trouvées",
@@ -503,21 +508,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 +531,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 +558,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 !",
@@ -1195,7 +1207,7 @@
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques:",
"add": "Ajouter +",
"add_a_delay_or_auto_close_the_survey": "Ajouter un délai ou fermer automatiquement l'enquête",
"add_a_four_digit_pin": "Ajoutez un code PIN à quatre chiffres.",
@@ -1279,6 +1291,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 +1302,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",
@@ -1302,7 +1316,7 @@
"columns": "Colonnes",
"company": "Société",
"company_logo": "Logo de l'entreprise",
"completed_responses": "réponses complètes.",
"completed_responses": "des réponses partielles ou complètes.",
"concat": "Concat +",
"conditional_logic": "Logique conditionnelle",
"confirm_default_language": "Confirmer la langue par défaut",
@@ -1607,6 +1621,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",
@@ -1799,7 +1818,6 @@
"last_quarter": "dernier trimestre",
"last_year": "l'année dernière",
"no_responses_found": "Aucune réponse trouvée",
"only_completed": "Uniquement terminé",
"other_values_found": "D'autres valeurs trouvées",
"overall": "Globalement",
"qr_code": "Code QR",
@@ -2771,6 +2789,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 +2846,18 @@
"understand_purchase_intention_question_2_headline": "Compris. Quelle est votre raison principale de visite aujourd'hui ?",
"understand_purchase_intention_question_2_placeholder": "Entrez votre réponse ici...",
"understand_purchase_intention_question_3_headline": "Qu'est-ce qui vous empêche de faire un achat aujourd'hui, s'il y a quelque chose ?",
"understand_purchase_intention_question_3_placeholder": "Entrez votre réponse ici..."
"understand_purchase_intention_question_3_placeholder": "Entrez votre réponse ici...",
"usability_question_10_headline": "J'ai dû beaucoup apprendre avant de pouvoir utiliser correctement le système.",
"usability_question_1_headline": "Je pourrais probablement utiliser ce système souvent.",
"usability_question_2_headline": "Le système semblait plus compliqué qu'il ne devait l'être.",
"usability_question_3_headline": "Le système était facile à comprendre.",
"usability_question_4_headline": "Je pense que j'aurais besoin de l'aide d'un expert en technologie pour utiliser ce système.",
"usability_question_5_headline": "Tout dans le système semblait bien fonctionner ensemble.",
"usability_question_6_headline": "Le système semblait incohérent dans la façon dont les choses fonctionnaient.",
"usability_question_7_headline": "Je pense que la plupart des gens pourraient apprendre à utiliser ce système rapidement.",
"usability_question_8_headline": "Utiliser le système semblait être une corvée.",
"usability_question_9_headline": "Je me suis senti confiant en utilisant le système.",
"usability_rating_description": "Mesurez la convivialité perçue en demandant aux utilisateurs d'évaluer leur expérience avec votre produit via un sondage standardisé de 10 questions.",
"usability_score_name": "Score d'Utilisabilité du Système (SUS)"
}
}

View File

@@ -124,6 +124,7 @@
"add_action": "Adicionar ação",
"add_filter": "Adicionar filtro",
"add_logo": "Adicionar logo",
"add_member": "Adicionar membro",
"add_project": "Adicionar projeto",
"add_to_team": "Adicionar à equipe",
"all": "Todos",
@@ -279,6 +280,8 @@
"on": "ligado",
"only_one_file_allowed": "É permitido apenas um arquivo",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários, gerentes e membros com acesso de gerenciamento podem realizar essa ação.",
"option_id": "ID da opção",
"option_ids": "IDs da Opção",
"or": "ou",
"organization": "organização",
"organization_id": "ID da Organização",
@@ -305,6 +308,7 @@
"privacy": "Política de Privacidade",
"product_manager": "Gerente de Produto",
"profile": "Perfil",
"profile_id": "ID de Perfil",
"project_configuration": "Configuração do Projeto",
"project_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
"project_id": "ID do Projeto",
@@ -385,6 +389,7 @@
"targeting": "mirando",
"team": "Time",
"team_access": "Acesso da equipe",
"team_id": "ID da Equipe",
"team_name": "Nome da equipe",
"teams": "Controle de Acesso",
"teams_not_found": "Equipes não encontradas",
@@ -503,21 +508,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 +531,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 +558,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 +1291,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 +1302,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",
@@ -1302,7 +1316,7 @@
"columns": "colunas",
"company": "empresa",
"company_logo": "Logo da empresa",
"completed_responses": "respostas completas",
"completed_responses": "respostas parciais ou completas.",
"concat": "Concatenar +",
"conditional_logic": "Lógica Condicional",
"confirm_default_language": "Confirmar idioma padrão",
@@ -1607,6 +1621,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",
@@ -1799,7 +1818,6 @@
"last_quarter": "Último trimestre",
"last_year": "Último ano",
"no_responses_found": "Nenhuma resposta encontrada",
"only_completed": "Somente concluído",
"other_values_found": "Outros valores encontrados",
"overall": "No geral",
"qr_code": "Código QR",
@@ -2771,6 +2789,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 +2846,18 @@
"understand_purchase_intention_question_2_headline": "Entendi. Qual é o principal motivo da sua visita hoje?",
"understand_purchase_intention_question_2_placeholder": "Digite sua resposta aqui...",
"understand_purchase_intention_question_3_headline": "O que, se é que tem algo, está te impedindo de fazer a compra hoje?",
"understand_purchase_intention_question_3_placeholder": "Digite sua resposta aqui..."
"understand_purchase_intention_question_3_placeholder": "Digite sua resposta aqui...",
"usability_question_10_headline": "Tive que aprender muito antes de poder começar a usar o sistema corretamente.",
"usability_question_1_headline": "Provavelmente eu usaria este sistema frequentemente.",
"usability_question_2_headline": "O sistema parecia mais complicado do que precisava ser.",
"usability_question_3_headline": "O sistema foi fácil de entender.",
"usability_question_4_headline": "Acho que precisaria da ajuda de um especialista em tecnologia para usar este sistema.",
"usability_question_5_headline": "Tudo no sistema parecia funcionar bem juntos.",
"usability_question_6_headline": "O sistema parecia inconsistente em como as coisas funcionavam.",
"usability_question_7_headline": "Eu acho que a maioria das pessoas poderia aprender a usar este sistema rapidamente.",
"usability_question_8_headline": "Usar o sistema foi uma dor de cabeça.",
"usability_question_9_headline": "Me senti confiante ao usar o sistema.",
"usability_rating_description": "Meça a usabilidade percebida perguntando aos usuários para avaliar sua experiência com seu produto usando uma pesquisa padronizada de 10 perguntas.",
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
}
}

View File

@@ -124,6 +124,7 @@
"add_action": "Adicionar ação",
"add_filter": "Adicionar filtro",
"add_logo": "Adicionar logótipo",
"add_member": "Adicionar membro",
"add_project": "Adicionar projeto",
"add_to_team": "Adicionar à equipa",
"all": "Todos",
@@ -279,6 +280,8 @@
"on": "Ligado",
"only_one_file_allowed": "Apenas um ficheiro é permitido",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Apenas proprietários e gestores podem realizar esta ação.",
"option_id": "ID de Opção",
"option_ids": "IDs de Opção",
"or": "ou",
"organization": "Organização",
"organization_id": "ID da Organização",
@@ -305,6 +308,7 @@
"privacy": "Política de Privacidade",
"product_manager": "Gestor de Produto",
"profile": "Perfil",
"profile_id": "ID do Perfil",
"project_configuration": "Configuração do Projeto",
"project_creation_description": "Organize questionários em projetos para um melhor controlo de acesso.",
"project_id": "ID do Projeto",
@@ -385,6 +389,7 @@
"targeting": "Segmentação",
"team": "Equipa",
"team_access": "Acesso da Equipa",
"team_id": "ID da Equipa",
"team_name": "Nome da equipa",
"teams": "Controlo de Acesso",
"teams_not_found": "Equipas não encontradas",
@@ -503,21 +508,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 +531,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 +558,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 +1291,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 +1302,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",
@@ -1302,7 +1316,7 @@
"columns": "Colunas",
"company": "Empresa",
"company_logo": "Logotipo da empresa",
"completed_responses": "respostas concluídas",
"completed_responses": "respostas parciais ou completas",
"concat": "Concatenar +",
"conditional_logic": "Lógica Condicional",
"confirm_default_language": "Confirmar idioma padrão",
@@ -1607,6 +1621,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",
@@ -1799,7 +1818,6 @@
"last_quarter": "Último trimestre",
"last_year": "Ano passado",
"no_responses_found": "Nenhuma resposta encontrada",
"only_completed": "Apenas concluído",
"other_values_found": "Outros valores encontrados",
"overall": "Geral",
"qr_code": "Código QR",
@@ -2771,6 +2789,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 +2846,18 @@
"understand_purchase_intention_question_2_headline": "Entendido. Qual é a sua principal razão para visitar hoje?",
"understand_purchase_intention_question_2_placeholder": "Escreva a sua resposta aqui...",
"understand_purchase_intention_question_3_headline": "O que, se alguma coisa, o está a impedir de fazer uma compra hoje?",
"understand_purchase_intention_question_3_placeholder": "Escreva a sua resposta aqui..."
"understand_purchase_intention_question_3_placeholder": "Escreva a sua resposta aqui...",
"usability_question_10_headline": "Tive que aprender muito antes de poder começar a usar o sistema corretamente.",
"usability_question_1_headline": "Provavelmente usaria este sistema com frequência.",
"usability_question_2_headline": "O sistema parecia mais complicado do que precisava ser.",
"usability_question_3_headline": "O sistema foi fácil de entender.",
"usability_question_4_headline": "Acho que precisaria de ajuda de um especialista em tecnologia para utilizar este sistema.",
"usability_question_5_headline": "Tudo no sistema parecia funcionar bem em conjunto.",
"usability_question_6_headline": "O sistema parecia inconsistente na forma como as coisas funcionavam.",
"usability_question_7_headline": "Acho que a maioria das pessoas poderia aprender a usar este sistema rapidamente.",
"usability_question_8_headline": "Usar o sistema pareceu complicado.",
"usability_question_9_headline": "Eu senti-me confiante ao usar o sistema.",
"usability_rating_description": "Meça a usabilidade percebida ao solicitar que os utilizadores avaliem a sua experiência com o seu produto usando um questionário padronizado de 10 perguntas.",
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
}
}

View File

@@ -124,6 +124,7 @@
"add_action": "新增操作",
"add_filter": "新增篩選器",
"add_logo": "新增標誌",
"add_member": "新增成員",
"add_project": "新增專案",
"add_to_team": "新增至團隊",
"all": "全部",
@@ -279,6 +280,8 @@
"on": "開啟",
"only_one_file_allowed": "僅允許一個檔案",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "只有擁有者、管理員和管理存取權限的成員才能執行此操作。",
"option_id": "選項 ID",
"option_ids": "選項 IDs",
"or": "或",
"organization": "組織",
"organization_id": "組織 ID",
@@ -305,6 +308,7 @@
"privacy": "隱私權政策",
"product_manager": "產品經理",
"profile": "個人資料",
"profile_id": "個人資料 ID",
"project_configuration": "專案組態",
"project_creation_description": "組織調查 在 專案中以便更好地存取控制。",
"project_id": "專案 ID",
@@ -385,6 +389,7 @@
"targeting": "目標設定",
"team": "團隊",
"team_access": "團隊存取權限",
"team_id": "團隊 ID",
"team_name": "團隊名稱",
"teams": "存取控制",
"teams_not_found": "找不到團隊",
@@ -503,21 +508,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 +531,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 +558,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 +1291,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 +1302,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": "核取方塊標籤",
@@ -1302,7 +1316,7 @@
"columns": "欄位",
"company": "公司",
"company_logo": "公司標誌",
"completed_responses": "完成的回應。",
"completed_responses": "部分或完整答复。",
"concat": "串連 +",
"conditional_logic": "條件邏輯",
"confirm_default_language": "確認預設語言",
@@ -1607,6 +1621,11 @@
"zip": "郵遞區號"
},
"error_deleting_survey": "刪除問卷時發生錯誤",
"filter": {
"complete_and_partial_responses": "完整 和 部分 回應",
"complete_responses": "完整回應",
"partial_responses": "部分回應"
},
"new_survey": "新增問卷",
"no_surveys_created_yet": "尚未建立任何問卷",
"open_options": "開啟選項",
@@ -1799,7 +1818,6 @@
"last_quarter": "上一季",
"last_year": "去年",
"no_responses_found": "找不到回應",
"only_completed": "僅已完成",
"other_values_found": "找到其他值",
"overall": "整體",
"qr_code": "QR 碼",
@@ -2771,6 +2789,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 +2846,18 @@
"understand_purchase_intention_question_2_headline": "瞭解了。您今天來訪的主要原因是什麼?",
"understand_purchase_intention_question_2_placeholder": "在此輸入您的答案...",
"understand_purchase_intention_question_3_headline": "有什麼阻礙您今天進行購買嗎?",
"understand_purchase_intention_question_3_placeholder": "在此輸入您的答案..."
"understand_purchase_intention_question_3_placeholder": "在此輸入您的答案...",
"usability_question_10_headline": "我 必須 學習 很多 東西 才能 正確 使用 該 系統。",
"usability_question_1_headline": "我可能會經常使用這個系統。",
"usability_question_2_headline": "系統感覺起來比實際需要的更複雜。",
"usability_question_3_headline": "系統很容易理解。",
"usability_question_4_headline": "我 認為 我 需要 技術 專家 的 幫助 才能 使用 這個 系統。",
"usability_question_5_headline": "系統中 的 所有 元素 看起來 都能 很好 地 運作。",
"usability_question_6_headline": "系統在運作上給人不一致的感覺。",
"usability_question_7_headline": "我認為大多數人可以快速 學會 使用 這個 系統。",
"usability_question_8_headline": "使用系統 感覺 令人 困擾。",
"usability_question_9_headline": "使用 系統 時,我 感到 有 信心。",
"usability_rating_description": "透過使用標準化的 十個問題 問卷,要求使用者評估他們對 您 產品的使用體驗,來衡量感知的 可用性。",
"usability_score_name": "系統 可用性 分數 (SUS)"
}
}

View File

@@ -324,6 +324,7 @@ export const authOptions: NextAuthOptions = {
if (account?.provider === "credentials" || account?.provider === "token") {
// check if user's email is verified or not
if (!user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
logger.error("Email Verification is Pending");
throw new Error("Email Verification is Pending");
}
await updateUserLastLoginAt(user.email);

View File

@@ -2,6 +2,7 @@ import { AUDIT_LOG_ENABLED, AUDIT_LOG_GET_USER_IP } from "@/lib/constants";
import { ActionClientCtx, AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { deepDiff, redactPII } from "@/lib/utils/logger-helpers";
import { logAuditEvent } from "@/modules/ee/audit-logs/lib/service";
import {
TActor,
@@ -13,7 +14,6 @@ import {
} from "@/modules/ee/audit-logs/types/audit-log";
import { getIsAuditLogsEnabled } from "@/modules/ee/license-check/lib/utils";
import { logger } from "@formbricks/logger";
import { deepDiff, redactPII } from "./utils";
/**
* Builds an audit event and logs it.

View File

@@ -2,6 +2,7 @@ import { getResponsesByContactId } from "@/lib/response/service";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { getTranslate } from "@/tolgee/server";
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
@@ -42,7 +43,7 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
<dt className="text-sm font-medium text-slate-500">{t("common.user_id")}</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.userId ? (
<span>{attributes.userId}</span>
<IdBadge id={attributes.userId} />
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}

View File

@@ -2,6 +2,7 @@
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { ColumnDef } from "@tanstack/react-table";
import { TContactTableData } from "../types/contact";
@@ -26,7 +27,7 @@ export const generateContactTableColumns = (
header: "User ID",
cell: ({ row }) => {
const userId = row.original.userId;
return <HighlightedText value={userId} searchValue={searchValue} />;
return <IdBadge id={userId} showCopyIconOnHover={true} />;
},
};

View File

@@ -1,6 +1,7 @@
"use client";
import { convertDateTimeStringShort } from "@/lib/time";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Label } from "@/modules/ui/components/label";
import { useTranslate } from "@tolgee/react";
import { TSegment } from "@formbricks/types/segment";
@@ -52,10 +53,7 @@ export const SegmentActivityTab = ({ currentSegment }: SegmentActivityTabProps)
</p>
</div>
<div>
<Label className="text-xs font-normal text-slate-500">
{t("environments.segments.segment_id")}
</Label>
<p className="text-xs text-slate-700">{currentSegment.id.toString()}</p>
<IdBadge id={currentSegment.id} label={t("environments.segments.segment_id")} variant="column" />
</div>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { getIsFreshInstance } from "@/lib/instance/service";
import { verifyInviteToken } from "@/lib/jwt";
import { createMembership } from "@/lib/membership/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { redactPII } from "@/lib/utils/logger-helpers";
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite";
@@ -31,12 +32,36 @@ export const handleSsoCallback = async ({
account: Account;
callbackUrl: string;
}) => {
const contextLogger = logger.withContext({
correlationId: crypto.randomUUID(),
name: "formbricks",
});
contextLogger.debug(
{
...redactPII({ user, account, callbackUrl }),
hasEmail: !!user.email,
hasName: !!user.name,
},
"SSO callback initiated"
);
const isSsoEnabled = await getIsSsoEnabled();
if (!isSsoEnabled) {
contextLogger.debug({ isSsoEnabled }, "SSO not enabled");
return false;
}
if (!user.email || account.type !== "oauth") {
contextLogger.debug(
{
hasEmail: !!user.email,
accountType: account.type,
reason: !user.email ? "missing_email" : "invalid_account_type",
},
"SSO callback rejected: missing email or invalid account type"
);
return false;
}
@@ -45,12 +70,18 @@ export const handleSsoCallback = async ({
if (provider === "saml") {
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
if (!isSamlSsoEnabled) {
contextLogger.debug({ provider: "saml" }, "SSO callback rejected: SAML not enabled in license");
return false;
}
}
if (account.provider) {
// check if accounts for this provider / account Id already exists
contextLogger.debug(
{ lookupType: "sso_provider_account" },
"Checking for existing user with SSO provider"
);
const existingUserWithAccount = await prisma.user.findFirst({
include: {
accounts: {
@@ -66,12 +97,29 @@ export const handleSsoCallback = async ({
});
if (existingUserWithAccount) {
contextLogger.debug(
{
existingUserId: existingUserWithAccount.id,
emailMatches: existingUserWithAccount.email === user.email,
},
"Found existing user with SSO provider"
);
// User with this provider found
// check if email still the same
if (existingUserWithAccount.email === user.email) {
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"SSO callback successful: existing user, email matches"
);
return true;
}
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"Email changed in SSO provider, checking for conflicts"
);
// user seemed to change his email within the provider
// check if user with this email already exist
// if not found just update user with new email address
@@ -79,9 +127,20 @@ export const handleSsoCallback = async ({
const otherUserWithEmail = await getUserByEmail(user.email);
if (!otherUserWithEmail) {
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id, action: "email_update" },
"No other user with this email found, updating user email after SSO provider change"
);
await updateUser(existingUserWithAccount.id, { email: user.email });
return true;
}
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id, conflictingUserId: otherUserWithEmail.id },
"SSO callback failed: email conflict after provider change"
);
throw new Error(
"Looks like you updated your email somewhere else. A user with this new email exists already."
);
@@ -90,13 +149,24 @@ export const handleSsoCallback = async ({
// There is no existing account for this identity provider / account id
// check if user account with this email already exists
// if user already exists throw error and request password login
contextLogger.debug({ lookupType: "email" }, "No existing SSO account found, checking for user by email");
const existingUserWithEmail = await getUserByEmail(user.email);
if (existingUserWithEmail) {
contextLogger.debug(
{ existingUserId: existingUserWithEmail.id, action: "existing_user_login" },
"SSO callback successful: existing user found by email"
);
// Sign in the user with the existing account
return true;
}
contextLogger.debug(
{ action: "new_user_creation" },
"No existing user found, proceeding with new user creation"
);
let userName = user.name;
if (provider === "openid") {
@@ -108,6 +178,16 @@ export const handleSsoCallback = async ({
} else if (oidcUser.preferred_username) {
userName = oidcUser.preferred_username;
}
contextLogger.debug(
{
hasName: !!oidcUser.name,
hasGivenName: !!oidcUser.given_name,
hasFamilyName: !!oidcUser.family_name,
hasPreferredUsername: !!oidcUser.preferred_username,
},
"Extracted OIDC user name"
);
}
if (provider === "saml") {
@@ -117,6 +197,14 @@ export const handleSsoCallback = async ({
} else if (samlUser.firstName || samlUser.lastName) {
userName = `${samlUser.firstName} ${samlUser.lastName}`;
}
contextLogger.debug(
{
hasName: !!samlUser.name,
hasFirstName: !!samlUser.firstName,
hasLastName: !!samlUser.lastName,
},
"Extracted SAML user name"
);
}
// Get multi-org license status
@@ -124,9 +212,23 @@ export const handleSsoCallback = async ({
const isFirstUser = await getIsFreshInstance();
contextLogger.debug(
{
isMultiOrgEnabled,
isFirstUser,
skipInviteForSso: SKIP_INVITE_FOR_SSO,
hasDefaultTeamId: !!DEFAULT_TEAM_ID,
},
"License and instance configuration checked"
);
// Additional security checks for self-hosted instances without auto-provisioning and no multi-org enabled
if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) {
if (!callbackUrl) {
contextLogger.debug(
{ reason: "missing_callback_url" },
"SSO callback rejected: missing callback URL for invite validation"
);
return false;
}
@@ -139,6 +241,10 @@ export const handleSsoCallback = async ({
// Allow sign-in if multi-org is enabled, otherwise check for invite token
if (source === "signin" && !inviteToken) {
contextLogger.debug(
{ reason: "signin_without_invite_token" },
"SSO callback rejected: signin without invite token"
);
return false;
}
@@ -146,16 +252,32 @@ export const handleSsoCallback = async ({
// Verify invite token and check email match
const { email, inviteId } = verifyInviteToken(inviteToken);
if (email !== user.email) {
contextLogger.debug(
{ reason: "invite_email_mismatch", inviteId },
"SSO callback rejected: invite token email mismatch"
);
return false;
}
// Check if invite token is still valid
const isValidInviteToken = await getIsValidInviteToken(inviteId);
if (!isValidInviteToken) {
contextLogger.debug(
{ reason: "invalid_invite_token", inviteId },
"SSO callback rejected: invalid or expired invite token"
);
return false;
}
contextLogger.debug({ inviteId }, "Invite token validation successful");
} catch (err) {
contextLogger.debug(
{
reason: "invite_token_validation_error",
error: err instanceof Error ? err.message : "unknown_error",
},
"SSO callback rejected: invite token validation failed"
);
// Log and reject on any validation errors
logger.error(err, "Invalid callbackUrl");
contextLogger.error(err, "Invalid callbackUrl");
return false;
}
}
@@ -163,6 +285,12 @@ export const handleSsoCallback = async ({
let organization: Organization | null = null;
if (!isFirstUser && !isMultiOrgEnabled) {
contextLogger.debug(
{
assignmentStrategy: SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID ? "default_team" : "first_organization",
},
"Determining organization assignment"
);
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
organization = await getOrganizationByTeamId(DEFAULT_TEAM_ID);
} else {
@@ -170,13 +298,29 @@ export const handleSsoCallback = async ({
}
if (!organization) {
contextLogger.debug(
{ reason: "no_organization_found" },
"SSO callback rejected: no organization found for assignment"
);
return false;
}
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!canDoRoleManagement && !callbackUrl) return false;
if (!canDoRoleManagement && !callbackUrl) {
contextLogger.debug(
{
reason: "insufficient_role_permissions",
organizationId: organization.id,
canDoRoleManagement,
},
"SSO callback rejected: insufficient role management permissions"
);
return false;
}
}
contextLogger.debug({ hasUserName: !!userName, identityProvider: provider }, "Creating new SSO user");
const userProfile = await createUser({
name:
userName ||
@@ -191,13 +335,28 @@ export const handleSsoCallback = async ({
locale: await findMatchingLocale(),
});
contextLogger.debug(
{ newUserId: userProfile.id, identityProvider: provider },
"New SSO user created successfully"
);
// send new user to brevo
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
if (isMultiOrgEnabled) return true;
if (isMultiOrgEnabled) {
contextLogger.debug(
{ isMultiOrgEnabled, newUserId: userProfile.id },
"Multi-org enabled, skipping organization assignment"
);
return true;
}
// Default organization assignment if env variable is set
if (organization) {
contextLogger.debug(
{ newUserId: userProfile.id, organizationId: organization.id, role: "member" },
"Assigning user to organization"
);
await createMembership(organization.id, userProfile.id, { role: "member", accepted: true });
await createAccount({
...account,
@@ -205,6 +364,10 @@ export const handleSsoCallback = async ({
});
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
contextLogger.debug(
{ newUserId: userProfile.id, defaultTeamId: DEFAULT_TEAM_ID },
"Creating default team membership"
);
await createDefaultTeamMembership(userProfile.id);
}
@@ -226,6 +389,7 @@ export const handleSsoCallback = async ({
// Without default organization assignment
return true;
}
contextLogger.debug("SSO callback successful: default return");
return true;
};

View File

@@ -85,6 +85,13 @@ vi.mock("@formbricks/lib/jwt", () => ({
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
debug: vi.fn(),
withContext: (context: Record<string, any>) => {
return {
...context,
debug: vi.fn(),
};
},
},
}));

View File

@@ -2,6 +2,7 @@
import { TProjectTeam } from "@/modules/ee/teams/project-teams/types/team";
import { TeamPermissionMapping } from "@/modules/ee/teams/utils/teams";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { useTranslate } from "@tolgee/react";
@@ -21,6 +22,7 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
{t("environments.project.teams.team_name")}
</TableHead>
<TableHead className="font-medium text-slate-500">{t("common.size")}</TableHead>
<TableHead className="font-medium text-slate-500">{t("common.team_id")}</TableHead>
<TableHead className="font-medium text-slate-500">
{t("environments.project.teams.permission")}
</TableHead>
@@ -40,6 +42,9 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
<TableCell>
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
</TableCell>
<TableCell>
<IdBadge id={team.id} showCopyIconOnHover={true} />
</TableCell>
<TableCell>
<p className="capitalize">{TeamPermissionMapping[team.permission]}</p>
</TableCell>

View File

@@ -26,6 +26,7 @@ import {
DialogTitle,
} from "@/modules/ui/components/dialog";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Input } from "@/modules/ui/components/input";
import {
Select,
@@ -235,6 +236,8 @@ export const TeamSettingsModal = ({
)}
/>
<IdBadge id={team.id} label={t("common.team_id")} variant="column" />
{/* Members Section */}
<div className="space-y-2">
<div className="flex flex-col space-y-1">

View File

@@ -313,32 +313,32 @@ export async function PreviewEmailTemplate({
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.PictureSelection:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Section className="mx-0 mt-4">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.PictureSelection:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
<Section className="mx-0 mt-4">
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
</Link>
)
)}
</Section>
<EmailFooter />
</EmailTemplateWrapper>
);
case TSurveyQuestionTypeEnum.Cal:
return (
<EmailTemplateWrapper styling={styling} surveyUrl={url}>

View File

@@ -2,10 +2,10 @@ import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { EnvironmentIdField } from "@/modules/projects/settings/(setup)/components/environment-id-field";
import { SetupInstructions } from "@/modules/projects/settings/(setup)/components/setup-instructions";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { EnvironmentNotice } from "@/modules/ui/components/environment-notice";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
@@ -38,7 +38,7 @@ export const AppConnectionPage = async (props) => {
<SettingsCard
title={t("environments.project.app-connection.environment_id")}
description={t("environments.project.app-connection.environment_id_description")}>
<EnvironmentIdField environmentId={params.environmentId} />
<IdBadge id={params.environmentId} />
</SettingsCard>
</div>
</PageContentWrapper>

View File

@@ -1,38 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { EnvironmentIdField } from "./environment-id-field";
vi.mock("@/modules/ui/components/code-block", () => ({
CodeBlock: ({ children, language }: any) => (
<pre data-testid="code-block" data-language={language}>
{children}
</pre>
),
}));
describe("EnvironmentIdField", () => {
afterEach(() => {
cleanup();
});
test("renders the environment id in a code block", () => {
const envId = "env-123";
render(<EnvironmentIdField environmentId={envId} />);
const codeBlock = screen.getByTestId("code-block");
expect(codeBlock).toBeInTheDocument();
expect(codeBlock).toHaveAttribute("data-language", "js");
expect(codeBlock).toHaveTextContent(envId);
});
test("applies the correct wrapper class", () => {
render(<EnvironmentIdField environmentId="env-abc" />);
const wrapper = codeBlockParent();
expect(wrapper).toHaveClass("prose");
expect(wrapper).toHaveClass("prose-slate");
expect(wrapper).toHaveClass("-mt-3");
});
});
function codeBlockParent() {
return screen.getByTestId("code-block").parentElement as HTMLElement;
}

View File

@@ -1,11 +0,0 @@
"use client";
import { CodeBlock } from "@/modules/ui/components/code-block";
export const EnvironmentIdField = ({ environmentId }: { environmentId: string }) => {
return (
<div className="prose prose-slate -mt-3">
<CodeBlock language="js">{environmentId}</CodeBlock>
</div>
);
};

View File

@@ -19,10 +19,11 @@ vi.mock("@/modules/ui/components/page-header", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/settings-id", () => ({
SettingsId: ({ title, id }: any) => (
<div data-testid="settings-id">
<p>{title}</p>:<p>{id}</p>
vi.mock("@/modules/ui/components/id-badge", () => ({
IdBadge: ({ id, label, variant }: any) => (
<div data-testid="id-badge" data-variant={variant}>
<span data-testid="id-badge-label">{label}</span>
<span data-testid="id-badge-id">{id}</span>
</div>
),
}));
@@ -104,7 +105,7 @@ describe("GeneralSettingsPage", () => {
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("project-config-navigation")).toBeInTheDocument();
expect(screen.getAllByTestId("settings-id").length).toBe(2);
expect(screen.getAllByTestId("id-badge").length).toBe(2);
expect(screen.getByTestId("edit-project-name-form")).toBeInTheDocument();
expect(screen.getByTestId("edit-waiting-time-form")).toBeInTheDocument();
expect(screen.getByTestId("delete-project")).toBeInTheDocument();

View File

@@ -3,9 +3,9 @@ import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getProjects } from "@/lib/project/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import packageJson from "@/package.json";
import { getTranslate } from "@/tolgee/server";
import { DeleteProject } from "./components/delete-project";
@@ -49,10 +49,10 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
isOwnerOrManager={isOwnerOrManager}
/>
</SettingsCard>
<div>
<SettingsId title={t("common.project_id")} id={project.id}></SettingsId>
<div className="space-y-2">
<IdBadge id={project.id} label={t("common.project_id")} variant="column" />
{!IS_FORMBRICKS_CLOUD && !IS_DEVELOPMENT && (
<SettingsId title={t("common.formbricks_version")} id={packageJson.version}></SettingsId>
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
)}
</div>
</PageContentWrapper>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ export const ClientLogo = ({ environmentId, projectLogo, previewSurvey = false }
src={projectLogo?.url}
className={cn(
previewSurvey ? "max-h-12" : "max-h-16 md:max-h-20",
"w-auto max-w-40 rounded-lg object-contain p-1 md:max-w-56"
"w-auto max-w-40 object-contain p-1 md:max-w-56"
)}
width={256}
height={64}

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,16 @@ export const DataTableSettingsModalItem = <T,>({ column, survey }: DataTableSett
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: column.id,
});
const isOptionIdColumn = column.id.endsWith("optionIds");
const getOptionIdColumnLabel = () => {
const questionId = column.id.split("optionIds")[0];
const question = survey?.questions.find((q) => q.id === questionId);
if (question) {
return `${getLocalizedValue(question.headline, "default")} - ${t("common.option_id")}`;
}
return null;
};
const getLabelFromColumnId = () => {
switch (column.id) {
@@ -77,7 +87,9 @@ export const DataTableSettingsModalItem = <T,>({ column, survey }: DataTableSett
<span className="max-w-xs truncate">{getLocalizedValue(question.headline, "default")}</span>
</div>
) : (
<span className="max-w-xs truncate">{getLabelFromColumnId()}</span>
<span className="max-w-xs truncate">
{isOptionIdColumn ? getOptionIdColumnLabel() : getLabelFromColumnId()}
</span>
)}
</div>
<Switch

View File

@@ -24,6 +24,16 @@ vi.mock("@tolgee/react", () => ({
}),
}));
// Mock lucide-react icons used in IdBadge
vi.mock("lucide-react", async () => {
const actual = await vi.importActual<typeof import("lucide-react")>("lucide-react");
return {
...actual,
Copy: () => "Copy Icon",
Check: () => "Check Icon",
};
});
describe("IdBadge", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

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

View File

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

View File

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

View File

@@ -1,35 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { SettingsId } from "./index";
describe("SettingsId", () => {
afterEach(() => {
cleanup();
});
test("renders the title and id correctly", () => {
render(<SettingsId title="Survey ID" id="survey-123" />);
const element = screen.getByText(/Survey ID: survey-123/);
expect(element).toBeInTheDocument();
expect(element.tagName.toLowerCase()).toBe("p");
});
test("applies correct styling", () => {
render(<SettingsId title="Environment ID" id="env-456" />);
const element = screen.getByText(/Environment ID: env-456/);
expect(element).toHaveClass("py-1");
expect(element).toHaveClass("text-xs");
expect(element).toHaveClass("text-slate-400");
});
test("renders with very long id", () => {
const longId = "a".repeat(100);
render(<SettingsId title="API Key" id={longId} />);
const element = screen.getByText(`API Key: ${longId}`);
expect(element).toBeInTheDocument();
});
});

View File

@@ -1,12 +0,0 @@
interface SettingsIdProps {
title: string;
id: string;
}
export const SettingsId = ({ title, id }: SettingsIdProps) => {
return (
<p className="py-1 text-xs text-slate-400">
{title}: {id}
</p>
);
};

View File

@@ -132,7 +132,7 @@
"ua-parser-js": "2.0.3",
"uuid": "11.1.0",
"webpack": "5.99.8",
"xlsx": "0.18.5",
"xlsx": "file:vendor/xlsx-0.20.3.tgz",
"zod": "3.24.4",
"zod-openapi": "4.2.4"
},

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

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

BIN
apps/web/vendor/xlsx-0.20.3.tgz vendored Normal file

Binary file not shown.

View File

@@ -49,6 +49,7 @@
"xm-and-surveys/surveys/general-features/variables",
"xm-and-surveys/surveys/general-features/hide-back-button",
"xm-and-surveys/surveys/general-features/email-followups",
"xm-and-surveys/surveys/general-features/quota-management",
"xm-and-surveys/surveys/general-features/spam-protection"
]
},

View File

@@ -0,0 +1,129 @@
---
title: "Quota Management"
description: "Control response collection by setting limits on specific segments to ensure balanced and representative survey datasets."
icon: "chart-pie"
---
## Overview
Quota Management allows you to set limits on the number of responses collected for specific segments or criteria in your survey. This feature helps ensure you collect a balanced and representative dataset while preventing oversaturation of certain response types.
<Note type="warning">
Quota Management is currently in beta and only available to select customers.
</Note>
<Note>
Quota Management is part of the [Enterprise Edition](/self-hosting/advanced/license).
</Note>
### Key benefits
- **Balanced Data Collection**: Ensure your survey responses are evenly distributed across different segments
- **Cost Control**: Prevent collecting more responses than needed from specific groups
- **Quality Assurance**: Maintain data quality by avoiding homogeneous response patterns
- **Automated Management**: Automatically stop collecting responses when quotas are met
### How Quota Management works
When you set up quotas for your survey, Formbricks automatically tracks responses against your defined limits. Once a quota is reached, the system can:
- Prevent new responses from that segment
- Skip respondents to the end of the survey
- Redirect respondents to a custom end screen
## Setting up Quotas
In the first step, you need to define the criteria for the quota:
<Steps>
<Step title="Name the quota">
Create a Quota and label it e.g. "Mobile Phone Users in Europe"
</Step>
<Step title="Set quota limit">
Set numerical limits for each hidden field value combination e.g. 500
</Step>
<Step title="Define inclusion criteria">
Choose a distinct set of answers to survey questions, variable values or hidden fields. Responses who match this set will be included in the quota.
</Step>
<Step title="Configure actions">
Choose what happens when this Quota is met (e.g. skip to specific end screen)
</Step>
</Steps>
## Quota actions
Configure what happens when a quota reaches its limit:
<Tabs>
<Tab title="Skip to End">
Jump respondents directly to the survey completion page
</Tab>
<Tab title="Custom Redirect (soon)">
Redirect respondents to a custom thank you page or alternative survey
</Tab>
</Tabs>
## Counting against Quotas
### 1. Count by Hidden Field value
Determine if a response falls in or out of a Quota based on hidden field values passed through URL parameters:
```
https://your-survey-url.com/s/abc123?product=credit-card&region=europe
```
### 2. Quota by survey responses
Create quotas based on specific answers to survey questions:
<Tabs>
<Tab title="Single Question Quota">
Set quotas for individual answer options:
- Question: "What is your gender?"
- Quota: 500 responses for "Male", 500 responses for "Female"
</Tab>
<Tab title="Multi-Question Quota">
Combine multiple question responses:
- Criteria: Age group "25-34" AND Location "Urban"
- Quota: 200 responses matching both criteria
</Tab>
</Tabs>
### 3. Multi-criteria quotas
Create complex quotas using multiple conditions:
<CodeGroup>
```example "Hidden Field + Response Combination"
Hidden Field: product = "mobile"
AND
Question Response: satisfaction = "very satisfied"
```
```example "Multiple Response Criteria"
Question 1: age_group = "18-25"
AND
Question 2: location = "urban"
AND
Question 3: income = "high"
Quota Limit: 50 responses
```
</CodeGroup>
### Partial vs. complete responses
<Info>
By default, Quota Management includes partial responses in quota counts. You can change this behavior by configuring the quota to only count complete responses.
</Info>
This means if a respondent starts but doesn't complete the survey, they may still count toward your quota if they've answered the qualifying questions.
## Quota monitoring
<Card title="Live Quota Status" icon="chart-line">
Monitor your quotas in real-time through the dashboard in the survey summary:
- **Current Count**: See how many responses each quota has collected
- **Progress Bars**: Visual representation of quota completion
- **Status Indicators**: Active, completed, or paused quota status
</Card>

View File

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

View File

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

View File

@@ -137,8 +137,7 @@ module "eks" {
cluster_version = "1.32"
enable_cluster_creator_admin_permissions = false
cluster_endpoint_public_access = true # TEMPORARY: Enable for deployments until Tailscale is fixed by new DevOps engineer
cluster_endpoint_public_access_cidrs = ["0.0.0.0/0"] # TEMPORARY: Will revert to private + Tailscale access
cluster_endpoint_public_access = false
cloudwatch_log_group_retention_in_days = 365
cluster_addons = {

View File

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

Some files were not shown because too many files have changed in this diff Show More