mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-31 20:39:11 -06:00
Compare commits
29 Commits
v3.16.1
...
fix-verify
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdfd1bae70 | ||
|
|
09404539d3 | ||
|
|
ee20af54c3 | ||
|
|
d08ec4c9ab | ||
|
|
891c83e232 | ||
|
|
0b02b00b72 | ||
|
|
a217cdd501 | ||
|
|
ebe50a4821 | ||
|
|
cb68d9defc | ||
|
|
c42a706789 | ||
|
|
3803111b19 | ||
|
|
30fdcff737 | ||
|
|
e83cfa85a4 | ||
|
|
eee9ee8995 | ||
|
|
ed89f12af8 | ||
|
|
f043314537 | ||
|
|
2ce842dd8d | ||
|
|
43b43839c5 | ||
|
|
8b6e3fec37 | ||
|
|
31bcf98779 | ||
|
|
b35cabcbcc | ||
|
|
4f435f1a1f | ||
|
|
99c1e434df | ||
|
|
b13699801b | ||
|
|
ceb2e85d96 | ||
|
|
c5f8b5ec32 | ||
|
|
bdbd57c2fc | ||
|
|
d44aa17814 | ||
|
|
23d38b4c5b |
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description:
|
||||
description: Migrate deprecated UI components to a unified component
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
177
.cursor/rules/storybook-create-new-story.mdc
Normal file
177
.cursor/rules/storybook-create-new-story.mdc
Normal file
@@ -0,0 +1,177 @@
|
||||
---
|
||||
description: Create a story in Storybook for a given component
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Formbricks Storybook Stories
|
||||
|
||||
## When generating Storybook stories for Formbricks components:
|
||||
|
||||
### 1. **File Structure**
|
||||
- Create `stories.tsx` (not `.stories.tsx`) in component directory
|
||||
- Use exact import: `import { Meta, StoryObj } from "@storybook/react-vite";`
|
||||
- Import component from `"./index"`
|
||||
|
||||
### 2. **Story Structure Template**
|
||||
```tsx
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ComponentName } from "./index";
|
||||
|
||||
// For complex components with configurable options
|
||||
// consider this as an example the options need to reflect the props types
|
||||
interface StoryOptions {
|
||||
showIcon: boolean;
|
||||
numberOfElements: number;
|
||||
customLabels: string[];
|
||||
}
|
||||
|
||||
type StoryProps = React.ComponentProps<typeof ComponentName> & StoryOptions;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI/ComponentName",
|
||||
component: ComponentName,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component: "The **ComponentName** component provides [description].",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
// Organize in exactly these categories: Behavior, Appearance, Content
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComponentName> & { args: StoryOptions };
|
||||
```
|
||||
|
||||
### 3. **ArgTypes Organization**
|
||||
Organize ALL argTypes into exactly three categories:
|
||||
- **Behavior**: disabled, variant, onChange, etc.
|
||||
- **Appearance**: size, color, layout, styling, etc.
|
||||
- **Content**: text, icons, numberOfElements, etc.
|
||||
|
||||
Format:
|
||||
```tsx
|
||||
argTypes: {
|
||||
propName: {
|
||||
control: "select" | "boolean" | "text" | "number",
|
||||
options: ["option1", "option2"], // for select
|
||||
description: "Clear description",
|
||||
table: {
|
||||
category: "Behavior" | "Appearance" | "Content",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 4. **Required Stories**
|
||||
Every component must include:
|
||||
- `Default`: Most common use case
|
||||
- `Disabled`: If component supports disabled state
|
||||
- `WithIcon`: If component supports icons
|
||||
- Variant stories for each variant (Primary, Secondary, Error, etc.)
|
||||
- Edge case stories (ManyElements, LongText, CustomStyling)
|
||||
|
||||
### 5. **Story Format**
|
||||
```tsx
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
// Props with realistic values
|
||||
},
|
||||
};
|
||||
|
||||
export const EdgeCase: Story = {
|
||||
args: { /* ... */ },
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this when [specific scenario].",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 6. **Dynamic Content Pattern**
|
||||
For components with dynamic content, create render function:
|
||||
```tsx
|
||||
const renderComponent = (args: StoryProps) => {
|
||||
const { numberOfElements, showIcon, customLabels } = args;
|
||||
|
||||
// Generate dynamic content
|
||||
const elements = Array.from({ length: numberOfElements }, (_, i) => ({
|
||||
id: `element-${i}`,
|
||||
label: customLabels[i] || `Element ${i + 1}`,
|
||||
icon: showIcon ? <IconComponent /> : undefined,
|
||||
}));
|
||||
|
||||
return <ComponentName {...args} elements={elements} />;
|
||||
};
|
||||
|
||||
export const Dynamic: Story = {
|
||||
render: renderComponent,
|
||||
args: {
|
||||
numberOfElements: 3,
|
||||
showIcon: true,
|
||||
customLabels: ["First", "Second", "Third"],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 7. **State Management**
|
||||
For interactive components:
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
|
||||
const ComponentWithState = (args: any) => {
|
||||
const [value, setValue] = useState(args.defaultValue);
|
||||
|
||||
return (
|
||||
<ComponentName
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue);
|
||||
args.onChange?.(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
render: ComponentWithState,
|
||||
args: { defaultValue: "initial" },
|
||||
};
|
||||
```
|
||||
|
||||
### 8. **Quality Requirements**
|
||||
- Include component description in parameters.docs
|
||||
- Add story documentation for non-obvious use cases
|
||||
- Test edge cases (overflow, empty states, many elements)
|
||||
- Ensure no TypeScript errors
|
||||
- Use realistic prop values
|
||||
- Include at least 3-5 story variants
|
||||
- Example values need to be in the context of survey application
|
||||
|
||||
### 9. **Naming Conventions**
|
||||
- **Story titles**: "UI/ComponentName"
|
||||
- **Story exports**: PascalCase (Default, WithIcon, ManyElements)
|
||||
- **Categories**: "Behavior", "Appearance", "Content" (exact spelling)
|
||||
- **Props**: camelCase matching component props
|
||||
|
||||
### 10. **Special Cases**
|
||||
- **Generic components**: Remove `component` from meta if type conflicts
|
||||
- **Form components**: Include Invalid, WithValue stories
|
||||
- **Navigation**: Include ManyItems stories
|
||||
- **Modals, Dropdowns and Popups **: Include trigger and content structure
|
||||
|
||||
## Generate stories that are comprehensive, well-documented, and reflect all component states and edge cases.
|
||||
@@ -194,9 +194,6 @@ REDIS_URL=redis://localhost:6379
|
||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||
# REDIS_HTTP_URL:
|
||||
|
||||
# The below is used for Rate Limiting for management API
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Bug report
|
||||
description: "Found a bug? Please fill out the sections below. \U0001F44D"
|
||||
type: bug
|
||||
projects: "formbricks/8"
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Questions
|
||||
url: https://github.com/formbricks/formbricks/discussions
|
||||
|
||||
@@ -11,6 +11,10 @@ inputs:
|
||||
sentry_auth_token:
|
||||
description: 'Sentry authentication token'
|
||||
required: true
|
||||
environment:
|
||||
description: 'Sentry environment (e.g., production, staging)'
|
||||
required: false
|
||||
default: 'staging'
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
@@ -107,7 +111,7 @@ runs:
|
||||
SENTRY_ORG: formbricks
|
||||
SENTRY_PROJECT: formbricks-cloud
|
||||
with:
|
||||
environment: production
|
||||
environment: ${{ inputs.environment }}
|
||||
version: ${{ inputs.release_version }}
|
||||
sourcemaps: './extracted-next/'
|
||||
|
||||
|
||||
17
.github/workflows/deploy-formbricks-cloud.yml
vendored
17
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -17,8 +17,8 @@ on:
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- stage
|
||||
- prod
|
||||
- staging
|
||||
- production
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
@@ -52,6 +52,7 @@ jobs:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:github
|
||||
args: --accept-routes
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
@@ -66,8 +67,8 @@ jobs:
|
||||
AWS_REGION: eu-central-1
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Prod
|
||||
if: inputs.ENVIRONMENT == 'prod'
|
||||
name: Deploy Formbricks Cloud Production
|
||||
if: inputs.ENVIRONMENT == 'production'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
@@ -84,8 +85,8 @@ jobs:
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Stage
|
||||
if: inputs.ENVIRONMENT == 'stage'
|
||||
name: Deploy Formbricks Cloud Staging
|
||||
if: inputs.ENVIRONMENT == 'staging'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
@@ -101,13 +102,13 @@ jobs:
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
- name: Purge Cloudflare Cache
|
||||
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
|
||||
if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }}
|
||||
env:
|
||||
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
# Set hostname based on environment
|
||||
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
|
||||
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
|
||||
PURGE_HOST="app.formbricks.com"
|
||||
else
|
||||
PURGE_HOST="stage.app.formbricks.com"
|
||||
|
||||
20
.github/workflows/formbricks-release.yml
vendored
20
.github/workflows/formbricks-release.yml
vendored
@@ -1,17 +1,22 @@
|
||||
name: Build, release & deploy Formbricks images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
name: Build & release stable docker image
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
name: Build & release docker image
|
||||
uses: ./.github/workflows/release-docker-github.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
|
||||
helm-chart-release:
|
||||
name: Release Helm Chart
|
||||
@@ -31,7 +36,7 @@ jobs:
|
||||
- helm-chart-release
|
||||
with:
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: "prod"
|
||||
ENVIRONMENT: ${{ env.ENVIRONMENT }}
|
||||
|
||||
upload-sentry-sourcemaps:
|
||||
name: Upload Sentry Sourcemaps
|
||||
@@ -54,3 +59,4 @@ jobs:
|
||||
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
||||
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
environment: ${{ env.ENVIRONMENT }}
|
||||
|
||||
@@ -29,6 +29,10 @@ jobs:
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
outputs:
|
||||
DOCKER_IMAGE: ${{ steps.extract_image_info.outputs.DOCKER_IMAGE }}
|
||||
RELEASE_VERSION: ${{ steps.extract_image_info.outputs.RELEASE_VERSION }}
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
@@ -38,6 +42,53 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Generate SemVer version from branch or tag
|
||||
id: generate_version
|
||||
run: |
|
||||
# Get reference name and type
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
REF_TYPE="${{ github.ref_type }}"
|
||||
|
||||
echo "Reference type: $REF_TYPE"
|
||||
echo "Reference name: $REF_NAME"
|
||||
|
||||
if [[ "$REF_TYPE" == "tag" ]]; then
|
||||
# If running from a tag, use the tag name
|
||||
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
|
||||
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
|
||||
VERSION=$(echo "$REF_NAME" | sed 's/^v//')
|
||||
echo "Using SemVer tag: $VERSION"
|
||||
else
|
||||
# Tag is not SemVer, treat as prerelease
|
||||
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-$SANITIZED_TAG"
|
||||
echo "Using tag as prerelease: $VERSION"
|
||||
fi
|
||||
else
|
||||
# Running from branch, use branch name as prerelease
|
||||
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-$SANITIZED_BRANCH"
|
||||
echo "Using branch as prerelease: $VERSION"
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Generated SemVer version: $VERSION"
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep version
|
||||
|
||||
- name: Set Sentry environment in .env
|
||||
run: |
|
||||
if ! grep -q "^SENTRY_ENVIRONMENT=staging$" .env 2>/dev/null; then
|
||||
echo "SENTRY_ENVIRONMENT=staging" >> .env
|
||||
echo "Added SENTRY_ENVIRONMENT=staging to .env file"
|
||||
else
|
||||
echo "SENTRY_ENVIRONMENT=staging already exists in .env file"
|
||||
fi
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
@@ -83,6 +134,21 @@ jobs:
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
|
||||
- name: Extract image info for sourcemap upload
|
||||
id: extract_image_info
|
||||
run: |
|
||||
# Use the first readable tag from metadata action output
|
||||
DOCKER_IMAGE=$(echo "${{ steps.meta.outputs.tags }}" | head -n1 | xargs)
|
||||
echo "DOCKER_IMAGE=$DOCKER_IMAGE" >> $GITHUB_OUTPUT
|
||||
|
||||
# Use the generated version for Sentry release
|
||||
RELEASE_VERSION="$VERSION"
|
||||
echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "Docker image: $DOCKER_IMAGE"
|
||||
echo "Release version: $RELEASE_VERSION"
|
||||
echo "Available tags: ${{ steps.meta.outputs.tags }}"
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# repository is public to avoid leaking data. If you would like to publish
|
||||
@@ -97,3 +163,25 @@ jobs:
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
||||
upload-sentry-sourcemaps:
|
||||
name: Upload Sentry Sourcemaps
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Upload Sentry Sourcemaps
|
||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker_image: ${{ needs.build.outputs.DOCKER_IMAGE }}
|
||||
release_version: ${{ needs.build.outputs.RELEASE_VERSION }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
environment: staging
|
||||
|
||||
15
.github/workflows/release-docker-github.yml
vendored
15
.github/workflows/release-docker-github.yml
vendored
@@ -7,6 +7,12 @@ name: Docker Release to Github
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
IS_PRERELEASE:
|
||||
description: 'Whether this is a prerelease (affects latest tag)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
outputs:
|
||||
VERSION:
|
||||
description: release version
|
||||
@@ -45,10 +51,12 @@ jobs:
|
||||
- name: Get Release Tag
|
||||
id: extract_release_tag
|
||||
run: |
|
||||
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
|
||||
TAG=${{ github.ref }}
|
||||
TAG=${TAG#refs/tags/v}
|
||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "Using tag-based version: $TAG"
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
@@ -81,6 +89,13 @@ jobs:
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
# Default semver tags (version, major.minor, major)
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
# Only tag as 'latest' for stable releases (not prereleases)
|
||||
type=raw,value=latest,enable=${{ inputs.IS_PRERELEASE != 'true' }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TProject } from "@formbricks/types/project";
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
|
||||
@@ -35,6 +36,7 @@ export const EnvironmentContextWrapper = ({
|
||||
() => ({
|
||||
environment,
|
||||
project,
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
[environment, project]
|
||||
);
|
||||
|
||||
@@ -30,16 +30,16 @@ interface ManageIntegrationProps {
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
const tableHeaders = [
|
||||
"common.survey",
|
||||
"environments.integrations.airtable.table_name",
|
||||
"common.questions",
|
||||
"common.updated_at",
|
||||
];
|
||||
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { t } = useTranslate();
|
||||
|
||||
const tableHeaders = [
|
||||
t("common.survey"),
|
||||
t("environments.integrations.airtable.table_name"),
|
||||
t("common.questions"),
|
||||
t("common.updated_at"),
|
||||
];
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
|
||||
@@ -100,7 +100,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
{tableHeaders.map((header) => (
|
||||
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
|
||||
{t(header)}
|
||||
{header}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -10,24 +10,19 @@ import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
OperationNotAllowedError,
|
||||
TooManyRequestsError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
|
||||
|
||||
const limiter = rateLimit({
|
||||
interval: 60 * 60, // 1 hour
|
||||
allowedPerInterval: 3, // max 3 calls for email verification per hour
|
||||
});
|
||||
TUserPersonalInfoUpdateInput,
|
||||
TUserUpdateInput,
|
||||
ZUserPersonalInfoUpdateInput,
|
||||
} from "@formbricks/types/user";
|
||||
|
||||
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
|
||||
return {
|
||||
@@ -41,18 +36,15 @@ async function handleEmailUpdate({
|
||||
parsedInput,
|
||||
payload,
|
||||
}: {
|
||||
ctx: any;
|
||||
parsedInput: any;
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: TUserPersonalInfoUpdateInput;
|
||||
payload: TUserUpdateInput;
|
||||
}) {
|
||||
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
||||
if (!inputEmail || ctx.user.email === inputEmail) return payload;
|
||||
|
||||
try {
|
||||
await limiter(ctx.user.id);
|
||||
} catch {
|
||||
throw new TooManyRequestsError("Too many requests");
|
||||
}
|
||||
await applyRateLimit(rateLimitConfigs.actions.emailUpdate, ctx.user.id);
|
||||
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
|
||||
}
|
||||
@@ -75,41 +67,35 @@ async function handleEmailUpdate({
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const updateUserAction = authenticatedActionClient
|
||||
.schema(
|
||||
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
|
||||
password: ZUserPassword.optional(),
|
||||
})
|
||||
)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
let payload = buildUserUpdatePayload(parsedInput);
|
||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: TUserPersonalInfoUpdateInput;
|
||||
}) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
let payload = buildUserUpdatePayload(parsedInput);
|
||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
let newObject = oldObject;
|
||||
if (Object.keys(payload).length > 0) {
|
||||
newObject = await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = newObject;
|
||||
|
||||
return true;
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
let newObject = oldObject;
|
||||
if (Object.keys(payload).length > 0) {
|
||||
newObject = await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = newObject;
|
||||
|
||||
return true;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateAvatarAction = z.object({
|
||||
avatarUrl: z.string(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -73,6 +74,10 @@ vi.mock("@/lib/response/service", () => ({
|
||||
getResponseCountBySurveyId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/display/service", () => ({
|
||||
getDisplayCountBySurveyId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
}));
|
||||
@@ -178,6 +183,7 @@ describe("ResponsesPage", () => {
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
|
||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(5);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
|
||||
});
|
||||
@@ -206,6 +212,8 @@ describe("ResponsesPage", () => {
|
||||
isReadOnly: false,
|
||||
user: mockUser,
|
||||
publicDomain: mockPublicDomain,
|
||||
responseCount: 10,
|
||||
displayCount: 5,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -40,6 +41,7 @@ const Page = async (props) => {
|
||||
|
||||
// Get response count for the CTA component
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
const publicDomain = getPublicDomain();
|
||||
@@ -56,6 +58,7 @@ const Page = async (props) => {
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
displayCount={displayCount}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { customAlphabet } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
|
||||
|
||||
const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
||||
surveyId: ZId,
|
||||
@@ -202,6 +203,61 @@ export const deleteResultShareUrlAction = authenticatedActionClient
|
||||
)
|
||||
);
|
||||
|
||||
const ZResetSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
organizationId: ZId,
|
||||
projectId: ZId,
|
||||
});
|
||||
|
||||
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZResetSurveyAction>;
|
||||
}) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: parsedInput.projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = null;
|
||||
|
||||
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
|
||||
parsedInput.surveyId
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
deletedResponsesCount: deletedResponsesCount,
|
||||
deletedDisplaysCount: deletedDisplaysCount,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedResponsesCount: deletedResponsesCount,
|
||||
deletedDisplaysCount: deletedDisplaysCount,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetEmailHtmlAction = z.object({
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
@@ -33,6 +33,18 @@ vi.mock("@tolgee/react", () => ({
|
||||
if (key === "environments.surveys.edit.caution_edit_duplicate") {
|
||||
return "Duplicate & Edit";
|
||||
}
|
||||
if (key === "environments.surveys.summary.reset_survey") {
|
||||
return "Reset survey";
|
||||
}
|
||||
if (key === "environments.surveys.summary.delete_all_existing_responses_and_displays") {
|
||||
return "Delete all existing responses and displays";
|
||||
}
|
||||
if (key === "environments.surveys.summary.reset_survey_warning") {
|
||||
return "Resetting a survey removes all responses and metadata of this survey. This cannot be undone.";
|
||||
}
|
||||
if (key === "environments.surveys.summary.survey_reset_successfully") {
|
||||
return "Survey reset successfully! 5 responses and 3 displays were deleted.";
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
@@ -40,12 +52,14 @@ vi.mock("@tolgee/react", () => ({
|
||||
|
||||
// Mock Next.js hooks
|
||||
const mockPush = vi.fn();
|
||||
const mockPathname = "/environments/env-id/surveys/survey-id/summary";
|
||||
const mockRefresh = vi.fn();
|
||||
const mockPathname = "/environments/test-env-id/surveys/test-survey-id/summary";
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
refresh: mockRefresh,
|
||||
}),
|
||||
usePathname: () => mockPathname,
|
||||
useSearchParams: () => mockSearchParams,
|
||||
@@ -69,6 +83,18 @@ vi.mock("@/modules/survey/list/actions", () => ({
|
||||
copySurveyToOtherEnvironmentAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../actions", () => ({
|
||||
resetSurveyAction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the useSingleUseId hook
|
||||
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
|
||||
useSingleUseId: vi.fn(() => ({
|
||||
singleUseId: "test-single-use-id",
|
||||
refreshSingleUseId: vi.fn().mockResolvedValue("test-single-use-id"),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock child components
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage",
|
||||
@@ -139,6 +165,34 @@ vi.mock("@/modules/ui/components/badge", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/confirmation-modal", () => ({
|
||||
ConfirmationModal: ({
|
||||
open,
|
||||
setOpen,
|
||||
title,
|
||||
text,
|
||||
buttonText,
|
||||
onConfirm,
|
||||
buttonVariant,
|
||||
buttonLoading,
|
||||
}: any) => (
|
||||
<div
|
||||
data-testid="confirmation-modal"
|
||||
data-open={open}
|
||||
data-loading={buttonLoading}
|
||||
data-variant={buttonVariant}>
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<div data-testid="modal-text">{text}</div>
|
||||
<button type="button" onClick={onConfirm} data-testid="confirm-button">
|
||||
{buttonText}
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)} data-testid="cancel-button">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, className }: any) => (
|
||||
<button type="button" data-testid="button" onClick={onClick} className={className}>
|
||||
@@ -170,9 +224,17 @@ vi.mock("@/modules/ui/components/iconbar", () => ({
|
||||
vi.mock("lucide-react", () => ({
|
||||
BellRing: () => <svg data-testid="bell-ring-icon" />,
|
||||
Eye: () => <svg data-testid="eye-icon" />,
|
||||
ListRestart: () => <svg data-testid="list-restart-icon" />,
|
||||
SquarePenIcon: () => <svg data-testid="square-pen-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/context/environment-context", () => ({
|
||||
useEnvironment: vi.fn(() => ({
|
||||
organizationId: "test-organization-id",
|
||||
project: { id: "test-project-id" },
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "test-env-id",
|
||||
@@ -262,6 +324,7 @@ const defaultProps = {
|
||||
user: mockUser,
|
||||
publicDomain: "https://example.com",
|
||||
responseCount: 0,
|
||||
displayCount: 0,
|
||||
segments: mockSegments,
|
||||
isContactsEnabled: true,
|
||||
isFormbricksCloud: false,
|
||||
@@ -278,19 +341,19 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("renders share survey button", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByText("Share survey")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders success message component", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByTestId("success-message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders survey status dropdown when app setup is completed", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
@@ -302,7 +365,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("renders icon bar with correct actions", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("icon-bar-action-0")).toBeInTheDocument(); // Bell ring
|
||||
@@ -326,7 +389,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("opens share modal when share button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
|
||||
@@ -336,7 +399,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("opens share modal when share param is true", () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "start");
|
||||
@@ -344,7 +407,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("navigates to edit when edit button is clicked and no responses", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
@@ -355,14 +418,15 @@ describe("SurveyAnalysisCTA", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
// With responseCount > 0, the edit button should be at icon-bar-action-2 (after reset button)
|
||||
await user.click(screen.getByTestId("icon-bar-action-2"));
|
||||
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
|
||||
});
|
||||
|
||||
test("navigates to notifications when bell icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-0"));
|
||||
|
||||
@@ -383,7 +447,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("does not show icon bar actions when read-only", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isReadOnly={true} />);
|
||||
|
||||
const iconBar = screen.getByTestId("icon-bar");
|
||||
expect(iconBar).toBeInTheDocument();
|
||||
@@ -394,7 +458,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
test("handles modal close correctly", async () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
// Verify modal is open initially
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
@@ -421,17 +485,577 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("does not show status dropdown when app setup is not completed", () => {
|
||||
const environmentWithoutAppSetup = { ...mockEnvironment, appSetupCompleted: false };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} environment={environmentWithoutAppSetup} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} environment={environmentWithoutAppSetup} />);
|
||||
|
||||
expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly with all props", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
|
||||
expect(screen.getByText("Share survey")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("success-message")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("duplicates survey when primary button is clicked in edit dialog", async () => {
|
||||
const mockCopySurveyAction = vi.mocked(
|
||||
await import("@/modules/survey/list/actions")
|
||||
).copySurveyToOtherEnvironmentAction;
|
||||
mockCopySurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
...mockSurvey,
|
||||
id: "new-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
triggers: [],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
},
|
||||
});
|
||||
|
||||
const toast = await import("react-hot-toast");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
// Click primary button (duplicate & edit)
|
||||
await user.click(screen.getByTestId("primary-button"));
|
||||
|
||||
expect(mockCopySurveyAction).toHaveBeenCalledWith({
|
||||
environmentId: "test-env-id",
|
||||
surveyId: "test-survey-id",
|
||||
targetEnvironmentId: "test-env-id",
|
||||
});
|
||||
expect(toast.default.success).toHaveBeenCalledWith("Survey duplicated successfully");
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/surveys/new-survey-id/edit");
|
||||
});
|
||||
|
||||
test("handles error when duplicating survey fails", async () => {
|
||||
const mockCopySurveyAction = vi.mocked(
|
||||
await import("@/modules/survey/list/actions")
|
||||
).copySurveyToOtherEnvironmentAction;
|
||||
mockCopySurveyAction.mockResolvedValue({
|
||||
data: undefined,
|
||||
serverError: "Duplication failed",
|
||||
validationErrors: undefined,
|
||||
bindArgsValidationErrors: [],
|
||||
});
|
||||
|
||||
const toast = await import("react-hot-toast");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
// Click primary button (duplicate & edit)
|
||||
await user.click(screen.getByTestId("primary-button"));
|
||||
|
||||
expect(toast.default.error).toHaveBeenCalledWith("Error message");
|
||||
});
|
||||
|
||||
test("navigates to edit when secondary button is clicked in edit dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
// Click secondary button (edit)
|
||||
await user.click(screen.getByTestId("secondary-button"));
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/surveys/test-survey-id/edit");
|
||||
});
|
||||
|
||||
test("shows loading state during duplication", async () => {
|
||||
const mockCopySurveyAction = vi.mocked(
|
||||
await import("@/modules/survey/list/actions")
|
||||
).copySurveyToOtherEnvironmentAction;
|
||||
|
||||
// Mock a delayed response
|
||||
mockCopySurveyAction.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
data: {
|
||||
...mockSurvey,
|
||||
id: "new-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
triggers: [],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
},
|
||||
}),
|
||||
100
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
// Click primary button (duplicate & edit)
|
||||
await user.click(screen.getByTestId("primary-button"));
|
||||
|
||||
// Check loading state
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-loading", "true");
|
||||
});
|
||||
|
||||
test("closes dialog after successful duplication", async () => {
|
||||
const mockCopySurveyAction = vi.mocked(
|
||||
await import("@/modules/survey/list/actions")
|
||||
).copySurveyToOtherEnvironmentAction;
|
||||
mockCopySurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
...mockSurvey,
|
||||
id: "new-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
triggers: [],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog (should be icon-bar-action-2 with responses)
|
||||
await user.click(screen.getByTestId("icon-bar-action-2"));
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Click primary button (duplicate & edit)
|
||||
await user.click(screen.getByTestId("primary-button"));
|
||||
|
||||
// Dialog should be closed
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
test("opens preview with single use ID when enabled", async () => {
|
||||
const mockUseSingleUseId = vi.mocked(
|
||||
await import("@/modules/survey/hooks/useSingleUseId")
|
||||
).useSingleUseId;
|
||||
mockUseSingleUseId.mockReturnValue({
|
||||
singleUseId: "test-single-use-id",
|
||||
refreshSingleUseId: vi.fn().mockResolvedValue("new-single-use-id"),
|
||||
});
|
||||
|
||||
const surveyWithSingleUse = {
|
||||
...mockSurvey,
|
||||
type: "link" as const,
|
||||
singleUse: { enabled: true, isEncrypted: false },
|
||||
};
|
||||
|
||||
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
"https://example.com/s/test-survey-id?suId=new-single-use-id&preview=true",
|
||||
"_blank"
|
||||
);
|
||||
windowOpenSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("handles single use ID generation failure", async () => {
|
||||
const mockUseSingleUseId = vi.mocked(
|
||||
await import("@/modules/survey/hooks/useSingleUseId")
|
||||
).useSingleUseId;
|
||||
mockUseSingleUseId.mockReturnValue({
|
||||
singleUseId: "test-single-use-id",
|
||||
refreshSingleUseId: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const surveyWithSingleUse = {
|
||||
...mockSurvey,
|
||||
type: "link" as const,
|
||||
singleUse: { enabled: true, isEncrypted: false },
|
||||
};
|
||||
|
||||
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith("https://example.com/s/test-survey-id?preview=true", "_blank");
|
||||
windowOpenSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("opens share modal with correct modal view when share button clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "share");
|
||||
});
|
||||
|
||||
test("handles different survey statuses correctly", () => {
|
||||
const completedSurvey = { ...mockSurvey, status: "completed" as const };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={completedSurvey} />);
|
||||
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles paused survey status", () => {
|
||||
const pausedSurvey = { ...mockSurvey, status: "paused" as const };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={pausedSurvey} />);
|
||||
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render share modal when user is null", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} user={null as any} />);
|
||||
|
||||
expect(screen.queryByTestId("share-survey-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with different isFormbricksCloud values", () => {
|
||||
const { rerender } = render(
|
||||
<SurveyAnalysisCTA {...defaultProps} displayCount={0} isFormbricksCloud={true} />
|
||||
);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isFormbricksCloud={false} />);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with different isContactsEnabled values", () => {
|
||||
const { rerender } = render(
|
||||
<SurveyAnalysisCTA {...defaultProps} displayCount={0} isContactsEnabled={true} />
|
||||
);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isContactsEnabled={false} />);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles app survey type", () => {
|
||||
const appSurvey = { ...mockSurvey, type: "app" as const };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={appSurvey} />);
|
||||
|
||||
// Should not show preview icon for app surveys
|
||||
expect(screen.queryByTestId("icon-bar-action-1")).toBeInTheDocument(); // This should be edit button
|
||||
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Edit");
|
||||
});
|
||||
|
||||
test("handles modal state changes correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
// Open modal via share button
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Close modal
|
||||
await user.click(screen.getByText("Close Modal"));
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
test("opens share modal via share button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
|
||||
// Should open the modal with share view
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "share");
|
||||
});
|
||||
|
||||
test("closes share modal and updates modal state", async () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
// Modal should be open initially due to share param
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
|
||||
await user.click(screen.getByText("Close Modal"));
|
||||
|
||||
// Should close the modal
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
test("handles empty segments array", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} segments={[]} />);
|
||||
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles zero response count", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} responseCount={0} />);
|
||||
|
||||
expect(screen.queryByTestId("edit-public-survey-alert-dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows all icon actions for non-readonly app survey", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
// Should show bell (notifications) and edit actions
|
||||
expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts");
|
||||
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Edit");
|
||||
});
|
||||
|
||||
test("shows all icon actions for non-readonly link survey", () => {
|
||||
const linkSurvey = { ...mockSurvey, type: "link" as const };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={linkSurvey} />);
|
||||
|
||||
// Should show bell (notifications), preview, and edit actions
|
||||
expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts");
|
||||
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview");
|
||||
expect(screen.getByTestId("icon-bar-action-2")).toHaveAttribute("title", "Edit");
|
||||
});
|
||||
|
||||
// Reset Survey Feature Tests
|
||||
test("shows reset survey button when responses exist", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows reset survey button when displays exist", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={3} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides reset survey button when no responses or displays exist", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={0} displayCount={0} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeUndefined();
|
||||
});
|
||||
|
||||
test("hides reset survey button for read-only users", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} responseCount={5} displayCount={3} />);
|
||||
|
||||
// For read-only users, there should be no icon bar actions
|
||||
expect(screen.queryAllByTestId(/icon-bar-action-/)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("opens reset confirmation modal when reset button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("modal-title")).toHaveTextContent("Delete all existing responses and displays");
|
||||
expect(screen.getByTestId("modal-text")).toHaveTextContent(
|
||||
"Resetting a survey removes all responses and metadata of this survey. This cannot be undone."
|
||||
);
|
||||
});
|
||||
|
||||
test("executes reset survey action when confirmed", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const toast = await import("react-hot-toast");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
expect(mockResetSurveyAction).toHaveBeenCalledWith({
|
||||
surveyId: "test-survey-id",
|
||||
organizationId: "test-organization-id",
|
||||
projectId: "test-project-id",
|
||||
});
|
||||
expect(toast.default.success).toHaveBeenCalledWith(
|
||||
"Survey reset successfully! 5 responses and 3 displays were deleted."
|
||||
);
|
||||
});
|
||||
|
||||
test("handles reset survey action error", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: undefined,
|
||||
serverError: "Reset failed",
|
||||
validationErrors: undefined,
|
||||
bindArgsValidationErrors: [],
|
||||
});
|
||||
|
||||
const toast = await import("react-hot-toast");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
expect(toast.default.error).toHaveBeenCalledWith("Error message");
|
||||
});
|
||||
|
||||
test("shows loading state during reset operation", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
|
||||
// Mock a delayed response
|
||||
mockResetSurveyAction.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
}),
|
||||
100
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
// Check loading state
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-loading", "true");
|
||||
});
|
||||
|
||||
test("closes reset modal after successful reset", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Confirm reset - wait for the action to complete
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
// Wait for the action to complete and the modal to close
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
});
|
||||
|
||||
test("cancels reset operation when cancel button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Cancel reset
|
||||
await user.click(screen.getByTestId("cancel-button"));
|
||||
|
||||
// Modal should be closed
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
test("shows destructive button variant for reset confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-variant", "destructive");
|
||||
});
|
||||
|
||||
test("refreshes page after successful reset", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
expect(mockRefresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
||||
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
||||
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
||||
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
|
||||
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { resetSurveyAction } from "../actions";
|
||||
|
||||
interface SurveyAnalysisCTAProps {
|
||||
survey: TSurvey;
|
||||
@@ -26,6 +30,7 @@ interface SurveyAnalysisCTAProps {
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
responseCount: number;
|
||||
displayCount: number;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -43,22 +48,25 @@ export const SurveyAnalysisCTA = ({
|
||||
user,
|
||||
publicDomain,
|
||||
responseCount,
|
||||
displayCount,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
}: SurveyAnalysisCTAProps) => {
|
||||
const { t } = useTranslate();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [modalState, setModalState] = useState<ModalState>({
|
||||
start: searchParams.get("share") === "true",
|
||||
share: false,
|
||||
});
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
|
||||
const { organizationId, project } = useEnvironment();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey);
|
||||
|
||||
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
|
||||
@@ -102,13 +110,45 @@ export const SurveyAnalysisCTA = ({
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getPreviewUrl = () => {
|
||||
const separator = surveyUrl.includes("?") ? "&" : "?";
|
||||
return `${surveyUrl}${separator}preview=true`;
|
||||
const getPreviewUrl = async () => {
|
||||
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
const newId = await refreshSingleUseId();
|
||||
if (newId) {
|
||||
surveyUrl.searchParams.set("suId", newId);
|
||||
}
|
||||
}
|
||||
|
||||
surveyUrl.searchParams.set("preview", "true");
|
||||
return surveyUrl.toString();
|
||||
};
|
||||
|
||||
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
|
||||
|
||||
const handleResetSurvey = async () => {
|
||||
setIsResetting(true);
|
||||
const result = await resetSurveyAction({
|
||||
surveyId: survey.id,
|
||||
organizationId: organizationId,
|
||||
projectId: project.id,
|
||||
});
|
||||
if (result?.data) {
|
||||
toast.success(
|
||||
t("environments.surveys.summary.survey_reset_successfully", {
|
||||
responseCount: result.data.deletedResponsesCount,
|
||||
displayCount: result.data.deletedDisplaysCount,
|
||||
})
|
||||
);
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
setIsResetting(false);
|
||||
setIsResetModalOpen(false);
|
||||
};
|
||||
|
||||
const iconActions = [
|
||||
{
|
||||
icon: BellRing,
|
||||
@@ -119,9 +159,18 @@ export const SurveyAnalysisCTA = ({
|
||||
{
|
||||
icon: Eye,
|
||||
tooltip: t("common.preview"),
|
||||
onClick: () => window.open(getPreviewUrl(), "_blank"),
|
||||
onClick: async () => {
|
||||
const previewUrl = await getPreviewUrl();
|
||||
window.open(previewUrl, "_blank");
|
||||
},
|
||||
isVisible: survey.type === "link",
|
||||
},
|
||||
{
|
||||
icon: ListRestart,
|
||||
tooltip: t("environments.surveys.summary.reset_survey"),
|
||||
onClick: () => setIsResetModalOpen(true),
|
||||
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
|
||||
},
|
||||
{
|
||||
icon: SquarePenIcon,
|
||||
tooltip: t("common.edit"),
|
||||
@@ -190,6 +239,17 @@ export const SurveyAnalysisCTA = ({
|
||||
secondaryButtonText={t("common.edit")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
open={isResetModalOpen}
|
||||
setOpen={setIsResetModalOpen}
|
||||
title={t("environments.surveys.summary.delete_all_existing_responses_and_displays")}
|
||||
text={t("environments.surveys.summary.reset_survey_warning")}
|
||||
buttonText={t("environments.surveys.summary.reset_survey")}
|
||||
onConfirm={handleResetSurvey}
|
||||
buttonVariant="destructive"
|
||||
buttonLoading={isResetting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -378,4 +378,56 @@ describe("AnonymousLinksTab", () => {
|
||||
screen.getByText("environments.surveys.share.anonymous_links.data_prefilling")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows read-only input with copy button when encryption is disabled", async () => {
|
||||
// surveyWithSingleUse has encryption disabled
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
// Check if single-use link is enabled
|
||||
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true");
|
||||
|
||||
// Check if encryption is disabled
|
||||
expect(screen.getByTestId("toggle-single-use-encryption-switch")).toHaveAttribute(
|
||||
"data-checked",
|
||||
"false"
|
||||
);
|
||||
|
||||
// Check for the custom URL display
|
||||
const surveyUrlWithCustomSuid = `${defaultProps.surveyUrl}?suId=CUSTOM-ID`;
|
||||
expect(screen.getByText(surveyUrlWithCustomSuid)).toBeInTheDocument();
|
||||
|
||||
// Check for the copy button and try to click it
|
||||
const copyButton = screen.getByText("common.copy");
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
// check if toast is called
|
||||
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
|
||||
// Check for the alert
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.anonymous_links.custom_single_use_id_title")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Ensure the number of links input is not visible
|
||||
expect(
|
||||
screen.queryByText("environments.surveys.share.anonymous_links.number_of_links_label")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides read-only input with copy button when encryption is enabled", async () => {
|
||||
// surveyWithEncryption has encryption enabled
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
|
||||
|
||||
// Check if single-use link is enabled
|
||||
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true");
|
||||
|
||||
// Check if encryption is enabled
|
||||
expect(screen.getByTestId("toggle-single-use-encryption-switch")).toHaveAttribute("data-checked", "true");
|
||||
|
||||
// Ensure the number of links input is visible
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.anonymous_links.number_of_links_label")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,14 +4,13 @@ import { updateSingleUseLinksAction } from "@/app/(app)/environments/[environmen
|
||||
import { DisableLinkModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal";
|
||||
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
|
||||
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
|
||||
import { getSurveyUrl } from "@/modules/analysis/utils";
|
||||
import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CirclePlayIcon } from "lucide-react";
|
||||
import { CirclePlayIcon, CopyIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -33,6 +32,7 @@ export const AnonymousLinksTab = ({
|
||||
setSurveyUrl,
|
||||
locale,
|
||||
}: AnonymousLinksTabProps) => {
|
||||
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
|
||||
const router = useRouter();
|
||||
const { t } = useTranslate();
|
||||
|
||||
@@ -173,11 +173,9 @@ export const AnonymousLinksTab = ({
|
||||
count,
|
||||
});
|
||||
|
||||
const baseSurveyUrl = getSurveyUrl(survey, publicDomain, "default");
|
||||
|
||||
if (!!response?.data?.length) {
|
||||
const singleUseIds = response.data;
|
||||
const surveyLinks = singleUseIds.map((singleUseId) => `${baseSurveyUrl}?suId=${singleUseId}`);
|
||||
const surveyLinks = singleUseIds.map((singleUseId) => `${surveyUrl}?suId=${singleUseId}`);
|
||||
|
||||
// Create content with just the links
|
||||
const csvContent = surveyLinks.join("\n");
|
||||
@@ -258,14 +256,33 @@ export const AnonymousLinksTab = ({
|
||||
/>
|
||||
|
||||
{!singleUseEncryption ? (
|
||||
<Alert variant="info" size="default">
|
||||
<AlertTitle>
|
||||
{t("environments.surveys.share.anonymous_links.custom_single_use_id_title")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("environments.surveys.share.anonymous_links.custom_single_use_id_description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<Alert variant="info" size="default">
|
||||
<AlertTitle>
|
||||
{t("environments.surveys.share.anonymous_links.custom_single_use_id_title")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("environments.surveys.share.anonymous_links.custom_single_use_id_description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid w-full grid-cols-6 items-center gap-2">
|
||||
<div className="col-span-5 truncate rounded-md border border-slate-200 px-2 py-1">
|
||||
<span className="truncate text-sm text-slate-900">{surveyUrlWithCustomSuid}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
}}
|
||||
className="col-span-1 gap-1 text-sm">
|
||||
{t("common.copy")}
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{singleUseEncryption && (
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
@@ -21,10 +22,12 @@ export const DisableLinkModal = ({ open, onOpenChange, type, onDisable }: Disabl
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent width="narrow" className="flex flex-col" hideCloseButton disableCloseOnOutsideClick>
|
||||
<DialogHeader className="text-sm font-medium text-slate-900">
|
||||
{type === "multi-use"
|
||||
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
|
||||
: t("common.are_you_sure")}
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-medium text-slate-900">
|
||||
{type === "multi-use"
|
||||
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
|
||||
: t("common.are_you_sure")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
|
||||
@@ -43,6 +43,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={user.locale}
|
||||
enforceSurveyUrlWidth
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { deleteResponsesAndDisplaysForSurvey } from "./survey";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
response: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
display: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("Tests for deleteResponsesAndDisplaysForSurvey service", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("Deletes all responses and displays for a survey", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
// Mock $transaction to return the results directly
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 5 }, { count: 3 }]);
|
||||
|
||||
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("Handles case with no responses or displays to delete", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
// Mock $transaction to return zero counts
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 0 }, { count: 0 }]);
|
||||
|
||||
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
|
||||
|
||||
expect(result).toEqual({
|
||||
deletedResponsesCount: 0,
|
||||
deletedDisplaysCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("Throws a generic Error for other exceptions", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
const mockErrorMessage = "Mock error message";
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(new Error(mockErrorMessage));
|
||||
|
||||
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const deleteResponsesAndDisplaysForSurvey = async (
|
||||
surveyId: string
|
||||
): Promise<{ deletedResponsesCount: number; deletedDisplaysCount: number }> => {
|
||||
try {
|
||||
// Delete all responses for this survey
|
||||
|
||||
const [deletedResponsesCount, deletedDisplaysCount] = await prisma.$transaction([
|
||||
prisma.response.deleteMany({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
},
|
||||
}),
|
||||
prisma.display.deleteMany({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
deletedResponsesCount: deletedResponsesCount.count,
|
||||
deletedDisplaysCount: deletedDisplaysCount.count,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -58,6 +58,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -281,7 +281,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
||||
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
||||
}`
|
||||
: t(filterRange)}
|
||||
: filterRange}
|
||||
</span>
|
||||
{isFilterDropDownOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
@@ -296,28 +296,28 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
|
||||
setDateRange({ from: undefined, to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).ALL_TIME)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_7_DAYS)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_30_DAYS)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
|
||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_MONTH)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -327,14 +327,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_MONTH)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
|
||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_QUARTER)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -344,7 +344,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_QUARTER)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -354,14 +354,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfMonth(getTodayDate()),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_6_MONTHS)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
|
||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_YEAR)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -371,7 +371,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_YEAR)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -380,7 +380,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
}}>
|
||||
<p className="text-sm text-slate-700 hover:ring-0">
|
||||
{t(getFilterDropDownLabels(t).CUSTOM_RANGE)}
|
||||
{getFilterDropDownLabels(t).CUSTOM_RANGE}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -15,11 +15,13 @@ import { useTranslate } from "@tolgee/react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
CheckIcon,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ContactIcon,
|
||||
EyeOff,
|
||||
FlagIcon,
|
||||
GlobeIcon,
|
||||
GridIcon,
|
||||
HashIcon,
|
||||
@@ -89,8 +91,9 @@ const questionIcons = {
|
||||
device: SmartphoneIcon,
|
||||
os: AirplayIcon,
|
||||
browser: GlobeIcon,
|
||||
source: GlobeIcon,
|
||||
source: ArrowUpFromDotIcon,
|
||||
action: MousePointerClickIcon,
|
||||
country: FlagIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
@@ -132,10 +135,16 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
|
||||
return "bg-amber-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getLabelStyle = (): string | undefined => {
|
||||
if (type !== OptionsType.META) return undefined;
|
||||
return label === "os" ? "uppercase" : "capitalize";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-5 w-[12rem] items-center sm:w-4/5">
|
||||
<span className={clsx("rounded-md p-1", getColor())}>{getIconType()}</span>
|
||||
<p className="ml-3 truncate text-sm text-slate-600">
|
||||
<p className={clsx("ml-3 truncate text-sm text-slate-600", getLabelStyle())}>
|
||||
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -9,13 +9,34 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/error-component", () => ({
|
||||
ErrorComponent: () => <div data-testid="ErrorComponent">ErrorComponent</div>,
|
||||
ErrorComponent: ({ title, description }: { title: string; description: string }) => (
|
||||
<div data-testid="ErrorComponent">
|
||||
<div data-testid="error-title">{title}</div>
|
||||
<div data-testid="error-description">{description}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"common.error_rate_limit_title": "Too Many Requests",
|
||||
"common.error_rate_limit_description": "You're making too many requests. Please slow down.",
|
||||
"common.error_component_title": "Something went wrong",
|
||||
"common.error_component_description": "An unexpected error occurred. Please try again.",
|
||||
"common.try_again": "Try Again",
|
||||
"common.go_to_dashboard": "Go to Dashboard",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/types/errors", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@formbricks/types/errors")>();
|
||||
return {
|
||||
@@ -40,8 +61,7 @@ describe("ErrorBoundary", () => {
|
||||
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
title: "Something went wrong",
|
||||
description: "An unexpected error occurred. Please try again.",
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
@@ -60,8 +80,7 @@ describe("ErrorBoundary", () => {
|
||||
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
title: "Something went wrong",
|
||||
description: "An unexpected error occurred. Please try again.",
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
@@ -76,13 +95,12 @@ describe("ErrorBoundary", () => {
|
||||
test("calls reset when try again button is clicked for general errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
title: "Something went wrong",
|
||||
description: "An unexpected error occurred. Please try again.",
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
|
||||
const tryAgainBtn = screen.getByRole("button", { name: "common.try_again" });
|
||||
const tryAgainBtn = screen.getByRole("button", { name: "Try Again" });
|
||||
userEvent.click(tryAgainBtn);
|
||||
await waitFor(() => expect(resetMock).toHaveBeenCalled());
|
||||
});
|
||||
@@ -90,16 +108,15 @@ describe("ErrorBoundary", () => {
|
||||
test("sets window.location.href to '/' when dashboard button is clicked for general errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
title: "Something went wrong",
|
||||
description: "An unexpected error occurred. Please try again.",
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
const originalLocation = window.location;
|
||||
delete (window as any).location;
|
||||
(window as any).location = undefined;
|
||||
(window as any).location = { href: "" };
|
||||
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
|
||||
const dashBtn = screen.getByRole("button", { name: "common.go_to_dashboard" });
|
||||
const dashBtn = screen.getByRole("button", { name: "Go to Dashboard" });
|
||||
userEvent.click(dashBtn);
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/");
|
||||
@@ -110,28 +127,60 @@ describe("ErrorBoundary", () => {
|
||||
test("does not show buttons for rate limit errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
title: "common.error_rate_limit_title",
|
||||
description: "common.error_rate_limit_description",
|
||||
type: "rate_limit",
|
||||
showButtons: false,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "common.try_again" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "common.go_to_dashboard" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Try Again" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Go to Dashboard" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows error component with custom title and description for rate limit errors", async () => {
|
||||
test("shows error component with rate limit messages for rate limit errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
title: "common.error_rate_limit_title",
|
||||
description: "common.error_rate_limit_description",
|
||||
type: "rate_limit",
|
||||
showButtons: false,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
|
||||
|
||||
expect(screen.getByTestId("ErrorComponent")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent("Too Many Requests");
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(
|
||||
"You're making too many requests. Please slow down."
|
||||
);
|
||||
expect(getClientErrorData).toHaveBeenCalledWith(dummyError);
|
||||
});
|
||||
|
||||
test("shows error component with general messages for general errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
|
||||
|
||||
expect(screen.getByTestId("ErrorComponent")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong");
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(
|
||||
"An unexpected error occurred. Please try again."
|
||||
);
|
||||
expect(getClientErrorData).toHaveBeenCalledWith(dummyError);
|
||||
});
|
||||
|
||||
test("shows buttons for general errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Try Again" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Go to Dashboard" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,30 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { ErrorComponent } from "@/modules/ui/components/error-component";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { getClientErrorData } from "@formbricks/types/errors";
|
||||
import { type ClientErrorType, getClientErrorData } from "@formbricks/types/errors";
|
||||
|
||||
/**
|
||||
* Get translated error messages based on error type
|
||||
* All translation keys are directly visible to Tolgee's static analysis
|
||||
*/
|
||||
const getErrorMessages = (type: ClientErrorType, t: (key: string) => string) => {
|
||||
if (type === "rate_limit") {
|
||||
return {
|
||||
title: t("common.error_rate_limit_title"),
|
||||
description: t("common.error_rate_limit_description"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: t("common.error_component_title"),
|
||||
description: t("common.error_component_description"),
|
||||
};
|
||||
};
|
||||
|
||||
const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) => {
|
||||
const { t } = useTranslate();
|
||||
const errorData = getClientErrorData(error);
|
||||
const { title, description } = getErrorMessages(errorData.type, t);
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error(error.message);
|
||||
@@ -19,7 +38,7 @@ const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) =>
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||
<ErrorComponent title={errorData.title} description={errorData.description} />
|
||||
<ErrorComponent title={title} description={description} />
|
||||
{errorData.showButtons && (
|
||||
<div className="mt-2">
|
||||
<Button variant="secondary" onClick={() => reset()} className="mr-2">
|
||||
|
||||
@@ -21,8 +21,11 @@ import {
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
|
||||
|
||||
const defaultButtonLabel = "common.next";
|
||||
const defaultBackButtonLabel = "common.back";
|
||||
const getDefaultButtonLabel = (label: string | undefined, t: TFnType) =>
|
||||
createI18nString(label || t("common.next"), []);
|
||||
|
||||
const getDefaultBackButtonLabel = (label: string | undefined, t: TFnType) =>
|
||||
createI18nString(label || t("common.back"), []);
|
||||
|
||||
export const buildMultipleChoiceQuestion = ({
|
||||
id,
|
||||
@@ -63,8 +66,8 @@ export const buildMultipleChoiceQuestion = ({
|
||||
const id = containsOther && isLastIndex ? "other" : choiceIds ? choiceIds[index] : createId();
|
||||
return { id, label: createI18nString(choice, []) };
|
||||
}),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
shuffleOption: shuffleOption || "none",
|
||||
required: required ?? false,
|
||||
logic,
|
||||
@@ -103,8 +106,8 @@ export const buildOpenTextQuestion = ({
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
required: required ?? false,
|
||||
longAnswer,
|
||||
logic,
|
||||
@@ -151,8 +154,8 @@ export const buildRatingQuestion = ({
|
||||
headline: createI18nString(headline, []),
|
||||
scale,
|
||||
range,
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
@@ -192,8 +195,8 @@ export const buildNPSQuestion = ({
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
@@ -228,8 +231,8 @@ export const buildConsentQuestion = ({
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
required: required ?? false,
|
||||
label: createI18nString(label, []),
|
||||
logic,
|
||||
@@ -266,8 +269,8 @@ export const buildCTAQuestion = ({
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
html: html ? createI18nString(html, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
|
||||
required: required ?? false,
|
||||
buttonExternal,
|
||||
|
||||
@@ -175,7 +175,6 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
|
||||
export const REDIS_URL = env.REDIS_URL;
|
||||
export const REDIS_HTTP_URL = env.REDIS_HTTP_URL;
|
||||
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
|
||||
export const UNKEY_ROOT_KEY = env.UNKEY_ROOT_KEY;
|
||||
|
||||
export const BREVO_API_KEY = env.BREVO_API_KEY;
|
||||
export const BREVO_LIST_ID = env.BREVO_LIST_ID;
|
||||
|
||||
@@ -116,7 +116,7 @@ export const env = createEnv({
|
||||
VERCEL_URL: z.string().optional(),
|
||||
WEBAPP_URL: z.string().url().optional(),
|
||||
UNSPLASH_ACCESS_KEY: z.string().optional(),
|
||||
UNKEY_ROOT_KEY: z.string().optional(),
|
||||
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
|
||||
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
|
||||
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
@@ -218,7 +218,6 @@ export const env = createEnv({
|
||||
VERCEL_URL: process.env.VERCEL_URL,
|
||||
WEBAPP_URL: process.env.WEBAPP_URL,
|
||||
UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY,
|
||||
UNKEY_ROOT_KEY: process.env.UNKEY_ROOT_KEY,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
|
||||
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "Umgebung nicht gefunden",
|
||||
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
|
||||
"error": "Fehler",
|
||||
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
|
||||
"error_component_title": "Fehler beim Laden der Ressourcen",
|
||||
"expand_rows": "Zeilen erweitern",
|
||||
"finish": "Fertigstellen",
|
||||
"follow_these": "Folge diesen",
|
||||
@@ -238,7 +236,6 @@
|
||||
"limits_reached": "Limits erreicht",
|
||||
"link": "Link",
|
||||
"link_and_email": "Link & E-Mail",
|
||||
"link_copied": "Link in die Zwischenablage kopiert!",
|
||||
"link_survey": "Link-Umfrage",
|
||||
"link_surveys": "Umfragen verknüpfen",
|
||||
"load_more": "Mehr laden",
|
||||
@@ -305,7 +302,6 @@
|
||||
"privacy": "Datenschutz",
|
||||
"product_manager": "Produktmanager",
|
||||
"profile": "Profil",
|
||||
"project": "Projekt",
|
||||
"project_configuration": "Projektkonfiguration",
|
||||
"project_id": "Projekt-ID",
|
||||
"project_name": "Projektname",
|
||||
@@ -415,7 +411,6 @@
|
||||
"website_and_app_connection": "Website & App Verbindung",
|
||||
"website_app_survey": "Website- & App-Umfrage",
|
||||
"website_survey": "Website-Umfrage",
|
||||
"weekly_summary": "Wöchentliche Zusammenfassung",
|
||||
"welcome_card": "Willkommenskarte",
|
||||
"you": "Du",
|
||||
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
|
||||
@@ -456,29 +451,7 @@
|
||||
"invite_email_text_par1": "Dein Kollege",
|
||||
"invite_email_text_par2": "hat Dich eingeladen, Formbricks zu nutzen. Um die Einladung anzunehmen, klicke bitte auf den untenstehenden Link:",
|
||||
"invite_member_email_subject": "Du wurdest eingeladen, Formbricks zu nutzen!",
|
||||
"live_survey_notification_completed": "Abgeschlossen",
|
||||
"live_survey_notification_draft": "Entwurf",
|
||||
"live_survey_notification_in_progress": "In Bearbeitung",
|
||||
"live_survey_notification_no_new_response": "Diese Woche keine neue Antwort erhalten \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "Noch keine Antworten!",
|
||||
"live_survey_notification_paused": "Pausiert",
|
||||
"live_survey_notification_scheduled": "Geplant",
|
||||
"live_survey_notification_view_more_responses": "Zeige {responseCount} weitere Antworten",
|
||||
"live_survey_notification_view_previous_responses": "Vorherige Antworten anzeigen",
|
||||
"live_survey_notification_view_response": "Antwort anzeigen",
|
||||
"new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:",
|
||||
"notification_footer_all_the_best": "Alles Gute,",
|
||||
"notification_footer_in_your_settings": "in deinen Einstellungen \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "Bitte ausstellen",
|
||||
"notification_footer_the_formbricks_team": "Dein Formbricks Team \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "Um wöchentliche Updates zu stoppen,",
|
||||
"notification_header_hey": "Hey \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "Wöchentlicher Bericht für",
|
||||
"notification_insight_completed": "Abgeschlossen",
|
||||
"notification_insight_completion_rate": "Completion Rate %",
|
||||
"notification_insight_displays": "Displays",
|
||||
"notification_insight_responses": "Antworten",
|
||||
"notification_insight_surveys": "Umfragen",
|
||||
"password_changed_email_heading": "Passwort geändert",
|
||||
"password_changed_email_text": "Dein Passwort wurde erfolgreich geändert.",
|
||||
"password_reset_notify_email_subject": "Dein Formbricks-Passwort wurde geändert",
|
||||
@@ -511,14 +484,7 @@
|
||||
"verification_email_verify_email": "E-Mail bestätigen",
|
||||
"verification_new_email_subject": "E-Mail-Änderungsbestätigung",
|
||||
"verification_security_notice": "Wenn du diese E-Mail-Änderung nicht angefordert hast, ignoriere bitte diese E-Mail oder kontaktiere sofort den Support.",
|
||||
"verified_link_survey_email_subject": "Deine Umfrage ist bereit zum Ausfüllen.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Wähle einen 15-minütigen Termin im Kalender unseres Gründers aus.",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Lass keine Woche vergehen, ohne etwas über deine Nutzer zu lernen:",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "Brauchst Du Hilfe, die richtige Umfrage für dein Produkt zu finden?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "oder antworte auf diese E-Mail :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Neue Umfrage einrichten",
|
||||
"weekly_summary_create_reminder_notification_body_text": "Wir würden dir gerne eine wöchentliche Zusammenfassung schicken, aber momentan laufen keine Umfragen für {projectName}.",
|
||||
"weekly_summary_email_subject": "{projectName} Nutzer-Insights – Letzte Woche von Formbricks"
|
||||
"verified_link_survey_email_subject": "Deine Umfrage ist bereit zum Ausfüllen."
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1120,10 +1086,8 @@
|
||||
"need_slack_or_discord_notifications": "Brauchst Du Slack- oder Discord-Benachrichtigungen",
|
||||
"notification_settings_updated": "Benachrichtigungseinstellungen aktualisiert",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "Richte eine Benachrichtigung ein, um eine E-Mail bei neuen Antworten zu erhalten",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "Bleib auf dem Laufenden mit einem wöchentlichen Update jeden Montag",
|
||||
"use_the_integration": "Integration nutzen",
|
||||
"want_to_loop_in_organization_mates": "Willst Du die Organisationskollegen einbeziehen?",
|
||||
"weekly_summary_projects": "Wöchentliche Zusammenfassung (Projekte)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Du wirst nicht mehr automatisch zu den Umfragen dieser Organisation angemeldet!",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Du wirst keine weiteren E-Mails für Antworten auf diese Umfrage erhalten!"
|
||||
},
|
||||
@@ -1282,8 +1246,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Umfrage automatisch zu Beginn des Tages (UTC) freigeben.",
|
||||
"back_button_label": "Zurück\"- Button ",
|
||||
"background_styling": "Hintergründe",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Blockiert die Umfrage, wenn bereits eine Antwort mit der Single Use Id (suId) existiert.",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Blockiert Umfrage, wenn die Umfrage-URL keine Single-Use-ID (suId) hat.",
|
||||
"brand_color": "Markenfarbe",
|
||||
"brightness": "Helligkeit",
|
||||
"button_label": "Beschriftung",
|
||||
@@ -1368,7 +1330,6 @@
|
||||
"does_not_start_with": "Fängt nicht an mit",
|
||||
"edit_recall": "Erinnerung bearbeiten",
|
||||
"edit_translations": "{lang} -Übersetzungen bearbeiten",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "Single Use Id (suId) in der Umfrage-URL verschlüsseln.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "Spamschutz verwendet reCAPTCHA v3, um Spam-Antworten herauszufiltern.",
|
||||
"enable_spam_protection": "Spamschutz",
|
||||
@@ -1444,7 +1405,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "Logo in dieser speziellen Umfrage verstecken",
|
||||
"hostname": "Hostname",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein",
|
||||
"how_it_works": "Wie es funktioniert",
|
||||
"if_you_need_more_please": "Wenn Du mehr brauchst, bitte",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Wenn Du diese Antwort brauchst, frag so lange, bis Du sie bekommst.",
|
||||
"ignore_waiting_time_between_surveys": "Wartezeit zwischen Umfragen ignorieren",
|
||||
@@ -1482,7 +1442,6 @@
|
||||
"limit_the_maximum_file_size": "Maximale Dateigröße begrenzen",
|
||||
"limit_upload_file_size_to": "Maximale Dateigröße für Uploads",
|
||||
"link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.",
|
||||
"link_used_message": "Link verwendet",
|
||||
"load_segment": "Segment laden",
|
||||
"logic_error_warning": "Änderungen werden zu Logikfehlern führen",
|
||||
"logic_error_warning_text": "Das Ändern des Fragetypen entfernt die Logikbedingungen von dieser Frage",
|
||||
@@ -1574,8 +1533,6 @@
|
||||
"show_survey_to_users": "Umfrage % der Nutzer anzeigen",
|
||||
"show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer",
|
||||
"simple": "Einfach",
|
||||
"single_use_survey_links": "Einmalige Umfragelinks",
|
||||
"single_use_survey_links_description": "Erlaube nur eine Antwort pro Umfragelink.",
|
||||
"six_points": "6 Punkte",
|
||||
"skip_button_label": "Überspringen-Button-Beschriftung",
|
||||
"smiley": "Smiley",
|
||||
@@ -1592,8 +1549,6 @@
|
||||
"subheading": "Zwischenüberschrift",
|
||||
"subtract": "Subtrahieren -",
|
||||
"suggest_colors": "Farben vorschlagen",
|
||||
"survey_already_answered_heading": "Die Umfrage wurde bereits beantwortet.",
|
||||
"survey_already_answered_subheading": "Du kannst diesen Link nur einmal verwenden.",
|
||||
"survey_completed_heading": "Umfrage abgeschlossen",
|
||||
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
|
||||
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
|
||||
@@ -1624,7 +1579,6 @@
|
||||
"upload": "Hochladen",
|
||||
"upload_at_least_2_images": "Lade mindestens 2 Bilder hoch",
|
||||
"upper_label": "Oberes Label",
|
||||
"url_encryption": "URL-Verschlüsselung",
|
||||
"url_filters": "URL-Filter",
|
||||
"url_not_supported": "URL nicht unterstützt",
|
||||
"use_with_caution": "Mit Vorsicht verwenden",
|
||||
@@ -1648,8 +1602,6 @@
|
||||
"zip": "Postleitzahl"
|
||||
},
|
||||
"error_deleting_survey": "Beim Löschen der Umfrage ist ein Fehler aufgetreten",
|
||||
"failed_to_copy_link_to_results": "Kopieren des Links zu den Ergebnissen fehlgeschlagen",
|
||||
"failed_to_copy_url": "Kopieren der URL fehlgeschlagen: nicht in einer Browserumgebung.",
|
||||
"new_survey": "Neue Umfrage",
|
||||
"no_surveys_created_yet": "Noch keine Umfragen erstellt",
|
||||
"open_options": "Optionen öffnen",
|
||||
@@ -1690,7 +1642,6 @@
|
||||
"this_response_is_in_progress": "Diese Antwort ist in Bearbeitung.",
|
||||
"zip_post_code": "PLZ / Postleitzahl"
|
||||
},
|
||||
"results_unpublished_successfully": "Ergebnisse wurden nicht erfolgreich veröffentlicht.",
|
||||
"search_by_survey_name": "Nach Umfragenamen suchen",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1789,8 +1740,8 @@
|
||||
"configure_alerts": "Benachrichtigungen konfigurieren",
|
||||
"congrats": "Glückwunsch! Deine Umfrage ist jetzt live.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
|
||||
"copy_link_to_public_results": "Link zu öffentlichen Ergebnissen kopieren",
|
||||
"custom_range": "Benutzerdefinierter Bereich...",
|
||||
"delete_all_existing_responses_and_displays": "Alle bestehenden Antworten und Anzeigen löschen",
|
||||
"download_qr_code": "QR Code herunterladen",
|
||||
"drop_offs": "Drop-Off Rate",
|
||||
"drop_offs_tooltip": "So oft wurde die Umfrage gestartet, aber nicht abgeschlossen.",
|
||||
@@ -1842,40 +1793,33 @@
|
||||
"last_month": "Letztes Monat",
|
||||
"last_quarter": "Letztes Quartal",
|
||||
"last_year": "Letztes Jahr",
|
||||
"link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"only_completed": "Nur vollständige Antworten",
|
||||
"other_values_found": "Andere Werte gefunden",
|
||||
"overall": "Insgesamt",
|
||||
"publish_to_web": "Im Web veröffentlichen",
|
||||
"publish_to_web_warning": "Du bist dabei, diese Umfrageergebnisse öffentlich zugänglich zu machen.",
|
||||
"publish_to_web_warning_description": "Deine Umfrageergebnisse werden öffentlich sein. Jeder außerhalb deiner Organisation kann darauf zugreifen, wenn er den Link hat.",
|
||||
"qr_code": "QR-Code",
|
||||
"qr_code_description": "Antworten, die per QR-Code gesammelt werden, sind anonym.",
|
||||
"qr_code_download_failed": "QR-Code-Download fehlgeschlagen",
|
||||
"qr_code_download_with_start_soon": "QR Code-Download startet bald",
|
||||
"qr_code_generation_failed": "Es gab ein Problem beim Laden des QR-Codes für die Umfrage. Bitte versuchen Sie es erneut.",
|
||||
"results_are_public": "Ergebnisse sind öffentlich",
|
||||
"reset_survey": "Umfrage zurücksetzen",
|
||||
"reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.",
|
||||
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
|
||||
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
|
||||
"setup_integrations": "Integrationen einrichten",
|
||||
"share_results": "Ergebnisse teilen",
|
||||
"share_survey": "Umfrage teilen",
|
||||
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",
|
||||
"show_all_responses_where": "Zeige alle Antworten, bei denen...",
|
||||
"starts": "Startet",
|
||||
"starts_tooltip": "So oft wurde die Umfrage gestartet.",
|
||||
"survey_results_are_public": "Deine Umfrageergebnisse sind öffentlich",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Deine Umfrageergebnisse stehen allen zur Verfügung, die den Link haben. Die Ergebnisse werden nicht von Suchmaschinen indexiert.",
|
||||
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
|
||||
"this_month": "Dieser Monat",
|
||||
"this_quarter": "Dieses Quartal",
|
||||
"this_year": "Dieses Jahr",
|
||||
"time_to_complete": "Zeit zur Fertigstellung",
|
||||
"ttc_tooltip": "Durchschnittliche Zeit bis zum Abschluss der Umfrage.",
|
||||
"unknown_question_type": "Unbekannter Fragetyp",
|
||||
"unpublish_from_web": "Aus dem Web entfernen",
|
||||
"use_personal_links": "Nutze persönliche Links",
|
||||
"view_site": "Seite ansehen",
|
||||
"waiting_for_response": "Warte auf eine Antwort \uD83E\uDDD8♂️",
|
||||
"whats_next": "Was kommt als Nächstes?",
|
||||
"your_survey_is_public": "Deine Umfrage ist öffentlich",
|
||||
@@ -2006,11 +1950,6 @@
|
||||
"this_user_has_all_the_power": "Dieser Benutzer hat alle Rechte."
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "Zurück zur Startseite",
|
||||
"page_not_found": "Seite nicht gefunden",
|
||||
"page_not_found_description": "Entschuldigung, wir konnten die gesuchten Antworten mit der geteilten ID nicht finden."
|
||||
},
|
||||
"templates": {
|
||||
"address": "Adresse",
|
||||
"address_description": "Frag nach einer Adresse",
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "Environment not found",
|
||||
"environment_notice": "You're currently in the {environment} environment.",
|
||||
"error": "Error",
|
||||
"error_component_description": "This resource doesn't exist or you don't have the necessary rights to access it.",
|
||||
"error_component_title": "Error loading resources",
|
||||
"expand_rows": "Expand rows",
|
||||
"finish": "Finish",
|
||||
"follow_these": "Follow these",
|
||||
@@ -238,7 +236,6 @@
|
||||
"limits_reached": "Limits Reached",
|
||||
"link": "Link",
|
||||
"link_and_email": "Link & Email",
|
||||
"link_copied": "Link copied to clipboard!",
|
||||
"link_survey": "Link Survey",
|
||||
"link_surveys": "Link Surveys",
|
||||
"load_more": "Load more",
|
||||
@@ -305,7 +302,6 @@
|
||||
"privacy": "Privacy Policy",
|
||||
"product_manager": "Product Manager",
|
||||
"profile": "Profile",
|
||||
"project": "Project",
|
||||
"project_configuration": "Project's Configuration",
|
||||
"project_id": "Project ID",
|
||||
"project_name": "Project Name",
|
||||
@@ -415,7 +411,6 @@
|
||||
"website_and_app_connection": "Website & App Connection",
|
||||
"website_app_survey": "Website & App Survey",
|
||||
"website_survey": "Website Survey",
|
||||
"weekly_summary": "Weekly summary",
|
||||
"welcome_card": "Welcome card",
|
||||
"you": "You",
|
||||
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
|
||||
@@ -456,29 +451,7 @@
|
||||
"invite_email_text_par1": "Your colleague",
|
||||
"invite_email_text_par2": "invited you to join them at Formbricks. To accept the invitation, please click the link below:",
|
||||
"invite_member_email_subject": "You're invited to collaborate on Formbricks!",
|
||||
"live_survey_notification_completed": "Completed",
|
||||
"live_survey_notification_draft": "Draft",
|
||||
"live_survey_notification_in_progress": "In Progress",
|
||||
"live_survey_notification_no_new_response": "No new response received this week \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "No Responses yet!",
|
||||
"live_survey_notification_paused": "Paused",
|
||||
"live_survey_notification_scheduled": "Scheduled",
|
||||
"live_survey_notification_view_more_responses": "View {responseCount} more Responses",
|
||||
"live_survey_notification_view_previous_responses": "View previous responses",
|
||||
"live_survey_notification_view_response": "View Response",
|
||||
"new_email_verification_text": "To verify your new email address, please click the button below:",
|
||||
"notification_footer_all_the_best": "All the best,",
|
||||
"notification_footer_in_your_settings": "in your settings \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "please turn them off",
|
||||
"notification_footer_the_formbricks_team": "The Formbricks Team \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "To halt Weekly Updates,",
|
||||
"notification_header_hey": "Hey \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "Weekly Report for",
|
||||
"notification_insight_completed": "Completed",
|
||||
"notification_insight_completion_rate": "Completion %",
|
||||
"notification_insight_displays": "Displays",
|
||||
"notification_insight_responses": "Responses",
|
||||
"notification_insight_surveys": "Surveys",
|
||||
"password_changed_email_heading": "Password changed",
|
||||
"password_changed_email_text": "Your password has been changed successfully.",
|
||||
"password_reset_notify_email_subject": "Your Formbricks password has been changed",
|
||||
@@ -511,14 +484,7 @@
|
||||
"verification_email_verify_email": "Verify email",
|
||||
"verification_new_email_subject": "Email change verification",
|
||||
"verification_security_notice": "If you did not request this email change, please ignore this email or contact support immediately.",
|
||||
"verified_link_survey_email_subject": "Your survey is ready to be filled out.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Pick a 15-minute slot in our CEOs calendar",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Don't let a week pass without learning about your users:",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "Need help finding the right survey for your product?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "or reply to this email :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Setup a new survey",
|
||||
"weekly_summary_create_reminder_notification_body_text": "We'd love to send you a Weekly Summary, but currently there are no surveys running for {projectName}.",
|
||||
"weekly_summary_email_subject": "{projectName} User Insights - Last Week by Formbricks"
|
||||
"verified_link_survey_email_subject": "Your survey is ready to be filled out."
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1120,10 +1086,8 @@
|
||||
"need_slack_or_discord_notifications": "Need Slack or Discord notifications",
|
||||
"notification_settings_updated": "Notification settings updated",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "Set up an alert to get an email on new responses",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "Stay up-to-date with a Weekly every Monday",
|
||||
"use_the_integration": "Use the integration",
|
||||
"want_to_loop_in_organization_mates": "Want to loop in organization mates",
|
||||
"weekly_summary_projects": "Weekly summary (Projects)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "You will not be auto-subscribed to this organization's surveys anymore!",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "You will not receive any more emails for responses on this survey!"
|
||||
},
|
||||
@@ -1282,8 +1246,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Automatically release the survey at the beginning of the day (UTC).",
|
||||
"back_button_label": "\"Back\" Button Label",
|
||||
"background_styling": "Background Styling",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Blocks survey if a submission with the Single Use Id (suId) exists already.",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Blocks survey if the survey URL has no Single Use Id (suId).",
|
||||
"brand_color": "Brand color",
|
||||
"brightness": "Brightness",
|
||||
"button_label": "Button Label",
|
||||
@@ -1368,7 +1330,6 @@
|
||||
"does_not_start_with": "Does not start with",
|
||||
"edit_recall": "Edit Recall",
|
||||
"edit_translations": "Edit {lang} translations",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "Enable encryption of Single Use Id (suId) in survey URL.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Enable participants to switch the survey language at any point during the survey.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "Spam protection uses reCAPTCHA v3 to filter out the spam responses.",
|
||||
"enable_spam_protection": "Spam protection",
|
||||
@@ -1444,7 +1405,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "Hide the logo in this specific survey",
|
||||
"hostname": "Hostname",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "How funky do you want your cards in {surveyTypeDerived} Surveys",
|
||||
"how_it_works": "How it works",
|
||||
"if_you_need_more_please": "If you need more, please",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "If you really want that answer, ask until you get it.",
|
||||
"ignore_waiting_time_between_surveys": "Ignore waiting time between surveys",
|
||||
@@ -1482,7 +1442,6 @@
|
||||
"limit_the_maximum_file_size": "Limit the maximum file size",
|
||||
"limit_upload_file_size_to": "Limit upload file size to",
|
||||
"link_survey_description": "Share a link to a survey page or embed it in a web page or email.",
|
||||
"link_used_message": "Link Used",
|
||||
"load_segment": "Load segment",
|
||||
"logic_error_warning": "Changing will cause logic errors",
|
||||
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
|
||||
@@ -1574,8 +1533,6 @@
|
||||
"show_survey_to_users": "Show survey to % of users",
|
||||
"show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users",
|
||||
"simple": "Simple",
|
||||
"single_use_survey_links": "Single-use survey links",
|
||||
"single_use_survey_links_description": "Allow only 1 response per survey link.",
|
||||
"six_points": "6 points",
|
||||
"skip_button_label": "Skip Button Label",
|
||||
"smiley": "Smiley",
|
||||
@@ -1592,8 +1549,6 @@
|
||||
"subheading": "Subheading",
|
||||
"subtract": "Subtract -",
|
||||
"suggest_colors": "Suggest colors",
|
||||
"survey_already_answered_heading": "The survey has already been answered.",
|
||||
"survey_already_answered_subheading": "You can only use this link once.",
|
||||
"survey_completed_heading": "Survey Completed",
|
||||
"survey_completed_subheading": "This free & open-source survey has been closed",
|
||||
"survey_display_settings": "Survey Display Settings",
|
||||
@@ -1624,7 +1579,6 @@
|
||||
"upload": "Upload",
|
||||
"upload_at_least_2_images": "Upload at least 2 images",
|
||||
"upper_label": "Upper Label",
|
||||
"url_encryption": "URL Encryption",
|
||||
"url_filters": "URL Filters",
|
||||
"url_not_supported": "URL not supported",
|
||||
"use_with_caution": "Use with caution",
|
||||
@@ -1648,8 +1602,6 @@
|
||||
"zip": "Zip"
|
||||
},
|
||||
"error_deleting_survey": "An error occured while deleting survey",
|
||||
"failed_to_copy_link_to_results": "Failed to copy link to results",
|
||||
"failed_to_copy_url": "Failed to copy URL: not in a browser environment.",
|
||||
"new_survey": "New Survey",
|
||||
"no_surveys_created_yet": "No surveys created yet",
|
||||
"open_options": "Open options",
|
||||
@@ -1690,7 +1642,6 @@
|
||||
"this_response_is_in_progress": "This response is in progress.",
|
||||
"zip_post_code": "ZIP / Post code"
|
||||
},
|
||||
"results_unpublished_successfully": "Results unpublished successfully.",
|
||||
"search_by_survey_name": "Search by survey name",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1789,8 +1740,8 @@
|
||||
"configure_alerts": "Configure alerts",
|
||||
"congrats": "Congrats! Your survey is live.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
|
||||
"copy_link_to_public_results": "Copy link to public results",
|
||||
"custom_range": "Custom range...",
|
||||
"delete_all_existing_responses_and_displays": "Delete all existing responses and displays",
|
||||
"download_qr_code": "Download QR code",
|
||||
"drop_offs": "Drop-Offs",
|
||||
"drop_offs_tooltip": "Number of times the survey has been started but not completed.",
|
||||
@@ -1842,40 +1793,33 @@
|
||||
"last_month": "Last month",
|
||||
"last_quarter": "Last quarter",
|
||||
"last_year": "Last year",
|
||||
"link_to_public_results_copied": "Link to public results copied",
|
||||
"no_responses_found": "No responses found",
|
||||
"only_completed": "Only completed",
|
||||
"other_values_found": "Other values found",
|
||||
"overall": "Overall",
|
||||
"publish_to_web": "Publish to web",
|
||||
"publish_to_web_warning": "You are about to release these survey results to the public.",
|
||||
"publish_to_web_warning_description": "Your survey results will be public. Anyone outside your organization can access them if they have the link.",
|
||||
"qr_code": "QR code",
|
||||
"qr_code_description": "Responses collected via QR code are anonymous.",
|
||||
"qr_code_download_failed": "QR code download failed",
|
||||
"qr_code_download_with_start_soon": "QR code download will start soon",
|
||||
"qr_code_generation_failed": "There was a problem, loading the survey QR Code. Please try again.",
|
||||
"results_are_public": "Results are public",
|
||||
"reset_survey": "Reset survey",
|
||||
"reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.",
|
||||
"selected_responses_csv": "Selected responses (CSV)",
|
||||
"selected_responses_excel": "Selected responses (Excel)",
|
||||
"setup_integrations": "Setup integrations",
|
||||
"share_results": "Share results",
|
||||
"share_survey": "Share survey",
|
||||
"show_all_responses_that_match": "Show all responses that match",
|
||||
"show_all_responses_where": "Show all responses where...",
|
||||
"starts": "Starts",
|
||||
"starts_tooltip": "Number of times the survey has been started.",
|
||||
"survey_results_are_public": "Your survey results are public!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Your survey results are shared with anyone who has the link. The results will not be indexed by search engines.",
|
||||
"survey_reset_successfully": "Survey reset successfully! {responseCount} responses and {displayCount} displays were deleted.",
|
||||
"this_month": "This month",
|
||||
"this_quarter": "This quarter",
|
||||
"this_year": "This year",
|
||||
"time_to_complete": "Time to Complete",
|
||||
"ttc_tooltip": "Average time to complete the survey.",
|
||||
"unknown_question_type": "Unknown Question Type",
|
||||
"unpublish_from_web": "Unpublish from web",
|
||||
"use_personal_links": "Use personal links",
|
||||
"view_site": "View site",
|
||||
"waiting_for_response": "Waiting for a response \uD83E\uDDD8♂️",
|
||||
"whats_next": "What's next?",
|
||||
"your_survey_is_public": "Your survey is public",
|
||||
@@ -2006,11 +1950,6 @@
|
||||
"this_user_has_all_the_power": "This user has all the power."
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "Back to home",
|
||||
"page_not_found": "Page not found",
|
||||
"page_not_found_description": "Sorry, we couldn't find the responses sharing ID you're looking for."
|
||||
},
|
||||
"templates": {
|
||||
"address": "Address",
|
||||
"address_description": "Ask for a mailing address",
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "Environnement non trouvé",
|
||||
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
|
||||
"error": "Erreur",
|
||||
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
|
||||
"error_component_title": "Erreur de chargement des ressources",
|
||||
"expand_rows": "Développer les lignes",
|
||||
"finish": "Terminer",
|
||||
"follow_these": "Suivez ceci",
|
||||
@@ -238,7 +236,6 @@
|
||||
"limits_reached": "Limites atteints",
|
||||
"link": "Lien",
|
||||
"link_and_email": "Liens et e-mail",
|
||||
"link_copied": " lien copié dans le presse-papiers !",
|
||||
"link_survey": "Enquête de lien",
|
||||
"link_surveys": "Sondages de lien",
|
||||
"load_more": "Charger plus",
|
||||
@@ -305,7 +302,6 @@
|
||||
"privacy": "Politique de confidentialité",
|
||||
"product_manager": "Chef de produit",
|
||||
"profile": "Profil",
|
||||
"project": "Projet",
|
||||
"project_configuration": "Configuration du projet",
|
||||
"project_id": "ID de projet",
|
||||
"project_name": "Nom du projet",
|
||||
@@ -415,7 +411,6 @@
|
||||
"website_and_app_connection": "Connexion Site Web & Application",
|
||||
"website_app_survey": "Sondage sur le site Web et l'application",
|
||||
"website_survey": "Sondage de site web",
|
||||
"weekly_summary": "Résumé hebdomadaire",
|
||||
"welcome_card": "Carte de bienvenue",
|
||||
"you": "Vous",
|
||||
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
|
||||
@@ -456,29 +451,7 @@
|
||||
"invite_email_text_par1": "Votre collègue",
|
||||
"invite_email_text_par2": "vous a invité à les rejoindre sur Formbricks. Pour accepter l'invitation, veuillez cliquer sur le lien ci-dessous :",
|
||||
"invite_member_email_subject": "Vous avez été invité à collaborer sur Formbricks !",
|
||||
"live_survey_notification_completed": "Terminé",
|
||||
"live_survey_notification_draft": "Brouillon",
|
||||
"live_survey_notification_in_progress": "En cours",
|
||||
"live_survey_notification_no_new_response": "Aucune nouvelle réponse reçue cette semaine \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "Aucune réponse pour le moment !",
|
||||
"live_survey_notification_paused": "En pause",
|
||||
"live_survey_notification_scheduled": "Programmé",
|
||||
"live_survey_notification_view_more_responses": "Voir {responseCount} réponses supplémentaires",
|
||||
"live_survey_notification_view_previous_responses": "Voir les réponses précédentes",
|
||||
"live_survey_notification_view_response": "Voir la réponse",
|
||||
"new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :",
|
||||
"notification_footer_all_the_best": "Tous mes vœux,",
|
||||
"notification_footer_in_your_settings": "dans vos paramètres \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "veuillez les éteindre",
|
||||
"notification_footer_the_formbricks_team": "L'équipe Formbricks \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "Pour arrêter les mises à jour hebdomadaires,",
|
||||
"notification_header_hey": "Salut \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "Rapport hebdomadaire pour",
|
||||
"notification_insight_completed": "Terminé",
|
||||
"notification_insight_completion_rate": "Pourcentage d'achèvement",
|
||||
"notification_insight_displays": "Affichages",
|
||||
"notification_insight_responses": "Réponses",
|
||||
"notification_insight_surveys": "Enquêtes",
|
||||
"password_changed_email_heading": "Mot de passe changé",
|
||||
"password_changed_email_text": "Votre mot de passe a été changé avec succès.",
|
||||
"password_reset_notify_email_subject": "Ton mot de passe Formbricks a été changé",
|
||||
@@ -511,14 +484,7 @@
|
||||
"verification_email_verify_email": "Vérifier l'email",
|
||||
"verification_new_email_subject": "Vérification du changement d'email",
|
||||
"verification_security_notice": "Si vous n'avez pas demandé ce changement d'email, veuillez ignorer cet email ou contacter le support immédiatement.",
|
||||
"verified_link_survey_email_subject": "Votre enquête est prête à être remplie.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Choisissez un créneau de 15 minutes dans le calendrier de notre PDG.",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Ne laissez pas une semaine passer sans en apprendre davantage sur vos utilisateurs :",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "Besoin d'aide pour trouver le bon sondage pour votre produit ?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "ou répondez à cet e-mail :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurer une nouvelle enquête",
|
||||
"weekly_summary_create_reminder_notification_body_text": "Nous aimerions vous envoyer un résumé hebdomadaire, mais actuellement, il n'y a pas d'enquêtes en cours pour {projectName}.",
|
||||
"weekly_summary_email_subject": "Aperçu des utilisateurs de {projectName} – La semaine dernière par Formbricks"
|
||||
"verified_link_survey_email_subject": "Votre enquête est prête à être remplie."
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1120,10 +1086,8 @@
|
||||
"need_slack_or_discord_notifications": "Besoin de notifications Slack ou Discord",
|
||||
"notification_settings_updated": "Paramètres de notification mis à jour",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "Configurez une alerte pour recevoir un e-mail lors de nouvelles réponses.",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "Restez à jour avec un hebdomadaire chaque lundi.",
|
||||
"use_the_integration": "Utilisez l'intégration",
|
||||
"want_to_loop_in_organization_mates": "Voulez-vous inclure des collègues de l'organisation ?",
|
||||
"weekly_summary_projects": "Résumé hebdomadaire (Projets)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Vous ne serez plus automatiquement abonné aux enquêtes de cette organisation !",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Vous ne recevrez plus d'e-mails concernant les réponses à cette enquête !"
|
||||
},
|
||||
@@ -1282,8 +1246,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Libérer automatiquement l'enquête au début de la journée (UTC).",
|
||||
"back_button_label": "Label du bouton \"Retour''",
|
||||
"background_styling": "Style de fond",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloque les enquêtes si une soumission avec l'Identifiant à Usage Unique (suId) existe déjà.",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloque les enquêtes si l'URL de l'enquête n'a pas d'Identifiant d'Utilisation Unique (suId).",
|
||||
"brand_color": "Couleur de marque",
|
||||
"brightness": "Luminosité",
|
||||
"button_label": "Label du bouton",
|
||||
@@ -1368,7 +1330,6 @@
|
||||
"does_not_start_with": "Ne commence pas par",
|
||||
"edit_recall": "Modifier le rappel",
|
||||
"edit_translations": "Modifier les traductions {lang}",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "Activer le chiffrement de l'identifiant à usage unique (suId) dans l'URL de l'enquête.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "La protection contre le spam utilise reCAPTCHA v3 pour filtrer les réponses indésirables.",
|
||||
"enable_spam_protection": "Protection contre le spam",
|
||||
@@ -1444,7 +1405,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "Cacher le logo dans cette enquête spécifique",
|
||||
"hostname": "Nom d'hôte",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}",
|
||||
"how_it_works": "Comment ça fonctionne",
|
||||
"if_you_need_more_please": "Si vous en avez besoin de plus, s'il vous plaît",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Si tu veux vraiment cette réponse, demande jusqu'à ce que tu l'obtiennes.",
|
||||
"ignore_waiting_time_between_surveys": "Ignorer le temps d'attente entre les enquêtes",
|
||||
@@ -1482,7 +1442,6 @@
|
||||
"limit_the_maximum_file_size": "Limiter la taille maximale du fichier",
|
||||
"limit_upload_file_size_to": "Limiter la taille des fichiers téléchargés à",
|
||||
"link_survey_description": "Partagez un lien vers une page d'enquête ou intégrez-le dans une page web ou un e-mail.",
|
||||
"link_used_message": "Lien utilisé",
|
||||
"load_segment": "Segment de chargement",
|
||||
"logic_error_warning": "Changer causera des erreurs logiques",
|
||||
"logic_error_warning_text": "Changer le type de question supprimera les conditions logiques de cette question.",
|
||||
@@ -1574,8 +1533,6 @@
|
||||
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
|
||||
"show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés",
|
||||
"simple": "Simple",
|
||||
"single_use_survey_links": "Liens d'enquête à usage unique",
|
||||
"single_use_survey_links_description": "Autoriser uniquement 1 réponse par lien d'enquête.",
|
||||
"six_points": "6 points",
|
||||
"skip_button_label": "Étiquette du bouton Ignorer",
|
||||
"smiley": "Sourire",
|
||||
@@ -1592,8 +1549,6 @@
|
||||
"subheading": "Sous-titre",
|
||||
"subtract": "Soustraire -",
|
||||
"suggest_colors": "Suggérer des couleurs",
|
||||
"survey_already_answered_heading": "L'enquête a déjà été répondue.",
|
||||
"survey_already_answered_subheading": "Vous ne pouvez utiliser ce lien qu'une seule fois.",
|
||||
"survey_completed_heading": "Enquête terminée",
|
||||
"survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée",
|
||||
"survey_display_settings": "Paramètres d'affichage de l'enquête",
|
||||
@@ -1624,7 +1579,6 @@
|
||||
"upload": "Télécharger",
|
||||
"upload_at_least_2_images": "Téléchargez au moins 2 images",
|
||||
"upper_label": "Étiquette supérieure",
|
||||
"url_encryption": "Chiffrement d'URL",
|
||||
"url_filters": "Filtres d'URL",
|
||||
"url_not_supported": "URL non supportée",
|
||||
"use_with_caution": "À utiliser avec précaution",
|
||||
@@ -1648,8 +1602,6 @@
|
||||
"zip": "Zip"
|
||||
},
|
||||
"error_deleting_survey": "Une erreur est survenue lors de la suppression de l'enquête.",
|
||||
"failed_to_copy_link_to_results": "Échec de la copie du lien vers les résultats",
|
||||
"failed_to_copy_url": "Échec de la copie de l'URL : pas dans un environnement de navigateur.",
|
||||
"new_survey": "Nouveau Sondage",
|
||||
"no_surveys_created_yet": "Aucun sondage créé pour le moment",
|
||||
"open_options": "Ouvrir les options",
|
||||
@@ -1690,7 +1642,6 @@
|
||||
"this_response_is_in_progress": "Cette réponse est en cours.",
|
||||
"zip_post_code": "Code postal"
|
||||
},
|
||||
"results_unpublished_successfully": "Résultats publiés avec succès.",
|
||||
"search_by_survey_name": "Recherche par nom d'enquête",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1789,8 +1740,8 @@
|
||||
"configure_alerts": "Configurer les alertes",
|
||||
"congrats": "Félicitations ! Votre enquête est en ligne.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
|
||||
"copy_link_to_public_results": "Copier le lien vers les résultats publics",
|
||||
"custom_range": "Plage personnalisée...",
|
||||
"delete_all_existing_responses_and_displays": "Supprimer toutes les réponses existantes et les affichages",
|
||||
"download_qr_code": "Télécharger code QR",
|
||||
"drop_offs": "Dépôts",
|
||||
"drop_offs_tooltip": "Nombre de fois que l'enquête a été commencée mais non terminée.",
|
||||
@@ -1842,40 +1793,33 @@
|
||||
"last_month": "Le mois dernier",
|
||||
"last_quarter": "dernier trimestre",
|
||||
"last_year": "l'année dernière",
|
||||
"link_to_public_results_copied": "Lien vers les résultats publics copié",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"only_completed": "Uniquement terminé",
|
||||
"other_values_found": "D'autres valeurs trouvées",
|
||||
"overall": "Globalement",
|
||||
"publish_to_web": "Publier sur le web",
|
||||
"publish_to_web_warning": "Vous êtes sur le point de rendre ces résultats d'enquête publics.",
|
||||
"publish_to_web_warning_description": "Les résultats de votre enquête seront publics. Toute personne en dehors de votre organisation pourra y accéder si elle a le lien.",
|
||||
"qr_code": "Code QR",
|
||||
"qr_code_description": "Les réponses collectées via le code QR sont anonymes.",
|
||||
"qr_code_download_failed": "Échec du téléchargement du code QR",
|
||||
"qr_code_download_with_start_soon": "Le téléchargement du code QR débutera bientôt",
|
||||
"qr_code_generation_failed": "\"Un problème est survenu lors du chargement du code QR du sondage. Veuillez réessayer.\"",
|
||||
"results_are_public": "Les résultats sont publics.",
|
||||
"reset_survey": "Réinitialiser l'enquête",
|
||||
"reset_survey_warning": "Réinitialiser un sondage supprime toutes les réponses et les affichages associés à ce sondage. Cela ne peut pas être annulé.",
|
||||
"selected_responses_csv": "Réponses sélectionnées (CSV)",
|
||||
"selected_responses_excel": "Réponses sélectionnées (Excel)",
|
||||
"setup_integrations": "Configurer les intégrations",
|
||||
"share_results": "Partager les résultats",
|
||||
"share_survey": "Partager l'enquête",
|
||||
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes",
|
||||
"show_all_responses_where": "Afficher toutes les réponses où...",
|
||||
"starts": "Commence",
|
||||
"starts_tooltip": "Nombre de fois que l'enquête a été commencée.",
|
||||
"survey_results_are_public": "Les résultats de votre enquête sont publics !",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Les résultats de votre enquête sont partagés avec quiconque possède le lien. Les résultats ne seront pas indexés par les moteurs de recherche.",
|
||||
"survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.",
|
||||
"this_month": "Ce mois-ci",
|
||||
"this_quarter": "Ce trimestre",
|
||||
"this_year": "Cette année",
|
||||
"time_to_complete": "Temps à compléter",
|
||||
"ttc_tooltip": "Temps moyen pour compléter l'enquête.",
|
||||
"unknown_question_type": "Type de question inconnu",
|
||||
"unpublish_from_web": "Désactiver la publication sur le web",
|
||||
"use_personal_links": "Utilisez des liens personnels",
|
||||
"view_site": "Voir le site",
|
||||
"waiting_for_response": "En attente d'une réponse \uD83E\uDDD8♂️",
|
||||
"whats_next": "Qu'est-ce qui vient ensuite ?",
|
||||
"your_survey_is_public": "Votre enquête est publique.",
|
||||
@@ -2006,11 +1950,6 @@
|
||||
"this_user_has_all_the_power": "Cet utilisateur a tout le pouvoir."
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "Retour à l'accueil",
|
||||
"page_not_found": "Page non trouvée",
|
||||
"page_not_found_description": "Désolé, nous n'avons pas pu trouver l'ID de partage des réponses que vous recherchez."
|
||||
},
|
||||
"templates": {
|
||||
"address": "Adresse",
|
||||
"address_description": "Demander une adresse postale",
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Você está atualmente no ambiente {environment}.",
|
||||
"error": "Erro",
|
||||
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
|
||||
"error_component_title": "Erro ao carregar recursos",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"finish": "Terminar",
|
||||
"follow_these": "Siga esses",
|
||||
@@ -238,7 +236,6 @@
|
||||
"limits_reached": "Limites Atingidos",
|
||||
"link": "link",
|
||||
"link_and_email": "Link & E-mail",
|
||||
"link_copied": "Link copiado para a área de transferência!",
|
||||
"link_survey": "Pesquisa de Link",
|
||||
"link_surveys": "Link de Pesquisas",
|
||||
"load_more": "Carregar mais",
|
||||
@@ -305,7 +302,6 @@
|
||||
"privacy": "Política de Privacidade",
|
||||
"product_manager": "Gerente de Produto",
|
||||
"profile": "Perfil",
|
||||
"project": "Projeto",
|
||||
"project_configuration": "Configuração do Projeto",
|
||||
"project_id": "ID do Projeto",
|
||||
"project_name": "Nome do Projeto",
|
||||
@@ -415,7 +411,6 @@
|
||||
"website_and_app_connection": "Conexão de Site e App",
|
||||
"website_app_survey": "Pesquisa de Site e App",
|
||||
"website_survey": "Pesquisa de Site",
|
||||
"weekly_summary": "Resumo semanal",
|
||||
"welcome_card": "Cartão de boas-vindas",
|
||||
"you": "Você",
|
||||
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
|
||||
@@ -456,29 +451,7 @@
|
||||
"invite_email_text_par1": "Seu colega",
|
||||
"invite_email_text_par2": "te convidou para se juntar a eles na Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
|
||||
"invite_member_email_subject": "Você foi convidado a colaborar no Formbricks!",
|
||||
"live_survey_notification_completed": "Concluído",
|
||||
"live_survey_notification_draft": "Rascunho",
|
||||
"live_survey_notification_in_progress": "Em andamento",
|
||||
"live_survey_notification_no_new_response": "Nenhuma resposta nova recebida essa semana \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "Ainda sem respostas!",
|
||||
"live_survey_notification_paused": "Pausado",
|
||||
"live_survey_notification_scheduled": "agendado",
|
||||
"live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas",
|
||||
"live_survey_notification_view_previous_responses": "Ver respostas anteriores",
|
||||
"live_survey_notification_view_response": "Ver Resposta",
|
||||
"new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:",
|
||||
"notification_footer_all_the_best": "Tudo de bom,",
|
||||
"notification_footer_in_your_settings": "nas suas configurações \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "por favor, desliga eles",
|
||||
"notification_footer_the_formbricks_team": "A Equipe Formbricks \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "Para parar as Atualizações Semanais,",
|
||||
"notification_header_hey": "Oi \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "Relatório Semanal de",
|
||||
"notification_insight_completed": "Concluído",
|
||||
"notification_insight_completion_rate": "Conclusão %",
|
||||
"notification_insight_displays": "telas",
|
||||
"notification_insight_responses": "Respostas",
|
||||
"notification_insight_surveys": "pesquisas",
|
||||
"password_changed_email_heading": "Senha alterada",
|
||||
"password_changed_email_text": "Sua senha foi alterada com sucesso.",
|
||||
"password_reset_notify_email_subject": "Sua senha Formbricks foi alterada",
|
||||
@@ -511,14 +484,7 @@
|
||||
"verification_email_verify_email": "Verificar e-mail",
|
||||
"verification_new_email_subject": "Verificação de alteração de e-mail",
|
||||
"verification_security_notice": "Se você não solicitou essa mudança de email, por favor ignore este email ou entre em contato com o suporte imediatamente.",
|
||||
"verified_link_survey_email_subject": "Sua pesquisa está pronta para ser preenchida.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um horário de 15 minutos na agenda do nosso CEO",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe uma semana passar sem aprender sobre seus usuários:",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "Precisa de ajuda pra encontrar a pesquisa certa pro seu produto?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "ou responde a esse e-mail :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurar uma nova pesquisa",
|
||||
"weekly_summary_create_reminder_notification_body_text": "Adoraríamos te enviar um Resumo Semanal, mas no momento não há pesquisas em andamento para {projectName}.",
|
||||
"weekly_summary_email_subject": "Insights de usuários do {projectName} – Semana passada por Formbricks"
|
||||
"verified_link_survey_email_subject": "Sua pesquisa está pronta para ser preenchida."
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1120,10 +1086,8 @@
|
||||
"need_slack_or_discord_notifications": "Preciso de notificações no Slack ou Discord",
|
||||
"notification_settings_updated": "Configurações de notificação atualizadas",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "Configura um alerta pra receber um e-mail com novas respostas",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "Fique por dentro com um resumo semanal toda segunda-feira",
|
||||
"use_the_integration": "Use a integração",
|
||||
"want_to_loop_in_organization_mates": "Quero incluir os colegas da organização",
|
||||
"weekly_summary_projects": "Resumo semanal (Projetos)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Você não vai ser mais inscrito automaticamente nas pesquisas dessa organização!",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Você não vai receber mais e-mails sobre respostas dessa pesquisa!"
|
||||
},
|
||||
@@ -1282,8 +1246,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Liberar automaticamente a pesquisa no começo do dia (UTC).",
|
||||
"back_button_label": "Voltar",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloqueia a pesquisa se já existir uma submissão com o Id de Uso Único (suId).",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloqueia a pesquisa se a URL da pesquisa não tiver um Id de Uso Único (suId).",
|
||||
"brand_color": "Cor da marca",
|
||||
"brightness": "brilho",
|
||||
"button_label": "Rótulo do Botão",
|
||||
@@ -1368,7 +1330,6 @@
|
||||
"does_not_start_with": "Não começa com",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções de {lang}",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "Habilitar criptografia do Id de Uso Único (suId) na URL da pesquisa.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
|
||||
"enable_spam_protection": "Proteção contra spam",
|
||||
@@ -1444,7 +1405,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "Esconder o logo nessa pesquisa específica",
|
||||
"hostname": "nome do host",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão descoladas você quer suas cartas em Pesquisas {surveyTypeDerived}",
|
||||
"how_it_works": "Como funciona",
|
||||
"if_you_need_more_please": "Se você precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Se você realmente quer essa resposta, pergunte até conseguir.",
|
||||
"ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre pesquisas",
|
||||
@@ -1482,7 +1442,6 @@
|
||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo do arquivo",
|
||||
"limit_upload_file_size_to": "Limitar tamanho do arquivo de upload para",
|
||||
"link_survey_description": "Compartilhe um link para a página da pesquisa ou incorpore-a em uma página da web ou e-mail.",
|
||||
"link_used_message": "Link Usado",
|
||||
"load_segment": "segmento de carga",
|
||||
"logic_error_warning": "Mudar vai causar erros de lógica",
|
||||
"logic_error_warning_text": "Mudar o tipo de pergunta vai remover as condições lógicas dessa pergunta",
|
||||
@@ -1574,8 +1533,6 @@
|
||||
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
|
||||
"simple": "Simples",
|
||||
"single_use_survey_links": "Links de pesquisa de uso único",
|
||||
"single_use_survey_links_description": "Permitir apenas 1 resposta por link da pesquisa.",
|
||||
"six_points": "6 pontos",
|
||||
"skip_button_label": "Botão de Pular",
|
||||
"smiley": "Sorridente",
|
||||
@@ -1592,8 +1549,6 @@
|
||||
"subheading": "Subtítulo",
|
||||
"subtract": "Subtrair -",
|
||||
"suggest_colors": "Sugerir cores",
|
||||
"survey_already_answered_heading": "A pesquisa já foi respondida.",
|
||||
"survey_already_answered_subheading": "Você só pode usar esse link uma vez.",
|
||||
"survey_completed_heading": "Pesquisa Concluída",
|
||||
"survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada",
|
||||
"survey_display_settings": "Configurações de Exibição da Pesquisa",
|
||||
@@ -1624,7 +1579,6 @@
|
||||
"upload": "Enviar",
|
||||
"upload_at_least_2_images": "Faz o upload de pelo menos 2 imagens",
|
||||
"upper_label": "Etiqueta Superior",
|
||||
"url_encryption": "Criptografia de URL",
|
||||
"url_filters": "Filtros de URL",
|
||||
"url_not_supported": "URL não suportada",
|
||||
"use_with_caution": "Use com cuidado",
|
||||
@@ -1648,8 +1602,6 @@
|
||||
"zip": "Fecho éclair"
|
||||
},
|
||||
"error_deleting_survey": "Ocorreu um erro ao deletar a pesquisa",
|
||||
"failed_to_copy_link_to_results": "Falha ao copiar link dos resultados",
|
||||
"failed_to_copy_url": "Falha ao copiar URL: não está em um ambiente de navegador.",
|
||||
"new_survey": "Nova Pesquisa",
|
||||
"no_surveys_created_yet": "Ainda não foram criadas pesquisas",
|
||||
"open_options": "Abre opções",
|
||||
@@ -1690,7 +1642,6 @@
|
||||
"this_response_is_in_progress": "Essa resposta está em andamento.",
|
||||
"zip_post_code": "CEP / Código postal"
|
||||
},
|
||||
"results_unpublished_successfully": "Resultados não publicados com sucesso.",
|
||||
"search_by_survey_name": "Buscar pelo nome da pesquisa",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1789,8 +1740,8 @@
|
||||
"configure_alerts": "Configurar alertas",
|
||||
"congrats": "Parabéns! Sua pesquisa está no ar.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.",
|
||||
"copy_link_to_public_results": "Copiar link para resultados públicos",
|
||||
"custom_range": "Intervalo personalizado...",
|
||||
"delete_all_existing_responses_and_displays": "Excluir todas as respostas e exibições existentes",
|
||||
"download_qr_code": "baixar código QR",
|
||||
"drop_offs": "Pontos de Entrega",
|
||||
"drop_offs_tooltip": "Número de vezes que a pesquisa foi iniciada mas não concluída.",
|
||||
@@ -1842,40 +1793,33 @@
|
||||
"last_month": "Último mês",
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Último ano",
|
||||
"link_to_public_results_copied": "Link pros resultados públicos copiado",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"only_completed": "Somente concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "No geral",
|
||||
"publish_to_web": "Publicar na web",
|
||||
"publish_to_web_warning": "Você está prestes a divulgar esses resultados da pesquisa para o público.",
|
||||
"publish_to_web_warning_description": "Os resultados da sua pesquisa serão públicos. Qualquer pessoa fora da sua organização pode acessá-los se tiver o link.",
|
||||
"qr_code": "Código QR",
|
||||
"qr_code_description": "Respostas coletadas via código QR são anônimas.",
|
||||
"qr_code_download_failed": "falha no download do código QR",
|
||||
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
|
||||
"qr_code_generation_failed": "Houve um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"reset_survey": "Redefinir pesquisa",
|
||||
"reset_survey_warning": "Redefinir uma pesquisa remove todas as respostas e exibições associadas a esta pesquisa. Isto não pode ser desfeito.",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_results": "Compartilhar resultados",
|
||||
"share_survey": "Compartilhar pesquisa",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
"show_all_responses_where": "Mostre todas as respostas onde...",
|
||||
"starts": "começa",
|
||||
"starts_tooltip": "Número de vezes que a pesquisa foi iniciada.",
|
||||
"survey_results_are_public": "Os resultados da sua pesquisa são públicos!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Os resultados da sua pesquisa são compartilhados com quem tiver o link. Os resultados não serão indexados por motores de busca.",
|
||||
"survey_reset_successfully": "Pesquisa redefinida com sucesso! {responseCount} respostas e {displayCount} exibições foram deletadas.",
|
||||
"this_month": "Este mês",
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"ttc_tooltip": "Tempo médio para completar a pesquisa.",
|
||||
"unknown_question_type": "Tipo de pergunta desconhecido",
|
||||
"unpublish_from_web": "Despublicar da web",
|
||||
"use_personal_links": "Use links pessoais",
|
||||
"view_site": "Ver site",
|
||||
"waiting_for_response": "Aguardando uma resposta \uD83E\uDDD8♂️",
|
||||
"whats_next": "E agora?",
|
||||
"your_survey_is_public": "Sua pesquisa é pública",
|
||||
@@ -2006,11 +1950,6 @@
|
||||
"this_user_has_all_the_power": "Esse usuário tem todo o poder."
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "Voltar pra casa",
|
||||
"page_not_found": "Página não encontrada",
|
||||
"page_not_found_description": "Desculpa, não conseguimos encontrar as respostas com o ID que você está procurando."
|
||||
},
|
||||
"templates": {
|
||||
"address": "endereço",
|
||||
"address_description": "Pede um endereço pra correspondência",
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Está atualmente no ambiente {environment}.",
|
||||
"error": "Erro",
|
||||
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
|
||||
"error_component_title": "Erro ao carregar recursos",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"finish": "Concluir",
|
||||
"follow_these": "Siga estes",
|
||||
@@ -238,7 +236,6 @@
|
||||
"limits_reached": "Limites Atingidos",
|
||||
"link": "Link",
|
||||
"link_and_email": "Link e Email",
|
||||
"link_copied": "Link copiado para a área de transferência!",
|
||||
"link_survey": "Ligar Inquérito",
|
||||
"link_surveys": "Ligar Inquéritos",
|
||||
"load_more": "Carregar mais",
|
||||
@@ -305,7 +302,6 @@
|
||||
"privacy": "Política de Privacidade",
|
||||
"product_manager": "Gestor de Produto",
|
||||
"profile": "Perfil",
|
||||
"project": "Projeto",
|
||||
"project_configuration": "Configuração do Projeto",
|
||||
"project_id": "ID do Projeto",
|
||||
"project_name": "Nome do Projeto",
|
||||
@@ -415,7 +411,6 @@
|
||||
"website_and_app_connection": "Ligação de Website e Aplicação",
|
||||
"website_app_survey": "Inquérito do Website e da Aplicação",
|
||||
"website_survey": "Inquérito do Website",
|
||||
"weekly_summary": "Resumo semanal",
|
||||
"welcome_card": "Cartão de boas-vindas",
|
||||
"you": "Você",
|
||||
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
|
||||
@@ -456,29 +451,7 @@
|
||||
"invite_email_text_par1": "O seu colega",
|
||||
"invite_email_text_par2": "convidou-o a juntar-se a eles no Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
|
||||
"invite_member_email_subject": "Está convidado a colaborar no Formbricks!",
|
||||
"live_survey_notification_completed": "Concluído",
|
||||
"live_survey_notification_draft": "Rascunho",
|
||||
"live_survey_notification_in_progress": "Em Progresso",
|
||||
"live_survey_notification_no_new_response": "Nenhuma nova resposta recebida esta semana \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "Ainda sem respostas!",
|
||||
"live_survey_notification_paused": "Pausado",
|
||||
"live_survey_notification_scheduled": "Agendado",
|
||||
"live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas",
|
||||
"live_survey_notification_view_previous_responses": "Ver respostas anteriores",
|
||||
"live_survey_notification_view_response": "Ver Resposta",
|
||||
"new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:",
|
||||
"notification_footer_all_the_best": "Tudo de bom,",
|
||||
"notification_footer_in_your_settings": "nas suas definições \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "por favor, desative-os",
|
||||
"notification_footer_the_formbricks_team": "A Equipa Formbricks \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "Para parar as Atualizações Semanais,",
|
||||
"notification_header_hey": "Olá \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "Relatório Semanal para",
|
||||
"notification_insight_completed": "Concluído",
|
||||
"notification_insight_completion_rate": "Conclusão %",
|
||||
"notification_insight_displays": "Ecrãs",
|
||||
"notification_insight_responses": "Respostas",
|
||||
"notification_insight_surveys": "Inquéritos",
|
||||
"password_changed_email_heading": "Palavra-passe alterada",
|
||||
"password_changed_email_text": "A sua palavra-passe foi alterada com sucesso.",
|
||||
"password_reset_notify_email_subject": "A sua palavra-passe do Formbricks foi alterada",
|
||||
@@ -511,14 +484,7 @@
|
||||
"verification_email_verify_email": "Verificar email",
|
||||
"verification_new_email_subject": "Verificação de alteração de email",
|
||||
"verification_security_notice": "Se não solicitou esta alteração de email, ignore este email ou contacte o suporte imediatamente.",
|
||||
"verified_link_survey_email_subject": "O seu inquérito está pronto para ser preenchido.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um intervalo de 15 minutos no calendário do nosso CEO",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe passar uma semana sem aprender sobre os seus utilizadores:",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "Precisa de ajuda para encontrar o inquérito certo para o seu produto?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "ou responda a este email :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurar um novo inquérito",
|
||||
"weekly_summary_create_reminder_notification_body_text": "Gostaríamos de lhe enviar um Resumo Semanal, mas de momento não há inquéritos a decorrer para {projectName}.",
|
||||
"weekly_summary_email_subject": "{projectName} Informações do Utilizador - Última Semana por Formbricks"
|
||||
"verified_link_survey_email_subject": "O seu inquérito está pronto para ser preenchido."
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1120,10 +1086,8 @@
|
||||
"need_slack_or_discord_notifications": "Precisa de notificações do Slack ou Discord",
|
||||
"notification_settings_updated": "Definições de notificações atualizadas",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "Configurar um alerta para receber um e-mail sobre novas respostas",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "Mantenha-se atualizado com um Resumo semanal todas as segundas-feiras",
|
||||
"use_the_integration": "Use a integração",
|
||||
"want_to_loop_in_organization_mates": "Quer incluir colegas da organização",
|
||||
"weekly_summary_projects": "Resumo semanal (Projetos)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Já não será automaticamente subscrito aos inquéritos desta organização!",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Não receberá mais emails para respostas a este inquérito!"
|
||||
},
|
||||
@@ -1282,8 +1246,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Lançar automaticamente o inquérito no início do dia (UTC).",
|
||||
"back_button_label": "Rótulo do botão \"Voltar\"",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloqueia o inquérito se já existir uma submissão com o Id de Uso Único (suId).",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloqueia o inquérito se o URL do inquérito não tiver um Id de Uso Único (suId).",
|
||||
"brand_color": "Cor da marca",
|
||||
"brightness": "Brilho",
|
||||
"button_label": "Rótulo do botão",
|
||||
@@ -1368,7 +1330,6 @@
|
||||
"does_not_start_with": "Não começa com",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções {lang}",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "Ativar encriptação do Id de Uso Único (suId) no URL do inquérito.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
|
||||
"enable_spam_protection": "Proteção contra spam",
|
||||
@@ -1444,7 +1405,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "Ocultar o logótipo neste inquérito específico",
|
||||
"hostname": "Nome do host",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão extravagantes quer os seus cartões em Inquéritos {surveyTypeDerived}",
|
||||
"how_it_works": "Como funciona",
|
||||
"if_you_need_more_please": "Se precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Se realmente quiser essa resposta, pergunte até obtê-la.",
|
||||
"ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre inquéritos",
|
||||
@@ -1482,7 +1442,6 @@
|
||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo do ficheiro",
|
||||
"limit_upload_file_size_to": "Limitar tamanho do ficheiro carregado a",
|
||||
"link_survey_description": "Partilhe um link para uma página de inquérito ou incorpore-o numa página web ou email.",
|
||||
"link_used_message": "Link Utilizado",
|
||||
"load_segment": "Carregar segmento",
|
||||
"logic_error_warning": "A alteração causará erros de lógica",
|
||||
"logic_error_warning_text": "Alterar o tipo de pergunta irá remover as condições lógicas desta pergunta",
|
||||
@@ -1574,8 +1533,6 @@
|
||||
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
|
||||
"simple": "Simples",
|
||||
"single_use_survey_links": "Links de inquérito de uso único",
|
||||
"single_use_survey_links_description": "Permitir apenas 1 resposta por link de inquérito.",
|
||||
"six_points": "6 pontos",
|
||||
"skip_button_label": "Rótulo do botão Ignorar",
|
||||
"smiley": "Sorridente",
|
||||
@@ -1592,8 +1549,6 @@
|
||||
"subheading": "Subtítulo",
|
||||
"subtract": "Subtrair -",
|
||||
"suggest_colors": "Sugerir cores",
|
||||
"survey_already_answered_heading": "O inquérito já foi respondido.",
|
||||
"survey_already_answered_subheading": "Só pode usar este link uma vez.",
|
||||
"survey_completed_heading": "Inquérito Concluído",
|
||||
"survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado",
|
||||
"survey_display_settings": "Configurações de Exibição do Inquérito",
|
||||
@@ -1624,7 +1579,6 @@
|
||||
"upload": "Carregar",
|
||||
"upload_at_least_2_images": "Carregue pelo menos 2 imagens",
|
||||
"upper_label": "Etiqueta Superior",
|
||||
"url_encryption": "Encriptação de URL",
|
||||
"url_filters": "Filtros de URL",
|
||||
"url_not_supported": "URL não suportado",
|
||||
"use_with_caution": "Usar com cautela",
|
||||
@@ -1648,8 +1602,6 @@
|
||||
"zip": "Comprimir"
|
||||
},
|
||||
"error_deleting_survey": "Ocorreu um erro ao eliminar o questionário",
|
||||
"failed_to_copy_link_to_results": "Falha ao copiar link para resultados",
|
||||
"failed_to_copy_url": "Falha ao copiar URL: não está num ambiente de navegador.",
|
||||
"new_survey": "Novo inquérito",
|
||||
"no_surveys_created_yet": "Ainda não foram criados questionários",
|
||||
"open_options": "Abrir opções",
|
||||
@@ -1690,7 +1642,6 @@
|
||||
"this_response_is_in_progress": "Esta resposta está em progresso.",
|
||||
"zip_post_code": "Código Postal"
|
||||
},
|
||||
"results_unpublished_successfully": "Resultados despublicados com sucesso.",
|
||||
"search_by_survey_name": "Pesquisar por nome do inquérito",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1789,8 +1740,8 @@
|
||||
"configure_alerts": "Configurar alertas",
|
||||
"congrats": "Parabéns! O seu inquérito está ativo.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.",
|
||||
"copy_link_to_public_results": "Copiar link para resultados públicos",
|
||||
"custom_range": "Intervalo personalizado...",
|
||||
"delete_all_existing_responses_and_displays": "Excluir todas as respostas existentes e exibições",
|
||||
"download_qr_code": "Transferir código QR",
|
||||
"drop_offs": "Desistências",
|
||||
"drop_offs_tooltip": "Número de vezes que o inquérito foi iniciado mas não concluído.",
|
||||
@@ -1842,40 +1793,33 @@
|
||||
"last_month": "Último mês",
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Ano passado",
|
||||
"link_to_public_results_copied": "Link para resultados públicos copiado",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"only_completed": "Apenas concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "Geral",
|
||||
"publish_to_web": "Publicar na web",
|
||||
"publish_to_web_warning": "Está prestes a divulgar estes resultados do inquérito ao público.",
|
||||
"publish_to_web_warning_description": "Os resultados do seu inquérito serão públicos. Qualquer pessoa fora da sua organização pode aceder a eles se tiver o link.",
|
||||
"qr_code": "Código QR",
|
||||
"qr_code_description": "Respostas recolhidas através de código QR são anónimas.",
|
||||
"qr_code_download_failed": "Falha ao transferir o código QR",
|
||||
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
|
||||
"qr_code_generation_failed": "Ocorreu um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"reset_survey": "Reiniciar inquérito",
|
||||
"reset_survey_warning": "Repor um inquérito remove todas as respostas e visualizações associadas a este inquérito. Isto não pode ser desfeito.",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_results": "Partilhar resultados",
|
||||
"share_survey": "Partilhar inquérito",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
"show_all_responses_where": "Mostrar todas as respostas onde...",
|
||||
"starts": "Começa",
|
||||
"starts_tooltip": "Número de vezes que o inquérito foi iniciado.",
|
||||
"survey_results_are_public": "Os resultados do seu inquérito são públicos!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Os resultados do seu inquérito são partilhados com qualquer pessoa que tenha o link. Os resultados não serão indexados pelos motores de busca.",
|
||||
"survey_reset_successfully": "Inquérito reiniciado com sucesso! {responseCount} respostas e {displayCount} exibições foram eliminadas.",
|
||||
"this_month": "Este mês",
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"ttc_tooltip": "Tempo médio para concluir o inquérito.",
|
||||
"unknown_question_type": "Tipo de Pergunta Desconhecido",
|
||||
"unpublish_from_web": "Despublicar da web",
|
||||
"use_personal_links": "Utilize links pessoais",
|
||||
"view_site": "Ver site",
|
||||
"waiting_for_response": "A aguardar uma resposta \uD83E\uDDD8♂️",
|
||||
"whats_next": "O que se segue?",
|
||||
"your_survey_is_public": "O seu inquérito é público",
|
||||
@@ -2006,11 +1950,6 @@
|
||||
"this_user_has_all_the_power": "Este utilizador tem todo o poder."
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "Voltar para casa",
|
||||
"page_not_found": "Página não encontrada",
|
||||
"page_not_found_description": "Desculpe, não conseguimos encontrar o ID de partilha de respostas que está a procurar."
|
||||
},
|
||||
"templates": {
|
||||
"address": "Endereço",
|
||||
"address_description": "Pedir um endereço de correspondência",
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "找不到環境",
|
||||
"environment_notice": "您目前在 '{'environment'}' 環境中。",
|
||||
"error": "錯誤",
|
||||
"error_component_description": "此資源不存在或您沒有存取權限。",
|
||||
"error_component_title": "載入資源錯誤",
|
||||
"expand_rows": "展開列",
|
||||
"finish": "完成",
|
||||
"follow_these": "按照這些步驟",
|
||||
@@ -238,7 +236,6 @@
|
||||
"limits_reached": "已達上限",
|
||||
"link": "連結",
|
||||
"link_and_email": "連結與電子郵件",
|
||||
"link_copied": "連結已複製到剪貼簿!",
|
||||
"link_survey": "連結問卷",
|
||||
"link_surveys": "連結問卷",
|
||||
"load_more": "載入更多",
|
||||
@@ -305,7 +302,6 @@
|
||||
"privacy": "隱私權政策",
|
||||
"product_manager": "產品經理",
|
||||
"profile": "個人資料",
|
||||
"project": "專案",
|
||||
"project_configuration": "專案組態",
|
||||
"project_id": "專案 ID",
|
||||
"project_name": "專案名稱",
|
||||
@@ -415,7 +411,6 @@
|
||||
"website_and_app_connection": "網站與應用程式連線",
|
||||
"website_app_survey": "網站與應用程式問卷",
|
||||
"website_survey": "網站問卷",
|
||||
"weekly_summary": "每週摘要",
|
||||
"welcome_card": "歡迎卡片",
|
||||
"you": "您",
|
||||
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
|
||||
@@ -456,29 +451,7 @@
|
||||
"invite_email_text_par1": "您的同事",
|
||||
"invite_email_text_par2": "邀請您加入 Formbricks。若要接受邀請,請點擊以下連結:",
|
||||
"invite_member_email_subject": "您被邀請協作 Formbricks!",
|
||||
"live_survey_notification_completed": "已完成",
|
||||
"live_survey_notification_draft": "草稿",
|
||||
"live_survey_notification_in_progress": "進行中",
|
||||
"live_survey_notification_no_new_response": "本週沒有收到新的回應 \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "尚無回應!",
|
||||
"live_survey_notification_paused": "已暫停",
|
||||
"live_survey_notification_scheduled": "已排程",
|
||||
"live_survey_notification_view_more_responses": "檢視另外 '{'responseCount'}' 個回應",
|
||||
"live_survey_notification_view_previous_responses": "檢視先前的回應",
|
||||
"live_survey_notification_view_response": "檢視回應",
|
||||
"new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:",
|
||||
"notification_footer_all_the_best": "祝您一切順利,",
|
||||
"notification_footer_in_your_settings": "在您的設定中 \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "請關閉它們",
|
||||
"notification_footer_the_formbricks_team": "Formbricks 團隊 \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "若要停止每週更新,",
|
||||
"notification_header_hey": "嗨 \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "每週報告,適用於",
|
||||
"notification_insight_completed": "已完成",
|
||||
"notification_insight_completion_rate": "完成率 %",
|
||||
"notification_insight_displays": "顯示次數",
|
||||
"notification_insight_responses": "回應數",
|
||||
"notification_insight_surveys": "問卷數",
|
||||
"password_changed_email_heading": "密碼已變更",
|
||||
"password_changed_email_text": "您的密碼已成功變更。",
|
||||
"password_reset_notify_email_subject": "您的 Formbricks 密碼已變更",
|
||||
@@ -511,14 +484,7 @@
|
||||
"verification_email_verify_email": "驗證電子郵件",
|
||||
"verification_new_email_subject": "電子郵件更改驗證",
|
||||
"verification_security_notice": "如果您沒有要求更改此電子郵件,請忽略此電子郵件或立即聯繫支援。",
|
||||
"verified_link_survey_email_subject": "您的 survey 已準備好填寫。",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "在我們 CEO 的日曆中選擇一個 15 分鐘的時段",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "不要讓一週過去而沒有了解您的使用者:",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "需要協助找到適合您產品的問卷嗎?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "或回覆此電子郵件 :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "設定新的問卷",
|
||||
"weekly_summary_create_reminder_notification_body_text": "我們很樂意向您發送每週摘要,但目前 '{'projectName'}' 沒有正在執行的問卷。",
|
||||
"weekly_summary_email_subject": "{projectName} 用戶洞察 - 上週 by Formbricks"
|
||||
"verified_link_survey_email_subject": "您的 survey 已準備好填寫。"
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1120,10 +1086,8 @@
|
||||
"need_slack_or_discord_notifications": "需要 Slack 或 Discord 通知嗎?",
|
||||
"notification_settings_updated": "通知設定已更新",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "設定警示以在收到新回應時收到電子郵件",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "每週一使用每週摘要保持最新資訊",
|
||||
"use_the_integration": "使用整合",
|
||||
"want_to_loop_in_organization_mates": "想要讓組織夥伴也參與嗎?",
|
||||
"weekly_summary_projects": "每週摘要(專案)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "您將不會再自動訂閱此組織的問卷!",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "您將不會再收到此問卷回應的電子郵件!"
|
||||
},
|
||||
@@ -1282,8 +1246,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "在指定日期(UTC時間)自動發佈問卷。",
|
||||
"back_button_label": "「返回」按鈕標籤",
|
||||
"background_styling": "背景樣式設定",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "如果已存在具有單次使用 ID (suId) 的提交,則封鎖問卷。",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "如果問卷網址沒有單次使用 ID (suId),則封鎖問卷。",
|
||||
"brand_color": "品牌顏色",
|
||||
"brightness": "亮度",
|
||||
"button_label": "按鈕標籤",
|
||||
@@ -1368,7 +1330,6 @@
|
||||
"does_not_start_with": "不以...開頭",
|
||||
"edit_recall": "編輯回憶",
|
||||
"edit_translations": "編輯 '{'language'}' 翻譯",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "啟用問卷網址中單次使用 ID (suId) 的加密。",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾郵件保護使用 reCAPTCHA v3 過濾垃圾回應。",
|
||||
"enable_spam_protection": "垃圾郵件保護",
|
||||
@@ -1444,7 +1405,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "在此特定問卷中隱藏標誌",
|
||||
"hostname": "主機名稱",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "您希望 '{'surveyTypeDerived'}' 問卷中的卡片有多酷炫",
|
||||
"how_it_works": "運作方式",
|
||||
"if_you_need_more_please": "如果您需要更多,請",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "如果您真的想要該答案,請詢問直到您獲得它。",
|
||||
"ignore_waiting_time_between_surveys": "忽略問卷之間的等待時間",
|
||||
@@ -1482,7 +1442,6 @@
|
||||
"limit_the_maximum_file_size": "限制最大檔案大小",
|
||||
"limit_upload_file_size_to": "限制上傳檔案大小為",
|
||||
"link_survey_description": "分享問卷頁面的連結或將其嵌入網頁或電子郵件中。",
|
||||
"link_used_message": "已使用連結",
|
||||
"load_segment": "載入區隔",
|
||||
"logic_error_warning": "變更將導致邏輯錯誤",
|
||||
"logic_error_warning_text": "變更問題類型將會從此問題中移除邏輯條件",
|
||||
@@ -1574,8 +1533,6 @@
|
||||
"show_survey_to_users": "將問卷顯示給 % 的使用者",
|
||||
"show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者",
|
||||
"simple": "簡單",
|
||||
"single_use_survey_links": "單次使用問卷連結",
|
||||
"single_use_survey_links_description": "每個問卷連結只允許 1 個回應。",
|
||||
"six_points": "6 分",
|
||||
"skip_button_label": "「跳過」按鈕標籤",
|
||||
"smiley": "表情符號",
|
||||
@@ -1592,8 +1549,6 @@
|
||||
"subheading": "副標題",
|
||||
"subtract": "減 -",
|
||||
"suggest_colors": "建議顏色",
|
||||
"survey_already_answered_heading": "問卷已回答。",
|
||||
"survey_already_answered_subheading": "您只能使用此連結一次。",
|
||||
"survey_completed_heading": "問卷已完成",
|
||||
"survey_completed_subheading": "此免費且開源的問卷已關閉",
|
||||
"survey_display_settings": "問卷顯示設定",
|
||||
@@ -1624,7 +1579,6 @@
|
||||
"upload": "上傳",
|
||||
"upload_at_least_2_images": "上傳至少 2 張圖片",
|
||||
"upper_label": "上標籤",
|
||||
"url_encryption": "網址加密",
|
||||
"url_filters": "網址篩選器",
|
||||
"url_not_supported": "不支援網址",
|
||||
"use_with_caution": "謹慎使用",
|
||||
@@ -1648,8 +1602,6 @@
|
||||
"zip": "郵遞區號"
|
||||
},
|
||||
"error_deleting_survey": "刪除問卷時發生錯誤",
|
||||
"failed_to_copy_link_to_results": "無法複製結果連結",
|
||||
"failed_to_copy_url": "無法複製網址:不在瀏覽器環境中。",
|
||||
"new_survey": "新增問卷",
|
||||
"no_surveys_created_yet": "尚未建立任何問卷",
|
||||
"open_options": "開啟選項",
|
||||
@@ -1690,7 +1642,6 @@
|
||||
"this_response_is_in_progress": "此回應正在進行中。",
|
||||
"zip_post_code": "郵遞區號"
|
||||
},
|
||||
"results_unpublished_successfully": "結果已成功取消發布。",
|
||||
"search_by_survey_name": "依問卷名稱搜尋",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1789,8 +1740,8 @@
|
||||
"configure_alerts": "設定警示",
|
||||
"congrats": "恭喜!您的問卷已上線。",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
|
||||
"copy_link_to_public_results": "複製公開結果的連結",
|
||||
"custom_range": "自訂範圍...",
|
||||
"delete_all_existing_responses_and_displays": "刪除 所有 現有 回應 和 顯示",
|
||||
"download_qr_code": "下載 QR code",
|
||||
"drop_offs": "放棄",
|
||||
"drop_offs_tooltip": "問卷已開始但未完成的次數。",
|
||||
@@ -1842,40 +1793,33 @@
|
||||
"last_month": "上個月",
|
||||
"last_quarter": "上一季",
|
||||
"last_year": "去年",
|
||||
"link_to_public_results_copied": "已複製公開結果的連結",
|
||||
"no_responses_found": "找不到回應",
|
||||
"only_completed": "僅已完成",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整體",
|
||||
"publish_to_web": "發布至網站",
|
||||
"publish_to_web_warning": "您即將將這些問卷結果發布到公共領域。",
|
||||
"publish_to_web_warning_description": "您的問卷結果將會是公開的。任何組織外的人員都可以存取這些結果(如果他們有連結)。",
|
||||
"qr_code": "QR 碼",
|
||||
"qr_code_description": "透過 QR code 收集的回應都是匿名的。",
|
||||
"qr_code_download_failed": "QR code 下載失敗",
|
||||
"qr_code_download_with_start_soon": "QR code 下載即將開始",
|
||||
"qr_code_generation_failed": "載入調查 QR Code 時發生問題。請再試一次。",
|
||||
"results_are_public": "結果是公開的",
|
||||
"reset_survey": "重設問卷",
|
||||
"reset_survey_warning": "重置 調查 會 移除 與 此 調查 相關 的 所有 回應 和 顯示 。 這 是 不可 撤銷 的 。",
|
||||
"selected_responses_csv": "選擇的回應 (CSV)",
|
||||
"selected_responses_excel": "選擇的回應 (Excel)",
|
||||
"setup_integrations": "設定整合",
|
||||
"share_results": "分享結果",
|
||||
"share_survey": "分享問卷",
|
||||
"show_all_responses_that_match": "顯示所有相符的回應",
|
||||
"show_all_responses_where": "顯示所有回應,其中...",
|
||||
"starts": "開始次數",
|
||||
"starts_tooltip": "問卷已開始的次數。",
|
||||
"survey_results_are_public": "您的問卷結果是公開的!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "您的問卷結果與任何擁有連結的人員分享。這些結果將不會被搜尋引擎編入索引。",
|
||||
"survey_reset_successfully": "調查 重置 成功!{responseCount} 條回應和 {displayCount} 個顯示被刪除。",
|
||||
"this_month": "本月",
|
||||
"this_quarter": "本季",
|
||||
"this_year": "今年",
|
||||
"time_to_complete": "完成時間",
|
||||
"ttc_tooltip": "完成問卷的平均時間。",
|
||||
"unknown_question_type": "未知的問題類型",
|
||||
"unpublish_from_web": "從網站取消發布",
|
||||
"use_personal_links": "使用 個人 連結",
|
||||
"view_site": "檢視網站",
|
||||
"waiting_for_response": "正在等待回應 \uD83E\uDDD8♂️",
|
||||
"whats_next": "下一步是什麼?",
|
||||
"your_survey_is_public": "您的問卷是公開的",
|
||||
@@ -2006,11 +1950,6 @@
|
||||
"this_user_has_all_the_power": "此使用者擁有所有權限。"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "返回首頁",
|
||||
"page_not_found": "找不到頁面",
|
||||
"page_not_found_description": "抱歉,我們找不到您要尋找的回應分享 ID。"
|
||||
},
|
||||
"templates": {
|
||||
"address": "地址",
|
||||
"address_description": "要求郵寄地址",
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface SurveyLinkDisplayProps {
|
||||
surveyUrl: string;
|
||||
enforceSurveyUrlWidth?: boolean;
|
||||
}
|
||||
|
||||
export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
|
||||
export const SurveyLinkDisplay = ({ surveyUrl, enforceSurveyUrlWidth = false }: SurveyLinkDisplayProps) => {
|
||||
return (
|
||||
<>
|
||||
{surveyUrl ? (
|
||||
<Input
|
||||
data-testid="survey-url-input"
|
||||
autoFocus={true}
|
||||
className="h-9 w-full text-ellipsis rounded-lg border bg-white px-3 py-1 text-slate-800 caret-transparent"
|
||||
className={cn(
|
||||
"h-9 w-full text-ellipsis rounded-lg border bg-white px-3 py-1 text-slate-800 caret-transparent",
|
||||
{
|
||||
"min-w-96": enforceSurveyUrlWidth,
|
||||
}
|
||||
)}
|
||||
value={surveyUrl}
|
||||
readOnly
|
||||
aria-label="Survey URL"
|
||||
/>
|
||||
) : (
|
||||
//loading state
|
||||
<div
|
||||
data-testid="loading-div"
|
||||
className="h-9 w-full min-w-96 animate-pulse rounded-lg bg-slate-100 px-3 py-1 text-slate-800 caret-transparent"
|
||||
className={cn(
|
||||
"h-9 w-full animate-pulse rounded-lg bg-slate-100 px-3 py-1 text-slate-800 caret-transparent",
|
||||
{
|
||||
"min-w-96": enforceSurveyUrlWidth,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getSurveyUrl } from "../../utils";
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
toast: {
|
||||
@@ -11,6 +12,24 @@ vi.mock("react-hot-toast", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the useSingleUseId hook
|
||||
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
|
||||
useSingleUseId: vi.fn(() => ({
|
||||
singleUseId: "test-single-use-id",
|
||||
refreshSingleUseId: vi.fn().mockResolvedValue("test-single-use-id"),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the survey utils
|
||||
vi.mock("../../utils", () => ({
|
||||
getSurveyUrl: vi.fn((survey, publicDomain, language) => {
|
||||
if (language && language !== "en") {
|
||||
return `${publicDomain}/s/${survey.id}?lang=${language}`;
|
||||
}
|
||||
return `${publicDomain}/s/${survey.id}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
const survey: TSurvey = {
|
||||
id: "survey-id",
|
||||
name: "Test Survey",
|
||||
@@ -161,7 +180,7 @@ describe("ShareSurveyLink", () => {
|
||||
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
});
|
||||
|
||||
test("opens the preview link in a new tab when preview button is clicked (no query params)", () => {
|
||||
test("opens the preview link in a new tab when preview button is clicked (no query params)", async () => {
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
@@ -175,10 +194,13 @@ describe("ShareSurveyLink", () => {
|
||||
const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab");
|
||||
fireEvent.click(previewButton);
|
||||
|
||||
// Wait for the async function to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(global.open).toHaveBeenCalledWith(`${surveyUrl}?preview=true`, "_blank");
|
||||
});
|
||||
|
||||
test("opens the preview link in a new tab when preview button is clicked (with query params)", () => {
|
||||
test("opens the preview link in a new tab when preview button is clicked (with query params)", async () => {
|
||||
const surveyWithParamsUrl = `${publicDomain}/s/survey-id?foo=bar`;
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
@@ -193,6 +215,9 @@ describe("ShareSurveyLink", () => {
|
||||
const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab");
|
||||
fireEvent.click(previewButton);
|
||||
|
||||
// Wait for the async function to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(global.open).toHaveBeenCalledWith(`${surveyWithParamsUrl}&preview=true`, "_blank");
|
||||
});
|
||||
|
||||
@@ -215,7 +240,9 @@ describe("ShareSurveyLink", () => {
|
||||
});
|
||||
|
||||
test("updates the survey URL when the language is changed", () => {
|
||||
const { rerender } = render(
|
||||
const mockGetSurveyUrl = vi.mocked(getSurveyUrl);
|
||||
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
publicDomain={publicDomain}
|
||||
@@ -231,16 +258,7 @@ describe("ShareSurveyLink", () => {
|
||||
const germanOption = screen.getByText("German");
|
||||
fireEvent.click(germanOption);
|
||||
|
||||
rerender(
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
publicDomain={publicDomain}
|
||||
surveyUrl={surveyUrl}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
expect(setSurveyUrl).toHaveBeenCalled();
|
||||
expect(surveyUrl).toContain("lang=de");
|
||||
expect(mockGetSurveyUrl).toHaveBeenCalledWith(survey, publicDomain, "de");
|
||||
expect(setSurveyUrl).toHaveBeenCalledWith(`${publicDomain}/s/${survey.id}?lang=de`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Copy, SquareArrowOutUpRight } from "lucide-react";
|
||||
@@ -16,6 +17,7 @@ interface ShareSurveyLinkProps {
|
||||
surveyUrl: string;
|
||||
setSurveyUrl: (url: string) => void;
|
||||
locale: TUserLocale;
|
||||
enforceSurveyUrlWidth?: boolean;
|
||||
}
|
||||
|
||||
export const ShareSurveyLink = ({
|
||||
@@ -24,6 +26,7 @@ export const ShareSurveyLink = ({
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
locale,
|
||||
enforceSurveyUrlWidth = false,
|
||||
}: ShareSurveyLinkProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
@@ -32,9 +35,29 @@ export const ShareSurveyLink = ({
|
||||
setSurveyUrl(url);
|
||||
};
|
||||
|
||||
const { refreshSingleUseId } = useSingleUseId(survey);
|
||||
|
||||
const getPreviewUrl = async () => {
|
||||
const previewUrl = new URL(surveyUrl);
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
const newId = await refreshSingleUseId();
|
||||
if (newId) {
|
||||
previewUrl.searchParams.set("suId", newId);
|
||||
}
|
||||
}
|
||||
|
||||
previewUrl.searchParams.set("preview", "true");
|
||||
return previewUrl.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex max-w-full flex-col items-center justify-center gap-2 md:flex-row"}>
|
||||
<SurveyLinkDisplay surveyUrl={surveyUrl} key={surveyUrl} />
|
||||
<div className={"flex max-w-full items-center justify-center gap-2"}>
|
||||
<SurveyLinkDisplay
|
||||
surveyUrl={surveyUrl}
|
||||
key={surveyUrl}
|
||||
enforceSurveyUrlWidth={enforceSurveyUrlWidth}
|
||||
/>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<LanguageDropdown survey={survey} setLanguage={handleLanguageChange} locale={locale} />
|
||||
<Button
|
||||
@@ -53,14 +76,9 @@ export const ShareSurveyLink = ({
|
||||
title={t("environments.surveys.preview_survey_in_a_new_tab")}
|
||||
aria-label={t("environments.surveys.preview_survey_in_a_new_tab")}
|
||||
disabled={!surveyUrl}
|
||||
onClick={() => {
|
||||
let previewUrl = surveyUrl;
|
||||
if (previewUrl.includes("?")) {
|
||||
previewUrl += "&preview=true";
|
||||
} else {
|
||||
previewUrl += "?preview=true";
|
||||
}
|
||||
window.open(previewUrl, "_blank");
|
||||
onClick={async () => {
|
||||
const url = await getPreviewUrl();
|
||||
window.open(url, "_blank");
|
||||
}}>
|
||||
{t("common.preview")}
|
||||
<SquareArrowOutUpRight />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiAuditLog } from "@/app/lib/api/with-api-logging";
|
||||
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
||||
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { ZodRawShape, z } from "zod";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "./authenticate-request";
|
||||
@@ -104,11 +105,10 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||
}
|
||||
|
||||
if (rateLimit) {
|
||||
const rateLimitResponse = await checkRateLimitAndThrowError({
|
||||
identifier: authentication.data.hashedApiKey,
|
||||
});
|
||||
if (!rateLimitResponse.ok) {
|
||||
return handleApiError(request, rateLimitResponse.error);
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v2, authentication.data.hashedApiKey);
|
||||
} catch (error) {
|
||||
return handleApiError(request, { type: "too_many_requests", details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
|
||||
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
|
||||
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { checkRateLimit } from "@/modules/core/rate-limit/rate-limit";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { err, ok, okVoid } from "@formbricks/types/error-handlers";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
vi.mock("../authenticate-request", () => ({
|
||||
authenticateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/rate-limit", () => ({
|
||||
checkRateLimitAndThrowError: vi.fn(),
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
handleApiError: vi.fn(),
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
api: {
|
||||
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
@@ -24,20 +28,31 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
handleApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAuthentication = {
|
||||
type: "apiKey" as const,
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-id",
|
||||
environmentType: "development" as const,
|
||||
projectId: "project-id",
|
||||
projectName: "Project Name",
|
||||
permission: "manage" as const,
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-api-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {} as any,
|
||||
} as any;
|
||||
|
||||
describe("apiWrapper", () => {
|
||||
test("should handle request and return response", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(okVoid());
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
const response = await apiWrapper({
|
||||
@@ -74,13 +89,7 @@ describe("apiWrapper", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
@@ -107,14 +116,7 @@ describe("apiWrapper", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
@@ -134,13 +136,7 @@ describe("apiWrapper", () => {
|
||||
test("should parse query schema correctly", async () => {
|
||||
const request = new Request("http://localhost?key=value");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
|
||||
const querySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
@@ -163,14 +159,7 @@ describe("apiWrapper", () => {
|
||||
test("should handle query schema errors", async () => {
|
||||
const request = new Request("http://localhost?foo%ZZ=abc");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const querySchema = z.object({ key: z.string() });
|
||||
@@ -190,13 +179,7 @@ describe("apiWrapper", () => {
|
||||
test("should parse params schema correctly", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
|
||||
const paramsSchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
@@ -220,14 +203,7 @@ describe("apiWrapper", () => {
|
||||
test("should handle no external params", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const paramsSchema = z.object({ key: z.string() });
|
||||
@@ -248,14 +224,7 @@ describe("apiWrapper", () => {
|
||||
test("should handle params schema errors", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const paramsSchema = z.object({ key: z.string() });
|
||||
@@ -273,21 +242,13 @@ describe("apiWrapper", () => {
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle rate limit errors", async () => {
|
||||
test("should handle rate limit exceeded", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(
|
||||
err({ type: "rateLimitExceeded" } as unknown as ApiErrorResponseV2)
|
||||
);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: false }));
|
||||
vi.mocked(handleApiError).mockImplementation(
|
||||
(_request: Request, _error: ApiErrorResponseV2): Response =>
|
||||
new Response("rate limit exceeded", { status: 429 })
|
||||
@@ -302,4 +263,24 @@ describe("apiWrapper", () => {
|
||||
expect(response.status).toBe(429);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle rate limit check failure gracefully", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
// When rate limiting fails (e.g., Redis connection issues), checkRateLimit fails open by returning allowed: true
|
||||
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
handler,
|
||||
});
|
||||
|
||||
// Should fail open for availability
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { MANAGEMENT_API_RATE_LIMIT, RATE_LIMITING_DISABLED, UNKEY_ROOT_KEY } from "@/lib/constants";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { type LimitOptions, Ratelimit, type RatelimitResponse } from "@unkey/ratelimit";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
|
||||
|
||||
export type RateLimitHelper = {
|
||||
identifier: string;
|
||||
opts?: LimitOptions;
|
||||
/**
|
||||
* Using a callback instead of a regular return to provide headers even
|
||||
* when the rate limit is reached and an error is thrown.
|
||||
**/
|
||||
onRateLimiterResponse?: (response: RatelimitResponse) => void;
|
||||
};
|
||||
|
||||
let warningDisplayed = false;
|
||||
|
||||
/** Prevent flooding the logs while testing/building */
|
||||
function logOnce(message: string) {
|
||||
if (warningDisplayed) return;
|
||||
logger.warn(message);
|
||||
warningDisplayed = true;
|
||||
}
|
||||
|
||||
export function rateLimiter() {
|
||||
if (RATE_LIMITING_DISABLED) {
|
||||
logOnce("Rate limiting disabled");
|
||||
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
|
||||
}
|
||||
|
||||
if (!UNKEY_ROOT_KEY) {
|
||||
logOnce("Disabled due to not finding UNKEY_ROOT_KEY env variable");
|
||||
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
|
||||
}
|
||||
const timeout = {
|
||||
fallback: { success: true, limit: 10, remaining: 999, reset: 0 },
|
||||
ms: 5000,
|
||||
};
|
||||
|
||||
const limiter = {
|
||||
api: new Ratelimit({
|
||||
rootKey: UNKEY_ROOT_KEY,
|
||||
namespace: "api",
|
||||
limit: MANAGEMENT_API_RATE_LIMIT.allowedPerInterval,
|
||||
duration: MANAGEMENT_API_RATE_LIMIT.interval * 1000,
|
||||
timeout,
|
||||
}),
|
||||
};
|
||||
|
||||
async function rateLimit({ identifier, opts }: RateLimitHelper) {
|
||||
return await limiter.api.limit(identifier, opts);
|
||||
}
|
||||
|
||||
return rateLimit;
|
||||
}
|
||||
|
||||
export const checkRateLimitAndThrowError = async ({
|
||||
identifier,
|
||||
opts,
|
||||
}: RateLimitHelper): Promise<Result<void, ApiErrorResponseV2>> => {
|
||||
const response = await rateLimiter()({ identifier, opts });
|
||||
const { success } = response;
|
||||
|
||||
if (!success) {
|
||||
return err({
|
||||
type: "too_many_requests",
|
||||
});
|
||||
}
|
||||
return okVoid();
|
||||
};
|
||||
@@ -1,114 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@unkey/ratelimit", () => ({
|
||||
Ratelimit: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("when rate limiting is disabled", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@/lib/constants");
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
...constants,
|
||||
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
|
||||
RATE_LIMITING_DISABLED: true,
|
||||
}));
|
||||
});
|
||||
|
||||
test("should log a warning once and return a stubbed response", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "warn");
|
||||
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
|
||||
|
||||
const res1 = await rateLimiter()({ identifier: "test-id" });
|
||||
expect(res1).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
|
||||
expect(loggerSpy).toHaveBeenCalled();
|
||||
|
||||
// Subsequent calls won't log again.
|
||||
await rateLimiter()({ identifier: "another-id" });
|
||||
|
||||
expect(loggerSpy).toHaveBeenCalledTimes(1);
|
||||
loggerSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when UNKEY_ROOT_KEY is missing", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@/lib/constants");
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
...constants,
|
||||
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
UNKEY_ROOT_KEY: "",
|
||||
}));
|
||||
});
|
||||
|
||||
test("should log a warning about missing UNKEY_ROOT_KEY and return stub response", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "warn");
|
||||
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
|
||||
const limiterFunc = rateLimiter();
|
||||
|
||||
const res = await limiterFunc({ identifier: "test-id" });
|
||||
expect(res).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
|
||||
expect(loggerSpy).toHaveBeenCalled();
|
||||
loggerSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when rate limiting is active (enabled)", () => {
|
||||
const mockResponse = { success: true, limit: 5, remaining: 2, reset: 1000 };
|
||||
let limitMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@/lib/constants");
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
...constants,
|
||||
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
UNKEY_ROOT_KEY: "valid-key",
|
||||
}));
|
||||
|
||||
limitMock = vi.fn().mockResolvedValue(mockResponse);
|
||||
const RatelimitMock = vi.fn().mockImplementation(() => {
|
||||
return { limit: limitMock };
|
||||
});
|
||||
vi.doMock("@unkey/ratelimit", () => ({
|
||||
Ratelimit: RatelimitMock,
|
||||
}));
|
||||
});
|
||||
|
||||
test("should create a rate limiter that calls the limit method with the proper arguments", async () => {
|
||||
const { rateLimiter } = await import("../rate-limit");
|
||||
const limiterFunc = rateLimiter();
|
||||
const res = await limiterFunc({ identifier: "abc", opts: { cost: 1 } });
|
||||
expect(limitMock).toHaveBeenCalledWith("abc", { cost: 1 });
|
||||
expect(res).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
test("checkRateLimitAndThrowError returns okVoid when rate limit is not exceeded", async () => {
|
||||
limitMock.mockResolvedValueOnce({ success: true, limit: 5, remaining: 3, reset: 1000 });
|
||||
|
||||
const { checkRateLimitAndThrowError } = await import("../rate-limit");
|
||||
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("checkRateLimitAndThrowError returns an error when the rate limit is exceeded", async () => {
|
||||
limitMock.mockResolvedValueOnce({ success: false, limit: 5, remaining: 0, reset: 1000 });
|
||||
|
||||
const { checkRateLimitAndThrowError } = await import("../rate-limit");
|
||||
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "too_many_requests" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -92,7 +92,7 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
|
||||
});
|
||||
}
|
||||
|
||||
const surveyUrlResult = getContactSurveyLink(params.contactId, params.surveyId, 7);
|
||||
const surveyUrlResult = await getContactSurveyLink(params.contactId, params.surveyId, 7);
|
||||
|
||||
if (!surveyUrlResult.ok) {
|
||||
return handleApiError(request, surveyUrlResult.error);
|
||||
|
||||
@@ -82,11 +82,11 @@ export const GET = async (
|
||||
}
|
||||
|
||||
// Generate survey links for each contact
|
||||
const contactLinks = contacts
|
||||
.map((contact) => {
|
||||
const contactLinks = await Promise.all(
|
||||
contacts.map(async (contact) => {
|
||||
const { contactId, attributes } = contact;
|
||||
|
||||
const surveyUrlResult = getContactSurveyLink(
|
||||
const surveyUrlResult = await getContactSurveyLink(
|
||||
contactId,
|
||||
params.surveyId,
|
||||
query?.expirationDays || undefined
|
||||
@@ -107,10 +107,11 @@ export const GET = async (
|
||||
expiresAt,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
);
|
||||
|
||||
const filteredContactLinks = contactLinks.filter(Boolean);
|
||||
return responses.successResponse({
|
||||
data: contactLinks,
|
||||
data: filteredContactLinks,
|
||||
meta,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("rateLimitConfigs", () => {
|
||||
|
||||
test("should have all action configurations", () => {
|
||||
const actionConfigs = Object.keys(rateLimitConfigs.actions);
|
||||
expect(actionConfigs).toEqual(["profileUpdate", "surveyFollowUp"]);
|
||||
expect(actionConfigs).toEqual(["emailUpdate", "surveyFollowUp"]);
|
||||
});
|
||||
|
||||
test("should have all share configurations", () => {
|
||||
@@ -137,7 +137,7 @@ describe("rateLimitConfigs", () => {
|
||||
{ config: rateLimitConfigs.auth.signup, identifier: "user-signup" },
|
||||
{ config: rateLimitConfigs.api.v1, identifier: "api-v1-key" },
|
||||
{ config: rateLimitConfigs.api.v2, identifier: "api-v2-key" },
|
||||
{ config: rateLimitConfigs.actions.profileUpdate, identifier: "user-profile" },
|
||||
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
|
||||
{ config: rateLimitConfigs.share.url, identifier: "share-url" },
|
||||
];
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export const rateLimitConfigs = {
|
||||
|
||||
// Server actions - varies by action type
|
||||
actions: {
|
||||
profileUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:profile" }, // 3 per hour
|
||||
emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 per hour
|
||||
surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour
|
||||
},
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export const PricingCard = ({
|
||||
window.open(plan.href, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
className="flex justify-center bg-white">
|
||||
{t(plan.CTA ?? "common.request_pricing")}
|
||||
{plan.CTA ?? t("common.request_pricing")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export const PricingCard = ({
|
||||
setLoading(false);
|
||||
}}
|
||||
className="flex justify-center">
|
||||
{t(plan.CTA ?? "common.start_free_trial")}
|
||||
{plan.CTA ?? t("common.start_free_trial")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -138,7 +138,7 @@ export const PricingCard = ({
|
||||
plan.featured ? "text-slate-900" : "text-slate-800",
|
||||
"text-sm font-semibold leading-6"
|
||||
)}>
|
||||
{t(plan.name)}
|
||||
{plan.name}
|
||||
</h2>
|
||||
{isCurrentPlan && (
|
||||
<Badge type="success" size="normal" text={t("environments.settings.billing.current_plan")} />
|
||||
@@ -155,7 +155,7 @@ export const PricingCard = ({
|
||||
? planPeriod === "monthly"
|
||||
? plan.price.monthly
|
||||
: plan.price.yearly
|
||||
: t(plan.price.monthly)}
|
||||
: plan.price.monthly}
|
||||
</p>
|
||||
{plan.id !== projectFeatureKeys.ENTERPRISE && (
|
||||
<div className="text-sm leading-5">
|
||||
@@ -196,7 +196,7 @@ export const PricingCard = ({
|
||||
className={cn(plan.featured ? "text-brand-dark" : "text-slate-500", "h-6 w-5 flex-none")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(mainFeature)}
|
||||
{mainFeature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -215,7 +215,7 @@ export const PricingCard = ({
|
||||
open={upgradeModalOpen}
|
||||
setOpen={setUpgradeModalOpen}
|
||||
text={t("environments.settings.billing.switch_plan_confirmation_text", {
|
||||
plan: t(plan.name),
|
||||
plan: plan.name,
|
||||
price: planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly,
|
||||
period:
|
||||
planPeriod === "monthly"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import * as crypto from "@/lib/crypto";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
|
||||
import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import * as contactSurveyLink from "./contact-survey-link";
|
||||
|
||||
// Mock all modules needed (this gets hoisted to the top of the file)
|
||||
@@ -33,12 +36,22 @@ vi.mock("@/lib/crypto", () => ({
|
||||
symmetricDecrypt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/survey", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/single-use-surveys", () => ({
|
||||
generateSurveySingleUseId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Contact Survey Link", () => {
|
||||
const mockContactId = "contact-123";
|
||||
const mockSurveyId = "survey-456";
|
||||
const mockToken = "mock.jwt.token";
|
||||
const mockEncryptedContactId = "encrypted-contact-id";
|
||||
const mockEncryptedSurveyId = "encrypted-survey-id";
|
||||
const mockedGetSurvey = vi.mocked(getSurvey);
|
||||
const mockedGenerateSurveySingleUseId = vi.mocked(generateSurveySingleUseId);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -60,11 +73,17 @@ describe("Contact Survey Link", () => {
|
||||
contactId: mockEncryptedContactId,
|
||||
surveyId: mockEncryptedSurveyId,
|
||||
} as any);
|
||||
|
||||
mockedGetSurvey.mockResolvedValue({
|
||||
id: mockSurveyId,
|
||||
singleUse: { enabled: false, isEncrypted: false },
|
||||
} as TSurvey);
|
||||
mockedGenerateSurveySingleUseId.mockReturnValue("single-use-id");
|
||||
});
|
||||
|
||||
describe("getContactSurveyLink", () => {
|
||||
test("creates a survey link with encrypted contact and survey IDs", () => {
|
||||
const result = contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
test("creates a survey link with encrypted contact and survey IDs", async () => {
|
||||
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
|
||||
// Verify encryption was called for both IDs
|
||||
expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockContactId, ENCRYPTION_KEY);
|
||||
@@ -85,11 +104,13 @@ describe("Contact Survey Link", () => {
|
||||
ok: true,
|
||||
data: `${getPublicDomain()}/c/${mockToken}`,
|
||||
});
|
||||
|
||||
expect(mockedGenerateSurveySingleUseId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("adds expiration to the token when expirationDays is provided", () => {
|
||||
test("adds expiration to the token when expirationDays is provided", async () => {
|
||||
const expirationDays = 7;
|
||||
contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId, expirationDays);
|
||||
await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId, expirationDays);
|
||||
|
||||
// Verify JWT sign was called with expiration
|
||||
expect(jwt.sign).toHaveBeenCalledWith(
|
||||
@@ -102,7 +123,55 @@ describe("Contact Survey Link", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("throws an error when ENCRYPTION_KEY is not available", async () => {
|
||||
test("returns a not_found error when survey does not exist", async () => {
|
||||
mockedGetSurvey.mockResolvedValue(null as unknown as TSurvey);
|
||||
|
||||
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, "unfound-survey-id");
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "not_found",
|
||||
message: "Survey not found",
|
||||
details: [{ field: "surveyId", issue: "not_found" }],
|
||||
},
|
||||
});
|
||||
expect(mockedGetSurvey).toHaveBeenCalledWith("unfound-survey-id");
|
||||
});
|
||||
|
||||
test("creates a link with unencrypted single use ID when enabled", async () => {
|
||||
mockedGetSurvey.mockResolvedValue({
|
||||
id: mockSurveyId,
|
||||
singleUse: { enabled: true, isEncrypted: false },
|
||||
} as TSurvey);
|
||||
mockedGenerateSurveySingleUseId.mockReturnValue("suId-unencrypted");
|
||||
|
||||
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
|
||||
expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(false);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-unencrypted`,
|
||||
});
|
||||
});
|
||||
|
||||
test("creates a link with encrypted single use ID when enabled and encrypted", async () => {
|
||||
mockedGetSurvey.mockResolvedValue({
|
||||
id: mockSurveyId,
|
||||
singleUse: { enabled: true, isEncrypted: true },
|
||||
} as TSurvey);
|
||||
mockedGenerateSurveySingleUseId.mockReturnValue("suId-encrypted");
|
||||
|
||||
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
|
||||
expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(true);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-encrypted`,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns an error when ENCRYPTION_KEY is not available", async () => {
|
||||
// Reset modules so the new mock is used by the module under test
|
||||
vi.resetModules();
|
||||
// Re‑mock constants to simulate missing ENCRYPTION_KEY
|
||||
@@ -113,7 +182,7 @@ describe("Contact Survey Link", () => {
|
||||
// Re‑import the modules so they pick up the new mock
|
||||
const { getContactSurveyLink } = await import("./contact-survey-link");
|
||||
|
||||
const result = getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
const result = await getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
@@ -141,7 +210,7 @@ describe("Contact Survey Link", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("throws an error when token verification fails", () => {
|
||||
test("returns an error when token verification fails", () => {
|
||||
vi.mocked(jwt.verify).mockImplementation(() => {
|
||||
throw new Error("Token verification failed");
|
||||
});
|
||||
@@ -157,7 +226,7 @@ describe("Contact Survey Link", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("throws an error when token has invalid format", () => {
|
||||
test("returns an error when token has invalid format", () => {
|
||||
// Mock JWT.verify to return an incomplete payload
|
||||
vi.mocked(jwt.verify).mockReturnValue({
|
||||
// Missing surveyId
|
||||
@@ -178,7 +247,7 @@ describe("Contact Survey Link", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("throws an error when ENCRYPTION_KEY is not available", async () => {
|
||||
test("returns an error when ENCRYPTION_KEY is not available", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: undefined,
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
// Creates an encrypted personalized survey link for a contact
|
||||
export const getContactSurveyLink = (
|
||||
export const getContactSurveyLink = async (
|
||||
contactId: string,
|
||||
surveyId: string,
|
||||
expirationDays?: number
|
||||
): Result<string, ApiErrorResponseV2> => {
|
||||
): Promise<Result<string, ApiErrorResponseV2>> => {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
@@ -19,10 +21,27 @@ export const getContactSurveyLink = (
|
||||
});
|
||||
}
|
||||
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
message: "Survey not found",
|
||||
details: [{ field: "surveyId", issue: "not_found" }],
|
||||
});
|
||||
}
|
||||
|
||||
const { enabled: isSingleUseEnabled, isEncrypted: isSingleUseEncrypted } = survey.singleUse ?? {};
|
||||
|
||||
// Encrypt the contact and survey IDs
|
||||
const encryptedContactId = symmetricEncrypt(contactId, ENCRYPTION_KEY);
|
||||
const encryptedSurveyId = symmetricEncrypt(surveyId, ENCRYPTION_KEY);
|
||||
|
||||
let singleUseId: string | undefined;
|
||||
|
||||
if (isSingleUseEnabled) {
|
||||
singleUseId = generateSurveySingleUseId(isSingleUseEncrypted ?? false);
|
||||
}
|
||||
|
||||
// Create JWT payload with encrypted IDs
|
||||
const payload = {
|
||||
contactId: encryptedContactId,
|
||||
@@ -43,7 +62,9 @@ export const getContactSurveyLink = (
|
||||
const token = jwt.sign(payload, ENCRYPTION_KEY, tokenOptions);
|
||||
|
||||
// Return the personalized URL
|
||||
return ok(`${getPublicDomain()}/c/${token}`);
|
||||
return singleUseId
|
||||
? ok(`${getPublicDomain()}/c/${token}?suId=${singleUseId}`)
|
||||
: ok(`${getPublicDomain()}/c/${token}`);
|
||||
};
|
||||
|
||||
// Validates and decrypts a contact survey JWT token
|
||||
@@ -59,7 +80,10 @@ export const verifyContactSurveyToken = (
|
||||
|
||||
try {
|
||||
// Verify the token
|
||||
const decoded = jwt.verify(token, ENCRYPTION_KEY) as { contactId: string; surveyId: string };
|
||||
const decoded = jwt.verify(token, ENCRYPTION_KEY) as {
|
||||
contactId: string;
|
||||
surveyId: string;
|
||||
};
|
||||
|
||||
if (!decoded || !decoded.contactId || !decoded.surveyId) {
|
||||
throw err("Invalid token format");
|
||||
|
||||
@@ -501,11 +501,11 @@ export const generatePersonalLinks = async (surveyId: string, segmentId: string,
|
||||
}
|
||||
|
||||
// Generate survey links for each contact
|
||||
const contactLinks = contactsResult
|
||||
.map((contact) => {
|
||||
const contactLinks = await Promise.all(
|
||||
contactsResult.map(async (contact) => {
|
||||
const { contactId, attributes } = contact;
|
||||
|
||||
const surveyUrlResult = getContactSurveyLink(contactId, surveyId, expirationDays);
|
||||
const surveyUrlResult = await getContactSurveyLink(contactId, surveyId, expirationDays);
|
||||
|
||||
if (!surveyUrlResult.ok) {
|
||||
logger.error(
|
||||
@@ -522,7 +522,8 @@ export const generatePersonalLinks = async (surveyId: string, segmentId: string,
|
||||
expirationDays,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
);
|
||||
|
||||
return contactLinks;
|
||||
const filteredContactLinks = contactLinks.filter(Boolean);
|
||||
return filteredContactLinks;
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
{firstQuestion.choices.map((choice) =>
|
||||
firstQuestion.allowMulti ? (
|
||||
<Img
|
||||
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
|
||||
key={choice.id}
|
||||
src={choice.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
|
||||
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}>
|
||||
|
||||
@@ -11,30 +11,30 @@ interface TriggerCheckboxGroupProps {
|
||||
allowChanges: boolean;
|
||||
}
|
||||
|
||||
const triggers: {
|
||||
title: string;
|
||||
value: PipelineTriggers;
|
||||
}[] = [
|
||||
{
|
||||
title: "environments.integrations.webhooks.response_created",
|
||||
value: "responseCreated",
|
||||
},
|
||||
{
|
||||
title: "environments.integrations.webhooks.response_updated",
|
||||
value: "responseUpdated",
|
||||
},
|
||||
{
|
||||
title: "environments.integrations.webhooks.response_finished",
|
||||
value: "responseFinished",
|
||||
},
|
||||
];
|
||||
|
||||
export const TriggerCheckboxGroup: React.FC<TriggerCheckboxGroupProps> = ({
|
||||
selectedTriggers,
|
||||
onCheckboxChange,
|
||||
allowChanges,
|
||||
}) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const triggers: {
|
||||
title: string;
|
||||
value: PipelineTriggers;
|
||||
}[] = [
|
||||
{
|
||||
title: t("environments.integrations.webhooks.response_created"),
|
||||
value: "responseCreated",
|
||||
},
|
||||
{
|
||||
title: t("environments.integrations.webhooks.response_updated"),
|
||||
value: "responseUpdated",
|
||||
},
|
||||
{
|
||||
title: t("environments.integrations.webhooks.response_finished"),
|
||||
value: "responseFinished",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="mt-1 rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
@@ -58,7 +58,7 @@ export const TriggerCheckboxGroup: React.FC<TriggerCheckboxGroupProps> = ({
|
||||
}}
|
||||
disabled={!allowChanges}
|
||||
/>
|
||||
<span className="ml-2">{t(trigger.title)}</span>
|
||||
<span className="ml-2">{trigger.title}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -95,6 +95,5 @@ describe("getOrganizationAccessKeyDisplayName", () => {
|
||||
test("returns tolgee string for other keys", () => {
|
||||
const t = vi.fn((k) => k);
|
||||
expect(getOrganizationAccessKeyDisplayName("otherKey", t)).toBe("otherKey");
|
||||
expect(t).toHaveBeenCalledWith("otherKey");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,6 @@ export const getOrganizationAccessKeyDisplayName = (key: string, t: TFnType) =>
|
||||
case "accessControl":
|
||||
return t("environments.project.api_keys.access_control");
|
||||
default:
|
||||
return t(key);
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,14 +16,6 @@ import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
const placements = [
|
||||
{ name: "common.bottom_right", value: "bottomRight", disabled: false },
|
||||
{ name: "common.top_right", value: "topRight", disabled: false },
|
||||
{ name: "common.top_left", value: "topLeft", disabled: false },
|
||||
{ name: "common.bottom_left", value: "bottomLeft", disabled: false },
|
||||
{ name: "common.centered_modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
interface EditPlacementProps {
|
||||
project: Project;
|
||||
environmentId: string;
|
||||
@@ -40,6 +32,14 @@ type EditPlacementFormValues = z.infer<typeof ZProjectPlacementInput>;
|
||||
|
||||
export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const placements = [
|
||||
{ name: t("common.bottom_right"), value: "bottomRight", disabled: false },
|
||||
{ name: t("common.top_right"), value: "topRight", disabled: false },
|
||||
{ name: t("common.top_left"), value: "topLeft", disabled: false },
|
||||
{ name: t("common.bottom_left"), value: "bottomLeft", disabled: false },
|
||||
{ name: t("common.centered_modal"), value: "center", disabled: false },
|
||||
];
|
||||
const form = useForm<EditPlacementFormValues>({
|
||||
defaultValues: {
|
||||
placement: project.placement,
|
||||
@@ -102,7 +102,7 @@ export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) =
|
||||
<Label
|
||||
htmlFor={placement.value}
|
||||
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
|
||||
{t(placement.name)}
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -12,16 +12,16 @@ import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group"
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
const placements = [
|
||||
{ name: "common.bottom_right", value: "bottomRight", disabled: false },
|
||||
{ name: "common.top_right", value: "topRight", disabled: false },
|
||||
{ name: "common.top_left", value: "topLeft", disabled: false },
|
||||
{ name: "common.bottom_left", value: "bottomLeft", disabled: false },
|
||||
{ name: "common.centered_modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
export const ProjectLookSettingsLoading = () => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const placements = [
|
||||
{ name: t("common.bottom_right"), value: "bottomRight", disabled: false },
|
||||
{ name: t("common.top_right"), value: "topRight", disabled: false },
|
||||
{ name: t("common.top_left"), value: "topLeft", disabled: false },
|
||||
{ name: t("common.bottom_left"), value: "bottomLeft", disabled: false },
|
||||
{ name: t("common.centered_modal"), value: "center", disabled: false },
|
||||
];
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.project_configuration")}>
|
||||
@@ -140,7 +140,7 @@ export const ProjectLookSettingsLoading = () => {
|
||||
className={cn(
|
||||
placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900"
|
||||
)}>
|
||||
{t(placement.name)}
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -62,7 +62,7 @@ export const TemplateFilters = ({
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:outline-none focus:ring-0",
|
||||
"rounded border border-slate-800 px-2 py-1 text-xs transition-all duration-150"
|
||||
)}>
|
||||
{t(filter.label)}
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ const getChannelTag = (channels: NonNullabeChannel[] | undefined, t: TFnType): s
|
||||
const labels = channels
|
||||
.map((channel) => {
|
||||
const label = getLabel(channel);
|
||||
if (label) return t(label);
|
||||
if (label) return label;
|
||||
return undefined;
|
||||
})
|
||||
.filter((label): label is string => !!label)
|
||||
@@ -78,12 +78,12 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) =>
|
||||
// if user selects an industry e.g. eCommerce than the tag should not say "Multiple industries" anymore but "E-Commerce".
|
||||
if (selectedFilter[1] !== null) {
|
||||
const industry = getIndustryMapping(t).find((industry) => industry.value === selectedFilter[1]);
|
||||
if (industry) return t(industry.label);
|
||||
if (industry) return industry.label;
|
||||
}
|
||||
if (!industries || industries.length === 0) return undefined;
|
||||
return industries.length > 1
|
||||
? t("environments.surveys.templates.multiple_industries")
|
||||
: t(getIndustryMapping(t).find((industry) => industry.value === industries[0])?.label ?? "");
|
||||
: getIndustryMapping(t).find((industry) => industry.value === industries[0])?.label;
|
||||
};
|
||||
|
||||
const industryTag = useMemo(
|
||||
@@ -93,7 +93,7 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) =>
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className={cn("rounded border px-1.5 py-0.5 text-xs", roleBasedStyling)}>{t(roleTag ?? "")}</div>
|
||||
<div className={cn("rounded border px-1.5 py-0.5 text-xs", roleBasedStyling)}>{roleTag}</div>
|
||||
{industryTag && (
|
||||
<div
|
||||
className={cn("rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 text-xs text-slate-500")}>
|
||||
|
||||
@@ -42,7 +42,7 @@ export const Placement = ({
|
||||
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id={placement.value} value={placement.value} disabled={placement.disabled} />
|
||||
<Label htmlFor={placement.value} className="text-slate-900">
|
||||
{t(placement.name)}
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -166,9 +166,9 @@ export const RecontactOptionsCard = ({
|
||||
className="aria-checked:border-brand-dark mx-5 disabled:border-slate-400 aria-checked:border-2"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-700">{t(option.name)}</p>
|
||||
<p className="font-semibold text-slate-700">{option.name}</p>
|
||||
|
||||
<p className="mt-2 text-xs font-normal text-slate-600">{t(option.description)}</p>
|
||||
<p className="mt-2 text-xs font-normal text-slate-600">{option.description}</p>
|
||||
</div>
|
||||
</Label>
|
||||
{option.id === "displaySome" && localSurvey.displayOption === "displaySome" && (
|
||||
|
||||
@@ -7,12 +7,10 @@ import { DatePicker } from "@/modules/ui/components/date-picker";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Slider } from "@/modules/ui/components/slider";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowUpRight, CheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { KeyboardEventHandler, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -47,12 +45,6 @@ export const ResponseOptionsCard = ({
|
||||
subheading: t("environments.surveys.edit.survey_completed_subheading"),
|
||||
});
|
||||
|
||||
const [singleUseMessage, setSingleUseMessage] = useState({
|
||||
heading: t("environments.surveys.edit.survey_already_answered_heading"),
|
||||
subheading: t("environments.surveys.edit.survey_already_answered_subheading"),
|
||||
});
|
||||
|
||||
const [singleUseEncryption, setSingleUseEncryption] = useState(true);
|
||||
const [runOnDate, setRunOnDate] = useState<Date | null>(null);
|
||||
const [closeOnDate, setCloseOnDate] = useState<Date | null>(null);
|
||||
const [recaptchaThreshold, setRecaptchaThreshold] = useState<number>(localSurvey.recaptcha?.threshold ?? 0);
|
||||
@@ -163,53 +155,6 @@ export const ResponseOptionsCard = ({
|
||||
setLocalSurvey({ ...localSurvey, surveyClosedMessage: message });
|
||||
};
|
||||
|
||||
const handleSingleUseSurveyToggle = () => {
|
||||
if (!localSurvey.singleUse?.enabled) {
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
singleUse: { enabled: true, ...singleUseMessage, isEncrypted: singleUseEncryption },
|
||||
});
|
||||
} else {
|
||||
setLocalSurvey({ ...localSurvey, singleUse: { enabled: false, isEncrypted: false } });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSingleUseSurveyMessageChange = ({
|
||||
heading,
|
||||
subheading,
|
||||
}: {
|
||||
heading?: string;
|
||||
subheading?: string;
|
||||
}) => {
|
||||
const message = {
|
||||
heading: heading ?? singleUseMessage.heading,
|
||||
subheading: subheading ?? singleUseMessage.subheading,
|
||||
};
|
||||
|
||||
const localSurveySingleUseEnabled = localSurvey.singleUse?.enabled ?? false;
|
||||
setSingleUseMessage(message);
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
singleUse: { enabled: localSurveySingleUseEnabled, ...message, isEncrypted: singleUseEncryption },
|
||||
});
|
||||
};
|
||||
|
||||
const hangleSingleUseEncryptionToggle = () => {
|
||||
if (!singleUseEncryption) {
|
||||
setSingleUseEncryption(true);
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
singleUse: { enabled: true, ...singleUseMessage, isEncrypted: true },
|
||||
});
|
||||
} else {
|
||||
setSingleUseEncryption(false);
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
singleUse: { enabled: true, ...singleUseMessage, isEncrypted: false },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleHideBackButtonToggle = () => {
|
||||
setLocalSurvey({ ...localSurvey, isBackButtonHidden: !localSurvey.isBackButtonHidden });
|
||||
};
|
||||
@@ -223,14 +168,6 @@ export const ResponseOptionsCard = ({
|
||||
setSurveyClosedMessageToggle(true);
|
||||
}
|
||||
|
||||
if (localSurvey.singleUse?.enabled) {
|
||||
setSingleUseMessage({
|
||||
heading: localSurvey.singleUse.heading ?? singleUseMessage.heading,
|
||||
subheading: localSurvey.singleUse.subheading ?? singleUseMessage.subheading,
|
||||
});
|
||||
setSingleUseEncryption(localSurvey.singleUse.isEncrypted);
|
||||
}
|
||||
|
||||
if (localSurvey.runOnDate) {
|
||||
setRunOnDate(localSurvey.runOnDate);
|
||||
setRunOnDateToggle(true);
|
||||
@@ -240,13 +177,7 @@ export const ResponseOptionsCard = ({
|
||||
setCloseOnDate(localSurvey.closeOnDate);
|
||||
setCloseOnDateToggle(true);
|
||||
}
|
||||
}, [
|
||||
localSurvey,
|
||||
singleUseMessage.heading,
|
||||
singleUseMessage.subheading,
|
||||
surveyClosedMessage.heading,
|
||||
surveyClosedMessage.subheading,
|
||||
]);
|
||||
}, [localSurvey, surveyClosedMessage.heading, surveyClosedMessage.subheading]);
|
||||
|
||||
const toggleAutocomplete = () => {
|
||||
if (autoComplete) {
|
||||
@@ -471,80 +402,6 @@ export const ResponseOptionsCard = ({
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
{/* Single User Survey Options */}
|
||||
<AdvancedOptionToggle
|
||||
htmlId="singleUserSurveyOptions"
|
||||
isChecked={!!localSurvey.singleUse?.enabled}
|
||||
onToggle={handleSingleUseSurveyToggle}
|
||||
title={t("environments.surveys.edit.single_use_survey_links")}
|
||||
description={t("environments.surveys.edit.single_use_survey_links_description")}
|
||||
childBorder={true}>
|
||||
<div className="flex w-full items-center space-x-1 p-4 pb-4">
|
||||
<div className="w-full cursor-pointer items-center bg-slate-50">
|
||||
<div className="row mb-2 flex cursor-default items-center space-x-2">
|
||||
<Label htmlFor="howItWorks">{t("environments.surveys.edit.how_it_works")}</Label>
|
||||
</div>
|
||||
<ul className="mb-3 ml-4 cursor-default list-inside list-disc space-y-1">
|
||||
<li className="text-sm text-slate-600">
|
||||
{t(
|
||||
"environments.surveys.edit.blocks_survey_if_the_survey_url_has_no_single_use_id_suid"
|
||||
)}
|
||||
</li>
|
||||
<li className="text-sm text-slate-600">
|
||||
{t(
|
||||
"environments.surveys.edit.blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already"
|
||||
)}
|
||||
</li>
|
||||
<li className="text-sm text-slate-600">
|
||||
<Link
|
||||
href="https://formbricks.com/docs/link-surveys/single-use-links"
|
||||
target="_blank"
|
||||
className="underline">
|
||||
{t("common.read_docs")} <ArrowUpRight className="inline" size={16} />
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<Label htmlFor="headline">{t("environments.surveys.edit.link_used_message")}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
id="heading"
|
||||
className="mb-4 mt-2 bg-white"
|
||||
name="heading"
|
||||
value={singleUseMessage.heading}
|
||||
onChange={(e) => handleSingleUseSurveyMessageChange({ heading: e.target.value })}
|
||||
/>
|
||||
|
||||
<Label htmlFor="headline">{t("environments.surveys.edit.subheading")}</Label>
|
||||
<Input
|
||||
className="mb-4 mt-2 bg-white"
|
||||
id="subheading"
|
||||
name="subheading"
|
||||
value={singleUseMessage.subheading}
|
||||
onChange={(e) => handleSingleUseSurveyMessageChange({ subheading: e.target.value })}
|
||||
/>
|
||||
<Label htmlFor="headline">{t("environments.surveys.edit.url_encryption")}</Label>
|
||||
<div>
|
||||
<div className="mt-2 flex items-center space-x-1">
|
||||
<Switch
|
||||
id="encryption-switch"
|
||||
checked={singleUseEncryption}
|
||||
onCheckedChange={hangleSingleUseEncryptionToggle}
|
||||
/>
|
||||
<Label htmlFor="encryption-label">
|
||||
<div className="ml-2">
|
||||
<p className="text-sm font-normal text-slate-600">
|
||||
{t(
|
||||
"environments.surveys.edit.enable_encryption_of_single_use_id_suid_in_survey_url"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
{/* Verify Email Section */}
|
||||
<AdvancedOptionToggle
|
||||
htmlId="verifyEmailBeforeSubmission"
|
||||
@@ -563,6 +420,8 @@ export const ResponseOptionsCard = ({
|
||||
/>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
{/* Protect Survey with Pin */}
|
||||
<AdvancedOptionToggle
|
||||
htmlId="protectSurveyWithPin"
|
||||
isChecked={isPinProtectionEnabled}
|
||||
@@ -585,6 +444,7 @@ export const ResponseOptionsCard = ({
|
||||
defaultValue={localSurvey.pin ? localSurvey.pin : undefined}
|
||||
onKeyDown={handleSurveyPinInputKeyDown}
|
||||
onChange={(e) => handleProtectSurveyPinChange(e.target.value)}
|
||||
maxLength={4}
|
||||
/>
|
||||
{verifyProtectWithPinError && (
|
||||
<p className="pt-1 text-sm text-red-700">{verifyProtectWithPinError}</p>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { sendFollowUpEmail } from "@/modules/survey/follow-ups/lib/email";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { FollowUpResult, FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
@@ -16,11 +17,6 @@ import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
const limiter = rateLimit({
|
||||
interval: 60 * 60, // 1 hour
|
||||
allowedPerInterval: 50, // max 50 calls per org per hour
|
||||
});
|
||||
|
||||
const evaluateFollowUp = async (
|
||||
followUp: TSurveyFollowUp,
|
||||
survey: TSurvey,
|
||||
@@ -191,7 +187,7 @@ export const sendFollowUpsForResponse = async (
|
||||
|
||||
// Check rate limit
|
||||
try {
|
||||
await limiter(organization.id);
|
||||
await applyRateLimit(rateLimitConfigs.actions.surveyFollowUp, organization.id);
|
||||
} catch {
|
||||
return err({
|
||||
code: FollowUpSendError.RATE_LIMIT_EXCEEDED,
|
||||
|
||||
@@ -14,7 +14,7 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList) => {
|
||||
if (survey.singleUse?.enabled) {
|
||||
const response = await generateSingleUseIdsAction({
|
||||
surveyId: survey.id,
|
||||
isEncrypted: !!survey.singleUse?.isEncrypted,
|
||||
isEncrypted: Boolean(survey.singleUse?.isEncrypted),
|
||||
count: 1,
|
||||
});
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export const LinkSurvey = ({
|
||||
|
||||
const prefillValue = getPrefillValue(survey, searchParams, languageCode);
|
||||
|
||||
const [autoFocus, setAutofocus] = useState(false);
|
||||
const [autoFocus, setAutoFocus] = useState(false);
|
||||
const hasFinishedSingleUseResponse = useMemo(() => {
|
||||
if (singleUseResponse?.finished) {
|
||||
return true;
|
||||
@@ -91,7 +91,7 @@ export const LinkSurvey = ({
|
||||
// Not in an iframe, enable autofocus on input fields.
|
||||
useEffect(() => {
|
||||
if (window.self === window.top) {
|
||||
setAutofocus(true);
|
||||
setAutoFocus(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run once
|
||||
}, []);
|
||||
@@ -121,7 +121,7 @@ export const LinkSurvey = ({
|
||||
return <SurveyLinkUsed singleUseMessage={survey.singleUse} project={project} />;
|
||||
}
|
||||
|
||||
if (survey.isVerifyEmailEnabled && emailVerificationStatus !== "verified") {
|
||||
if (survey.isVerifyEmailEnabled && emailVerificationStatus !== "verified" && !isPreview) {
|
||||
if (emailVerificationStatus === "fishy") {
|
||||
return (
|
||||
<VerifyEmail
|
||||
|
||||
@@ -64,7 +64,7 @@ export const renderSurvey = async ({
|
||||
return (
|
||||
<SurveyInactive
|
||||
status={survey.status}
|
||||
surveyClosedMessage={survey.surveyClosedMessage ? survey.surveyClosedMessage : undefined}
|
||||
surveyClosedMessage={survey.surveyClosedMessage ?? undefined}
|
||||
project={project || undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
|
||||
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
|
||||
import { getExistingContactResponse } from "@/modules/survey/link/lib/data";
|
||||
import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
|
||||
import { getBasicSurveyMetadata } from "@/modules/survey/link/lib/metadata-utils";
|
||||
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
@@ -14,6 +15,7 @@ interface ContactSurveyPageProps {
|
||||
jwt: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
suId?: string;
|
||||
verify?: string;
|
||||
lang?: string;
|
||||
embed?: string;
|
||||
@@ -46,9 +48,10 @@ export const generateMetadata = async (props: ContactSurveyPageProps): Promise<M
|
||||
export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
|
||||
const t = await getTranslate();
|
||||
const { jwt } = params;
|
||||
const { preview } = searchParams;
|
||||
const { preview, suId } = searchParams;
|
||||
|
||||
const result = verifyContactSurveyToken(jwt);
|
||||
if (!result.ok) {
|
||||
@@ -62,6 +65,7 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
|
||||
// So we show SurveyInactive without project data (shows branding by default for backward compatibility)
|
||||
return <SurveyInactive status="link invalid" />;
|
||||
}
|
||||
|
||||
const { surveyId, contactId } = result.data;
|
||||
|
||||
const existingResponse = await getExistingContactResponse(surveyId, contactId)();
|
||||
@@ -81,10 +85,26 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isSingleUseSurvey = survey?.singleUse?.enabled;
|
||||
const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted;
|
||||
|
||||
let singleUseId: string | undefined = undefined;
|
||||
|
||||
if (isSingleUseSurvey) {
|
||||
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
|
||||
if (!validatedSingleUseId) {
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
|
||||
}
|
||||
|
||||
singleUseId = validatedSingleUseId;
|
||||
}
|
||||
|
||||
return renderSurvey({
|
||||
survey,
|
||||
searchParams,
|
||||
contactId,
|
||||
isPreview,
|
||||
singleUseId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
|
||||
import { verifyTokenForLinkSurvey } from "@/lib/jwt";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { getEmailVerificationDetails } from "./helper";
|
||||
import { checkAndValidateSingleUseId, getEmailVerificationDetails } from "./helper";
|
||||
|
||||
vi.mock("@/lib/jwt", () => ({
|
||||
verifyTokenForLinkSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/singleUseSurveys", () => ({
|
||||
validateSurveySingleUseId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getEmailVerificationDetails", () => {
|
||||
const mockedVerifyTokenForLinkSurvey = vi.mocked(verifyTokenForLinkSurvey);
|
||||
const testSurveyId = "survey-123";
|
||||
@@ -54,3 +59,82 @@ describe("getEmailVerificationDetails", () => {
|
||||
expect(mockedVerifyTokenForLinkSurvey).toHaveBeenCalledWith(testToken, testSurveyId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAndValidateSingleUseId", () => {
|
||||
const mockedValidateSurveySingleUseId = vi.mocked(validateSurveySingleUseId);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("returns null when no suid is provided", () => {
|
||||
const result = checkAndValidateSingleUseId();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns null when suid is empty string", () => {
|
||||
const result = checkAndValidateSingleUseId("");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns suid as-is when isEncrypted is false", () => {
|
||||
const testSuid = "plain-suid-123";
|
||||
const result = checkAndValidateSingleUseId(testSuid, false);
|
||||
|
||||
expect(result).toBe(testSuid);
|
||||
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns suid as-is when isEncrypted is not provided (defaults to false)", () => {
|
||||
const testSuid = "plain-suid-123";
|
||||
const result = checkAndValidateSingleUseId(testSuid);
|
||||
|
||||
expect(result).toBe(testSuid);
|
||||
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns validated suid when isEncrypted is true and validation succeeds", () => {
|
||||
const encryptedSuid = "encrypted-suid-123";
|
||||
const validatedSuid = "validated-suid-456";
|
||||
mockedValidateSurveySingleUseId.mockReturnValueOnce(validatedSuid);
|
||||
|
||||
const result = checkAndValidateSingleUseId(encryptedSuid, true);
|
||||
|
||||
expect(result).toBe(validatedSuid);
|
||||
expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid);
|
||||
});
|
||||
|
||||
test("returns null when isEncrypted is true and validation returns undefined", () => {
|
||||
const encryptedSuid = "invalid-encrypted-suid";
|
||||
mockedValidateSurveySingleUseId.mockReturnValueOnce(undefined);
|
||||
|
||||
const result = checkAndValidateSingleUseId(encryptedSuid, true);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid);
|
||||
});
|
||||
|
||||
test("returns null when isEncrypted is true and validation returns empty string", () => {
|
||||
const encryptedSuid = "invalid-encrypted-suid";
|
||||
mockedValidateSurveySingleUseId.mockReturnValueOnce("");
|
||||
|
||||
const result = checkAndValidateSingleUseId(encryptedSuid, true);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid);
|
||||
});
|
||||
|
||||
test("returns null when isEncrypted is true and validation returns null", () => {
|
||||
const encryptedSuid = "invalid-encrypted-suid";
|
||||
mockedValidateSurveySingleUseId.mockReturnValueOnce(null as any);
|
||||
|
||||
const result = checkAndValidateSingleUseId(encryptedSuid, true);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
|
||||
import { verifyTokenForLinkSurvey } from "@/lib/jwt";
|
||||
|
||||
interface emailVerificationDetails {
|
||||
@@ -25,3 +26,15 @@ export const getEmailVerificationDetails = async (
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const checkAndValidateSingleUseId = (suid?: string, isEncrypted = false): string | null => {
|
||||
if (!suid?.trim()) return null;
|
||||
|
||||
if (isEncrypted) {
|
||||
const validatedSingleUseId = validateSurveySingleUseId(suid);
|
||||
if (!validatedSingleUseId) return null;
|
||||
return validatedSingleUseId;
|
||||
}
|
||||
|
||||
return suid;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,16 @@ import { logger } from "@formbricks/logger";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { LinkSurveyPage, generateMetadata } from "./page";
|
||||
|
||||
// Mock server-side constants to prevent client-side access
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_RECAPTCHA_CONFIGURED: false,
|
||||
RECAPTCHA_SITE_KEY: "test-key",
|
||||
IMPRINT_URL: "https://example.com/imprint",
|
||||
PRIVACY_URL: "https://example.com/privacy",
|
||||
ENCRYPTION_KEY: "0".repeat(32),
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: vi.fn(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
|
||||
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
|
||||
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
|
||||
import { getResponseBySingleUseId, getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
|
||||
import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
|
||||
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
|
||||
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
|
||||
import type { Metadata } from "next";
|
||||
@@ -60,23 +60,13 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
|
||||
let singleUseId: string | undefined = undefined;
|
||||
|
||||
if (isSingleUseSurvey) {
|
||||
// check if the single use id is present for single use surveys
|
||||
if (!suId) {
|
||||
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
|
||||
if (!validatedSingleUseId) {
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
|
||||
}
|
||||
|
||||
// if encryption is enabled, validate the single use id
|
||||
let validatedSingleUseId: string | undefined = undefined;
|
||||
if (isSingleUseSurveyEncrypted) {
|
||||
validatedSingleUseId = validateSurveySingleUseId(suId);
|
||||
if (!validatedSingleUseId) {
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
|
||||
}
|
||||
}
|
||||
// if encryption is disabled, use the suId as is
|
||||
singleUseId = validatedSingleUseId ?? suId;
|
||||
singleUseId = validatedSingleUseId;
|
||||
}
|
||||
|
||||
let singleUseResponse;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { DropdownMenuItem } from "@/modules/ui/components/dropdown-menu";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
|
||||
import { TSortOption } from "@formbricks/types/surveys/types";
|
||||
import { SortOption } from "./sort-option";
|
||||
|
||||
// Mock dependencies
|
||||
@@ -21,7 +20,7 @@ vi.mock("@tolgee/react", () => ({
|
||||
describe("SortOption", () => {
|
||||
const mockOption: TSortOption = {
|
||||
label: "test.sort.option",
|
||||
value: "testValue",
|
||||
value: "createdAt",
|
||||
};
|
||||
|
||||
const mockHandleSortChange = vi.fn();
|
||||
@@ -32,14 +31,14 @@ describe("SortOption", () => {
|
||||
});
|
||||
|
||||
test("renders correctly with the option label", () => {
|
||||
render(<SortOption option={mockOption} sortBy="otherValue" handleSortChange={mockHandleSortChange} />);
|
||||
render(<SortOption option={mockOption} sortBy="createdAt" handleSortChange={mockHandleSortChange} />);
|
||||
|
||||
expect(screen.getByText("test.sort.option")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-menu-item")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("applies correct styling when option is selected", () => {
|
||||
render(<SortOption option={mockOption} sortBy="testValue" handleSortChange={mockHandleSortChange} />);
|
||||
render(<SortOption option={mockOption} sortBy="createdAt" handleSortChange={mockHandleSortChange} />);
|
||||
|
||||
const circleIndicator = screen.getByTestId("dropdown-menu-item").querySelector("span");
|
||||
expect(circleIndicator).toHaveClass("bg-brand-dark");
|
||||
@@ -47,11 +46,10 @@ describe("SortOption", () => {
|
||||
});
|
||||
|
||||
test("applies correct styling when option is not selected", () => {
|
||||
render(
|
||||
<SortOption option={mockOption} sortBy="differentValue" handleSortChange={mockHandleSortChange} />
|
||||
);
|
||||
render(<SortOption option={mockOption} sortBy="updatedAt" handleSortChange={mockHandleSortChange} />);
|
||||
|
||||
const circleIndicator = screen.getByTestId("dropdown-menu-item").querySelector("span");
|
||||
expect(circleIndicator).toHaveClass("border-white");
|
||||
expect(circleIndicator).not.toHaveClass("bg-brand-dark");
|
||||
expect(circleIndicator).not.toHaveClass("outline-brand-dark");
|
||||
});
|
||||
@@ -59,7 +57,7 @@ describe("SortOption", () => {
|
||||
test("calls handleSortChange when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SortOption option={mockOption} sortBy="otherValue" handleSortChange={mockHandleSortChange} />);
|
||||
render(<SortOption option={mockOption} sortBy="createdAt" handleSortChange={mockHandleSortChange} />);
|
||||
|
||||
await user.click(screen.getByTestId("dropdown-menu-item"));
|
||||
expect(mockHandleSortChange).toHaveBeenCalledTimes(1);
|
||||
@@ -67,7 +65,7 @@ describe("SortOption", () => {
|
||||
});
|
||||
|
||||
test("translates the option label", () => {
|
||||
render(<SortOption option={mockOption} sortBy="otherValue" handleSortChange={mockHandleSortChange} />);
|
||||
render(<SortOption option={mockOption} sortBy="createdAt" handleSortChange={mockHandleSortChange} />);
|
||||
|
||||
// The mock for useTranslate returns the key itself, so we're checking if translation was attempted
|
||||
expect(screen.getByText(mockOption.label)).toBeInTheDocument();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { DropdownMenuItem } from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface SortOptionProps {
|
||||
@@ -11,7 +10,6 @@ interface SortOptionProps {
|
||||
}
|
||||
|
||||
export const SortOption = ({ option, sortBy, handleSortChange }: SortOptionProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.label}
|
||||
@@ -22,7 +20,7 @@ export const SortOption = ({ option, sortBy, handleSortChange }: SortOptionProps
|
||||
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
|
||||
<span
|
||||
className={`h-4 w-4 rounded-full border ${sortBy === option.value ? "bg-brand-dark outline-brand-dark border-slate-900 outline" : "border-white"}`}></span>
|
||||
<p className="font-normal text-white">{t(option.label)}</p>
|
||||
<p className="font-normal text-white">{option.label}</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -219,16 +219,18 @@ export const SurveyDropDownMenu = ({
|
||||
{t("common.preview_survey")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="copy-link"
|
||||
className="flex w-full items-center"
|
||||
onClick={async (e) => handleCopyLink(e)}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.copy_link")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
{!survey.singleUse?.enabled && (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="copy-link"
|
||||
className="flex w-full items-center"
|
||||
onClick={async (e) => handleCopyLink(e)}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.copy_link")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isSurveyCreationDeletionDisabled && (
|
||||
|
||||
@@ -4,11 +4,6 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TFilterOption } from "@formbricks/types/surveys/types";
|
||||
import { SurveyFilterDropdown } from "./survey-filter-dropdown";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/checkbox", () => ({
|
||||
Checkbox: ({ checked, className }) => (
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { TFilterOption } from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -30,7 +29,6 @@ export const SurveyFilterDropdown = ({
|
||||
isOpen,
|
||||
toggleDropdown,
|
||||
}: SurveyFilterDropdownProps) => {
|
||||
const { t } = useTranslate();
|
||||
const triggerClasses = `surveyFilterDropdown min-w-auto h-8 rounded-md border border-slate-700 sm:px-2 cursor-pointer outline-none
|
||||
${selectedOptions.length > 0 ? "bg-slate-900 text-white" : "hover:bg-slate-900"}`;
|
||||
|
||||
@@ -56,7 +54,7 @@ export const SurveyFilterDropdown = ({
|
||||
checked={selectedOptions.includes(option.value)}
|
||||
className={`bg-white ${selectedOptions.includes(option.value) ? "bg-brand-dark border-none" : ""}`}
|
||||
/>
|
||||
<p className="font-normal text-white">{t(option.label)}</p>
|
||||
<p className="font-normal text-white">{option.label}</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
@@ -183,7 +183,7 @@ export const SurveyFilters = ({
|
||||
<span className="text-sm">
|
||||
{t("common.sort_by")}:{" "}
|
||||
{getSortOptions(t).find((option) => option.value === sortBy)
|
||||
? t(getSortOptions(t).find((option) => option.value === sortBy)?.label ?? "")
|
||||
? getSortOptions(t).find((option) => option.value === sortBy)?.label
|
||||
: ""}
|
||||
</span>
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4" />
|
||||
|
||||
@@ -19,9 +19,13 @@ const meta: Meta<StoryProps> = {
|
||||
component: Alert,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
controls: {
|
||||
sort: "requiredFirst",
|
||||
exclude: [],
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The **Alert** component displays important messages to users with various styles and optional actions. It supports different variants for different message types and can include icons and buttons.",
|
||||
},
|
||||
},
|
||||
},
|
||||
// These argTypes are for story controls, not component props
|
||||
@@ -61,10 +65,10 @@ const meta: Meta<StoryProps> = {
|
||||
control: "boolean",
|
||||
description: "Whether to show action buttons",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
},
|
||||
order: 4,
|
||||
order: 1,
|
||||
},
|
||||
title: {
|
||||
control: "text",
|
||||
@@ -91,7 +95,7 @@ const meta: Meta<StoryProps> = {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 2,
|
||||
order: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -160,7 +164,7 @@ export const Small: Story = {
|
||||
};
|
||||
|
||||
// With custom icon
|
||||
export const withButtonAndIcon: Story = {
|
||||
export const WithButtonAndIcon: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "default",
|
||||
@@ -170,6 +174,13 @@ export const withButtonAndIcon: Story = {
|
||||
showButton: true,
|
||||
actionButtonText: "Learn more",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you need both visual emphasis and actionable content.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Error variant
|
||||
@@ -186,7 +197,7 @@ export const Destructive: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Only use if the user needs to take immediate action or there is a critical error.",
|
||||
story: "Use for critical errors that need immediate attention or action.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -206,7 +217,7 @@ export const Warning: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this to make the user aware of potential issues.",
|
||||
story: "Use to make the user aware of potential issues or risks.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -226,7 +237,7 @@ export const Info: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this to give contextual information and support the user.",
|
||||
story: "Use to give contextual information and support the user.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -246,7 +257,46 @@ export const Success: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this to give positive feedback.",
|
||||
story: "Use to give positive feedback and confirm successful actions.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "info",
|
||||
title: "Information",
|
||||
description: "This alert has an icon for better visual hierarchy.",
|
||||
showIcon: true,
|
||||
showButton: false,
|
||||
actionButtonText: "",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use icons to improve visual hierarchy and message clarity.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "warning",
|
||||
title: "Long Alert Title That Might Wrap to Multiple Lines",
|
||||
description:
|
||||
"This is a very long alert description that demonstrates how the alert component handles longer content. It should wrap gracefully and maintain proper spacing and readability even with extensive text content.",
|
||||
showIcon: true,
|
||||
showButton: true,
|
||||
actionButtonText: "Acknowledge",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows how the alert handles longer content with proper text wrapping.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Badge } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/Badge",
|
||||
component: Badge,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
type: {
|
||||
control: "select",
|
||||
options: ["warning", "success", "error", "gray"],
|
||||
},
|
||||
size: { control: "select", options: ["small", "normal", "large"] },
|
||||
className: { control: "text" },
|
||||
},
|
||||
} satisfies Meta<typeof Badge>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
text: "Warning",
|
||||
type: "warning",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
text: "Success",
|
||||
type: "success",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
text: "Error",
|
||||
type: "error",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const Gray: Story = {
|
||||
args: {
|
||||
text: "Gray",
|
||||
type: "gray",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeWarning: Story = {
|
||||
args: {
|
||||
text: "Warning",
|
||||
type: "warning",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeSuccess: Story = {
|
||||
args: {
|
||||
text: "Success",
|
||||
type: "success",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeError: Story = {
|
||||
args: {
|
||||
text: "Error",
|
||||
type: "error",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeGray: Story = {
|
||||
args: {
|
||||
text: "Gray",
|
||||
type: "gray",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinyWarning: Story = {
|
||||
args: {
|
||||
text: "Warning",
|
||||
type: "warning",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinySuccess: Story = {
|
||||
args: {
|
||||
text: "Success",
|
||||
type: "success",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinyError: Story = {
|
||||
args: {
|
||||
text: "Error",
|
||||
type: "error",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinyGray: Story = {
|
||||
args: {
|
||||
text: "Gray",
|
||||
type: "gray",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
217
apps/web/modules/ui/components/badge/stories.tsx
Normal file
217
apps/web/modules/ui/components/badge/stories.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Badge } from "./index";
|
||||
|
||||
const meta: Meta<typeof Badge> = {
|
||||
title: "UI/Badge",
|
||||
component: Badge,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The **Badge** component displays small status indicators or labels with different colors and sizes. Use it to highlight important information or show status states.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
type: {
|
||||
control: "select",
|
||||
options: ["warning", "success", "error", "gray"],
|
||||
description: "Color variant of the badge",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "gray" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
size: {
|
||||
control: "select",
|
||||
options: ["tiny", "normal", "large"],
|
||||
description: "Size of the badge",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "normal" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
role: {
|
||||
control: "text",
|
||||
description: "Accessibility role attribute",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
text: {
|
||||
control: "text",
|
||||
description: "Badge content text",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Badge>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
text: "Badge",
|
||||
type: "gray",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
text: "Warning",
|
||||
type: "warning",
|
||||
size: "normal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for warnings or actions that need attention.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
text: "Success",
|
||||
type: "success",
|
||||
size: "normal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use to indicate successful operations or positive states.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Destructive: Story = {
|
||||
args: {
|
||||
text: "Error",
|
||||
type: "error",
|
||||
size: "normal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for errors or failed operations.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Gray: Story = {
|
||||
args: {
|
||||
text: "Gray",
|
||||
type: "gray",
|
||||
size: "normal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for neutral information or inactive states.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Tiny: Story = {
|
||||
args: {
|
||||
text: "Tiny",
|
||||
type: "gray",
|
||||
size: "tiny",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when space is very limited or for subtle indicators.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
text: "Large",
|
||||
type: "gray",
|
||||
size: "large",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for prominent badges or when more visibility is needed.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongText: Story = {
|
||||
args: {
|
||||
text: "Very Long Badge Text",
|
||||
type: "warning",
|
||||
size: "normal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Badge handles longer text content gracefully.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
args: {
|
||||
text: "Custom",
|
||||
type: "gray",
|
||||
size: "normal",
|
||||
className: "bg-purple-100 border-purple-200 text-purple-800",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "You can override the default styling with custom CSS classes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRole: Story = {
|
||||
args: {
|
||||
text: "Status",
|
||||
type: "success",
|
||||
size: "normal",
|
||||
role: "status",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use role attribute for better accessibility, especially for dynamic status updates.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { Button } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
|
||||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["outline", "default", "secondary", "ghost", "destructive", "link"],
|
||||
},
|
||||
size: { control: "select", options: ["sm", "lg", "fab", "icon"] },
|
||||
},
|
||||
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
|
||||
args: { onClick: fn() },
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "default",
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "secondary",
|
||||
},
|
||||
};
|
||||
|
||||
export const Minimal: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "ghost",
|
||||
},
|
||||
};
|
||||
|
||||
export const Warn: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "destructive",
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "default",
|
||||
loading: true,
|
||||
},
|
||||
};
|
||||
253
apps/web/modules/ui/components/button/stories.tsx
Normal file
253
apps/web/modules/ui/components/button/stories.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { Button } from "./index";
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: "UI/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The **Button** component provides clickable actions with multiple variants and sizes. It supports loading states, different visual styles, and can be used as a child component wrapper.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
loading: {
|
||||
control: "boolean",
|
||||
description: "Shows loading spinner and disables interaction",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
description: "Disables the button interaction",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
asChild: {
|
||||
control: "boolean",
|
||||
description: "Render as a child component using Slot",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
onClick: {
|
||||
action: "clicked",
|
||||
description: "Click handler function",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "function" },
|
||||
},
|
||||
order: 4,
|
||||
},
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["default", "destructive", "outline", "secondary", "ghost", "link"],
|
||||
description: "Visual style variant of the button",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
size: {
|
||||
control: "select",
|
||||
options: ["default", "sm", "lg", "icon"],
|
||||
description: "Size of the button",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
children: {
|
||||
control: "text",
|
||||
description: "Button content",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "React.ReactNode" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
args: { onClick: fn() },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "default",
|
||||
},
|
||||
};
|
||||
|
||||
export const Destructive: Story = {
|
||||
args: {
|
||||
children: "Delete",
|
||||
variant: "destructive",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for actions that are destructive or potentially harmful, like deleting data.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Outline: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "outline",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for secondary actions or when you need a button with less visual weight.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "secondary",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for secondary actions that are less important than the primary action.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "ghost",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for subtle actions or when you need minimal visual impact.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Link: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "link",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you want button functionality but link appearance.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
children: "Small Button",
|
||||
size: "sm",
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
children: "Large Button",
|
||||
size: "lg",
|
||||
},
|
||||
};
|
||||
|
||||
export const Icon: Story = {
|
||||
args: {
|
||||
children: "×",
|
||||
size: "icon",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for icon-only buttons. The button will be square-shaped.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
children: "Loading...",
|
||||
variant: "default",
|
||||
loading: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Use to show loading state during async operations. The button becomes disabled and shows a spinner.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
children: "Disabled Button",
|
||||
disabled: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when the button action is temporarily unavailable.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
args: {
|
||||
children: "Custom Button",
|
||||
className: "bg-purple-500 hover:bg-purple-600 text-white",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "You can override the default styling with custom CSS classes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,68 +1,132 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { Card } from "./index";
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button } from "../button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./index";
|
||||
|
||||
const meta = {
|
||||
interface CardStoryProps {
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
footerContent: string;
|
||||
showFooter: boolean;
|
||||
className?: string;
|
||||
footerButton?: boolean;
|
||||
}
|
||||
|
||||
const meta: Meta<CardStoryProps> = {
|
||||
title: "UI/Card",
|
||||
component: Card,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha" },
|
||||
docs: {
|
||||
description: {
|
||||
component: `The **card** component is used to display a card with a label, description, and optional icon. It can also display a status and buttons for connecting and viewing documentation.`,
|
||||
component:
|
||||
"The **Card** component is a flexible container for content with consistent styling. It includes subcomponents like CardHeader, CardTitle, CardDescription, CardContent, and CardFooter for structured layouts.",
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
icon: { control: "text" },
|
||||
},
|
||||
argTypes: {
|
||||
title: {
|
||||
control: "text",
|
||||
description: "Card title",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
description: {
|
||||
control: "text",
|
||||
description: "Card description",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
content: {
|
||||
control: "text",
|
||||
description: "Main content of the card",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
footerContent: {
|
||||
control: "text",
|
||||
description: "Content for the card footer",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
showFooter: {
|
||||
control: "boolean",
|
||||
description: "Toggle footer visibility",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
footerButton: {
|
||||
control: "boolean",
|
||||
description: "Show a button in the footer",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
table: { category: "Appearance" },
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Card>;
|
||||
render: ({ title, description, content, footerContent, showFooter, footerButton, className }) => (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>{content}</p>
|
||||
</CardContent>
|
||||
{showFooter && (
|
||||
<CardFooter>{footerButton ? <Button>{footerContent}</Button> : <p>{footerContent}</p>}</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<CardStoryProps>;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Primary: Story = {
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: "Card Label",
|
||||
description: "This is the description of the card.",
|
||||
connectText: "Connect",
|
||||
connectHref: "#",
|
||||
connectNewTab: false,
|
||||
docsText: "Docs",
|
||||
docsHref: "#",
|
||||
docsNewTab: false,
|
||||
connected: true,
|
||||
statusText: "Connected",
|
||||
title: "Default Card",
|
||||
description: "This is the default card description.",
|
||||
content: "This is the main content area of the card. You can put any React node here.",
|
||||
footerContent: "Footer content",
|
||||
showFooter: true,
|
||||
footerButton: false,
|
||||
className: "w-96",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disconnected: Story = {
|
||||
export const HeaderOnly: Story = {
|
||||
args: {
|
||||
label: "Card Label",
|
||||
description: "This is the description of the card.",
|
||||
connectText: "Connect",
|
||||
connectHref: "#",
|
||||
connectNewTab: false,
|
||||
docsText: "Docs",
|
||||
docsHref: "#",
|
||||
docsNewTab: false,
|
||||
connected: false,
|
||||
statusText: "Disconnected",
|
||||
...Default.args,
|
||||
title: "Header Only",
|
||||
description: "This card only has a header.",
|
||||
content: "",
|
||||
showFooter: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "A card that only displays a header. Useful for short announcements or titles.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
export const LongContent: Story = {
|
||||
args: {
|
||||
label: "Card Label",
|
||||
description: "This is the description of the card.",
|
||||
connectText: "Connect",
|
||||
connectHref: "#",
|
||||
connectNewTab: false,
|
||||
docsText: "Docs",
|
||||
docsHref: "#",
|
||||
docsNewTab: false,
|
||||
connected: true,
|
||||
statusText: "Connected",
|
||||
icon: <BellRing />,
|
||||
...Default.args,
|
||||
title: "Card with Long Content",
|
||||
description: "This card demonstrates how longer content is handled.",
|
||||
content:
|
||||
"This is a card with a longer content section to demonstrate how the card component handles extensive text content. The card will expand to accommodate the content while maintaining proper spacing and readability. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
footerContent: "Read More",
|
||||
showFooter: true,
|
||||
footerButton: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows how the card handles longer content with proper spacing and layout.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
import { fn } from "storybook/test";
|
||||
import { ColorPicker } from "./index";
|
||||
|
||||
const meta: Meta<typeof ColorPicker> = {
|
||||
title: "ui/ColorPicker",
|
||||
title: "UI/ColorPicker",
|
||||
component: ColorPicker,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
|
||||
@@ -91,7 +91,7 @@ export const SelectedRowSettings = <T,>({
|
||||
<>
|
||||
<div className="bg-primary flex items-center gap-x-2 rounded-md p-1 px-2 text-xs text-white">
|
||||
<div className="lowercase">
|
||||
{selectedRowCount} {t(`common.${type}s`)} {t("common.selected")}
|
||||
{`${selectedRowCount} ${type === "response" ? t("common.responses") : t("common.contacts")} ${t("common.selected")}`}
|
||||
</div>
|
||||
<Separator />
|
||||
<Button
|
||||
@@ -146,7 +146,7 @@ export const SelectedRowSettings = <T,>({
|
||||
<DeleteDialog
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
deleteWhat={t(`common.${type}`)}
|
||||
deleteWhat={type === "response" ? t("common.responses") : t("common.contacts")}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
text={deleteDialogText}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "../button";
|
||||
import {
|
||||
@@ -44,14 +44,17 @@ const DefaultBodyContent = (elementCount: number): React.ReactNode => {
|
||||
};
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI/Modal",
|
||||
title: "UI/Dialog",
|
||||
component: DialogContent,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: {
|
||||
sort: "requiredFirst",
|
||||
exclude: [],
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The **Dialog** component provides modal dialogs for displaying content over the main interface. It supports customizable headers, body content, footers, and various interaction patterns.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ToolbarPlugin } from "./toolbar-plugin";
|
||||
- bold
|
||||
- italic
|
||||
- link
|
||||
- underline
|
||||
*/
|
||||
export type TextEditorProps = {
|
||||
getText: () => string;
|
||||
|
||||
@@ -147,6 +147,7 @@ vi.mock("lucide-react", () => ({
|
||||
Bold: () => <span data-testid="bold-icon">Bold</span>,
|
||||
Italic: () => <span data-testid="italic-icon">Italic</span>,
|
||||
Link: () => <span data-testid="link-icon">Link</span>,
|
||||
Underline: () => <span data-testid="underline-icon">Underline</span>,
|
||||
ChevronDownIcon: () => <span data-testid="chevron-icon">ChevronDown</span>,
|
||||
}));
|
||||
|
||||
@@ -186,6 +187,7 @@ describe("ToolbarPlugin", () => {
|
||||
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("bold-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("italic-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("underline-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -218,20 +220,57 @@ describe("ToolbarPlugin", () => {
|
||||
});
|
||||
|
||||
test("excludes toolbar items when specified", () => {
|
||||
render(
|
||||
const { rerender } = render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
excludedToolbarItems={["bold", "italic"]}
|
||||
excludedToolbarItems={["bold", "italic", "underline"]}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should not render bold and italic buttons but should render link
|
||||
expect(screen.queryByTestId("bold-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("italic-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("underline-icon")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
|
||||
|
||||
// Rerender with different excluded items
|
||||
rerender(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
excludedToolbarItems={["blockType", "link"]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("dropdown-menu")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("link-icon")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("bold-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("italic-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("underline-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("excludes all toolbar items when specified", () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
excludedToolbarItems={["blockType", "bold", "italic", "underline", "link"]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("dropdown-menu")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("bold-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("italic-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("underline-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("link-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles firstRender and updateTemplate props", () => {
|
||||
@@ -253,4 +292,122 @@ describe("ToolbarPlugin", () => {
|
||||
// the component renders without errors when these props are provided
|
||||
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("User Interactions", () => {
|
||||
test("dispatches bold format command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const boldIcon = screen.getByTestId("bold-icon");
|
||||
const boldButton = boldIcon.parentElement;
|
||||
expect(boldButton).toBeInTheDocument();
|
||||
expect(boldButton).not.toBeNull();
|
||||
await userEvent.click(boldButton!);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("formatText", "bold");
|
||||
});
|
||||
|
||||
test("dispatches italic format command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const italicIcon = screen.getByTestId("italic-icon");
|
||||
const italicButton = italicIcon.parentElement;
|
||||
expect(italicButton).toBeInTheDocument();
|
||||
expect(italicButton).not.toBeNull();
|
||||
await userEvent.click(italicButton!);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("formatText", "italic");
|
||||
});
|
||||
|
||||
test("dispatches underline format command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const underlineIcon = screen.getByTestId("underline-icon");
|
||||
const underlineButton = underlineIcon.parentElement;
|
||||
expect(underlineButton).toBeInTheDocument();
|
||||
expect(underlineButton).not.toBeNull();
|
||||
await userEvent.click(underlineButton!);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("formatText", "underline");
|
||||
});
|
||||
|
||||
test("dispatches link command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const linkIcon = screen.getByTestId("link-icon");
|
||||
const linkButton = linkIcon.parentElement;
|
||||
expect(linkButton).toBeInTheDocument();
|
||||
expect(linkButton).not.toBeNull();
|
||||
await userEvent.click(linkButton!);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("toggleLink", {
|
||||
url: "https://",
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatches numbered list command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownTrigger = screen.getByTestId("dropdown-menu-trigger");
|
||||
await userEvent.click(dropdownTrigger);
|
||||
|
||||
const numberedListButton = screen.getAllByTestId("button")[1]; // ol
|
||||
await userEvent.click(numberedListButton);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("insertOrderedList", undefined);
|
||||
});
|
||||
|
||||
test("dispatches bulleted list command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownTrigger = screen.getByTestId("dropdown-menu-trigger");
|
||||
await userEvent.click(dropdownTrigger);
|
||||
|
||||
const bulletedListButton = screen.getAllByTestId("button")[2]; // ul
|
||||
await userEvent.click(bulletedListButton);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("insertUnorderedList", undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,11 +29,12 @@ import {
|
||||
$getSelection,
|
||||
$insertNodes,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
PASTE_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from "lexical";
|
||||
import { COMMAND_PRIORITY_CRITICAL, PASTE_COMMAND } from "lexical";
|
||||
import { Bold, ChevronDownIcon, Italic, Link } from "lucide-react";
|
||||
import { Bold, ChevronDownIcon, Italic, Link, Underline } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AddVariablesDropdown } from "./add-variables-dropdown";
|
||||
@@ -235,6 +236,7 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
|
||||
const [isLink, setIsLink] = useState(false);
|
||||
const [isBold, setIsBold] = useState(false);
|
||||
const [isItalic, setIsItalic] = useState(false);
|
||||
const [isUnderline, setIsUnderline] = useState(false);
|
||||
|
||||
// save ref to setText to use it in event listeners safely
|
||||
const setText = useRef<any>(props.setText);
|
||||
@@ -334,7 +336,7 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
|
||||
}
|
||||
setIsBold(selection.hasFormat("bold"));
|
||||
setIsItalic(selection.hasFormat("italic"));
|
||||
|
||||
setIsUnderline(selection.hasFormat("underline"));
|
||||
const node = getSelectedNode(selection);
|
||||
const parent = node.getParent();
|
||||
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||||
@@ -459,95 +461,94 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
|
||||
|
||||
if (!props.editable) return <></>;
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: "bold",
|
||||
icon: Bold,
|
||||
onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold"),
|
||||
active: isBold,
|
||||
},
|
||||
{
|
||||
key: "italic",
|
||||
icon: Italic,
|
||||
onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic"),
|
||||
active: isItalic,
|
||||
},
|
||||
{
|
||||
key: "underline",
|
||||
icon: Underline,
|
||||
onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline"),
|
||||
active: isUnderline,
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
icon: Link,
|
||||
onClick: insertLink,
|
||||
active: isLink,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="toolbar flex" ref={toolbarRef}>
|
||||
<>
|
||||
{!props.excludedToolbarItems?.includes("blockType") && supportedBlockTypes.has(blockType) && (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="text-subtle">
|
||||
<>
|
||||
<span className={"icon" + blockType} />
|
||||
<span className="text text-default hidden sm:flex">
|
||||
{blockTypeToBlockName[blockType as keyof BlockType]}
|
||||
</span>
|
||||
<ChevronDownIcon className="text-default ml-2 h-4 w-4" />
|
||||
</>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{Object.keys(blockTypeToBlockName).map((key) => {
|
||||
return (
|
||||
<DropdownMenuItem key={key}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => format(key)}
|
||||
className={cn(
|
||||
"w-full rounded-none focus:ring-0",
|
||||
blockType === key ? "bg-subtle w-full" : ""
|
||||
)}>
|
||||
<>
|
||||
<span className={"icon block-type " + key} />
|
||||
<span>{blockTypeToBlockName[key]}</span>
|
||||
</>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
|
||||
<>
|
||||
{!props.excludedToolbarItems?.includes("bold") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
|
||||
}}
|
||||
className={isBold ? "bg-subtle active-button" : "inactive-button"}>
|
||||
<Bold />
|
||||
</Button>
|
||||
)}
|
||||
{!props.excludedToolbarItems?.includes("italic") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
|
||||
}}
|
||||
className={isItalic ? "bg-subtle active-button" : "inactive-button"}>
|
||||
<Italic />
|
||||
</Button>
|
||||
)}
|
||||
{!props.excludedToolbarItems?.includes("link") && (
|
||||
{!props.excludedToolbarItems?.includes("blockType") && supportedBlockTypes.has(blockType) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="text-subtle">
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={insertLink}
|
||||
className={isLink ? "bg-subtle active-button" : "inactive-button"}>
|
||||
<Link />
|
||||
</Button>
|
||||
{isLink ? (
|
||||
createPortal(<FloatingLinkEditor editor={editor} />, props.container ?? document.body)
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<span className={cn("icon", blockType)} />
|
||||
<span className="text text-default hidden sm:flex">
|
||||
{blockTypeToBlockName[blockType as keyof BlockType]}
|
||||
</span>
|
||||
<ChevronDownIcon className="text-default ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{props.variables && (
|
||||
<div className="ml-auto">
|
||||
<AddVariablesDropdown
|
||||
addVariable={addVariable}
|
||||
isTextEditor={true}
|
||||
variables={props.variables || []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{Object.keys(blockTypeToBlockName).map((key) => {
|
||||
return (
|
||||
<DropdownMenuItem key={key}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => format(key)}
|
||||
className={cn(
|
||||
"w-full rounded-none focus:ring-0",
|
||||
blockType === key ? "bg-subtle w-full" : ""
|
||||
)}>
|
||||
<>
|
||||
<span className={cn("icon block-type", key)} />
|
||||
<span>{blockTypeToBlockName[key]}</span>
|
||||
</>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{items.map(({ key, icon: Icon, onClick, active }) =>
|
||||
!props.excludedToolbarItems?.includes(key) ? (
|
||||
<Button
|
||||
key={key}
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={active ? "bg-subtle active-button" : "inactive-button"}>
|
||||
<Icon />
|
||||
</Button>
|
||||
) : null
|
||||
)}
|
||||
{isLink &&
|
||||
!props.excludedToolbarItems?.includes("link") &&
|
||||
createPortal(<FloatingLinkEditor editor={editor} />, props.container ?? document.body)}
|
||||
|
||||
{props.variables && (
|
||||
<div className="ml-auto">
|
||||
<AddVariablesDropdown
|
||||
addVariable={addVariable}
|
||||
isTextEditor={true}
|
||||
variables={props.variables || []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,5 +20,6 @@ export const exampleTheme = {
|
||||
text: {
|
||||
bold: "fb-editor-text-bold",
|
||||
italic: "fb-editor-text-italic",
|
||||
underline: "fb-editor-text-underline",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
font-style: italic !important;
|
||||
}
|
||||
|
||||
.fb-editor-text-underline {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.fb-editor-link {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,59 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ErrorComponent } from "./index";
|
||||
|
||||
// Mock the useTranslate hook
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"common.error_component_title": "Something went wrong",
|
||||
"common.error_component_description": "An unexpected error occurred. Please try again.",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ErrorComponent", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders error title", () => {
|
||||
test("renders with default translations when no props provided", () => {
|
||||
render(<ErrorComponent />);
|
||||
expect(screen.getByTestId("error-title")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong");
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(
|
||||
"An unexpected error occurred. Please try again."
|
||||
);
|
||||
});
|
||||
|
||||
test("renders error description", () => {
|
||||
render(<ErrorComponent />);
|
||||
expect(screen.getByTestId("error-description")).toBeInTheDocument();
|
||||
test("renders with custom title when provided", () => {
|
||||
const customTitle = "Custom Error Title";
|
||||
render(<ErrorComponent title={customTitle} />);
|
||||
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent(customTitle);
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(
|
||||
"An unexpected error occurred. Please try again."
|
||||
);
|
||||
});
|
||||
|
||||
test("renders with custom description when provided", () => {
|
||||
const customDescription = "Custom error description";
|
||||
render(<ErrorComponent description={customDescription} />);
|
||||
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong");
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(customDescription);
|
||||
});
|
||||
|
||||
test("renders with both custom title and description when provided", () => {
|
||||
const customTitle = "Custom Error Title";
|
||||
const customDescription = "Custom error description";
|
||||
render(<ErrorComponent title={customTitle} description={customDescription} />);
|
||||
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent(customTitle);
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(customDescription);
|
||||
});
|
||||
|
||||
test("renders error icon", () => {
|
||||
@@ -23,4 +62,16 @@ describe("ErrorComponent", () => {
|
||||
const iconElement = document.querySelector("[aria-hidden='true']");
|
||||
expect(iconElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("uses fallback translation when title is empty string", () => {
|
||||
render(<ErrorComponent title="" />);
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong");
|
||||
});
|
||||
|
||||
test("uses fallback translation when description is empty string", () => {
|
||||
render(<ErrorComponent description="" />);
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(
|
||||
"An unexpected error occurred. Please try again."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,17 +4,15 @@ import { useTranslate } from "@tolgee/react";
|
||||
import { XCircleIcon } from "lucide-react";
|
||||
|
||||
interface ErrorComponentProps {
|
||||
/** Pre-translated title text. If not provided, uses default error title */
|
||||
title?: string;
|
||||
/** Pre-translated description text. If not provided, uses default error description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const ErrorComponent: React.FC<ErrorComponentProps> = ({ title, description }) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
// Use custom title/description if provided, otherwise fallback to translations
|
||||
const errorTitle = title || "common.error_component_title";
|
||||
const errorDescription = description || "common.error_component_description";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
@@ -23,10 +21,10 @@ export const ErrorComponent: React.FC<ErrorComponentProps> = ({ title, descripti
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800" data-testid="error-title">
|
||||
{t(errorTitle)}
|
||||
{title || t("common.error_component_title")}
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-red-700" data-testid="error-description">
|
||||
<p>{t(errorDescription)}</p>
|
||||
<p>{description || t("common.error_component_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -274,7 +274,7 @@ export const InputCombobox = ({
|
||||
)}
|
||||
<CommandList className="m-1">
|
||||
<CommandEmpty className="mx-2 my-0 text-xs font-semibold text-slate-500">
|
||||
{emptyDropdownText ? t(emptyDropdownText) : t("environments.surveys.edit.no_option_found")}
|
||||
{emptyDropdownText ?? t("environments.surveys.edit.no_option_found")}
|
||||
</CommandEmpty>
|
||||
{options && options.length > 0 && (
|
||||
<CommandGroup>
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FileIcon, FolderIcon, ImageIcon } from "lucide-react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { InputCombobox } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "UI/InputCombobox",
|
||||
component: InputCombobox,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The \`InputCombobox\` component is a versatile combination of an input field and a dropdown menu.
|
||||
It supports various features such as:
|
||||
- Searchable options
|
||||
- Grouped options
|
||||
- Multi-select
|
||||
- Icons for options
|
||||
- Clearable selection
|
||||
- Custom input props
|
||||
- Handling both dropdown and input modes
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof InputCombobox>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const commonOptions = [
|
||||
{ label: "File", value: "file", icon: FileIcon },
|
||||
{ label: "Folder", value: "folder", icon: FolderIcon },
|
||||
{ label: "Image", value: "image", icon: ImageIcon },
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
id: "input-combobox-default",
|
||||
showSearch: true,
|
||||
searchPlaceholder: "Search...",
|
||||
options: commonOptions,
|
||||
value: null,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
clearable: true,
|
||||
withInput: false,
|
||||
allowMultiSelect: false,
|
||||
showCheckIcon: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithInput: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
withInput: true,
|
||||
inputProps: {
|
||||
placeholder: "Type or select an option",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const GroupedOptions: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
groupedOptions: [
|
||||
{
|
||||
label: "Common",
|
||||
value: "common",
|
||||
options: commonOptions,
|
||||
},
|
||||
{
|
||||
label: "Advanced",
|
||||
value: "advanced",
|
||||
options: [
|
||||
{ label: "Database", value: "database" },
|
||||
{ label: "Network", value: "network" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiSelect: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
allowMultiSelect: true,
|
||||
value: ["file", "image"],
|
||||
},
|
||||
};
|
||||
|
||||
export const Clearable: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
value: "folder",
|
||||
clearable: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutSearch: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
showSearch: false,
|
||||
},
|
||||
};
|
||||
319
apps/web/modules/ui/components/input-combo-box/stories.tsx
Normal file
319
apps/web/modules/ui/components/input-combo-box/stories.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { FileIcon, FolderIcon, ImageIcon } from "lucide-react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { InputCombobox } from "./index";
|
||||
|
||||
interface StoryOptions {
|
||||
numberOfOptions: number;
|
||||
showCustomIcons: boolean;
|
||||
enableMultiSelect: boolean;
|
||||
enableClearable: boolean;
|
||||
}
|
||||
|
||||
type StoryProps = React.ComponentProps<typeof InputCombobox> & StoryOptions;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI/InputCombobox",
|
||||
component: InputCombobox,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The **InputCombobox** component is a versatile combination of an input field and a dropdown menu. It supports searchable options, grouped options, multi-select, icons, clearable selection, and both dropdown and input modes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
allowMultiSelect: {
|
||||
control: "boolean",
|
||||
description: "Allow selecting multiple options",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
clearable: {
|
||||
control: "boolean",
|
||||
description: "Allow clearing the selection",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
showSearch: {
|
||||
control: "boolean",
|
||||
description: "Show search input in dropdown",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "true" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
withInput: {
|
||||
control: "boolean",
|
||||
description: "Include input field alongside dropdown",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 4,
|
||||
},
|
||||
showCheckIcon: {
|
||||
control: "boolean",
|
||||
description: "Show check icon for selected items",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 5,
|
||||
},
|
||||
onChangeValue: {
|
||||
action: "value changed",
|
||||
description: "Callback when value changes",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "function" },
|
||||
},
|
||||
order: 6,
|
||||
},
|
||||
comboboxClasses: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes for the combobox",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
control: "text",
|
||||
description: "Placeholder text for search input",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "Search..." },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
emptyDropdownText: {
|
||||
control: "text",
|
||||
description: "Text to show when no options found",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
id: {
|
||||
control: "text",
|
||||
description: "Unique identifier for the component",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof InputCombobox>;
|
||||
|
||||
const commonOptions = [
|
||||
{ label: "File", value: "file", icon: FileIcon },
|
||||
{ label: "Folder", value: "folder", icon: FolderIcon },
|
||||
{ label: "Image", value: "image", icon: ImageIcon },
|
||||
];
|
||||
|
||||
const simpleOptions = [
|
||||
{ label: "Option 1", value: "option1" },
|
||||
{ label: "Option 2", value: "option2" },
|
||||
{ label: "Option 3", value: "option3" },
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
id: "input-combobox-default",
|
||||
showSearch: true,
|
||||
searchPlaceholder: "Search...",
|
||||
options: commonOptions,
|
||||
value: commonOptions[0].value,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
clearable: false,
|
||||
withInput: false,
|
||||
allowMultiSelect: false,
|
||||
showCheckIcon: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithInput: Story = {
|
||||
args: {
|
||||
id: "input-combobox-with-input",
|
||||
showSearch: true,
|
||||
options: commonOptions,
|
||||
value: null,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
withInput: true,
|
||||
inputProps: {
|
||||
placeholder: "Type or select an option",
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you need both dropdown selection and free text input functionality.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultiSelect: Story = {
|
||||
args: {
|
||||
id: "input-combobox-multi-select",
|
||||
options: commonOptions,
|
||||
value: ["file", "image"],
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
allowMultiSelect: true,
|
||||
showCheckIcon: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when users need to select multiple options from the dropdown.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Clearable: Story = {
|
||||
args: {
|
||||
id: "input-combobox-clearable",
|
||||
options: commonOptions,
|
||||
value: "folder",
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
clearable: true,
|
||||
showCheckIcon: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when users should be able to clear their selection.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const GroupedOptions: Story = {
|
||||
args: {
|
||||
id: "input-combobox-grouped",
|
||||
groupedOptions: [
|
||||
{
|
||||
label: "Common",
|
||||
value: "common",
|
||||
options: commonOptions,
|
||||
},
|
||||
{
|
||||
label: "Advanced",
|
||||
value: "advanced",
|
||||
options: [
|
||||
{ label: "Database", value: "database" },
|
||||
{ label: "Network", value: "network" },
|
||||
{ label: "Security", value: "security" },
|
||||
],
|
||||
},
|
||||
],
|
||||
value: null,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
showCheckIcon: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you need to organize options into logical groups.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutSearch: Story = {
|
||||
args: {
|
||||
id: "input-combobox-no-search",
|
||||
options: simpleOptions,
|
||||
value: null,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
showSearch: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you have a small number of options and don't need search functionality.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyOptions: Story = {
|
||||
args: {
|
||||
id: "input-combobox-many-options",
|
||||
options: Array.from({ length: 50 }, (_, i) => ({
|
||||
label: `Option ${i + 1}`,
|
||||
value: `option${i + 1}`,
|
||||
})),
|
||||
value: null,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
showSearch: true,
|
||||
searchPlaceholder: "Search from 50 options...",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you have many options and need search functionality for better UX.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
args: {
|
||||
id: "input-combobox-custom",
|
||||
options: commonOptions,
|
||||
value: null,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
comboboxClasses: "border-blue-300 hover:border-blue-400",
|
||||
showCheckIcon: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "You can customize the appearance with custom CSS classes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyState: Story = {
|
||||
args: {
|
||||
id: "input-combobox-empty",
|
||||
options: [],
|
||||
value: null,
|
||||
onChangeValue: (value, option) => logger.debug({ value, option }, "onChangeValue"),
|
||||
emptyDropdownText: "No options available",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows how the component handles empty options list.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,87 +1,277 @@
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Input } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/Input",
|
||||
const meta: Meta<typeof Input> = {
|
||||
title: "UI/Input",
|
||||
component: Input,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The \`Input\` component is used to input the form fields.
|
||||
It supports all standard HTML input attributes, along with some additional props to handle specific use cases:
|
||||
- \`isInvalid\`: Adds a visual indicator for invalid input.
|
||||
- \`crossOrigin\`: Specifies how the element handles cross-origin requests.
|
||||
- \`dangerouslySetInnerHTML\`: Allows setting inner HTML content directly, similar to the native \`dangerouslySetInnerHTML\` in React.`,
|
||||
component:
|
||||
"The **Input** component is a versatile form input field that supports all standard HTML input attributes. It includes visual indicators for invalid states and supports various input types with consistent styling.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Input>;
|
||||
argTypes: {
|
||||
isInvalid: {
|
||||
control: "boolean",
|
||||
description: "Shows invalid state with red border",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
description: "Disables the input field",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
required: {
|
||||
control: "boolean",
|
||||
description: "Makes the input required",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
readOnly: {
|
||||
control: "boolean",
|
||||
description: "Makes the input read-only",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 4,
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
type: {
|
||||
control: "select",
|
||||
options: ["text", "email", "password", "number", "tel", "url", "search"],
|
||||
description: "Input type",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "text" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
placeholder: {
|
||||
control: "text",
|
||||
description: "Placeholder text",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
value: {
|
||||
control: "text",
|
||||
description: "Input value",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
defaultValue: {
|
||||
control: "text",
|
||||
description: "Default input value",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
type Story = StoryObj<typeof Input>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
value: "",
|
||||
className: "",
|
||||
isInvalid: false,
|
||||
disabled: false,
|
||||
placeholder: "Enter text",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
value: "Sample text",
|
||||
placeholder: "Enter text",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you need to display a pre-filled value.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Invalid: Story = {
|
||||
args: {
|
||||
isInvalid: true,
|
||||
disabled: false,
|
||||
placeholder: "Invalid input",
|
||||
value: "Invalid value",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use to show validation errors with red border styling.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
isInvalid: false,
|
||||
disabled: true,
|
||||
placeholder: "Disabled input",
|
||||
value: "Cannot edit this",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when the input should not be editable.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
value: "Prefilled text",
|
||||
isInvalid: false,
|
||||
disabled: false,
|
||||
placeholder: "Enter text",
|
||||
readOnly: true,
|
||||
value: "Read-only value",
|
||||
placeholder: "Read-only input",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you want to display data that cannot be edited but can be selected.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomClass: Story = {
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
className: "rounded-lg bg-slate-50 text-base",
|
||||
isInvalid: false,
|
||||
disabled: false,
|
||||
placeholder: "Input with custom class",
|
||||
required: true,
|
||||
placeholder: "Required field *",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for mandatory form fields.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Password: Story = {
|
||||
args: {
|
||||
type: "password",
|
||||
value: "abcd",
|
||||
isInvalid: false,
|
||||
disabled: false,
|
||||
placeholder: "Enter password",
|
||||
value: "secretpassword",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for password input fields with hidden text.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Email: Story = {
|
||||
args: {
|
||||
type: "email",
|
||||
isInvalid: false,
|
||||
disabled: false,
|
||||
placeholder: "john.doe@email.com",
|
||||
placeholder: "john.doe@example.com",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for email input with built-in validation.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NumberInput: Story = {
|
||||
args: {
|
||||
type: "number",
|
||||
placeholder: "Enter number",
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for numeric input with optional min/max validation.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Search: Story = {
|
||||
args: {
|
||||
type: "search",
|
||||
placeholder: "Search...",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for search input fields with search-specific styling.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomStyling: Story = {
|
||||
args: {
|
||||
className: "rounded-lg bg-slate-50 text-base border-2 border-blue-300 focus:border-blue-500",
|
||||
placeholder: "Custom styled input",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "You can customize the appearance with additional CSS classes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongText: Story = {
|
||||
args: {
|
||||
placeholder:
|
||||
"This is a very long placeholder text that demonstrates how the input handles overflow content gracefully",
|
||||
value: "This is a very long input value that shows how the text scrolls within the input field",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows how the input handles long content with scrolling.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { Card } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/IntegrationCard",
|
||||
const meta: Meta<typeof Card> = {
|
||||
title: "UI/IntegrationCard",
|
||||
component: Card,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
@@ -12,8 +12,60 @@ const meta = {
|
||||
component: `The **card** component is used to display a card with a label, description, and optional icon. It can also display a status and buttons for connecting and viewing documentation.`,
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
icon: { control: "text" },
|
||||
},
|
||||
argTypes: {
|
||||
// Behavior
|
||||
connectHref: {
|
||||
control: "text",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
connectNewTab: {
|
||||
control: "boolean",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
docsHref: {
|
||||
control: "text",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
docsNewTab: {
|
||||
control: "boolean",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
connected: {
|
||||
control: "boolean",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
|
||||
// Content
|
||||
label: {
|
||||
control: "text",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
description: {
|
||||
control: "text",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
connectText: {
|
||||
control: "text",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
docsText: {
|
||||
control: "text",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
statusText: {
|
||||
control: "text",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
|
||||
// Appearance
|
||||
icon: {
|
||||
control: false,
|
||||
table: { category: "Appearance" },
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Card>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user