mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-29 18:00:26 -06:00
Compare commits
11 Commits
ReviewBot/
...
ReviewBot/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9a26c6f79 | ||
|
|
b141561c96 | ||
|
|
0332a2efe3 | ||
|
|
be8e461f55 | ||
|
|
722ee68b4c | ||
|
|
e4078a3307 | ||
|
|
907a9dc563 | ||
|
|
f6df94081d | ||
|
|
2436192995 | ||
|
|
f54e2e032a | ||
|
|
1a28660dfd |
@@ -165,3 +165,6 @@ ENTERPRISE_LICENSE_KEY=
|
||||
|
||||
# Ignore Rate Limiting across the Formbricks app
|
||||
# RATE_LIMITING_DISABLED=1
|
||||
|
||||
# OpenTelemetry URL for tracing
|
||||
# OPENTELEMETRY_LISTENER_URL=http://localhost:4318/v1/traces
|
||||
|
||||
3
.github/workflows/ecs-deployment.yml
vendored
3
.github/workflows/ecs-deployment.yml
vendored
@@ -78,6 +78,9 @@ jobs:
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }}
|
||||
DATABASE_URL=${{ env.DATABASE_URL }}
|
||||
ENCRYPTION_KEY=${{ env.ENCRYPTION_KEY }}
|
||||
NEXT_PUBLIC_SENTRY_DSN=${{ env.NEXT_PUBLIC_SENTRY_DSN }}
|
||||
|
||||
- name: Sign the images with GitHub OIDC Token
|
||||
|
||||
1
.github/workflows/kamal.yml
vendored
1
.github/workflows/kamal.yml
vendored
@@ -68,6 +68,7 @@ jobs:
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
||||
S3_REGION: ${{ vars.S3_REGION }}
|
||||
S3_BUCKET_NAME: ${{ vars.S3_BUCKET_NAME }}
|
||||
OPENTELEMETRY_LISTENER_URL: ${{ vars.OPENTELEMETRY_LISTENER_URL }}
|
||||
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
|
||||
|
||||
steps:
|
||||
|
||||
3
.github/workflows/pr.yml
vendored
3
.github/workflows/pr.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
- "!(**.md|.github/CODEOWNERS)"
|
||||
|
||||
test:
|
||||
name: Run Tests
|
||||
name: Run Unit Tests
|
||||
needs: [changes]
|
||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||
uses: ./.github/workflows/test.yml
|
||||
@@ -58,6 +58,7 @@ jobs:
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
name: PR Check Summary
|
||||
needs: [lint, test, build, e2e-test]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
4
.github/workflows/release-docker-github.yml
vendored
4
.github/workflows/release-docker-github.yml
vendored
@@ -89,6 +89,10 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }}
|
||||
DATABASE_URL=${{ env.DATABASE_URL }}
|
||||
ENCRYPTION_KEY=${{ env.ENCRYPTION_KEY }}
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
|
||||
4
.github/workflows/release-docker.yml
vendored
4
.github/workflows/release-docker.yml
vendored
@@ -52,3 +52,7 @@ jobs:
|
||||
tags: |
|
||||
${{ secrets.DOCKER_USERNAME }}/formbricks:${{ env.RELEASE_TAG }}
|
||||
${{ secrets.DOCKER_USERNAME }}/formbricks:latest
|
||||
build-args: |
|
||||
NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }}
|
||||
DATABASE_URL=${{ env.DATABASE_URL }}
|
||||
ENCRYPTION_KEY=${{ env.ENCRYPTION_KEY }}
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -3,7 +3,7 @@ on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
build:
|
||||
name: Tests
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Fence } from "@/components/shared/Fence";
|
||||
import {generateManagementApiMetadata} from "@/lib/utils"
|
||||
import { generateManagementApiMetadata } from "@/lib/utils";
|
||||
|
||||
export const metadata = generateManagementApiMetadata("Surveys",["Fetch","Create","Update","Delete"])
|
||||
export const metadata = generateManagementApiMetadata("Surveys", ["Fetch", "Create", "Update", "Delete"]);
|
||||
|
||||
#### Management API
|
||||
|
||||
# Surveys API
|
||||
|
||||
This set of API can be used to
|
||||
|
||||
- [List All Surveys](#list-all-surveys)
|
||||
- [Get Survey](#get-survey-by-id)
|
||||
- [Create Survey](#create-survey)
|
||||
@@ -22,8 +23,7 @@ This set of API can be used to
|
||||
|
||||
<Row>
|
||||
<Col>
|
||||
|
||||
Retrieve all the surveys you have for the environment.
|
||||
Retrieve all the surveys you have for the environment with pagination.
|
||||
|
||||
### Mandatory Headers
|
||||
|
||||
@@ -33,14 +33,26 @@ This set of API can be used to
|
||||
</Property>
|
||||
</Properties>
|
||||
|
||||
### Query Parameters
|
||||
<Properties>
|
||||
<Property name="offset" type="number">
|
||||
The number of surveys to skip before returning the results.
|
||||
</Property>
|
||||
|
||||
<Property name="limit" type="number">
|
||||
The number of surveys to return.
|
||||
</Property>
|
||||
</Properties>
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
|
||||
<CodeGroup title="Request" tag="GET" label="/api/v1/management/surveys">
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
|
||||
curl --location \
|
||||
'https://app.formbricks.com/api/v1/management/surveys' \
|
||||
'https://app.formbricks.com/api/v1/management/surveys?offset=20&limit=10' \
|
||||
--header \
|
||||
'x-api-key: <your-api-key>'
|
||||
```
|
||||
@@ -403,7 +415,6 @@ This set of API can be used to
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
|
||||
@@ -453,7 +464,7 @@ This set of API can be used to
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
```json {{ title: '401 Not Authenticated' }}
|
||||
{
|
||||
"code": "not_authenticated",
|
||||
@@ -497,7 +508,6 @@ This set of API can be used to
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
|
||||
@@ -568,7 +578,7 @@ This set of API can be used to
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
```json {{ title: '401 Not Authenticated' }}
|
||||
{
|
||||
"code": "not_authenticated",
|
||||
@@ -585,7 +595,6 @@ This set of API can be used to
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Delete Survey by ID {{ tag: 'DELETE', label: '/api/v1/management/surveys/<survey-id>' }}
|
||||
|
||||
<Row>
|
||||
|
||||
@@ -183,6 +183,7 @@ These variables can be provided at the runtime i.e. in your docker-compose file.
|
||||
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_ISSUER | Issuer URL for Custom OpenID Connect Provider (should have `.well-known` configured at this) | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | `RS256` |
|
||||
| OPENTELEMETRY_LISTENER_URL | URL for OpenTelemetry listener inside Formbricks. | optional | | |
|
||||
|
||||
## Build-time Variables
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import * as DOMPurify from "dompurify";
|
||||
|
||||
export default function HtmlBody({ htmlString, questionId }: { htmlString: string; questionId: string }) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={questionId}
|
||||
className="fb-block fb-font-normal fb-leading-6 text-sm text-slate-500 dark:text-slate-300"
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(htmlString) }}></label>
|
||||
dangerouslySetInnerHTML={{ __html: htmlString }}></label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,50 +1,51 @@
|
||||
import { slugifyWithCounter } from "@sindresorhus/slugify";
|
||||
import glob from "fast-glob";
|
||||
import * as fs from "fs";
|
||||
import { toString } from "mdast-util-to-string";
|
||||
import * as path from "path";
|
||||
import { remark } from "remark";
|
||||
import remarkMdx from "remark-mdx";
|
||||
import { createLoader } from "simple-functional-loader";
|
||||
import { filter } from "unist-util-filter";
|
||||
import { SKIP, visit } from "unist-util-visit";
|
||||
import * as url from "url";
|
||||
import { slugifyWithCounter } from '@sindresorhus/slugify'
|
||||
import glob from 'fast-glob'
|
||||
import * as fs from 'fs'
|
||||
import { toString } from 'mdast-util-to-string'
|
||||
import * as path from 'path'
|
||||
import { remark } from 'remark'
|
||||
import remarkMdx from 'remark-mdx'
|
||||
import { createLoader } from 'simple-functional-loader'
|
||||
import { filter } from 'unist-util-filter'
|
||||
import { SKIP, visit } from 'unist-util-visit'
|
||||
import * as url from 'url'
|
||||
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
const processor = remark().use(remarkMdx).use(extractSections);
|
||||
const slugify = slugifyWithCounter();
|
||||
const __filename = url.fileURLToPath(import.meta.url)
|
||||
const processor = remark().use(remarkMdx).use(extractSections)
|
||||
const slugify = slugifyWithCounter()
|
||||
|
||||
function isObjectExpression(node) {
|
||||
return (
|
||||
node.type === "mdxTextExpression" && node.data?.estree?.body?.[0]?.expression?.type === "ObjectExpression"
|
||||
);
|
||||
node.type === 'mdxTextExpression' &&
|
||||
node.data?.estree?.body?.[0]?.expression?.type === 'ObjectExpression'
|
||||
)
|
||||
}
|
||||
|
||||
function excludeObjectExpressions(tree) {
|
||||
return filter(tree, (node) => !isObjectExpression(node));
|
||||
return filter(tree, (node) => !isObjectExpression(node))
|
||||
}
|
||||
|
||||
function extractSections() {
|
||||
return (tree, { sections }) => {
|
||||
slugify.reset();
|
||||
slugify.reset()
|
||||
|
||||
visit(tree, (node) => {
|
||||
if (node.type === "heading" || node.type === "paragraph") {
|
||||
let content = toString(excludeObjectExpressions(node));
|
||||
if (node.type === "heading" && node.depth <= 2) {
|
||||
let hash = node.depth === 1 ? null : slugify(content);
|
||||
sections.push([content, hash, []]);
|
||||
if (node.type === 'heading' || node.type === 'paragraph') {
|
||||
let content = toString(excludeObjectExpressions(node))
|
||||
if (node.type === 'heading' && node.depth <= 2) {
|
||||
let hash = node.depth === 1 ? null : slugify(content)
|
||||
sections.push([content, hash, []])
|
||||
} else {
|
||||
sections.at(-1)?.[2].push(content);
|
||||
sections.at(-1)?.[2].push(content)
|
||||
}
|
||||
return SKIP;
|
||||
return SKIP
|
||||
}
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default function (nextConfig = {}) {
|
||||
let cache = new Map();
|
||||
export default function Search(nextConfig = {}) {
|
||||
let cache = new Map()
|
||||
|
||||
return Object.assign({}, nextConfig, {
|
||||
webpack(config, options) {
|
||||
@@ -52,26 +53,26 @@ export default function (nextConfig = {}) {
|
||||
test: __filename,
|
||||
use: [
|
||||
createLoader(function () {
|
||||
let appDir = path.resolve("./app");
|
||||
this.addContextDependency(appDir);
|
||||
let appDir = path.resolve('./src/app')
|
||||
this.addContextDependency(appDir)
|
||||
|
||||
let files = glob.sync("**/*.mdx", { cwd: appDir });
|
||||
let files = glob.sync('**/*.mdx', { cwd: appDir })
|
||||
let data = files.map((file) => {
|
||||
let url = "/" + file.replace(/(^|\/)page\.mdx$/, "");
|
||||
let mdx = fs.readFileSync(path.join(appDir, file), "utf8");
|
||||
let url = '/' + file.replace(/(^|\/)page\.mdx$/, '')
|
||||
let mdx = fs.readFileSync(path.join(appDir, file), 'utf8')
|
||||
|
||||
let sections = [];
|
||||
let sections = []
|
||||
|
||||
if (cache.get(file)?.[0] === mdx) {
|
||||
sections = cache.get(file)[1];
|
||||
sections = cache.get(file)[1]
|
||||
} else {
|
||||
let vfile = { value: mdx, sections };
|
||||
processor.runSync(processor.parse(vfile), vfile);
|
||||
cache.set(file, [mdx, sections]);
|
||||
let vfile = { value: mdx, sections }
|
||||
processor.runSync(processor.parse(vfile), vfile)
|
||||
cache.set(file, [mdx, sections])
|
||||
}
|
||||
|
||||
return { url, sections };
|
||||
});
|
||||
return { url, sections }
|
||||
})
|
||||
|
||||
// When this file is imported within the application
|
||||
// the following module is loaded:
|
||||
@@ -119,16 +120,16 @@ export default function (nextConfig = {}) {
|
||||
pageTitle: item.doc.pageTitle,
|
||||
}))
|
||||
}
|
||||
`;
|
||||
`
|
||||
}),
|
||||
],
|
||||
});
|
||||
})
|
||||
|
||||
if (typeof nextConfig.webpack === "function") {
|
||||
return nextConfig.webpack(config, options);
|
||||
if (typeof nextConfig.webpack === 'function') {
|
||||
return nextConfig.webpack(config, options)
|
||||
}
|
||||
|
||||
return config;
|
||||
return config
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,62 +12,60 @@
|
||||
},
|
||||
"browserslist": "defaults, not ie <= 11",
|
||||
"dependencies": {
|
||||
"@algolia/autocomplete-core": "^1.13.0",
|
||||
"@algolia/autocomplete-core": "^1.17.0",
|
||||
"@calcom/embed-react": "^1.3.2",
|
||||
"@docsearch/react": "^3.5.2",
|
||||
"@docsearch/react": "^3.6.0",
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@headlessui/react": "^1.7.18",
|
||||
"@headlessui/tailwindcss": "^0.2.0",
|
||||
"lucide-react": "^0.344.0",
|
||||
"lucide-react": "^0.356.0",
|
||||
"@mapbox/rehype-prism": "^0.9.0",
|
||||
"@mdx-js/loader": "^3.0.0",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"@next/mdx": "14.0.4",
|
||||
"@mdx-js/loader": "^3.0.1",
|
||||
"@mdx-js/react": "^3.0.1",
|
||||
"@next/mdx": "14.1.3",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.0.6",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/react-highlight-words": "^0.16.5",
|
||||
"acorn": "^8.10.0",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"clsx": "^2.0.0",
|
||||
"fast-glob": "^3.3.1",
|
||||
"flexsearch": "^0.7.31",
|
||||
"framer-motion": "10.17.8",
|
||||
"acorn": "^8.11.3",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"clsx": "^2.1.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"flexsearch": "^0.7.43",
|
||||
"framer-motion": "11.0.13",
|
||||
"lottie-web": "^5.12.2",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"mdx-annotations": "^0.1.4",
|
||||
"next": "13.4.19",
|
||||
"next": "14.1.3",
|
||||
"next-plausible": "^3.12.0",
|
||||
"next-seo": "^6.4.0",
|
||||
"next-seo": "^6.5.0",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"next-themes": "^0.2.1",
|
||||
"next-themes": "^0.3.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-highlight-words": "^0.20.0",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-icons": "^5.0.1",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-responsive-embed": "^2.1.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remark-mdx": "^3.0.0",
|
||||
"sharp": "^0.33.1",
|
||||
"remark-mdx": "^3.0.1",
|
||||
"sharp": "^0.33.2",
|
||||
"shiki": "^0.14.7",
|
||||
"simple-functional-loader": "^1.2.1",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"unist-util-filter": "^5.0.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"zustand": "^4.4.7"
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/react-highlight-words": "^0.16.7",
|
||||
"eslint-config-formbricks": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,11 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
"Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.",
|
||||
href: "https://infisical.com",
|
||||
},
|
||||
{
|
||||
name: "Keep",
|
||||
description: "Open source alert management and AIOps platform.",
|
||||
href: "https://keephq.dev",
|
||||
},
|
||||
{
|
||||
name: "Langfuse",
|
||||
description: "Open source LLM engineering platform. Debug, analyze and iterate together.",
|
||||
@@ -169,7 +174,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
name: "Requestly",
|
||||
description:
|
||||
"Makes frontend development cycle 10x faster with API Client, Mock Server, Intercept & Modify HTTP Requests and Session Replays.",
|
||||
href: "https://requestly.io",
|
||||
href: "https://requestly.com",
|
||||
},
|
||||
{
|
||||
name: "Revert",
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"extends": "@formbricks/tsconfig/nextjs.json",
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../../packages/types/*.d.ts"],
|
||||
"exclude": ["../../.env"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ const ResponsePage = ({
|
||||
|
||||
const deleteResponse = (responseId: string) => {
|
||||
setResponses(responses.filter((response) => response.id !== responseId));
|
||||
if (responseCount) {
|
||||
setResponseCount(responseCount - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const updateResponse = (responseId: string, updatedResponse: TResponse) => {
|
||||
|
||||
@@ -48,21 +48,10 @@ export default function SurveyEditor({
|
||||
}: SurveyEditorProps): JSX.Element {
|
||||
const [activeView, setActiveView] = useState<"questions" | "settings">("questions");
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
const [localSurvey, setLocalSurvey] = useState<TSurvey | null>();
|
||||
const [localSurvey, setLocalSurvey] = useState<TSurvey | null>(survey);
|
||||
const [invalidQuestions, setInvalidQuestions] = useState<String[] | null>(null);
|
||||
const [localProduct, setLocalProduct] = useState<TProduct>(product);
|
||||
|
||||
useEffect(() => {
|
||||
if (survey) {
|
||||
const surveyClone = structuredClone(survey);
|
||||
setLocalSurvey(surveyClone);
|
||||
|
||||
if (survey.questions.length > 0) {
|
||||
setActiveQuestionId(survey.questions[0].id);
|
||||
}
|
||||
}
|
||||
}, [survey]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
|
||||
@@ -4,13 +4,13 @@ import { Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { SURVEYS_PER_PAGE, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getSurveyCount } from "@formbricks/lib/survey/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import ContentWrapper from "@formbricks/ui/ContentWrapper";
|
||||
import SurveysList from "@formbricks/ui/SurveysList";
|
||||
@@ -42,21 +42,22 @@ export default async function SurveysPage({ params }) {
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
const surveys = await getSurveys(params.environmentId, 1); // workaround for now; only get the first page; better approach is in development
|
||||
|
||||
const surveyCount = await getSurveyCount(params.environmentId);
|
||||
|
||||
const environments = await getEnvironments(product.id);
|
||||
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
|
||||
|
||||
return (
|
||||
<ContentWrapper className="flex h-full flex-col justify-between">
|
||||
{surveys.length > 0 ? (
|
||||
{surveyCount > 0 ? (
|
||||
<SurveysList
|
||||
environment={environment}
|
||||
surveys={surveys}
|
||||
otherEnvironment={otherEnvironment}
|
||||
isViewer={isViewer}
|
||||
WEBAPP_URL={WEBAPP_URL}
|
||||
userId={session.user.id}
|
||||
surveysPerPage={SURVEYS_PER_PAGE}
|
||||
/>
|
||||
) : (
|
||||
<SurveyStarter
|
||||
@@ -66,7 +67,7 @@ export default async function SurveysPage({ params }) {
|
||||
user={session.user}
|
||||
/>
|
||||
)}
|
||||
{/* <SurveysList environmentId={params.environmentId} /> */}
|
||||
|
||||
<WidgetStatusIndicator environmentId={params.environmentId} type="mini" />
|
||||
</ContentWrapper>
|
||||
);
|
||||
|
||||
@@ -49,34 +49,48 @@ export const SigninForm = ({
|
||||
|
||||
const formMethods = useForm<TSigninFormState>();
|
||||
|
||||
const handleSignInResponse = async (data: TSigninFormState) => {
|
||||
const signInResponse = await signIn("credentials", {
|
||||
callbackUrl: searchParams?.get("callbackUrl") || "/",
|
||||
email: data.email.toLowerCase(),
|
||||
password: data.password,
|
||||
...(totpLogin && { totpCode: data.totpCode }),
|
||||
...(totpBackup && { backupCode: data.backupCode }),
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
return signInResponse;
|
||||
};
|
||||
|
||||
const handleSignInError = (signInResponse: any, data: TSigninFormState) => {
|
||||
if (signInResponse?.error === "second factor required") {
|
||||
setTotpLogin(true);
|
||||
setLoggingIn(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signInResponse?.error === "Email Verification is Pending") {
|
||||
router.push(`/auth/verification-requested?email=${data.email}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signInResponse?.error) {
|
||||
setLoggingIn(false);
|
||||
setSignInError(signInResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signInResponse?.error) {
|
||||
router.push(searchParams?.get("callbackUrl") || "/");
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit: SubmitHandler<TSigninFormState> = async (data) => {
|
||||
setLoggingIn(true);
|
||||
|
||||
|
||||
try {
|
||||
const signInResponse = await signIn("credentials", {
|
||||
callbackUrl: searchParams?.get("callbackUrl") || "/",
|
||||
email: data.email.toLowerCase(),
|
||||
password: data.password,
|
||||
...(totpLogin && { totpCode: data.totpCode }),
|
||||
...(totpBackup && { backupCode: data.backupCode }),
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (signInResponse?.error === "second factor required") {
|
||||
setTotpLogin(true);
|
||||
setLoggingIn(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signInResponse?.error) {
|
||||
setLoggingIn(false);
|
||||
setSignInError(signInResponse.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!signInResponse?.error) {
|
||||
router.push(searchParams?.get("callbackUrl") || "/");
|
||||
}
|
||||
const signInResponse = await handleSignInResponse(data);
|
||||
handleSignInError(signInResponse, data);
|
||||
} catch (error) {
|
||||
const errorMessage = error.toString();
|
||||
const errorFeedback = errorMessage.includes("Invalid URL")
|
||||
@@ -87,15 +101,6 @@ export const SigninForm = ({
|
||||
setLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [isPasswordFocused, setIsPasswordFocused] = useState(false);
|
||||
const [totpLogin, setTotpLogin] = useState(false);
|
||||
const [totpBackup, setTotpBackup] = useState(false);
|
||||
const [signInError, setSignInError] = useState("");
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const error = searchParams?.get("error");
|
||||
const callbackUrl = searchParams?.get("callbackUrl");
|
||||
const inviteToken = callbackUrl ? new URL(callbackUrl).searchParams.get("token") : null;
|
||||
|
||||
|
||||
@@ -10,7 +10,12 @@ export async function GET(request: Request) {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const surveys = await getSurveys(authentication.environmentId!);
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
|
||||
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
|
||||
|
||||
const surveys = await getSurveys(authentication.environmentId!, limit, offset);
|
||||
return responses.successResponse(surveys);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
|
||||
@@ -52,6 +52,9 @@ export async function GET(req: NextRequest) {
|
||||
{
|
||||
width: 800,
|
||||
height: 400,
|
||||
headers: {
|
||||
"Cache-Control": "public, s-maxage=600, max-age=1800, stale-while-revalidate=600, stale-if-error=600",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
27
apps/web/instrumentation.node.ts
Normal file
27
apps/web/instrumentation.node.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||
import { Resource } from "@opentelemetry/resources";
|
||||
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||
import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
||||
import { SEMRESATTRS_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
||||
|
||||
export function startInstrumentationForNode(url: string) {
|
||||
try {
|
||||
const exporter = new OTLPTraceExporter({
|
||||
url,
|
||||
});
|
||||
|
||||
const sdk = new NodeSDK({
|
||||
resource: new Resource({
|
||||
[SEMRESATTRS_SERVICE_NAME]: "Formbricks",
|
||||
}),
|
||||
traceExporter: exporter,
|
||||
spanProcessor: new SimpleSpanProcessor(exporter),
|
||||
instrumentations: [getNodeAutoInstrumentations()],
|
||||
});
|
||||
|
||||
sdk.start();
|
||||
} catch (err) {
|
||||
console.error("Unable to setup Telemetry:", err);
|
||||
}
|
||||
}
|
||||
7
apps/web/instrumentation.ts
Normal file
7
apps/web/instrumentation.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs" && process.env.OPENTELEMETRY_LISTENER_URL) {
|
||||
const { startInstrumentationForNode } = await import("./instrumentation.node");
|
||||
|
||||
startInstrumentationForNode(process.env.OPENTELEMETRY_LISTENER_URL);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const nextConfig = {
|
||||
output: "standalone",
|
||||
experimental: {
|
||||
serverComponentsExternalPackages: ["@aws-sdk"],
|
||||
instrumentationHook: true,
|
||||
},
|
||||
transpilePackages: ["@formbricks/database", "@formbricks/ee", "@formbricks/ui", "@formbricks/lib"],
|
||||
images: {
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@headlessui/react": "^1.7.18",
|
||||
"@json2csv/node": "^7.0.6",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.43.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.49.1",
|
||||
"@opentelemetry/resources": "^1.22.0",
|
||||
"@opentelemetry/sdk-node": "^0.49.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.22.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@react-email/components": "^0.0.15",
|
||||
|
||||
@@ -12,15 +12,17 @@ test.describe("JS Package Test", async () => {
|
||||
await signUpAndLogin(page, name, email, password);
|
||||
await finishOnboarding(page);
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
// await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
|
||||
await page.getByRole("link", { name: "New survey", exact: true }).click();
|
||||
await page
|
||||
.getByText("Product ExperienceProduct Market Fit (Superhuman)Measure PMF by assessing how")
|
||||
.isVisible();
|
||||
|
||||
await page
|
||||
.getByText("Product ExperienceProduct Market Fit (Superhuman)Measure PMF by assessing how")
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Use this template" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
|
||||
await expect(page.locator("#howToSendCardTrigger")).toBeVisible();
|
||||
@@ -31,7 +33,7 @@ test.describe("JS Package Test", async () => {
|
||||
await page.locator("#howToSendCardOption-web").click();
|
||||
|
||||
await expect(page.getByText("Survey Trigger")).toBeVisible();
|
||||
await page.getByText("Survey Trigger").click();
|
||||
// await page.getByText("Survey Trigger").click();
|
||||
|
||||
await page.getByRole("combobox").click();
|
||||
await page.getByLabel("New Session").click();
|
||||
@@ -44,6 +46,12 @@ test.describe("JS Package Test", async () => {
|
||||
})();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary/);
|
||||
|
||||
expect(page.getByRole("link", { name: "Surveys" })).toBeVisible();
|
||||
await page.getByRole("link", { name: "Surveys" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Surveys" })).toBeVisible();
|
||||
|
||||
await page.screenshot();
|
||||
});
|
||||
|
||||
test("JS Display Survey on Page", async ({ page }) => {
|
||||
@@ -105,7 +113,7 @@ test.describe("JS Package Test", async () => {
|
||||
// Formbricks Modal is not visible
|
||||
await expect(page.getByText("Powered by Formbricks")).not.toBeVisible({ timeout: 10000 });
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(1500);
|
||||
await page.waitForTimeout(3000);
|
||||
});
|
||||
|
||||
test("Admin validates Displays & Response", async ({ page }) => {
|
||||
|
||||
@@ -86,7 +86,7 @@ test.describe("Invite, accept and remove team member", async () => {
|
||||
await page.getByRole("link", { name: "Create account" }).click();
|
||||
|
||||
await signupUsingInviteToken(page, name, email, password);
|
||||
await finishOnboarding(page);
|
||||
await finishOnboarding(page, false);
|
||||
});
|
||||
|
||||
test("Remove member", async ({ page }) => {
|
||||
|
||||
@@ -54,24 +54,40 @@ export const login = async (page: Page, email: string, password: string): Promis
|
||||
await page.getByRole("button", { name: "Login with Email" }).click();
|
||||
};
|
||||
|
||||
export const finishOnboarding = async (page: Page): Promise<void> => {
|
||||
export const finishOnboarding = async (page: Page, deleteExampleSurvey: boolean = true): Promise<void> => {
|
||||
await page.waitForURL("/onboarding");
|
||||
await expect(page).toHaveURL("/onboarding");
|
||||
|
||||
const hiddenSkipButton = page.locator("#FB__INTERNAL__SKIP_ONBOARDING");
|
||||
hiddenSkipButton.evaluate((el: HTMLElement) => el.click());
|
||||
|
||||
// await page.getByRole("button", { name: "In-app Surveys Run a survey" }).click();
|
||||
|
||||
// await page.getByRole("button", { name: "Skip" }).click();
|
||||
// await page.getByRole("button", { name: "Skip" }).click();
|
||||
|
||||
// await page.getByRole("button", { name: "I am not sure how to do this" }).click();
|
||||
// await page.locator("input").click();
|
||||
// await page.locator("input").fill("test@gmail.com");
|
||||
// await page.getByRole("button", { name: "Invite" }).click();
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
await expect(page.getByText("My Product")).toBeVisible();
|
||||
|
||||
let currentDir = process.cwd();
|
||||
let htmlFilePath = currentDir + "/packages/js/index.html";
|
||||
|
||||
const environmentId =
|
||||
/\/environments\/([^/]+)\/surveys/.exec(page.url())?.[1] ??
|
||||
(() => {
|
||||
throw new Error("Unable to parse environmentId from URL");
|
||||
})();
|
||||
|
||||
let htmlFile = replaceEnvironmentIdInHtml(htmlFilePath, environmentId);
|
||||
await page.goto(htmlFile);
|
||||
|
||||
// Formbricks In App Sync has happened
|
||||
const syncApi = await page.waitForResponse((response) => response.url().includes("/in-app/sync"));
|
||||
expect(syncApi.status()).toBe(200);
|
||||
|
||||
await page.goto("/");
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
|
||||
if (deleteExampleSurvey) {
|
||||
await page.click("#example-survey-survey-actions");
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
await page.getByRole("button", { name: "Delete" }).click();
|
||||
await page.reload();
|
||||
}
|
||||
};
|
||||
|
||||
export const replaceEnvironmentIdInHtml = (filePath: string, environmentId: string): string => {
|
||||
@@ -113,9 +129,11 @@ export const createSurvey = async (
|
||||
const addQuestion = "Add QuestionAdd a new question to your survey";
|
||||
|
||||
await signUpAndLogin(page, name, email, password);
|
||||
await finishOnboarding(page);
|
||||
await finishOnboarding(page, false);
|
||||
|
||||
await page.getByRole("link", { name: "New survey", exact: true }).click();
|
||||
await page.getByRole("heading", { name: "Start from Scratch" }).click();
|
||||
await page.getByRole("button", { name: "Create survey", exact: true }).click();
|
||||
|
||||
// Welcome Card
|
||||
await expect(page.locator("#welcome-toggle")).toBeVisible();
|
||||
@@ -226,11 +244,7 @@ export const createSurvey = async (
|
||||
await page.getByLabel("Question").fill(params.fileUploadQuestion.question);
|
||||
|
||||
// Thank You Card
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: /^Thank You CardShown$/ })
|
||||
.nth(1)
|
||||
.click();
|
||||
page.getByText("Thank You CardShownShow").click();
|
||||
await page.getByLabel("Question").fill(params.thankYouCard.headline);
|
||||
await page.getByLabel("Description").fill(params.thankYouCard.description);
|
||||
};
|
||||
|
||||
@@ -76,6 +76,7 @@ env:
|
||||
- NEXT_PUBLIC_FORMBRICKS_API_HOST
|
||||
- NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID
|
||||
- NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID
|
||||
- OPENTELEMETRY_LISTENER_URL
|
||||
- NEXT_PUBLIC_SENTRY_DSN
|
||||
- CLOUDFLARE_EMAIL
|
||||
- CLOUDFLARE_DNS_API_TOKEN
|
||||
|
||||
@@ -75,6 +75,7 @@ export const MAIL_FROM = env.MAIL_FROM;
|
||||
|
||||
export const NEXTAUTH_SECRET = env.NEXTAUTH_SECRET;
|
||||
export const ITEMS_PER_PAGE = 50;
|
||||
export const SURVEYS_PER_PAGE = 20;
|
||||
export const RESPONSES_PER_PAGE = 10;
|
||||
export const TEXT_RESPONSES_PER_PAGE = 5;
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ export const env = createEnv({
|
||||
OIDC_DISPLAY_NAME: z.string().optional(),
|
||||
OIDC_ISSUER: z.string().optional(),
|
||||
OIDC_SIGNING_ALGORITHM: z.string().optional(),
|
||||
OPENTELEMETRY_LISTENER_URL: z.string().optional(),
|
||||
ONBOARDING_DISABLED: z.string().optional(),
|
||||
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
PRIVACY_URL: z
|
||||
@@ -145,6 +146,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_POSTHOG_API_HOST: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
|
||||
NEXT_PUBLIC_POSTHOG_API_KEY: process.env.NEXT_PUBLIC_POSTHOG_API_KEY,
|
||||
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
|
||||
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
|
||||
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
|
||||
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID,
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
"test": "dotenv -e ../../.env -- vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.529.1",
|
||||
"@aws-sdk/s3-presigned-post": "3.529.1",
|
||||
"@aws-sdk/s3-request-presigner": "3.529.1",
|
||||
"@aws-sdk/client-s3": "3.533.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.533.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.533.0",
|
||||
"@formbricks/api": "*",
|
||||
"@formbricks/database": "*",
|
||||
"@formbricks/types": "*",
|
||||
|
||||
@@ -263,19 +263,29 @@ export const getSurveysByActionClassId = async (actionClassId: string, page?: nu
|
||||
return surveys.map((survey) => formatDateFields(survey, ZSurvey));
|
||||
};
|
||||
|
||||
export const getSurveys = async (environmentId: string, page?: number): Promise<TSurvey[]> => {
|
||||
export const getSurveys = async (
|
||||
environmentId: string,
|
||||
limit?: number,
|
||||
offset?: number
|
||||
): Promise<TSurvey[]> => {
|
||||
const surveys = await unstable_cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
validateInputs([environmentId, ZId], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
|
||||
let surveysPrisma;
|
||||
|
||||
try {
|
||||
surveysPrisma = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
},
|
||||
select: selectSurvey,
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
orderBy: [
|
||||
{
|
||||
updatedAt: "desc",
|
||||
},
|
||||
],
|
||||
take: limit ? limit : undefined,
|
||||
skip: offset ? offset : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -308,7 +318,7 @@ export const getSurveys = async (environmentId: string, page?: number): Promise<
|
||||
}
|
||||
return surveys;
|
||||
},
|
||||
[`getSurveys-${environmentId}-${page}`],
|
||||
[`getSurveys-${environmentId}-${limit}-${offset}`],
|
||||
{
|
||||
tags: [surveyCache.tag.byEnvironmentId(environmentId)],
|
||||
revalidate: SERVICES_REVALIDATION_INTERVAL,
|
||||
@@ -320,6 +330,37 @@ export const getSurveys = async (environmentId: string, page?: number): Promise<
|
||||
return surveys.map((survey) => formatDateFields(survey, ZSurvey));
|
||||
};
|
||||
|
||||
export const getSurveyCount = async (environmentId: string): Promise<number> => {
|
||||
const count = await unstable_cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
try {
|
||||
const surveyCount = await prisma.survey.count({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
return surveyCount;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
console.error(error);
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getSurveyCount-${environmentId}`],
|
||||
{
|
||||
tags: [surveyCache.tag.byEnvironmentId(environmentId)],
|
||||
revalidate: SERVICES_REVALIDATION_INTERVAL,
|
||||
}
|
||||
)();
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> => {
|
||||
validateInputs([updatedSurvey, ZSurveyWithRefinements]);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
deleteSurvey,
|
||||
duplicateSurvey,
|
||||
getSurvey,
|
||||
getSurveyCount,
|
||||
getSurveys,
|
||||
getSurveysByActionClassId,
|
||||
getSyncSurveys,
|
||||
@@ -31,6 +32,10 @@ import {
|
||||
updateSurveyInput,
|
||||
} from "./__mock__/survey.mock";
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.survey.count.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
describe("Tests for getSurvey", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Returns a survey", async () => {
|
||||
@@ -95,7 +100,7 @@ describe("Tests for getSurveysByActionClassId", () => {
|
||||
|
||||
describe("Tests for getSurveys", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Returns an array of surveys for a given environmentId and page", async () => {
|
||||
it("Returns an array of surveys for a given environmentId, limit(optional) and offset(optional)", async () => {
|
||||
prisma.survey.findMany.mockResolvedValueOnce([mockSurveyOutput]);
|
||||
const surveys = await getSurveys(mockId);
|
||||
expect(surveys).toEqual([mockTransformedSurveyOutput]);
|
||||
@@ -311,3 +316,29 @@ describe("Tests for getSyncedSurveys", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for getSurveyCount service", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Counts the total number of surveys for a given environment ID", async () => {
|
||||
const count = await getSurveyCount(mockId);
|
||||
expect(count).toEqual(1);
|
||||
});
|
||||
|
||||
it("Returns zero count when there are no surveys for a given environment ID", async () => {
|
||||
prisma.survey.count.mockResolvedValue(0);
|
||||
const count = await getSurveyCount(mockId);
|
||||
expect(count).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
testInputValidation(getSurveyCount, "123");
|
||||
|
||||
it("Throws a generic Error for other unexpected issues", async () => {
|
||||
const mockErrorMessage = "Mock error message";
|
||||
prisma.survey.count.mockRejectedValue(new Error(mockErrorMessage));
|
||||
|
||||
await expect(getSurveyCount(mockId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,31 @@ interface QuestionImageProps {
|
||||
|
||||
export default function QuestionImage({ imgUrl, altText = "Image" }: QuestionImageProps) {
|
||||
return (
|
||||
<div className="mb-4 rounded-md">
|
||||
<div className="group/image relative mb-4 block rounded-md">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={imgUrl} alt={altText} className="mb-4 rounded-md" />
|
||||
<img src={imgUrl} alt={altText} className="rounded-md" />
|
||||
<a
|
||||
href={imgUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="absolute bottom-2 right-2 flex items-center gap-2 rounded-md bg-gray-800 bg-opacity-40 p-1.5 text-white opacity-0 backdrop-blur-lg transition duration-300 ease-in-out hover:bg-opacity-65 group-hover/image:opacity-100">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-expand">
|
||||
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
|
||||
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
|
||||
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
|
||||
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function PictureSelectionQuestion({
|
||||
Array.isArray(value) && value.includes(choice.id)
|
||||
? `border-brand text-brand z-10 border-4 shadow-xl focus:border-4`
|
||||
: "",
|
||||
"border-border focus:border-border-highlight focus:bg-accent-selected-bg relative box-border inline-block h-28 w-full overflow-hidden rounded-xl border focus:outline-none"
|
||||
"border-border focus:border-border-highlight focus:bg-accent-selected-bg group/image relative box-border inline-block h-28 w-full cursor-pointer overflow-hidden rounded-xl border focus:outline-none"
|
||||
)}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -127,6 +127,30 @@ export default function PictureSelectionQuestion({
|
||||
alt={choice.imageUrl.split("/").pop()}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<a
|
||||
href={choice.imageUrl}
|
||||
target="_blank"
|
||||
title="Open in new tab"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute bottom-2 right-2 flex items-center gap-2 whitespace-nowrap rounded-md bg-gray-800 bg-opacity-40 p-1.5 text-white opacity-0 backdrop-blur-lg transition duration-300 ease-in-out hover:bg-opacity-65 group-hover/image:opacity-100">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-expand">
|
||||
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
|
||||
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
|
||||
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
|
||||
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
|
||||
</svg>
|
||||
</a>
|
||||
{question.allowMulti ? (
|
||||
<input
|
||||
id={`${choice.id}-checked`}
|
||||
|
||||
@@ -8,10 +8,20 @@ import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { deleteSurvey, duplicateSurvey, getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { deleteSurvey, duplicateSurvey, getSurvey, getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { generateSurveySingleUseId } from "@formbricks/lib/utils/singleUseSurveys";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const getSurveyAction = async (surveyId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await getSurvey(surveyId);
|
||||
};
|
||||
|
||||
export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
@@ -212,3 +222,13 @@ export async function generateSingleUseIdAction(surveyId: string, isEncrypted: b
|
||||
|
||||
return generateSurveySingleUseId(isEncrypted);
|
||||
}
|
||||
|
||||
export async function getSurveysAction(environmentId: string, limit?: number, offset?: number) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await getSurveys(environmentId, limit, offset);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ interface SurveyCardProps {
|
||||
isViewer: boolean;
|
||||
WEBAPP_URL: string;
|
||||
orientation: string;
|
||||
duplicateSurvey: (survey: TSurvey) => void;
|
||||
deleteSurvey: (surveyId: string) => void;
|
||||
}
|
||||
export default function SurveyCard({
|
||||
survey,
|
||||
@@ -26,6 +28,8 @@ export default function SurveyCard({
|
||||
isViewer,
|
||||
WEBAPP_URL,
|
||||
orientation,
|
||||
deleteSurvey,
|
||||
duplicateSurvey,
|
||||
}: SurveyCardProps) {
|
||||
const isSurveyCreationDeletionDisabled = isViewer;
|
||||
|
||||
@@ -85,6 +89,8 @@ export default function SurveyCard({
|
||||
webAppUrl={WEBAPP_URL}
|
||||
singleUseId={singleUseId}
|
||||
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
|
||||
duplicateSurvey={duplicateSurvey}
|
||||
deleteSurvey={deleteSurvey}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -145,6 +151,8 @@ export default function SurveyCard({
|
||||
webAppUrl={WEBAPP_URL}
|
||||
singleUseId={singleUseId}
|
||||
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
|
||||
duplicateSurvey={duplicateSurvey}
|
||||
deleteSurvey={deleteSurvey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../../DropdownMenu";
|
||||
import LoadingSpinner from "../../LoadingSpinner";
|
||||
import { copyToOtherEnvironmentAction, deleteSurveyAction, duplicateSurveyAction } from "../actions";
|
||||
import {
|
||||
copyToOtherEnvironmentAction,
|
||||
deleteSurveyAction,
|
||||
duplicateSurveyAction,
|
||||
getSurveyAction,
|
||||
} from "../actions";
|
||||
|
||||
interface SurveyDropDownMenuProps {
|
||||
environmentId: string;
|
||||
@@ -29,6 +34,8 @@ interface SurveyDropDownMenuProps {
|
||||
webAppUrl: string;
|
||||
singleUseId?: string;
|
||||
isSurveyCreationDeletionDisabled?: boolean;
|
||||
duplicateSurvey: (survey: TSurvey) => void;
|
||||
deleteSurvey: (surveyId: string) => void;
|
||||
}
|
||||
|
||||
export default function SurveyDropDownMenu({
|
||||
@@ -39,6 +46,8 @@ export default function SurveyDropDownMenu({
|
||||
webAppUrl,
|
||||
singleUseId,
|
||||
isSurveyCreationDeletionDisabled,
|
||||
deleteSurvey,
|
||||
duplicateSurvey,
|
||||
}: SurveyDropDownMenuProps) {
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -51,6 +60,7 @@ export default function SurveyDropDownMenu({
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteSurveyAction(survey.id);
|
||||
deleteSurvey(survey.id);
|
||||
router.refresh();
|
||||
setDeleteDialogOpen(false);
|
||||
toast.success("Survey deleted successfully.");
|
||||
@@ -63,8 +73,10 @@ export default function SurveyDropDownMenu({
|
||||
const duplicateSurveyAndRefresh = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await duplicateSurveyAction(environmentId, surveyId);
|
||||
const duplicatedSurvey = await duplicateSurveyAction(environmentId, surveyId);
|
||||
router.refresh();
|
||||
const transformedDuplicatedSurvey = await getSurveyAction(duplicatedSurvey.id);
|
||||
if (transformedDuplicatedSurvey) duplicateSurvey(transformedDuplicatedSurvey);
|
||||
toast.success("Survey duplicated successfully.");
|
||||
} catch (error) {
|
||||
toast.error("Failed to duplicate the survey.");
|
||||
@@ -95,7 +107,9 @@ export default function SurveyDropDownMenu({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
id={`${survey.name.toLowerCase().split(" ").join("-")}-survey-actions`}
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div className="rounded-lg border p-2 hover:bg-slate-50">
|
||||
@@ -228,6 +242,6 @@ export default function SurveyDropDownMenu({
|
||||
text="Are you sure you want to delete this survey and all of its responses? This action cannot be undone."
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { Button } from "../v2/Button";
|
||||
import { getSurveysAction } from "./actions";
|
||||
import SurveyCard from "./components/SurveyCard";
|
||||
import SurveyFilters from "./components/SurveyFilters";
|
||||
|
||||
interface SurveysListProps {
|
||||
environment: TEnvironment;
|
||||
surveys: TSurvey[];
|
||||
otherEnvironment: TEnvironment;
|
||||
isViewer: boolean;
|
||||
WEBAPP_URL: string;
|
||||
userId: string;
|
||||
surveysPerPage: number;
|
||||
}
|
||||
|
||||
export default function SurveysList({
|
||||
environment,
|
||||
surveys,
|
||||
otherEnvironment,
|
||||
isViewer,
|
||||
WEBAPP_URL,
|
||||
userId,
|
||||
surveysPerPage: surveysLimit,
|
||||
}: SurveysListProps) {
|
||||
const [surveys, setSurveys] = useState<TSurvey[]>([]);
|
||||
const [isFetching, setIsFetching] = useState(true);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
|
||||
const [filteredSurveys, setFilteredSurveys] = useState<TSurvey[]>(surveys);
|
||||
|
||||
// Initialize orientation state with a function that checks if window is defined
|
||||
const [orientation, setOrientation] = useState(() =>
|
||||
typeof localStorage !== "undefined" ? localStorage.getItem("surveyOrientation") || "grid" : "grid"
|
||||
@@ -38,6 +44,37 @@ export default function SurveysList({
|
||||
localStorage.setItem("surveyOrientation", orientation);
|
||||
}, [orientation]);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchInitialSurveys() {
|
||||
setIsFetching(true);
|
||||
const res = await getSurveysAction(environment.id, surveysLimit);
|
||||
if (res.length < surveysLimit) setHasMore(false);
|
||||
setSurveys(res);
|
||||
setIsFetching(false);
|
||||
}
|
||||
fetchInitialSurveys();
|
||||
}, [environment.id, surveysLimit]);
|
||||
|
||||
const fetchNextPage = useCallback(async () => {
|
||||
setIsFetching(true);
|
||||
const newSurveys = await getSurveysAction(environment.id, surveysLimit, surveys.length);
|
||||
if (newSurveys.length === 0 || newSurveys.length < surveysLimit) {
|
||||
setHasMore(false);
|
||||
}
|
||||
setSurveys([...surveys, ...newSurveys]);
|
||||
setIsFetching(false);
|
||||
}, [environment.id, surveys, surveysLimit]);
|
||||
|
||||
const handleDeleteSurvey = async (surveyId: string) => {
|
||||
const newSurveys = surveys.filter((survey) => survey.id !== surveyId);
|
||||
setSurveys(newSurveys);
|
||||
};
|
||||
|
||||
const handleDuplicateSurvey = async (survey: TSurvey) => {
|
||||
const newSurveys = [survey, ...surveys];
|
||||
setSurveys(newSurveys);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between">
|
||||
@@ -77,6 +114,8 @@ export default function SurveysList({
|
||||
isViewer={isViewer}
|
||||
WEBAPP_URL={WEBAPP_URL}
|
||||
orientation={orientation}
|
||||
duplicateSurvey={handleDuplicateSurvey}
|
||||
deleteSurvey={handleDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -94,17 +133,27 @@ export default function SurveysList({
|
||||
isViewer={isViewer}
|
||||
WEBAPP_URL={WEBAPP_URL}
|
||||
orientation={orientation}
|
||||
duplicateSurvey={handleDuplicateSurvey}
|
||||
deleteSurvey={handleDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center py-5">
|
||||
<Button onClick={fetchNextPage} variant="secondary" size="sm" loading={isFetching}>
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<span className="mb-4 h-24 w-24 rounded-full bg-slate-100 p-6 text-5xl">🕵️</span>
|
||||
|
||||
<div className="text-slate-600">No surveys found</div>
|
||||
<div className="text-slate-600">{isFetching ? "Fetching Surveys" : "No surveys found"}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
3627
pnpm-lock.yaml
generated
3627
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -104,6 +104,8 @@
|
||||
"NEXT_PUBLIC_FORMBRICKS_COM_API_HOST",
|
||||
"NEXT_PUBLIC_FORMBRICKS_COM_ENVIRONMENT_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_COM_DOCS_FEEDBACK_SURVEY_ID",
|
||||
"OPENTELEMETRY_LISTENER_URL",
|
||||
"NEXT_RUNTIME",
|
||||
"NEXTAUTH_SECRET",
|
||||
"NEXTAUTH_URL",
|
||||
"NODE_ENV",
|
||||
|
||||
Reference in New Issue
Block a user