mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-24 15:10:36 -06:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad842e0e80 | ||
|
|
dcf4109c5b | ||
|
|
05287c135e | ||
|
|
6ff8ec21cf | ||
|
|
7b6e22aa04 | ||
|
|
ee56914285 | ||
|
|
a2e9cd3c43 | ||
|
|
359f29a264 | ||
|
|
576b15fec0 | ||
|
|
42434290da | ||
|
|
62c6189dfd | ||
|
|
21c9ebbca3 | ||
|
|
658d4687f9 | ||
|
|
3775453db8 | ||
|
|
edcaf8e639 | ||
|
|
3aa658a64e | ||
|
|
58fc66ad1c | ||
|
|
f68f87645f | ||
|
|
25f99da172 | ||
|
|
5da6faa972 | ||
|
|
02b25138ef |
4
.github/workflows/build-docs.yml
vendored
4
.github/workflows/build-docs.yml
vendored
@@ -1,6 +1,10 @@
|
||||
name: Build Docs
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docs
|
||||
|
||||
4
.github/workflows/build-web.yml
vendored
4
.github/workflows/build-web.yml
vendored
@@ -1,6 +1,10 @@
|
||||
name: Build Web
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Formbricks-web
|
||||
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
schedule:
|
||||
# Runs "At 00:00." (see https://crontab.guru)
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cron-weeklySummary:
|
||||
env:
|
||||
|
||||
2
.github/workflows/cron-weeklySummary.yml
vendored
2
.github/workflows/cron-weeklySummary.yml
vendored
@@ -9,6 +9,8 @@ on:
|
||||
- cron: "0 8 * * 1"
|
||||
jobs:
|
||||
cron-weeklySummary:
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||
|
||||
55
.github/workflows/draft-release.yml
vendored
Normal file
55
.github/workflows/draft-release.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Draft release
|
||||
run-name: Draft release ${{ inputs.next_version }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
next_version:
|
||||
required: true
|
||||
type: string
|
||||
description: "Version name"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
draft_release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config --local user.email "github-actions@github.com"
|
||||
git config --local user.name "GitHub Actions"
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Bump version
|
||||
run: |
|
||||
cd apps/web
|
||||
pnpm version ${{ inputs.next_version }} --no-workspaces-update
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git add .
|
||||
git commit -m "chore: release v${{ inputs.next_version }}"
|
||||
git push
|
||||
|
||||
- name: Draft release
|
||||
run: gh release create v${{ inputs.next_version }} --generate-notes --draft
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ inputs.next_version }}
|
||||
45
.github/workflows/e2e.yml
vendored
45
.github/workflows/e2e.yml
vendored
@@ -1,9 +1,28 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
AZURE_CLIENT_ID:
|
||||
required: false
|
||||
AZURE_TENANT_ID:
|
||||
required: false
|
||||
AZURE_SUBSCRIPTION_ID:
|
||||
required: false
|
||||
PLAYWRIGHT_SERVICE_URL:
|
||||
required: false
|
||||
# Add other secrets if necessary
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
TELEMETRY_DISABLED: 1
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Run E2E Tests
|
||||
@@ -83,7 +102,31 @@ jobs:
|
||||
- name: Install Playwright
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
- name: Run E2E Tests
|
||||
- name: Set Azure Secret Variables
|
||||
run: |
|
||||
if [[ -n "${{ secrets.AZURE_CLIENT_ID }}" && -n "${{ secrets.AZURE_TENANT_ID }}" && -n "${{ secrets.AZURE_SUBSCRIPTION_ID }}" ]]; then
|
||||
echo "AZURE_ENABLED=true" >> $GITHUB_ENV
|
||||
else
|
||||
echo "AZURE_ENABLED=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Azure login
|
||||
if: env.AZURE_ENABLED == 'true'
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Run E2E Tests (Azure)
|
||||
if: env.AZURE_ENABLED == 'true'
|
||||
env:
|
||||
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||
run: |
|
||||
pnpm test-e2e:azure
|
||||
|
||||
- name: Run E2E Tests (Local)
|
||||
if: env.AZURE_ENABLED == 'false'
|
||||
run: |
|
||||
pnpm test:e2e
|
||||
|
||||
|
||||
10
.github/workflows/lint.yml
vendored
10
.github/workflows/lint.yml
vendored
@@ -1,6 +1,10 @@
|
||||
name: Lint
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Linters
|
||||
@@ -8,16 +12,16 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
39
.github/workflows/pr.yml
vendored
39
.github/workflows/pr.yml
vendored
@@ -1,5 +1,13 @@
|
||||
name: PR Update
|
||||
|
||||
# Update permissions to include all necessary ones
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
actions: read
|
||||
checks: write
|
||||
id-token: write
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
@@ -12,55 +20,28 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
has-files-requiring-all-checks: ${{ steps.filter.outputs.has-files-requiring-all-checks }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
has-files-requiring-all-checks:
|
||||
- "!(**.md|.github/CODEOWNERS)"
|
||||
|
||||
test:
|
||||
name: Run Unit Tests
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/test.yml
|
||||
secrets: inherit
|
||||
|
||||
lint:
|
||||
name: Run Linters
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/lint.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
name: Build Formbricks-web
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/build-web.yml
|
||||
secrets: inherit
|
||||
|
||||
docs:
|
||||
name: Build Docs
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/build-docs.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-test:
|
||||
name: Run E2E Tests
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -69,6 +50,10 @@ jobs:
|
||||
needs: [lint, test, build, e2e-test, docs]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
statuses: write
|
||||
steps:
|
||||
- name: fail if conditional jobs failed
|
||||
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
|
||||
|
||||
5
.github/workflows/release-changesets.yml
vendored
5
.github/workflows/release-changesets.yml
vendored
@@ -6,6 +6,11 @@ on:
|
||||
# branches:
|
||||
# - main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
packages: write
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
env:
|
||||
|
||||
2
.github/workflows/release-docker.yml
vendored
2
.github/workflows/release-docker.yml
vendored
@@ -8,6 +8,8 @@ on:
|
||||
jobs:
|
||||
release-image-on-dockerhub:
|
||||
name: Release on Dockerhub
|
||||
permissions:
|
||||
contents: read
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -6,6 +6,8 @@ jobs:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
@@ -14,7 +14,6 @@ module.exports = {
|
||||
typescript: {
|
||||
project: "tsconfig.json",
|
||||
},
|
||||
caseSensitive: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
37
apps/docs/app/self-hosting/rate-limiting/page.mdx
Normal file
37
apps/docs/app/self-hosting/rate-limiting/page.mdx
Normal file
@@ -0,0 +1,37 @@
|
||||
# Rate Limiting
|
||||
|
||||
To protect the platform from abuse and ensure fair usage, rate limiting is enforced by default on an IP-address basis. If a client exceeds the allowed number of requests within the specified time window, the API will return a `429 Too Many Requests` status code.
|
||||
|
||||
## Default Rate Limits
|
||||
|
||||
The following rate limits apply to various endpoints:
|
||||
|
||||
| **Endpoint** | **Rate Limit** | **Time Window** |
|
||||
| ----------------------- | -------------- | --------------- |
|
||||
| `POST /login` | 30 requests | 15 minutes |
|
||||
| `POST /signup` | 30 requests | 60 minutes |
|
||||
| `POST /verify-email` | 10 requests | 60 minutes |
|
||||
| `POST /forgot-password` | 5 requests | 60 minutes |
|
||||
| `GET /client-side-api` | 100 requests | 1 minute |
|
||||
| `POST /share` | 100 requests | 60 minutes |
|
||||
|
||||
If a request exceeds the defined rate limit, the server will respond with:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 429,
|
||||
"error": "Too many requests, Please try after a while!"
|
||||
}
|
||||
```
|
||||
|
||||
## Disabling Rate Limiting
|
||||
|
||||
For self-hosters, rate limiting can be disabled if necessary. However, we **strongly recommend keeping rate limiting enabled in production environments** to prevent abuse.
|
||||
|
||||
To disable rate limiting, set the following environment variable:
|
||||
|
||||
```bash
|
||||
RATE_LIMITING_DISABLED=1
|
||||
```
|
||||
|
||||
After making this change, restart your server to apply the new setting.
|
||||
@@ -144,6 +144,7 @@ export const navigation: NavGroup[] = [
|
||||
{ title: "Integrations", href: "/self-hosting/integrations" },
|
||||
{ title: "License", href: "/self-hosting/license" },
|
||||
{ title: "Cluster Setup", href: "/self-hosting/cluster-setup" },
|
||||
{ title: "Rate Limiting", href: "/self-hosting/rate-limiting" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -24,6 +24,9 @@ info:
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: http://{{baseurl}}
|
||||
variables:
|
||||
baseurl:
|
||||
default: "localhost:3000"
|
||||
tags:
|
||||
- name: Client API
|
||||
description: >-
|
||||
@@ -151,7 +154,7 @@ tags:
|
||||
|
||||
|
||||
Methods allowed: Get All, Get ,Create, and Delete Webhooks
|
||||
|
||||
|
||||
paths:
|
||||
/api/v1/client/{environmentId}/displays:
|
||||
post:
|
||||
|
||||
@@ -35,6 +35,6 @@
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "8.4.7",
|
||||
"tsup": "8.3.5",
|
||||
"vite": "6.0.3"
|
||||
"vite": "6.0.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,14 @@ export const EditorCardMenu = ({
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
|
||||
card.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
card.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
|
||||
card.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
card.type === TSurveyQuestionTypeEnum.Ranking) ||
|
||||
(type === TSurveyQuestionTypeEnum.Ranking &&
|
||||
card.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
|
||||
card.type === TSurveyQuestionTypeEnum.Ranking) ||
|
||||
(type === TSurveyQuestionTypeEnum.Ranking && card.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
|
||||
) {
|
||||
updateCard(cardIdx, {
|
||||
choices: card.choices,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { QuestionFormInput } from "@/modules/surveys/components/QuestionFormInput";
|
||||
import { RecallWrapper } from "@/modules/surveys/components/QuestionFormInput/components/RecallWrapper";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useRef } from "react";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { headlineToRecall, recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSurvey, TSurveyEndScreenCard } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
@@ -35,6 +38,7 @@ export const EndScreenForm = ({
|
||||
locale,
|
||||
}: EndScreenFormProps) => {
|
||||
const t = useTranslations();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [showEndingCardCTA, setshowEndingCardCTA] = useState<boolean>(
|
||||
endingCard.type === "endScreen" &&
|
||||
(!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink)
|
||||
@@ -103,7 +107,7 @@ export const EndScreenForm = ({
|
||||
id="buttonLabel"
|
||||
label={t("environments.surveys.edit.button_label")}
|
||||
placeholder={t("environments.surveys.edit.create_your_own_survey")}
|
||||
className="bg-white"
|
||||
className="rounded-md"
|
||||
value={endingCard.buttonLabel}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length + endingCardIndex}
|
||||
@@ -117,14 +121,61 @@ export const EndScreenForm = ({
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("environments.surveys.edit.button_url")}</Label>
|
||||
<Input
|
||||
id="buttonLink"
|
||||
name="buttonLink"
|
||||
className="bg-white"
|
||||
placeholder="https://formbricks.com"
|
||||
value={endingCard.buttonLink}
|
||||
onChange={(e) => updateSurvey({ buttonLink: e.target.value })}
|
||||
/>
|
||||
<div className="rounded-md bg-white">
|
||||
<RecallWrapper
|
||||
value={endingCard.buttonLink ?? ""}
|
||||
questionId={endingCard.id}
|
||||
onChange={(val, recallItems, fallbacks) => {
|
||||
const updatedValue = {
|
||||
...endingCard,
|
||||
buttonLink:
|
||||
recallItems && fallbacks ? headlineToRecall(val, recallItems, fallbacks) : val,
|
||||
};
|
||||
|
||||
updateSurvey(updatedValue);
|
||||
}}
|
||||
onAddFallback={() => {
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
contactAttributeKeys={contactAttributeKeys}
|
||||
isRecallAllowed
|
||||
localSurvey={localSurvey}
|
||||
usedLanguageCode={"default"}
|
||||
render={({ value, onChange, highlightedJSX, children }) => {
|
||||
return (
|
||||
<div className="group relative">
|
||||
{/* The highlight container is absolutely positioned behind the input */}
|
||||
<div
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent`}
|
||||
dir="auto"
|
||||
key={highlightedJSX.toString()}>
|
||||
{highlightedJSX}
|
||||
</div>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="buttonLink"
|
||||
name="buttonLink"
|
||||
className="relative text-black caret-black"
|
||||
placeholder="https://formbricks.com"
|
||||
value={
|
||||
recallToHeadline(
|
||||
{
|
||||
[selectedLanguageCode]: value,
|
||||
},
|
||||
localSurvey,
|
||||
false,
|
||||
"default",
|
||||
contactAttributeKeys
|
||||
)[selectedLanguageCode]
|
||||
}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -166,7 +166,7 @@ export const MatrixQuestionForm = ({
|
||||
<div
|
||||
className="flex items-center"
|
||||
onKeyDown={(e) => handleKeyDown(e, "row")}
|
||||
key={`row-${index}-${question.rows.length}`}>
|
||||
key={`row-${index}`}>
|
||||
<QuestionFormInput
|
||||
id={`row-${index}`}
|
||||
label={""}
|
||||
@@ -219,7 +219,7 @@ export const MatrixQuestionForm = ({
|
||||
<div
|
||||
className="flex items-center"
|
||||
onKeyDown={(e) => handleKeyDown(e, "column")}
|
||||
key={`column-${index}-${question.columns.length}`}>
|
||||
key={`column-${index}`}>
|
||||
<QuestionFormInput
|
||||
id={`column-${index}`}
|
||||
label={""}
|
||||
|
||||
@@ -148,7 +148,7 @@ export const AddIntegrationModal = ({
|
||||
{
|
||||
id: "createdAt",
|
||||
name: t("common.created_at"),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
type: TSurveyQuestionTypeEnum.Date,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ export const TYPE_MAPPING = {
|
||||
[TSurveyQuestionTypeEnum.OpenText]: [
|
||||
"created_by",
|
||||
"created_time",
|
||||
"date",
|
||||
"email",
|
||||
"last_edited_by",
|
||||
"last_edited_time",
|
||||
|
||||
@@ -48,7 +48,9 @@ export const MatrixQuestionSummary = ({
|
||||
return "";
|
||||
};
|
||||
|
||||
const columns = questionSummary.data[0] ? Object.keys(questionSummary.data[0].columnPercentages) : [];
|
||||
const columns = questionSummary.data[0]
|
||||
? questionSummary.data[0].columnPercentages.map((c) => c.column)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
@@ -81,7 +83,7 @@ export const MatrixQuestionSummary = ({
|
||||
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
|
||||
</TooltipRenderer>
|
||||
</td>
|
||||
{Object.entries(columnPercentages).map(([column, percentage]) => (
|
||||
{columnPercentages.map(({ column, percentage }) => (
|
||||
<td
|
||||
key={column}
|
||||
className="text-center text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
|
||||
@@ -778,13 +778,15 @@ export const getQuestionSummary = async (
|
||||
totalResponsesForRow += countMap[row][col];
|
||||
});
|
||||
|
||||
const columnPercentages = columns.reduce((acc, col) => {
|
||||
const columnPercentages = columns.map((col) => {
|
||||
const count = countMap[row][col];
|
||||
const percentage =
|
||||
totalResponsesForRow > 0 ? ((count / totalResponsesForRow) * 100).toFixed(2) : "0.00";
|
||||
acc[col] = percentage;
|
||||
return acc;
|
||||
}, {});
|
||||
return {
|
||||
column: col,
|
||||
percentage: Number(percentage),
|
||||
};
|
||||
});
|
||||
|
||||
return { rowLabel: row, columnPercentages, totalResponsesForRow };
|
||||
});
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getApiKeyFromKey } from "@formbricks/lib/apiKey/service";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironmentIdFromApiKey } from "./lib/api-key";
|
||||
|
||||
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
if (apiKey) {
|
||||
const apiKeyData = await getApiKeyFromKey(apiKey);
|
||||
if (apiKeyData) {
|
||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (environmentId) {
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentId: apiKeyData.environmentId,
|
||||
environmentId,
|
||||
};
|
||||
return authentication;
|
||||
}
|
||||
|
||||
50
apps/web/app/api/v1/lib/api-key.ts
Normal file
50
apps/web/app/api/v1/lib/api-key.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { apiKeyCache } from "@/lib/cache/api-key";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { getHash } from "@formbricks/lib/crypto";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
|
||||
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Promise<string | null> => {
|
||||
const hashedKey = getHash(apiKey);
|
||||
return cache(
|
||||
async () => {
|
||||
validateInputs([apiKey, ZString]);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new InvalidInputError("API key cannot be null or undefined.");
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey,
|
||||
},
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiKeyData) {
|
||||
throw new ResourceNotFoundError("apiKey", apiKey);
|
||||
}
|
||||
|
||||
return apiKeyData.environmentId;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getEnvironmentIdFromApiKey-${apiKey}`],
|
||||
{
|
||||
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
|
||||
}
|
||||
)();
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { headers } from "next/headers";
|
||||
import { getApiKeyFromKey } from "@formbricks/lib/apiKey/service";
|
||||
import { deleteWebhook, getWebhook } from "@formbricks/lib/webhook/service";
|
||||
|
||||
export const GET = async (_: Request, props: { params: Promise<{ webhookId: string }> }) => {
|
||||
@@ -10,8 +10,8 @@ export const GET = async (_: Request, props: { params: Promise<{ webhookId: stri
|
||||
if (!apiKey) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const apiKeyData = await getApiKeyFromKey(apiKey);
|
||||
if (!apiKeyData) {
|
||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentId) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export const GET = async (_: Request, props: { params: Promise<{ webhookId: stri
|
||||
if (!webhook) {
|
||||
return responses.notFoundResponse("Webhook", params.webhookId);
|
||||
}
|
||||
if (webhook.environmentId !== apiKeyData.environmentId) {
|
||||
if (webhook.environmentId !== environmentId) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
return responses.successResponse(webhook);
|
||||
@@ -33,8 +33,8 @@ export const DELETE = async (_: Request, props: { params: Promise<{ webhookId: s
|
||||
if (!apiKey) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const apiKeyData = await getApiKeyFromKey(apiKey);
|
||||
if (!apiKeyData) {
|
||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentId) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const DELETE = async (_: Request, props: { params: Promise<{ webhookId: s
|
||||
if (!webhook) {
|
||||
return responses.notFoundResponse("Webhook", params.webhookId);
|
||||
}
|
||||
if (webhook.environmentId !== apiKeyData.environmentId) {
|
||||
if (webhook.environmentId !== environmentId) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { headers } from "next/headers";
|
||||
import { getApiKeyFromKey } from "@formbricks/lib/apiKey/service";
|
||||
import { createWebhook, getWebhooks } from "@formbricks/lib/webhook/service";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { ZWebhookInput } from "@formbricks/types/webhooks";
|
||||
@@ -12,14 +12,14 @@ export const GET = async () => {
|
||||
if (!apiKey) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const apiKeyData = await getApiKeyFromKey(apiKey);
|
||||
if (!apiKeyData) {
|
||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentId) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
// get webhooks from database
|
||||
try {
|
||||
const webhooks = await getWebhooks(apiKeyData.environmentId);
|
||||
const webhooks = await getWebhooks(environmentId);
|
||||
return Response.json({ data: webhooks });
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
@@ -35,8 +35,8 @@ export const POST = async (request: Request) => {
|
||||
if (!apiKey) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const apiKeyData = await getApiKeyFromKey(apiKey);
|
||||
if (!apiKeyData) {
|
||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentId) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const webhookInput = await request.json();
|
||||
@@ -52,7 +52,7 @@ export const POST = async (request: Request) => {
|
||||
|
||||
// add webhook to database
|
||||
try {
|
||||
const webhook = await createWebhook(apiKeyData.environmentId, inputValidation.data);
|
||||
const webhook = await createWebhook(environmentId, inputValidation.data);
|
||||
return responses.successResponse(webhook);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
|
||||
35
apps/web/app/setup/organization/create/actions.ts
Normal file
35
apps/web/app/setup/organization/create/actions.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
"use server";
|
||||
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { z } from "zod";
|
||||
import { gethasNoOrganizations } from "@formbricks/lib/instance/service";
|
||||
import { createMembership } from "@formbricks/lib/membership/service";
|
||||
import { createOrganization } from "@formbricks/lib/organization/service";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
|
||||
const ZCreateOrganizationAction = z.object({
|
||||
organizationName: z.string(),
|
||||
});
|
||||
|
||||
export const createOrganizationAction = authenticatedActionClient
|
||||
.schema(ZCreateOrganizationAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const hasNoOrganizations = await gethasNoOrganizations();
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
if (!hasNoOrganizations && !isMultiOrgEnabled) {
|
||||
throw new OperationNotAllowedError("This action can only be performed on a fresh instance.");
|
||||
}
|
||||
|
||||
const newOrganization = await createOrganization({
|
||||
name: parsedInput.organizationName,
|
||||
});
|
||||
|
||||
await createMembership(newOrganization.id, ctx.user.id, {
|
||||
role: "owner",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
return newOrganization;
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createOrganizationAction } from "@/modules/organization/actions";
|
||||
import { createOrganizationAction } from "@/app/setup/organization/create/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FormControl, FormError, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { apiKeyCache } from "@/lib/cache/api-key";
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { teamCache } from "@/lib/cache/team";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { actionClassCache } from "@formbricks/lib/actionClass/cache";
|
||||
import { apiKeyCache } from "@formbricks/lib/apiKey/cache";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { segmentCache } from "@formbricks/lib/cache/segment";
|
||||
import { environmentCache } from "@formbricks/lib/environment/cache";
|
||||
|
||||
@@ -18,9 +18,11 @@ export const CsvTable = ({ data }: CsvTableProps) => {
|
||||
className="grid gap-4 border-b-2 border-slate-100 bg-slate-100 p-4 text-left"
|
||||
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))` }}>
|
||||
{columns.map((header, index) => (
|
||||
<div key={index} className="font-semibold capitalize">
|
||||
<span
|
||||
key={index}
|
||||
className="overflow-hidden text-ellipsis whitespace-nowrap text-sm font-semibold capitalize">
|
||||
{header.replace(/_/g, " ")}
|
||||
</div>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -30,9 +32,9 @@ export const CsvTable = ({ data }: CsvTableProps) => {
|
||||
className="grid gap-4 border-b border-gray-200 bg-white p-4 text-left last:border-b-0"
|
||||
style={{ gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))` }}>
|
||||
{columns.map((header, colIndex) => (
|
||||
<div key={colIndex} className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<span key={colIndex} className="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{row[header]}
|
||||
</div>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
getProjectIdFromApiKeyId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { createApiKey, deleteApiKey } from "@/modules/projects/settings/lib/api-key";
|
||||
import { createApiKey, deleteApiKey } from "@/modules/projects/settings/api-keys/lib/api-key";
|
||||
import { z } from "zod";
|
||||
import { ZApiKeyCreateInput } from "@formbricks/types/api-keys";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZApiKeyCreateInput } from "./types/api-keys";
|
||||
|
||||
const ZDeleteApiKeyAction = z.object({
|
||||
id: ZId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getApiKeys } from "@/modules/projects/settings/api-keys/lib/api-key";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getApiKeys } from "@formbricks/lib/apiKey/service";
|
||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { TApiKey } from "@/modules/projects/settings/api-keys/types/api-keys";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { FilesIcon, TrashIcon } from "lucide-react";
|
||||
@@ -8,7 +9,6 @@ import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TApiKey } from "@formbricks/types/api-keys";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createApiKeyAction, deleteApiKeyAction } from "../actions";
|
||||
import { AddApiKeyModal } from "./add-api-key-modal";
|
||||
@@ -60,7 +60,6 @@ export const EditAPIKeys = ({
|
||||
environmentId: environmentTypeId,
|
||||
apiKeyData: { label: data.label },
|
||||
});
|
||||
console.log("createApiKeyResponse", createApiKeyResponse);
|
||||
if (createApiKeyResponse?.data) {
|
||||
const updatedApiKeys = [...apiKeysLocal!, createApiKeyResponse.data];
|
||||
setApiKeysLocal(updatedApiKeys);
|
||||
|
||||
@@ -1,14 +1,48 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { apiKeyCache } from "@/lib/cache/api-key";
|
||||
import { TApiKeyCreateInput, ZApiKeyCreateInput } from "@/modules/projects/settings/api-keys/types/api-keys";
|
||||
import { TApiKey } from "@/modules/projects/settings/api-keys/types/api-keys";
|
||||
import { ApiKey, Prisma } from "@prisma/client";
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { apiKeyCache } from "@formbricks/lib/apiKey/cache";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { TApiKey, TApiKeyCreateInput, ZApiKeyCreateInput } from "@formbricks/types/api-keys";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const deleteApiKey = async (id: string): Promise<TApiKey | null> => {
|
||||
export const getApiKeys = reactCache(
|
||||
async (environmentId: string, page?: number): Promise<ApiKey[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const apiKeys = await prisma.apiKey.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
});
|
||||
|
||||
return apiKeys;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getApiKeys-${environmentId}-${page}`],
|
||||
{
|
||||
tags: [apiKeyCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const deleteApiKey = async (id: string): Promise<ApiKey | null> => {
|
||||
validateInputs([id, ZId]);
|
||||
|
||||
try {
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiKey } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { ZApiKey } from "@formbricks/database/zod/api-keys";
|
||||
|
||||
export const ZApiKeyCreateInput = ZApiKey.required({
|
||||
label: true,
|
||||
}).pick({
|
||||
label: true,
|
||||
});
|
||||
|
||||
export type TApiKeyCreateInput = z.infer<typeof ZApiKeyCreateInput>;
|
||||
|
||||
export interface TApiKey extends ApiKey {
|
||||
apiKey?: string;
|
||||
}
|
||||
@@ -163,7 +163,6 @@ export const ThemeStyling = ({
|
||||
setOpen={setBackgroundStylingOpen}
|
||||
environmentId={environmentId}
|
||||
colors={colors}
|
||||
key={form.watch("background.bg")}
|
||||
isSettingsPage
|
||||
isUnsplashConfigured={isUnsplashConfigured}
|
||||
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
|
||||
|
||||
@@ -385,7 +385,7 @@ export const QuestionFormInput = ({
|
||||
{recallComponents}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<>
|
||||
{id === "headline" && !isWelcomeCard && (
|
||||
<TooltipRenderer tooltipContent={t("environments.surveys.edit.add_photo_or_video")}>
|
||||
<Button
|
||||
@@ -418,7 +418,7 @@ export const QuestionFormInput = ({
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
@@ -129,7 +129,7 @@
|
||||
"@types/nodemailer": "6.4.17",
|
||||
"@types/papaparse": "5.3.15",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"vite": "6.0.3",
|
||||
"vite": "6.0.9",
|
||||
"vitest": "2.1.8",
|
||||
"vitest-mock-extended": "2.0.2"
|
||||
}
|
||||
|
||||
@@ -1,125 +1,144 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import http from "http";
|
||||
import { test } from "./lib/fixtures";
|
||||
import { replaceEnvironmentIdInHtml } from "./utils/helper";
|
||||
|
||||
const HTML_TEMPLATE = `<head>
|
||||
<script type="text/javascript">
|
||||
!(function () {
|
||||
var t = document.createElement("script");
|
||||
(t.type = "text/javascript"), (t.async = !0), (t.src = "http://localhost:3000/js/formbricks.umd.cjs");
|
||||
var e = document.getElementsByTagName("script")[0];
|
||||
e.parentNode.insertBefore(t, e),
|
||||
setTimeout(function () {
|
||||
formbricks.init({
|
||||
environmentId: "ENVIRONMENT_ID",
|
||||
userId: "RANDOM_USER_ID",
|
||||
apiHost: "http://localhost:3000",
|
||||
});
|
||||
}, 500);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body style="background-color: #fff">
|
||||
<p>This is my sample page using the Formbricks JS javascript widget</p>
|
||||
</body>
|
||||
`;
|
||||
|
||||
test.describe("JS Package Test", async () => {
|
||||
let server: http.Server;
|
||||
let environmentId: string;
|
||||
|
||||
test("Tests", async ({ page, users }) => {
|
||||
await test.step("Admin creates an In-App Survey", async () => {
|
||||
const user = await users.create();
|
||||
await user.login();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
|
||||
await page.getByRole("heading", { name: "Product Market Fit (Superhuman)" }).isVisible();
|
||||
await page.getByRole("heading", { name: "Product Market Fit (Superhuman)" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Use this template" }).isVisible();
|
||||
await page.getByRole("button", { name: "Use this template" }).click();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/edit/);
|
||||
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
|
||||
await expect(page.locator("#howToSendCardTrigger")).toBeVisible();
|
||||
await page.locator("#howToSendCardTrigger").click();
|
||||
|
||||
await expect(page.locator("#howToSendCardOption-app")).toBeVisible();
|
||||
await page.locator("#howToSendCardOption-app").click();
|
||||
|
||||
await page.locator("#whenToSendCardTrigger").click();
|
||||
|
||||
await page.getByRole("button", { name: "Add action" }).click();
|
||||
await page.getByText("New SessionGets fired when a").click();
|
||||
|
||||
await page.locator("#recontactOptionsCardTrigger").click();
|
||||
|
||||
await page.locator("label").filter({ hasText: "Keep showing while conditions" }).click();
|
||||
await page.locator("#recontactDays").check();
|
||||
|
||||
await page.getByRole("button", { name: "Publish" }).click();
|
||||
|
||||
environmentId =
|
||||
/\/environments\/([^/]+)\/surveys/.exec(page.url())?.[1] ??
|
||||
(() => {
|
||||
throw new Error("Unable to parse environmentId from URL");
|
||||
})();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary/);
|
||||
test.beforeAll(async () => {
|
||||
// Create a simple HTTP server
|
||||
server = http.createServer((_, res) => {
|
||||
const htmlContent = HTML_TEMPLATE.replace("ENVIRONMENT_ID", environmentId || "");
|
||||
res.writeHead(200, { "Content-Type": "text/html" });
|
||||
res.end(htmlContent);
|
||||
});
|
||||
|
||||
await test.step("JS display survey on page and submit response", async () => {
|
||||
let currentDir = process.cwd();
|
||||
let htmlFilePath = currentDir + "/packages/js/index.html";
|
||||
|
||||
let htmlFile = replaceEnvironmentIdInHtml(htmlFilePath, environmentId);
|
||||
await page.goto(htmlFile);
|
||||
|
||||
// Formbricks In App Sync has happened
|
||||
const syncApi = await page.waitForResponse(
|
||||
(response) => {
|
||||
return response.url().includes("/environment");
|
||||
},
|
||||
{
|
||||
timeout: 120000,
|
||||
}
|
||||
);
|
||||
|
||||
expect(syncApi.status()).toBe(200);
|
||||
|
||||
// Formbricks Modal exists in the DOM
|
||||
await expect(page.locator("#formbricks-modal-container")).toHaveCount(1);
|
||||
|
||||
// Formbricks Modal is visible
|
||||
await expect(
|
||||
page.locator("#questionCard-0").getByRole("link", { name: "Powered by Formbricks" })
|
||||
).toBeVisible();
|
||||
|
||||
// Fill the Survey
|
||||
await page.getByRole("button", { name: "Happy to help!" }).click();
|
||||
await page.locator("label").filter({ hasText: "Somewhat disappointed" }).click();
|
||||
await page.locator("#questionCard-1").getByRole("button", { name: "Next" }).click();
|
||||
await page.locator("label").filter({ hasText: "Founder" }).click();
|
||||
await page.locator("#questionCard-2").getByRole("button", { name: "Next" }).click();
|
||||
await page
|
||||
.locator("#questionCard-3")
|
||||
.getByLabel("textarea")
|
||||
.fill("People who believe that PMF is necessary");
|
||||
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
|
||||
await page.locator("#questionCard-4").getByLabel("textarea").fill("Much higher response rates!");
|
||||
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
|
||||
await page.locator("#questionCard-5").getByLabel("textarea").fill("Make this end to end test pass!");
|
||||
await page.getByRole("button", { name: "Finish" }).click();
|
||||
|
||||
// loading spinner -> wait for it to disappear
|
||||
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
await test.step("Admin validates Displays & Response", async () => {
|
||||
await page.goto("/");
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
|
||||
await page.getByRole("link", { name: "product Market Fit (Superhuman)" }).click();
|
||||
(await page.waitForSelector("text=Responses")).isVisible();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const impressionsCount = await page.getByRole("button", { name: "Impressions" }).innerText();
|
||||
expect(impressionsCount).toEqual("Impressions\n\n1");
|
||||
|
||||
await expect(page.getByRole("link", { name: "Responses (1)" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Completed 100%" })).toBeVisible();
|
||||
|
||||
await expect(page.getByText("1 Responses", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("CTR100%")).toBeVisible();
|
||||
await expect(page.getByText("Somewhat disappointed100%")).toBeVisible();
|
||||
await expect(page.getByText("Founder100%")).toBeVisible();
|
||||
await expect(page.getByText("People who believe that PMF").first()).toBeVisible();
|
||||
await expect(page.getByText("Much higher response rates!").first()).toBeVisible();
|
||||
await expect(page.getByText("Make this end to end test").first()).toBeVisible();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(3004, () => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Cleanup: close the server
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
test("Create, display and validate PMF survey", async ({ page, users }) => {
|
||||
// Create and login user
|
||||
const user = await users.create();
|
||||
await user.login();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
|
||||
// Extract environmentId early in the test
|
||||
environmentId =
|
||||
/\/environments\/([^/]+)\/surveys/.exec(page.url())?.[1] ??
|
||||
(() => {
|
||||
throw new Error("Unable to parse environmentId from URL");
|
||||
})();
|
||||
|
||||
// Create survey from template
|
||||
await page.getByRole("heading", { name: "Product Market Fit (Superhuman)" }).isVisible();
|
||||
await page.getByRole("heading", { name: "Product Market Fit (Superhuman)" }).click();
|
||||
await page.getByRole("button", { name: "Use this template" }).isVisible();
|
||||
await page.getByRole("button", { name: "Use this template" }).click();
|
||||
|
||||
// Configure survey settings
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/edit/);
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
|
||||
await expect(page.locator("#howToSendCardTrigger")).toBeVisible();
|
||||
await page.locator("#howToSendCardTrigger").click();
|
||||
await expect(page.locator("#howToSendCardOption-app")).toBeVisible();
|
||||
await page.locator("#howToSendCardOption-app").click();
|
||||
|
||||
await page.locator("#whenToSendCardTrigger").click();
|
||||
await page.getByRole("button", { name: "Add action" }).click();
|
||||
await page.getByText("New SessionGets fired when a").click();
|
||||
|
||||
await page.locator("#recontactOptionsCardTrigger").click();
|
||||
await page.locator("label").filter({ hasText: "Keep showing while conditions" }).click();
|
||||
await page.locator("#recontactDays").check();
|
||||
|
||||
await page.getByRole("button", { name: "Publish" }).click();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary/);
|
||||
|
||||
// No need for file operations anymore, just use the server
|
||||
await page.goto("http://localhost:3004");
|
||||
|
||||
const syncApi = await page.waitForResponse((response) => response.url().includes("/environment"), {
|
||||
timeout: 120000,
|
||||
});
|
||||
expect(syncApi.status()).toBe(200);
|
||||
|
||||
await expect(page.locator("#formbricks-modal-container")).toHaveCount(1);
|
||||
await expect(
|
||||
page.locator("#questionCard-0").getByRole("link", { name: "Powered by Formbricks" })
|
||||
).toBeVisible();
|
||||
|
||||
// Fill the survey
|
||||
await page.getByRole("button", { name: "Happy to help!" }).click();
|
||||
await page.locator("label").filter({ hasText: "Somewhat disappointed" }).click();
|
||||
await page.locator("#questionCard-1").getByRole("button", { name: "Next" }).click();
|
||||
await page.locator("label").filter({ hasText: "Founder" }).click();
|
||||
await page.locator("#questionCard-2").getByRole("button", { name: "Next" }).click();
|
||||
await page
|
||||
.locator("#questionCard-3")
|
||||
.getByLabel("textarea")
|
||||
.fill("People who believe that PMF is necessary");
|
||||
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
|
||||
await page.locator("#questionCard-4").getByLabel("textarea").fill("Much higher response rates!");
|
||||
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
|
||||
await page.locator("#questionCard-5").getByLabel("textarea").fill("Make this end to end test pass!");
|
||||
await page.getByRole("button", { name: "Finish" }).click();
|
||||
|
||||
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Validate displays and response
|
||||
await page.goto("/");
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
await page.getByRole("link", { name: "product Market Fit (Superhuman)" }).click();
|
||||
await page.waitForSelector("text=Responses");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const impressionsCount = await page.getByRole("button", { name: "Impressions" }).innerText();
|
||||
expect(impressionsCount).toEqual("Impressions\n\n1");
|
||||
|
||||
await expect(page.getByRole("link", { name: "Responses (1)" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Completed 100%" })).toBeVisible();
|
||||
await expect(page.getByText("1 Responses", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("CTR100%")).toBeVisible();
|
||||
await expect(page.getByText("Somewhat disappointed100%")).toBeVisible();
|
||||
await expect(page.getByText("Founder100%")).toBeVisible();
|
||||
await expect(page.getByText("People who believe that PMF").first()).toBeVisible();
|
||||
await expect(page.getByText("Much higher response rates!").first()).toBeVisible();
|
||||
await expect(page.getByText("Make this end to end test").first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,6 @@ test.use({
|
||||
test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
let url: string | null;
|
||||
|
||||
test.slow();
|
||||
|
||||
test("Create survey and submit response", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.login();
|
||||
@@ -615,7 +613,7 @@ test.describe("Multi Language Survey Create", async () => {
|
||||
});
|
||||
|
||||
test.describe("Testing Survey with advanced logic", async () => {
|
||||
test.setTimeout(240000);
|
||||
test.setTimeout(300000);
|
||||
let url: string | null;
|
||||
|
||||
test("Create survey and submit response", async ({ page, users }) => {
|
||||
|
||||
@@ -43,7 +43,7 @@ x-environment: &environment
|
||||
# SMTP_AUTHENTICATED:
|
||||
|
||||
# (Additional option for TLS (port 465) only)
|
||||
# SMTP_SECURE_ENABLED: 1
|
||||
# SMTP_SECURE_ENABLED:
|
||||
|
||||
# If set to 0, the server will accept connections without requiring authorization from the list of supplied CAs (default is 1).
|
||||
# SMTP_REJECT_UNAUTHORIZED_TLS: 0
|
||||
|
||||
@@ -27,11 +27,13 @@
|
||||
"release": "turbo run build --filter=@formbricks/js... && changeset publish",
|
||||
"test": "turbo run test --no-cache",
|
||||
"test:e2e": "playwright test",
|
||||
"test-e2e:azure": "pnpm test:e2e -c playwright.service.config.ts --workers=20",
|
||||
"prepare": "husky install",
|
||||
"storybook": "turbo run storybook",
|
||||
"fb-migrate-dev": "pnpm --filter @formbricks/database create-migration && pnpm prisma generate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@azure/microsoft-playwright-testing": "1.0.0-beta.6",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@playwright/test": "1.49.1",
|
||||
"eslint": "8.57.0",
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"buffer": "6.0.3",
|
||||
"terser": "5.37.0",
|
||||
"vite": "6.0.3",
|
||||
"vite": "6.0.9",
|
||||
"vite-plugin-dts": "4.3.0",
|
||||
"vite-plugin-node-polyfills": "0.22.0"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type ApiKey } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZApiKey = z.object({
|
||||
@@ -7,12 +8,4 @@ export const ZApiKey = z.object({
|
||||
label: z.string().nullable(),
|
||||
hashedKey: z.string(),
|
||||
environmentId: z.string().cuid2(),
|
||||
apiKey: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TApiKey = z.infer<typeof ZApiKey>;
|
||||
|
||||
export const ZApiKeyCreateInput = z.object({
|
||||
label: z.string(),
|
||||
});
|
||||
export type TApiKeyCreateInput = z.infer<typeof ZApiKeyCreateInput>;
|
||||
}) satisfies z.ZodType<ApiKey>;
|
||||
@@ -47,7 +47,7 @@
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"terser": "5.37.0",
|
||||
"vite": "6.0.3",
|
||||
"vite": "6.0.9",
|
||||
"vite-plugin-dts": "4.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<head>
|
||||
<script type="text/javascript">
|
||||
!(function () {
|
||||
var t = document.createElement("script");
|
||||
(t.type = "text/javascript"), (t.async = !0), (t.src = "http://localhost:3000/js/formbricks.umd.cjs");
|
||||
var e = document.getElementsByTagName("script")[0];
|
||||
e.parentNode.insertBefore(t, e),
|
||||
setTimeout(function () {
|
||||
formbricks.init({
|
||||
environmentId: "cm4tqiyd2000fibzprjfhsny2",
|
||||
userId: "RANDOM_USER_ID",
|
||||
apiHost: "http://localhost:3000",
|
||||
});
|
||||
}, 500);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body style="background-color: #fff">
|
||||
<p>This is my sample page using the Formbricks JS javascript widget</p>
|
||||
</body>
|
||||
@@ -45,7 +45,7 @@
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"terser": "5.37.0",
|
||||
"vite": "6.0.3",
|
||||
"vite": "6.0.9",
|
||||
"vite-plugin-dts": "4.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import "server-only";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { cache } from "../cache";
|
||||
import { hasUserEnvironmentAccess } from "../environment/auth";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { apiKeyCache } from "./cache";
|
||||
import { getApiKey } from "./service";
|
||||
|
||||
export const canUserAccessApiKey = (userId: string, apiKeyId: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([userId, ZId], [apiKeyId, ZId]);
|
||||
|
||||
try {
|
||||
const apiKeyFromServer = await getApiKey(apiKeyId);
|
||||
if (!apiKeyFromServer) return false;
|
||||
|
||||
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, apiKeyFromServer.environmentId);
|
||||
if (!hasAccessToEnvironment) return false;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
[`canUserAccessApiKey-${userId}-${apiKeyId}`],
|
||||
{ tags: [apiKeyCache.tag.byId(apiKeyId)] }
|
||||
)();
|
||||
@@ -1,109 +0,0 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TApiKey } from "@formbricks/types/api-keys";
|
||||
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { cache } from "../cache";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { getHash } from "../crypto";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { apiKeyCache } from "./cache";
|
||||
|
||||
export const getApiKey = reactCache(
|
||||
async (apiKeyId: string): Promise<TApiKey | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([apiKeyId, ZString]);
|
||||
|
||||
if (!apiKeyId) {
|
||||
throw new InvalidInputError("API key cannot be null or undefined.");
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
id: apiKeyId,
|
||||
},
|
||||
});
|
||||
|
||||
return apiKeyData;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getApiKey-${apiKeyId}`],
|
||||
{
|
||||
tags: [apiKeyCache.tag.byId(apiKeyId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getApiKeys = reactCache(
|
||||
async (environmentId: string, page?: number): Promise<TApiKey[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const apiKeys = await prisma.apiKey.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
});
|
||||
|
||||
return apiKeys;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getApiKeys-${environmentId}-${page}`],
|
||||
{
|
||||
tags: [apiKeyCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getApiKeyFromKey = reactCache(async (apiKey: string): Promise<TApiKey | null> => {
|
||||
const hashedKey = getHash(apiKey);
|
||||
return cache(
|
||||
async () => {
|
||||
validateInputs([apiKey, ZString]);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new InvalidInputError("API key cannot be null or undefined.");
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey,
|
||||
},
|
||||
});
|
||||
|
||||
return apiKeyData;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getApiKeyFromKey-${apiKey}`],
|
||||
{
|
||||
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
|
||||
}
|
||||
)();
|
||||
});
|
||||
@@ -79,7 +79,7 @@ export const MAIL_FROM = env.MAIL_FROM;
|
||||
export const NEXTAUTH_SECRET = env.NEXTAUTH_SECRET;
|
||||
export const ITEMS_PER_PAGE = 30;
|
||||
export const SURVEYS_PER_PAGE = 12;
|
||||
export const RESPONSES_PER_PAGE = 20;
|
||||
export const RESPONSES_PER_PAGE = 25;
|
||||
export const TEXT_RESPONSES_PER_PAGE = 5;
|
||||
export const INSIGHTS_PER_PAGE = 10;
|
||||
export const DOCUMENTS_PER_PAGE = 10;
|
||||
|
||||
@@ -567,6 +567,7 @@
|
||||
"this_action_will_be_triggered_when_the_page_is_loaded": "Diese Aktion wird ausgelöst, wenn die Seite geladen ist.",
|
||||
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Diese Aktion wird ausgelöst, wenn der Benutzer 50% der Seite scrollt.",
|
||||
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Diese Aktion wird ausgelöst, wenn der Benutzer versucht, die Seite zu verlassen.",
|
||||
"this_is_a_code_action_please_make_changes_in_your_code_base": "Dies ist eine Code-Aktion. Bitte nehmen Sie Änderungen an Ihrem Code vor.",
|
||||
"this_is_a_code_action_you_can_only_change_the_description": "Das ist eine Code-Aktion. Du kannst nur die Beschreibung ändern.",
|
||||
"track_new_user_action": "Neue Benutzeraktion verfolgen",
|
||||
"track_user_action_to_display_surveys_or_create_user_segment": "Benutzeraktionen verfolgen, um Umfragen anzuzeigen oder Benutzersegmente zu erstellen.",
|
||||
|
||||
@@ -567,6 +567,7 @@
|
||||
"this_action_will_be_triggered_when_the_page_is_loaded": "This action will be triggered when the page is loaded.",
|
||||
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "This action will be triggered when the user scrolls 50% of the page.",
|
||||
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "This action will be triggered when the user tries to leave the page.",
|
||||
"this_is_a_code_action_please_make_changes_in_your_code_base": "This is a code action. Please make changes in your code base.",
|
||||
"this_is_a_code_action_you_can_only_change_the_description": "This is a code action. You can only change the description.",
|
||||
"track_new_user_action": "Track New User Action",
|
||||
"track_user_action_to_display_surveys_or_create_user_segment": "Track user action to display surveys or create user segment.",
|
||||
|
||||
@@ -567,6 +567,7 @@
|
||||
"this_action_will_be_triggered_when_the_page_is_loaded": "Cette action sera déclenchée lorsque la page sera chargée.",
|
||||
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Cette action sera déclenchée lorsque l'utilisateur fera défiler 50 % de la page.",
|
||||
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Cette action sera déclenchée lorsque l'utilisateur essaiera de quitter la page.",
|
||||
"this_is_a_code_action_please_make_changes_in_your_code_base": "Ceci est une action de code. Veuillez apporter des modifications à votre base de code.",
|
||||
"this_is_a_code_action_you_can_only_change_the_description": "Ceci est une action de code. Vous ne pouvez changer que la description.",
|
||||
"track_new_user_action": "Suivre l'action des nouveaux utilisateurs",
|
||||
"track_user_action_to_display_surveys_or_create_user_segment": "Suivre l'action de l'utilisateur pour afficher des enquêtes ou créer un segment d'utilisateur.",
|
||||
|
||||
@@ -567,6 +567,7 @@
|
||||
"this_action_will_be_triggered_when_the_page_is_loaded": "Essa ação vai ser disparada quando a página carregar.",
|
||||
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Essa ação vai ser acionada quando o usuário rolar 50% da página.",
|
||||
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Essa ação será acionada quando o usuário tentar sair da página.",
|
||||
"this_is_a_code_action_please_make_changes_in_your_code_base": "Esta é uma ação de código. Por favor, faça alterações na sua base de código.",
|
||||
"this_is_a_code_action_you_can_only_change_the_description": "Essa é uma ação de código. Você só pode mudar a descrição.",
|
||||
"track_new_user_action": "Rastrear Ação de Novo Usuário",
|
||||
"track_user_action_to_display_surveys_or_create_user_segment": "Rastrear ações do usuário para exibir pesquisas ou criar segmento de usuários.",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
".",
|
||||
"../types/*.d.ts",
|
||||
"../../apps/web/lib/cache/contact-attribute-key.ts",
|
||||
"../../apps/web/modules/utils/hooks"
|
||||
"../../apps/web/modules/utils/hooks",
|
||||
"../../apps/web/lib/cache/api-key.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.74.5",
|
||||
"terser": "5.37.0",
|
||||
"vite": "6.0.3",
|
||||
"vite": "6.0.9",
|
||||
"vite-plugin-dts": "4.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"serve": "14.2.4",
|
||||
"tailwindcss": "3.4.16",
|
||||
"terser": "5.37.0",
|
||||
"vite": "6.0.3",
|
||||
"vite": "6.0.9",
|
||||
"vite-plugin-dts": "4.3.0",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ButtonHTMLAttributes } from "preact/compat";
|
||||
import { useCallback } from "preact/hooks";
|
||||
import { ButtonHTMLAttributes, useRef } from "preact/compat";
|
||||
import { useCallback, useEffect } from "preact/hooks";
|
||||
|
||||
interface SubmitButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
buttonLabel: string | undefined;
|
||||
@@ -17,17 +17,34 @@ export function SubmitButton({
|
||||
type,
|
||||
...props
|
||||
}: SubmitButtonProps) {
|
||||
const buttonRef = useCallback(
|
||||
(currentButton: HTMLButtonElement | null) => {
|
||||
if (currentButton && focus) {
|
||||
setTimeout(() => {
|
||||
currentButton.focus();
|
||||
}, 200);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter" && !disabled) {
|
||||
event.preventDefault();
|
||||
const button = buttonRef.current;
|
||||
if (button) {
|
||||
button.click();
|
||||
}
|
||||
}
|
||||
},
|
||||
[focus]
|
||||
[disabled]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (buttonRef.current && focus) {
|
||||
setTimeout(() => {
|
||||
buttonRef.current?.focus();
|
||||
}, 200);
|
||||
}
|
||||
}, [focus]);
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
|
||||
@@ -57,25 +57,30 @@ export function EndingCard({
|
||||
</div>
|
||||
);
|
||||
|
||||
const processAndRedirect = (urlString: string) => {
|
||||
try {
|
||||
const url = replaceRecallInfo(urlString, responseData, variablesData);
|
||||
if (url && new URL(url)) {
|
||||
window.top?.location.replace(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Invalid URL after recall processing:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isRedirectDisabled && endingCard.type === "endScreen" && endingCard.buttonLink) {
|
||||
window.top?.location.replace(endingCard.buttonLink);
|
||||
processAndRedirect(endingCard.buttonLink);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isCurrent) {
|
||||
if (!isRedirectDisabled && endingCard.type === "redirectToUrl" && endingCard.url) {
|
||||
try {
|
||||
const url = replaceRecallInfo(endingCard.url, responseData, variablesData);
|
||||
if (url && new URL(url)) {
|
||||
window.top?.location.replace(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Invalid URL after recall processing:", error);
|
||||
}
|
||||
processAndRedirect(endingCard.url);
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnter = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSubmit();
|
||||
|
||||
@@ -32,7 +32,7 @@ export const StackedCard = ({
|
||||
cardArrangement,
|
||||
}: StackedCardProps) => {
|
||||
const isHidden = offset < 0;
|
||||
const [delayedOffset, setDelayedOffset] = useState<number>(0);
|
||||
const [delayedOffset, setDelayedOffset] = useState<number>(offset);
|
||||
const [contentOpacity, setContentOpacity] = useState<number>(0);
|
||||
const currentCardHeight = offset === 0 ? "auto" : offset < 0 ? "initial" : cardHeight;
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "./common";
|
||||
|
||||
export const ZContact = z.object({
|
||||
id: ZId,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export type TContact = z.infer<typeof ZContact>;
|
||||
@@ -20,12 +20,6 @@ export const ZDisplayCreateInput = z.object({
|
||||
|
||||
export type TDisplayCreateInput = z.infer<typeof ZDisplayCreateInput>;
|
||||
|
||||
export const ZDisplaysWithSurveyName = ZDisplay.extend({
|
||||
surveyName: z.string(),
|
||||
});
|
||||
|
||||
export type TDisplaysWithSurveyName = z.infer<typeof ZDisplaysWithSurveyName>;
|
||||
|
||||
export const ZDisplayFilters = z.object({
|
||||
createdAt: z
|
||||
.object({
|
||||
|
||||
@@ -2573,7 +2573,12 @@ export const ZSurveyQuestionSummaryMatrix = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
rowLabel: z.string(),
|
||||
columnPercentages: z.record(z.string(), z.number()),
|
||||
columnPercentages: z.array(
|
||||
z.object({
|
||||
column: z.string(),
|
||||
percentage: z.number(),
|
||||
})
|
||||
),
|
||||
totalResponsesForRow: z.number(),
|
||||
})
|
||||
),
|
||||
|
||||
@@ -17,6 +17,6 @@
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"vite": "6.0.3"
|
||||
"vite": "6.0.9"
|
||||
}
|
||||
}
|
||||
|
||||
22
playwright.service.config.ts
Normal file
22
playwright.service.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ServiceOS, getServiceConfig } from "@azure/microsoft-playwright-testing";
|
||||
import { defineConfig } from "@playwright/test";
|
||||
import config from "./playwright.config";
|
||||
|
||||
/* Learn more about service configuration at https://aka.ms/mpt/config */
|
||||
export default defineConfig(
|
||||
config,
|
||||
getServiceConfig(config, {
|
||||
exposeNetwork: "<loopback>",
|
||||
timeout: 30000,
|
||||
os: ServiceOS.LINUX,
|
||||
useCloudHostedBrowsers: true, // Set to false if you want to only use reporting and not cloud hosted browsers
|
||||
}),
|
||||
{
|
||||
/*
|
||||
Playwright Testing service reporter is added by default.
|
||||
This will override any reporter options specified in the base playwright config.
|
||||
If you are using more reporters, please update your configuration accordingly.
|
||||
*/
|
||||
reporter: [["list"], ["@azure/microsoft-playwright-testing/reporter"]],
|
||||
}
|
||||
);
|
||||
1049
pnpm-lock.yaml
generated
1049
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user