mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 06:00:51 -06:00
Compare commits
134 Commits
add-cursor
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09f40ad816 | ||
|
|
689b6491b3 | ||
|
|
b70b2eef95 | ||
|
|
392a95834b | ||
|
|
66d9cc8eac | ||
|
|
befdc078f1 | ||
|
|
13b983b3b2 | ||
|
|
1e285ebe4e | ||
|
|
a7c4971952 | ||
|
|
c8689d91d5 | ||
|
|
73a2ff7421 | ||
|
|
0c28e89b41 | ||
|
|
a736436e29 | ||
|
|
7dbb0300d3 | ||
|
|
e71f3f412c | ||
|
|
07ed926225 | ||
|
|
15dc83a4eb | ||
|
|
3ce07edf43 | ||
|
|
0f34d9cc5f | ||
|
|
e9f800f017 | ||
|
|
ba2070b638 | ||
|
|
75cdb25d27 | ||
|
|
6bc7db852c | ||
|
|
ffb4eac1a4 | ||
|
|
56da3b5725 | ||
|
|
c189af5482 | ||
|
|
5dbf42fd6a | ||
|
|
42525a86a8 | ||
|
|
b96f0e67c5 | ||
|
|
2d7b99ba26 | ||
|
|
666a79044f | ||
|
|
c3d97c2932 | ||
|
|
cc5d630a05 | ||
|
|
be38d76ccf | ||
|
|
a8eea306e5 | ||
|
|
4fd53ac115 | ||
|
|
eb92392ed1 | ||
|
|
7412b32526 | ||
|
|
193346a70d | ||
|
|
a1d4754b04 | ||
|
|
f4b918a4b6 | ||
|
|
fb9a0b197a | ||
|
|
95b6c16dd1 | ||
|
|
cfdf09650f | ||
|
|
4c94fc25ae | ||
|
|
ccf501d925 | ||
|
|
04dfbe0777 | ||
|
|
cbf255ab0d | ||
|
|
942366956c | ||
|
|
a6ee796cef | ||
|
|
a535529bd3 | ||
|
|
018cef61a6 | ||
|
|
c53e4f54cb | ||
|
|
e2fd71abfd | ||
|
|
f888aa8a19 | ||
|
|
2698817adb | ||
|
|
2c18912f2f | ||
|
|
f57497d8b3 | ||
|
|
aab6798b29 | ||
|
|
f07092595f | ||
|
|
c03c7ec1ed | ||
|
|
628de8e6ae | ||
|
|
be4b54a827 | ||
|
|
e03df83e88 | ||
|
|
ed26427302 | ||
|
|
554809742b | ||
|
|
28adfb905c | ||
|
|
05c455ed62 | ||
|
|
f7687bc0ea | ||
|
|
af34391309 | ||
|
|
70978fbbdf | ||
|
|
f6683d1165 | ||
|
|
13be7a8970 | ||
|
|
0472d5e8f0 | ||
|
|
00a61f7abe | ||
|
|
6999abba3b | ||
|
|
9ae66f44ae | ||
|
|
7933d0077a | ||
|
|
cc8289fa33 | ||
|
|
c458051839 | ||
|
|
718a199d5b | ||
|
|
5ab9fdf1e3 | ||
|
|
5741209aa9 | ||
|
|
35d0d8ed54 | ||
|
|
5bce5c0a3b | ||
|
|
c61212964c | ||
|
|
b8d41a6e9b | ||
|
|
eedd5200a4 | ||
|
|
71a85c7126 | ||
|
|
341e2639e1 | ||
|
|
056470e6f0 | ||
|
|
e965ad4b97 | ||
|
|
12e703c02b | ||
|
|
07065f2675 | ||
|
|
7ca45cefeb | ||
|
|
4df28878db | ||
|
|
b355d05b25 | ||
|
|
e757e9aec9 | ||
|
|
cf4119baf6 | ||
|
|
6be2ae3071 | ||
|
|
600b793641 | ||
|
|
cde03b6997 | ||
|
|
00371bfb01 | ||
|
|
6be6782531 | ||
|
|
3ae4f8aa68 | ||
|
|
3d3c69a92b | ||
|
|
b1b94eaa66 | ||
|
|
67cc96449d | ||
|
|
bf41a53b86 | ||
|
|
26292ecf39 | ||
|
|
056e572a31 | ||
|
|
d7bbd219a3 | ||
|
|
fe5ff9a71c | ||
|
|
4e3438683e | ||
|
|
f587446079 | ||
|
|
7a3d05eb9a | ||
|
|
906b4da33c | ||
|
|
33b9ee3a50 | ||
|
|
5a693a548c | ||
|
|
20614c2b12 | ||
|
|
0c5e079d6f | ||
|
|
b3c16c8731 | ||
|
|
a6d45a63fa | ||
|
|
a5fa876aa3 | ||
|
|
c9a50a6ff2 | ||
|
|
19389bfffc | ||
|
|
accb4f461d | ||
|
|
c04c351244 | ||
|
|
f7f8f07778 | ||
|
|
3634385c6c | ||
|
|
8bdfc0686f | ||
|
|
74405cc05f | ||
|
|
785359955a | ||
|
|
f6157d5109 |
@@ -1,3 +0,0 @@
|
|||||||
Build the full app and if you run into any errors, fix them and build again until the app builds.
|
|
||||||
|
|
||||||
Complete when: This is complete only when the app build fully.
|
|
||||||
352
.cursor/commands/create-question.md
Normal file
352
.cursor/commands/create-question.md
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
# Create New Question Element
|
||||||
|
|
||||||
|
Use this command to scaffold a new question element component in `packages/survey-ui/src/elements/`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
When creating a new question type (e.g., `single-select`, `rating`, `nps`), follow these steps:
|
||||||
|
|
||||||
|
1. **Create the component file** `{question-type}.tsx` with this structure:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as React from "react";
|
||||||
|
import { ElementHeader } from "../components/element-header";
|
||||||
|
import { useTextDirection } from "../hooks/use-text-direction";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
interface {QuestionType}Props {
|
||||||
|
/** Unique identifier for the element container */
|
||||||
|
elementId: string;
|
||||||
|
/** The main question or prompt text displayed as the headline */
|
||||||
|
headline: string;
|
||||||
|
/** Optional descriptive text displayed below the headline */
|
||||||
|
description?: string;
|
||||||
|
/** Unique identifier for the input/control group */
|
||||||
|
inputId: string;
|
||||||
|
/** Current value */
|
||||||
|
value?: {ValueType};
|
||||||
|
/** Callback function called when the value changes */
|
||||||
|
onChange: (value: {ValueType}) => void;
|
||||||
|
/** Whether the field is required (shows asterisk indicator) */
|
||||||
|
required?: boolean;
|
||||||
|
/** Error message to display */
|
||||||
|
errorMessage?: string;
|
||||||
|
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||||
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
/** Whether the controls are disabled */
|
||||||
|
disabled?: boolean;
|
||||||
|
// Add question-specific props here
|
||||||
|
}
|
||||||
|
|
||||||
|
function {QuestionType}({
|
||||||
|
elementId,
|
||||||
|
headline,
|
||||||
|
description,
|
||||||
|
inputId,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
required = false,
|
||||||
|
errorMessage,
|
||||||
|
dir = "auto",
|
||||||
|
disabled = false,
|
||||||
|
// ... question-specific props
|
||||||
|
}: {QuestionType}Props): React.JSX.Element {
|
||||||
|
// Ensure value is always the correct type (handle undefined/null)
|
||||||
|
const currentValue = value ?? {defaultValue};
|
||||||
|
|
||||||
|
// Detect text direction from content
|
||||||
|
const detectedDir = useTextDirection({
|
||||||
|
dir,
|
||||||
|
textContent: [headline, description ?? "", /* add other text content from question */],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||||
|
{/* Headline */}
|
||||||
|
<ElementHeader
|
||||||
|
headline={headline}
|
||||||
|
description={description}
|
||||||
|
required={required}
|
||||||
|
htmlFor={inputId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Question-specific controls */}
|
||||||
|
{/* TODO: Add your question-specific UI here */}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="text-destructive flex items-center gap-1 text-sm" dir={detectedDir}>
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { {QuestionType} };
|
||||||
|
export type { {QuestionType}Props };
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create the Storybook file** `{question-type}.stories.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Decorator, Meta, StoryObj } from "@storybook/react";
|
||||||
|
import React from "react";
|
||||||
|
import { {QuestionType}, type {QuestionType}Props } from "./{question-type}";
|
||||||
|
|
||||||
|
// Styling options for the StylingPlayground story
|
||||||
|
interface StylingOptions {
|
||||||
|
// Question styling
|
||||||
|
questionHeadlineFontFamily: string;
|
||||||
|
questionHeadlineFontSize: string;
|
||||||
|
questionHeadlineFontWeight: string;
|
||||||
|
questionHeadlineColor: string;
|
||||||
|
questionDescriptionFontFamily: string;
|
||||||
|
questionDescriptionFontWeight: string;
|
||||||
|
questionDescriptionFontSize: string;
|
||||||
|
questionDescriptionColor: string;
|
||||||
|
// Add component-specific styling options here
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoryProps = {QuestionType}Props & Partial<StylingOptions>;
|
||||||
|
|
||||||
|
const meta: Meta<StoryProps> = {
|
||||||
|
title: "UI-package/Elements/{QuestionType}",
|
||||||
|
component: {QuestionType},
|
||||||
|
parameters: {
|
||||||
|
layout: "centered",
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: "A complete {question type} question element...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: ["autodocs"],
|
||||||
|
argTypes: {
|
||||||
|
headline: {
|
||||||
|
control: "text",
|
||||||
|
description: "The main question text",
|
||||||
|
table: { category: "Content" },
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
control: "text",
|
||||||
|
description: "Optional description or subheader text",
|
||||||
|
table: { category: "Content" },
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
control: "object",
|
||||||
|
description: "Current value",
|
||||||
|
table: { category: "State" },
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
control: "boolean",
|
||||||
|
description: "Whether the field is required",
|
||||||
|
table: { category: "Validation" },
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
control: "text",
|
||||||
|
description: "Error message to display",
|
||||||
|
table: { category: "Validation" },
|
||||||
|
},
|
||||||
|
dir: {
|
||||||
|
control: { type: "select" },
|
||||||
|
options: ["ltr", "rtl", "auto"],
|
||||||
|
description: "Text direction for RTL support",
|
||||||
|
table: { category: "Layout" },
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
control: "boolean",
|
||||||
|
description: "Whether the controls are disabled",
|
||||||
|
table: { category: "State" },
|
||||||
|
},
|
||||||
|
onChange: {
|
||||||
|
action: "changed",
|
||||||
|
table: { category: "Events" },
|
||||||
|
},
|
||||||
|
// Add question-specific argTypes here
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<StoryProps>;
|
||||||
|
|
||||||
|
// Decorator to apply CSS variables from story args
|
||||||
|
const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
|
||||||
|
const args = context.args as StoryProps;
|
||||||
|
const {
|
||||||
|
questionHeadlineFontFamily,
|
||||||
|
questionHeadlineFontSize,
|
||||||
|
questionHeadlineFontWeight,
|
||||||
|
questionHeadlineColor,
|
||||||
|
questionDescriptionFontFamily,
|
||||||
|
questionDescriptionFontSize,
|
||||||
|
questionDescriptionFontWeight,
|
||||||
|
questionDescriptionColor,
|
||||||
|
// Extract component-specific styling options
|
||||||
|
} = args;
|
||||||
|
|
||||||
|
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
|
||||||
|
"--fb-question-headline-font-family": questionHeadlineFontFamily,
|
||||||
|
"--fb-question-headline-font-size": questionHeadlineFontSize,
|
||||||
|
"--fb-question-headline-font-weight": questionHeadlineFontWeight,
|
||||||
|
"--fb-question-headline-color": questionHeadlineColor,
|
||||||
|
"--fb-question-description-font-family": questionDescriptionFontFamily,
|
||||||
|
"--fb-question-description-font-size": questionDescriptionFontSize,
|
||||||
|
"--fb-question-description-font-weight": questionDescriptionFontWeight,
|
||||||
|
"--fb-question-description-color": questionDescriptionColor,
|
||||||
|
// Add component-specific CSS variables
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={cssVarStyle} className="w-[600px]">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StylingPlayground: Story = {
|
||||||
|
args: {
|
||||||
|
headline: "Example question?",
|
||||||
|
description: "Example description",
|
||||||
|
// Default styling values
|
||||||
|
questionHeadlineFontFamily: "system-ui, sans-serif",
|
||||||
|
questionHeadlineFontSize: "1.125rem",
|
||||||
|
questionHeadlineFontWeight: "600",
|
||||||
|
questionHeadlineColor: "#1e293b",
|
||||||
|
questionDescriptionFontFamily: "system-ui, sans-serif",
|
||||||
|
questionDescriptionFontSize: "0.875rem",
|
||||||
|
questionDescriptionFontWeight: "400",
|
||||||
|
questionDescriptionColor: "#64748b",
|
||||||
|
// Add component-specific default values
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
// Question styling argTypes
|
||||||
|
questionHeadlineFontFamily: {
|
||||||
|
control: "text",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
questionHeadlineFontSize: {
|
||||||
|
control: "text",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
questionHeadlineFontWeight: {
|
||||||
|
control: "text",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
questionHeadlineColor: {
|
||||||
|
control: "color",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
questionDescriptionFontFamily: {
|
||||||
|
control: "text",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
questionDescriptionFontSize: {
|
||||||
|
control: "text",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
questionDescriptionFontWeight: {
|
||||||
|
control: "text",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
questionDescriptionColor: {
|
||||||
|
control: "color",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
// Add component-specific argTypes
|
||||||
|
},
|
||||||
|
decorators: [withCSSVariables],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
headline: "Example question?",
|
||||||
|
// Add default props
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithDescription: Story = {
|
||||||
|
args: {
|
||||||
|
headline: "Example question?",
|
||||||
|
description: "Example description text",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Required: Story = {
|
||||||
|
args: {
|
||||||
|
headline: "Example question?",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithError: Story = {
|
||||||
|
args: {
|
||||||
|
headline: "Example question?",
|
||||||
|
errorMessage: "This field is required",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
headline: "Example question?",
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RTL: Story = {
|
||||||
|
args: {
|
||||||
|
headline: "مثال على السؤال؟",
|
||||||
|
description: "مثال على الوصف",
|
||||||
|
// Add RTL-specific props
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add CSS variables** to `packages/survey-ui/src/styles/globals.css` if needed:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Component-specific CSS variables */
|
||||||
|
--fb-{component}-{property}: {default-value};
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Export from** `packages/survey-ui/src/index.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export { {QuestionType}, type {QuestionType}Props } from "./elements/{question-type}";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Requirements
|
||||||
|
|
||||||
|
- ✅ Always use `ElementHeader` component for headline/description
|
||||||
|
- ✅ Always use `useTextDirection` hook for RTL support
|
||||||
|
- ✅ Always handle undefined/null values safely (e.g., `Array.isArray(value) ? value : []`)
|
||||||
|
- ✅ Always include error message display if applicable
|
||||||
|
- ✅ Always support disabled state if applicable
|
||||||
|
- ✅ Always add JSDoc comments to props interface
|
||||||
|
- ✅ Always create Storybook stories with styling playground
|
||||||
|
- ✅ Always export types from component file
|
||||||
|
- ✅ Always add to index.ts exports
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
- `open-text.tsx` - Text input/textarea question (string value)
|
||||||
|
- `multi-select.tsx` - Multiple checkbox selection (string[] value)
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
When creating a new question element, verify:
|
||||||
|
|
||||||
|
- [ ] Component file created with proper structure
|
||||||
|
- [ ] Props interface with JSDoc comments for all props
|
||||||
|
- [ ] Uses `ElementHeader` component (don't duplicate header logic)
|
||||||
|
- [ ] Uses `useTextDirection` hook for RTL support
|
||||||
|
- [ ] Handles undefined/null values safely
|
||||||
|
- [ ] Storybook file created with styling playground
|
||||||
|
- [ ] Includes common stories: Default, WithDescription, Required, WithError, Disabled, RTL
|
||||||
|
- [ ] CSS variables added to `globals.css` if component needs custom styling
|
||||||
|
- [ ] Exported from `index.ts` with types
|
||||||
|
- [ ] TypeScript types properly exported
|
||||||
|
- [ ] Error message display included if applicable
|
||||||
|
- [ ] Disabled state supported if applicable
|
||||||
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Open a draft PR with a concise description of what we’ve done in this PR and what remains to be done.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
Run the unit tests of all files we’ve changed so far:
|
|
||||||
|
|
||||||
1. Check the diff to main
|
|
||||||
2. Determine all files that we changed
|
|
||||||
3. Run the corresponding unit tests
|
|
||||||
4. If tests are failing, update the unit test following the guideline in the corresponding cursor rule.
|
|
||||||
|
|
||||||
Complete when: This is complete when all unit tests pass.
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Run /unit-test and if complete /build and if complete /pr
|
|
||||||
@@ -179,14 +179,14 @@ For endpoints serving client SDKs, coordinate TTLs across layers:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Client SDK cache (expiresAt) - longest TTL for fewer requests
|
// Client SDK cache (expiresAt) - longest TTL for fewer requests
|
||||||
const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client)
|
const CLIENT_TTL = 60; // 1 minute (seconds for client)
|
||||||
|
|
||||||
// Server Redis cache - shorter TTL ensures fresh data for clients
|
// Server Redis cache - shorter TTL ensures fresh data for clients
|
||||||
const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds
|
const SERVER_TTL = 60 * 1000; // 1 minutes in milliseconds
|
||||||
|
|
||||||
// HTTP cache headers (seconds)
|
// HTTP cache headers (seconds)
|
||||||
const BROWSER_TTL = 60 * 60; // 1 hour (max-age)
|
const BROWSER_TTL = 60; // 1 minute (max-age)
|
||||||
const CDN_TTL = 60 * 30; // 30 minutes (s-maxage)
|
const CDN_TTL = 60; // 1 minute (s-maxage)
|
||||||
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
|
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
---
|
---
|
||||||
description: >
|
description: >
|
||||||
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
|
globs: schema.prisma
|
||||||
and data patterns. It should be used **only when the agent explicitly requests database schema-level
|
alwaysApply: false
|
||||||
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
|
|
||||||
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
|
||||||
globs: []
|
|
||||||
alwaysApply: agent-requested
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Formbricks Database Schema Reference
|
# Formbricks Database Schema Reference
|
||||||
|
|
||||||
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
||||||
|
|||||||
457
.cursor/rules/i18n-management.mdc
Normal file
457
.cursor/rules/i18n-management.mdc
Normal file
@@ -0,0 +1,457 @@
|
|||||||
|
---
|
||||||
|
title: i18n Management with Lingo.dev
|
||||||
|
description: Guidelines for managing internationalization (i18n) with Lingo.dev, including translation workflow, key validation, and best practices
|
||||||
|
---
|
||||||
|
|
||||||
|
# i18n Management with Lingo.dev
|
||||||
|
|
||||||
|
This rule defines the workflow and best practices for managing internationalization (i18n) in the Formbricks project using Lingo.dev.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Formbricks uses [Lingo.dev](https://lingo.dev) for managing translations across multiple languages. The translation workflow includes:
|
||||||
|
|
||||||
|
1. **Translation Keys**: Defined in code using the `t()` function from `react-i18next`
|
||||||
|
2. **Translation Files**: JSON files stored in `apps/web/locales/` for each supported language
|
||||||
|
3. **Validation**: Automated scanning to detect missing and unused translation keys
|
||||||
|
4. **CI/CD**: Pre-commit hooks and GitHub Actions to enforce translation quality
|
||||||
|
|
||||||
|
## Translation Workflow
|
||||||
|
|
||||||
|
### 1. Using Translations in Code
|
||||||
|
|
||||||
|
When adding translatable text in the web app, use the `t()` function or `<Trans>` component:
|
||||||
|
|
||||||
|
**Using the `t()` function:**
|
||||||
|
```tsx
|
||||||
|
import { useTranslate } from "@/lib/i18n/translate";
|
||||||
|
|
||||||
|
const MyComponent = () => {
|
||||||
|
const { t } = useTranslate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{t("common.welcome")}</h1>
|
||||||
|
<p>{t("pages.dashboard.description")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Using the `<Trans>` component (for text with HTML elements):**
|
||||||
|
```tsx
|
||||||
|
import { Trans } from "react-i18next";
|
||||||
|
|
||||||
|
const MyComponent = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<Trans
|
||||||
|
i18nKey="auth.terms_agreement"
|
||||||
|
components={{
|
||||||
|
link: <a href="/terms" />,
|
||||||
|
b: <b />
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Naming Conventions:**
|
||||||
|
- Use dot notation for nested keys: `section.subsection.key`
|
||||||
|
- Use descriptive names: `auth.login.success_message` not `auth.msg1`
|
||||||
|
- Group related keys together: `auth.*`, `errors.*`, `common.*`
|
||||||
|
- Use lowercase with underscores: `user_profile_settings` not `UserProfileSettings`
|
||||||
|
|
||||||
|
### 2. Translation File Structure
|
||||||
|
|
||||||
|
Translation files are located in `apps/web/locales/` and use the following naming convention:
|
||||||
|
- `en-US.json` (English - United States, default)
|
||||||
|
- `de-DE.json` (German)
|
||||||
|
- `fr-FR.json` (French)
|
||||||
|
- `pt-BR.json` (Portuguese - Brazil)
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
**File Structure:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"welcome": "Welcome",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": {
|
||||||
|
"title": "Login",
|
||||||
|
"email_placeholder": "Enter your email",
|
||||||
|
"password_placeholder": "Enter your password"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Adding New Translation Keys
|
||||||
|
|
||||||
|
When adding new translation keys:
|
||||||
|
|
||||||
|
1. **Add the key in your code** using `t("your.new.key")`
|
||||||
|
2. **Add translation for that key in en-US.json file**
|
||||||
|
3. **Run the translation workflow:**
|
||||||
|
```bash
|
||||||
|
pnpm i18n
|
||||||
|
```
|
||||||
|
This will:
|
||||||
|
- Generate translations for all languages using Lingo.dev
|
||||||
|
- Validate that all keys are present and used
|
||||||
|
|
||||||
|
4. **Review and commit** the generated translation files
|
||||||
|
|
||||||
|
### 4. Available Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate translations using Lingo.dev
|
||||||
|
pnpm generate-translations
|
||||||
|
|
||||||
|
# Scan and validate translation keys
|
||||||
|
pnpm scan-translations
|
||||||
|
|
||||||
|
# Full workflow: generate + validate
|
||||||
|
pnpm i18n
|
||||||
|
|
||||||
|
# Validate only (without generation)
|
||||||
|
pnpm i18n:validate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Translation Key Validation
|
||||||
|
|
||||||
|
### Automated Validation
|
||||||
|
|
||||||
|
The project includes automated validation that runs:
|
||||||
|
- **Pre-commit hook**: Validates translations before allowing commits (when `LINGODOTDEV_API_KEY` is set)
|
||||||
|
- **GitHub Actions**: Validates translations on every PR and push to main
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
|
||||||
|
The validation script (`scan-translations.ts`) checks for:
|
||||||
|
|
||||||
|
1. **Missing Keys**: Translation keys used in code but not present in translation files
|
||||||
|
2. **Unused Keys**: Translation keys present in translation files but not used in code
|
||||||
|
3. **Incomplete Translations**: Keys that exist in the default language (`en-US`) but are missing in target languages
|
||||||
|
|
||||||
|
**What gets scanned:**
|
||||||
|
- All `.ts` and `.tsx` files in `apps/web/`
|
||||||
|
- Both `t()` function calls and `<Trans i18nKey="">` components
|
||||||
|
- All locale files (`de-DE.json`, `fr-FR.json`, `ja-JP.json`, etc.)
|
||||||
|
|
||||||
|
**What gets excluded:**
|
||||||
|
- Test files (`*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx`)
|
||||||
|
- Build directories (`node_modules`, `dist`, `build`, `.next`, `coverage`)
|
||||||
|
- Locale files themselves (from code scanning)
|
||||||
|
|
||||||
|
**Note:** Test files are excluded because they often use mock or example translation keys for testing purposes that don't need to exist in production translation files.
|
||||||
|
|
||||||
|
### Fixing Validation Errors
|
||||||
|
|
||||||
|
#### Missing Keys
|
||||||
|
|
||||||
|
If you encounter missing key errors:
|
||||||
|
|
||||||
|
```
|
||||||
|
❌ MISSING KEYS (2):
|
||||||
|
|
||||||
|
These keys are used in code but not found in translation files:
|
||||||
|
|
||||||
|
• auth.signup.email_required
|
||||||
|
• settings.profile.update_success
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resolution:**
|
||||||
|
1. Ensure that translations for those keys are present in en-US.json .
|
||||||
|
2. Run `pnpm generate-translations` to have Lingo.dev generate the missing translations
|
||||||
|
3. OR manually add the keys to `apps/web/locales/en-US.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"signup": {
|
||||||
|
"email_required": "Email is required"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"profile": {
|
||||||
|
"update_success": "Profile updated successfully"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. Run `pnpm scan-translations` to verify
|
||||||
|
4. Commit the changes
|
||||||
|
|
||||||
|
#### Unused Keys
|
||||||
|
|
||||||
|
If you encounter unused key errors:
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠️ UNUSED KEYS (1):
|
||||||
|
|
||||||
|
These keys exist in translation files but are not used in code:
|
||||||
|
|
||||||
|
• old.deprecated.key
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resolution:**
|
||||||
|
1. If the key is truly unused, remove it from all translation files
|
||||||
|
2. If the key should be used, add it to your code using `t("old.deprecated.key")`
|
||||||
|
3. Run `pnpm scan-translations` to verify
|
||||||
|
4. Commit the changes
|
||||||
|
|
||||||
|
#### Incomplete Translations
|
||||||
|
|
||||||
|
If you encounter incomplete translation errors:
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠️ INCOMPLETE TRANSLATIONS:
|
||||||
|
|
||||||
|
Some keys from en-US are missing in target languages:
|
||||||
|
|
||||||
|
📝 de-DE (5 missing keys):
|
||||||
|
• auth.new_feature.title
|
||||||
|
• auth.new_feature.description
|
||||||
|
• settings.advanced.option
|
||||||
|
... and 2 more
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resolution:**
|
||||||
|
1. **Recommended:** Run `pnpm generate-translations` to have Lingo.dev automatically translate the missing keys
|
||||||
|
2. **Manual:** Add the missing keys to the target language files:
|
||||||
|
```bash
|
||||||
|
# Copy the structure from en-US.json and translate the values
|
||||||
|
# For example, in de-DE.json:
|
||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"new_feature": {
|
||||||
|
"title": "Neues Feature",
|
||||||
|
"description": "Beschreibung des neuen Features"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. Run `pnpm scan-translations` to verify all translations are complete
|
||||||
|
4. Commit the changes
|
||||||
|
|
||||||
|
## Pre-commit Hook Behavior
|
||||||
|
|
||||||
|
The pre-commit hook will:
|
||||||
|
|
||||||
|
1. Run `lint-staged` for code formatting
|
||||||
|
2. If `LINGODOTDEV_API_KEY` is set:
|
||||||
|
- Generate translations using Lingo.dev
|
||||||
|
- Validate translation keys
|
||||||
|
- Auto-add updated locale files to the commit
|
||||||
|
- **Block the commit** if validation fails
|
||||||
|
3. If `LINGODOTDEV_API_KEY` is not set:
|
||||||
|
- Skip translation validation (for community contributors)
|
||||||
|
- Show a warning message
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
### LINGODOTDEV_API_KEY
|
||||||
|
|
||||||
|
This is the API key for Lingo.dev integration.
|
||||||
|
|
||||||
|
**For Core Team:**
|
||||||
|
- Add to your local `.env` file
|
||||||
|
- Required for running translation generation
|
||||||
|
|
||||||
|
**For Community Contributors:**
|
||||||
|
- Not required for local development
|
||||||
|
- Translation validation will be skipped
|
||||||
|
- The CI will still validate translations
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Keep Keys Organized
|
||||||
|
|
||||||
|
Group related keys together:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"login": { ... },
|
||||||
|
"signup": { ... },
|
||||||
|
"forgot_password": { ... }
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"header": { ... },
|
||||||
|
"sidebar": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Avoid Hardcoded Strings
|
||||||
|
|
||||||
|
**❌ Bad:**
|
||||||
|
```tsx
|
||||||
|
<button>Click here</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Good:**
|
||||||
|
```tsx
|
||||||
|
<button>{t("common.click_here")}</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Use Interpolation for Dynamic Content
|
||||||
|
|
||||||
|
**❌ Bad:**
|
||||||
|
```tsx
|
||||||
|
{t("welcome")} {userName}!
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Good:**
|
||||||
|
```tsx
|
||||||
|
{t("auth.welcome_message", { userName })}
|
||||||
|
```
|
||||||
|
|
||||||
|
With translation:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"welcome_message": "Welcome, {userName}!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Avoid Dynamic Key Construction
|
||||||
|
|
||||||
|
**❌ Bad:**
|
||||||
|
```tsx
|
||||||
|
const key = `errors.${errorCode}`;
|
||||||
|
t(key);
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ Good:**
|
||||||
|
```tsx
|
||||||
|
switch (errorCode) {
|
||||||
|
case "401":
|
||||||
|
return t("errors.unauthorized");
|
||||||
|
case "404":
|
||||||
|
return t("errors.not_found");
|
||||||
|
default:
|
||||||
|
return t("errors.unknown");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Test Translation Keys
|
||||||
|
|
||||||
|
When adding new features:
|
||||||
|
1. Add translation keys
|
||||||
|
2. Test in multiple languages using the language switcher
|
||||||
|
3. Ensure text doesn't overflow in longer translations (German, French)
|
||||||
|
4. Run `pnpm scan-translations` before committing
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: Pre-commit hook fails with validation errors
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Run the full i18n workflow
|
||||||
|
pnpm i18n
|
||||||
|
|
||||||
|
# Fix any missing or unused keys
|
||||||
|
# Then commit again
|
||||||
|
git add .
|
||||||
|
git commit -m "your message"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Translation validation passes locally but fails in CI
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Ensure all translation files are committed
|
||||||
|
- Check that `scan-translations.ts` hasn't been modified
|
||||||
|
- Verify that locale files are properly formatted JSON
|
||||||
|
|
||||||
|
### Issue: Cannot commit because of missing translations
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# If you have LINGODOTDEV_API_KEY:
|
||||||
|
pnpm generate-translations
|
||||||
|
|
||||||
|
# If you don't have the API key (community contributor):
|
||||||
|
# Manually add the missing keys to en-US.json
|
||||||
|
# Then run validation:
|
||||||
|
pnpm scan-translations
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Getting "unused keys" for keys that are used
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- The script scans `.ts` and `.tsx` files only
|
||||||
|
- If keys are used in other file types, they may be flagged
|
||||||
|
- Verify the key is actually used with `grep -r "your.key" apps/web/`
|
||||||
|
- If it's a false positive, consider updating the scanning patterns in `scan-translations.ts`
|
||||||
|
|
||||||
|
## AI Assistant Guidelines
|
||||||
|
|
||||||
|
When assisting with i18n-related tasks, always:
|
||||||
|
|
||||||
|
1. **Use the `t()` function** for all user-facing text
|
||||||
|
2. **Follow key naming conventions** (lowercase, dots for nesting)
|
||||||
|
3. **Run validation** after making changes: `pnpm scan-translations`
|
||||||
|
4. **Fix missing keys** by adding them to `en-US.json`
|
||||||
|
5. **Remove unused keys** from all translation files
|
||||||
|
6. **Test the pre-commit hook** if making changes to translation workflow
|
||||||
|
7. **Update this rule file** if translation workflow changes
|
||||||
|
|
||||||
|
### Fixing Missing Translation Keys
|
||||||
|
|
||||||
|
When the AI encounters missing translation key errors:
|
||||||
|
|
||||||
|
1. Identify the missing keys from the error output
|
||||||
|
2. Determine the appropriate section and naming for each key
|
||||||
|
3. Add the keys to `apps/web/locales/en-US.json` with meaningful English text
|
||||||
|
4. Ensure proper JSON structure and nesting
|
||||||
|
5. Run `pnpm scan-translations` to verify
|
||||||
|
6. Inform the user that other language files will be updated via Lingo.dev
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```typescript
|
||||||
|
// Error: Missing key "settings.api.rate_limit_exceeded"
|
||||||
|
|
||||||
|
// Add to en-US.json:
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"api": {
|
||||||
|
"rate_limit_exceeded": "API rate limit exceeded. Please try again later."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Removing Unused Translation Keys
|
||||||
|
|
||||||
|
When the AI encounters unused translation key errors:
|
||||||
|
|
||||||
|
1. Verify the keys are truly unused by searching the codebase
|
||||||
|
2. Remove the keys from `apps/web/locales/en-US.json`
|
||||||
|
3. Note that removal from other language files can be handled via Lingo.dev
|
||||||
|
4. Run `pnpm scan-translations` to verify
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
This project previously used Tolgee for translations. As of this migration:
|
||||||
|
|
||||||
|
- **Old scripts**: `tolgee-pull` is deprecated (kept for reference)
|
||||||
|
- **New scripts**: Use `pnpm i18n` or `pnpm generate-translations`
|
||||||
|
- **Old workflows**: `tolgee.yml` and `tolgee-missing-key-check.yml` removed
|
||||||
|
- **New workflow**: `translation-check.yml` handles all validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** October 14, 2025
|
||||||
|
**Related Files:**
|
||||||
|
- `scan-translations.ts` - Translation validation script
|
||||||
|
- `.husky/pre-commit` - Pre-commit hook with i18n validation
|
||||||
|
- `.github/workflows/translation-check.yml` - CI workflow for translation validation
|
||||||
|
- `apps/web/locales/*.json` - Translation files
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
@@ -1,322 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Testing Patterns & Best Practices
|
|
||||||
|
|
||||||
## Running Tests
|
|
||||||
|
|
||||||
### Test Commands
|
|
||||||
From the **root directory** (formbricks/):
|
|
||||||
- `npm test` - Run all tests across all packages (recommended for CI/full testing)
|
|
||||||
- `npm run test:coverage` - Run all tests with coverage reports
|
|
||||||
- `npm run test:e2e` - Run end-to-end tests with Playwright
|
|
||||||
|
|
||||||
From the **apps/web directory** (apps/web/):
|
|
||||||
- `npm run test` - Run only web app tests (fastest for development)
|
|
||||||
- `npm run test:coverage` - Run web app tests with coverage
|
|
||||||
- `npm run test -- <file-pattern>` - Run specific test files
|
|
||||||
|
|
||||||
### Examples
|
|
||||||
```bash
|
|
||||||
# Run all tests from root (takes ~3 minutes, runs 790 test files with 5334+ tests)
|
|
||||||
npm test
|
|
||||||
|
|
||||||
# Run specific test file from apps/web (fastest for development)
|
|
||||||
npm run test -- modules/cache/lib/service.test.ts
|
|
||||||
|
|
||||||
# Run tests matching pattern from apps/web
|
|
||||||
npm run test -- modules/ee/license-check/lib/license.test.ts
|
|
||||||
|
|
||||||
# Run with coverage from root
|
|
||||||
npm run test:coverage
|
|
||||||
|
|
||||||
# Run specific test with watch mode from apps/web (for development)
|
|
||||||
npm run test -- --watch modules/cache/lib/service.test.ts
|
|
||||||
|
|
||||||
# Run tests for a specific directory from apps/web
|
|
||||||
npm run test -- modules/cache/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Performance Tips
|
|
||||||
- **For development**: Use `apps/web` directory commands to run only web app tests
|
|
||||||
- **For CI/validation**: Use root directory commands to run all packages
|
|
||||||
- **For specific features**: Use file patterns to target specific test files
|
|
||||||
- **For debugging**: Use `--watch` mode for continuous testing during development
|
|
||||||
|
|
||||||
### Test File Organization
|
|
||||||
- Place test files in the **same directory** as the source file
|
|
||||||
- Use `.test.ts` for utility/service tests (Node environment)
|
|
||||||
- Use `.test.tsx` for React component tests (jsdom environment)
|
|
||||||
|
|
||||||
## Test File Naming & Environment
|
|
||||||
|
|
||||||
### File Extensions
|
|
||||||
- Use `.test.tsx` for React component/hook tests (runs in jsdom environment)
|
|
||||||
- Use `.test.ts` for utility/service tests (runs in Node environment)
|
|
||||||
- The vitest config uses `environmentMatchGlobs` to automatically set jsdom for `.tsx` files
|
|
||||||
|
|
||||||
### Test Structure
|
|
||||||
```typescript
|
|
||||||
// Import the mocked functions first
|
|
||||||
import { useHook } from "@/path/to/hook";
|
|
||||||
import { serviceFunction } from "@/path/to/service";
|
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
vi.mock("@/path/to/hook", () => ({
|
|
||||||
useHook: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("ComponentName", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
// Setup default mocks
|
|
||||||
});
|
|
||||||
|
|
||||||
test("descriptive test name", async () => {
|
|
||||||
// Test implementation
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## React Hook Testing
|
|
||||||
|
|
||||||
### Context Mocking
|
|
||||||
When testing hooks that use React Context:
|
|
||||||
```typescript
|
|
||||||
vi.mocked(useResponseFilter).mockReturnValue({
|
|
||||||
selectedFilter: {
|
|
||||||
filter: [],
|
|
||||||
responseStatus: "all",
|
|
||||||
},
|
|
||||||
setSelectedFilter: vi.fn(),
|
|
||||||
selectedOptions: {
|
|
||||||
questionOptions: [],
|
|
||||||
questionFilterOptions: [],
|
|
||||||
},
|
|
||||||
setSelectedOptions: vi.fn(),
|
|
||||||
dateRange: { from: new Date(), to: new Date() },
|
|
||||||
setDateRange: vi.fn(),
|
|
||||||
resetState: vi.fn(),
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing Async Hooks
|
|
||||||
- Always use `waitFor` for async operations
|
|
||||||
- Test both loading and completed states
|
|
||||||
- Verify API calls with correct parameters
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
test("fetches data on mount", async () => {
|
|
||||||
const { result } = renderHook(() => useHook());
|
|
||||||
|
|
||||||
expect(result.current.isLoading).toBe(true);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isLoading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.data).toBe(expectedData);
|
|
||||||
expect(vi.mocked(apiCall)).toHaveBeenCalledWith(expectedParams);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing Hook Dependencies
|
|
||||||
To test useEffect dependencies, ensure mocks return different values:
|
|
||||||
```typescript
|
|
||||||
// First render
|
|
||||||
mockGetFormattedFilters.mockReturnValue(mockFilters);
|
|
||||||
|
|
||||||
// Change dependency and trigger re-render
|
|
||||||
const newMockFilters = { ...mockFilters, finished: true };
|
|
||||||
mockGetFormattedFilters.mockReturnValue(newMockFilters);
|
|
||||||
rerender();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Testing
|
|
||||||
|
|
||||||
### Race Condition Testing
|
|
||||||
Test AbortController implementation:
|
|
||||||
```typescript
|
|
||||||
test("cancels previous request when new request is made", async () => {
|
|
||||||
let resolveFirst: (value: any) => void;
|
|
||||||
let resolveSecond: (value: any) => void;
|
|
||||||
|
|
||||||
const firstPromise = new Promise((resolve) => {
|
|
||||||
resolveFirst = resolve;
|
|
||||||
});
|
|
||||||
const secondPromise = new Promise((resolve) => {
|
|
||||||
resolveSecond = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mocked(apiCall)
|
|
||||||
.mockReturnValueOnce(firstPromise as any)
|
|
||||||
.mockReturnValueOnce(secondPromise as any);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useHook());
|
|
||||||
|
|
||||||
// Trigger second request
|
|
||||||
result.current.refetch();
|
|
||||||
|
|
||||||
// Resolve in order - first should be cancelled
|
|
||||||
resolveFirst!({ data: 100 });
|
|
||||||
resolveSecond!({ data: 200 });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isLoading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should have result from second request
|
|
||||||
expect(result.current.data).toBe(200);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cleanup Testing
|
|
||||||
```typescript
|
|
||||||
test("cleans up on unmount", () => {
|
|
||||||
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
|
|
||||||
|
|
||||||
const { unmount } = renderHook(() => useHook());
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
expect(abortSpy).toHaveBeenCalled();
|
|
||||||
abortSpy.mockRestore();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling Testing
|
|
||||||
|
|
||||||
### API Error Testing
|
|
||||||
```typescript
|
|
||||||
test("handles API errors gracefully", async () => {
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
vi.mocked(apiCall).mockRejectedValue(new Error("API Error"));
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useHook());
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isLoading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith("Error message:", expect.any(Error));
|
|
||||||
expect(result.current.data).toBe(fallbackValue);
|
|
||||||
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cancelled Request Testing
|
|
||||||
```typescript
|
|
||||||
test("does not update state for cancelled requests", async () => {
|
|
||||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
|
|
||||||
let rejectFirst: (error: any) => void;
|
|
||||||
const firstPromise = new Promise((_, reject) => {
|
|
||||||
rejectFirst = reject;
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mocked(apiCall)
|
|
||||||
.mockReturnValueOnce(firstPromise as any)
|
|
||||||
.mockResolvedValueOnce({ data: 42 });
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useHook());
|
|
||||||
result.current.refetch();
|
|
||||||
|
|
||||||
const abortError = new Error("Request cancelled");
|
|
||||||
rejectFirst!(abortError);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.isLoading).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should not log error for cancelled request
|
|
||||||
expect(consoleSpy).not.toHaveBeenCalled();
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Type Safety in Tests
|
|
||||||
|
|
||||||
### Mock Type Assertions
|
|
||||||
Use type assertions for edge cases:
|
|
||||||
```typescript
|
|
||||||
vi.mocked(apiCall).mockResolvedValue({
|
|
||||||
data: null as any, // For testing null handling
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mocked(apiCall).mockResolvedValue({
|
|
||||||
data: undefined as any, // For testing undefined handling
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Proper Mock Typing
|
|
||||||
Ensure mocks match the actual interface:
|
|
||||||
```typescript
|
|
||||||
const mockSurvey: TSurvey = {
|
|
||||||
id: "survey-123",
|
|
||||||
name: "Test Survey",
|
|
||||||
// ... other required properties
|
|
||||||
} as unknown as TSurvey; // Use when partial mocking is needed
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Test Patterns
|
|
||||||
|
|
||||||
### Testing State Changes
|
|
||||||
```typescript
|
|
||||||
test("updates state correctly", async () => {
|
|
||||||
const { result } = renderHook(() => useHook());
|
|
||||||
|
|
||||||
// Initial state
|
|
||||||
expect(result.current.value).toBe(initialValue);
|
|
||||||
|
|
||||||
// Trigger change
|
|
||||||
result.current.updateValue(newValue);
|
|
||||||
|
|
||||||
// Verify change
|
|
||||||
expect(result.current.value).toBe(newValue);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing Multiple Scenarios
|
|
||||||
```typescript
|
|
||||||
test("handles different modes", async () => {
|
|
||||||
// Test regular mode
|
|
||||||
vi.mocked(useParams).mockReturnValue({ surveyId: "123" });
|
|
||||||
const { rerender } = renderHook(() => useHook());
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(vi.mocked(regularApi)).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
rerender();
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(vi.mocked(sharingApi)).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Organization
|
|
||||||
|
|
||||||
### Comprehensive Test Coverage
|
|
||||||
For hooks, ensure you test:
|
|
||||||
- ✅ Initialization (with/without initial values)
|
|
||||||
- ✅ Data fetching (success/error cases)
|
|
||||||
- ✅ State updates and refetching
|
|
||||||
- ✅ Dependency changes triggering effects
|
|
||||||
- ✅ Manual actions (refetch, reset)
|
|
||||||
- ✅ Race condition prevention
|
|
||||||
- ✅ Cleanup on unmount
|
|
||||||
- ✅ Mode switching (if applicable)
|
|
||||||
- ✅ Edge cases (null/undefined data)
|
|
||||||
|
|
||||||
### Test Naming
|
|
||||||
Use descriptive test names that explain the scenario:
|
|
||||||
- ✅ "initializes with initial count"
|
|
||||||
- ✅ "fetches response count on mount for regular survey"
|
|
||||||
- ✅ "cancels previous request when new request is made"
|
|
||||||
- ❌ "test hook"
|
|
||||||
- ❌ "it works"
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
description: Whenever the user asks to write or update a test file for .tsx or .ts files.
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md).
|
|
||||||
After writing the tests, run them and check if there's any issue with the tests and if all of them are passing. Fix the issues and rerun the tests until all pass.
|
|
||||||
13
.env.example
13
.env.example
@@ -9,8 +9,12 @@
|
|||||||
WEBAPP_URL=http://localhost:3000
|
WEBAPP_URL=http://localhost:3000
|
||||||
|
|
||||||
# Required for next-auth. Should be the same as WEBAPP_URL
|
# Required for next-auth. Should be the same as WEBAPP_URL
|
||||||
|
# If your pplication uses a custom base path, specify the route to the API endpoint in full, e.g. NEXTAUTH_URL=https://example.com/custom-route/api/auth
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Can be used to deploy the application under a sub-path of a domain. This can only be set at build time
|
||||||
|
# BASE_PATH=
|
||||||
|
|
||||||
# Encryption keys
|
# Encryption keys
|
||||||
# Please set both for now, we will change this in the future
|
# Please set both for now, we will change this in the future
|
||||||
|
|
||||||
@@ -189,8 +193,9 @@ REDIS_URL=redis://localhost:6379
|
|||||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||||
# REDIS_HTTP_URL:
|
# REDIS_HTTP_URL:
|
||||||
|
|
||||||
# INTERCOM_APP_ID=
|
# Chatwoot
|
||||||
# INTERCOM_SECRET_KEY=
|
# CHATWOOT_BASE_URL=
|
||||||
|
# CHATWOOT_WEBSITE_TOKEN=
|
||||||
|
|
||||||
# Enable Prometheus metrics
|
# Enable Prometheus metrics
|
||||||
# PROMETHEUS_ENABLED=
|
# PROMETHEUS_ENABLED=
|
||||||
@@ -214,3 +219,7 @@ REDIS_URL=redis://localhost:6379
|
|||||||
# AUDIT_LOG_ENABLED=0
|
# AUDIT_LOG_ENABLED=0
|
||||||
# If the ip should be added in the log or not. Default 0
|
# If the ip should be added in the log or not. Default 0
|
||||||
# AUDIT_LOG_GET_USER_IP=0
|
# AUDIT_LOG_GET_USER_IP=0
|
||||||
|
|
||||||
|
|
||||||
|
# Lingo.dev API key for translation generation
|
||||||
|
LINGODOTDEV_API_KEY=your_api_key_here
|
||||||
32
.github/copilot-instructions.md
vendored
32
.github/copilot-instructions.md
vendored
@@ -1,32 +0,0 @@
|
|||||||
# Testing Instructions
|
|
||||||
|
|
||||||
When generating test files inside the "/app/web" path, follow these rules:
|
|
||||||
|
|
||||||
- You are an experienced senior software engineer
|
|
||||||
- Use vitest
|
|
||||||
- Ensure 100% code coverage
|
|
||||||
- Add as few comments as possible
|
|
||||||
- The test file should be located in the same folder as the original file
|
|
||||||
- Use the `test` function instead of `it`
|
|
||||||
- Follow the same test pattern used for other files in the package where the file is located
|
|
||||||
- All imports should be at the top of the file, not inside individual tests
|
|
||||||
- For mocking inside "test" blocks use "vi.mocked"
|
|
||||||
- If the file is located in the "packages/survey" path, use "@testing-library/preact" instead of "@testing-library/react"
|
|
||||||
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
|
|
||||||
- When using "screen.getByText" check for the tolgee string if it is being used in the file.
|
|
||||||
- The types for mocked variables can be found in the "packages/types" path. Be sure that every imported type exists before using it. Don't create types that are not already in the codebase.
|
|
||||||
- When mocking data check if the properties added are part of the type of the object being mocked. Only specify known properties, don't use properties that are not part of the type.
|
|
||||||
|
|
||||||
If it's a test for a ".tsx" file, follow these extra instructions:
|
|
||||||
|
|
||||||
- Add this code inside the "describe" block and before any test:
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
- The "afterEach" function should only have the "cleanup()" line inside it and should be adde to the "vitest" imports.
|
|
||||||
- For click events, import userEvent from "@testing-library/user-event"
|
|
||||||
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
|
|
||||||
- You don't need to mock @tolgee/react
|
|
||||||
- Use "import "@testing-library/jest-dom/vitest";"
|
|
||||||
54
.github/workflows/e2e.yml
vendored
54
.github/workflows/e2e.yml
vendored
@@ -3,26 +3,20 @@ name: E2E Tests
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
secrets:
|
secrets:
|
||||||
AZURE_CLIENT_ID:
|
|
||||||
required: false
|
|
||||||
AZURE_TENANT_ID:
|
|
||||||
required: false
|
|
||||||
AZURE_SUBSCRIPTION_ID:
|
|
||||||
required: false
|
|
||||||
PLAYWRIGHT_SERVICE_URL:
|
PLAYWRIGHT_SERVICE_URL:
|
||||||
required: false
|
required: false
|
||||||
|
PLAYWRIGHT_SERVICE_ACCESS_TOKEN:
|
||||||
|
required: false
|
||||||
ENTERPRISE_LICENSE_KEY:
|
ENTERPRISE_LICENSE_KEY:
|
||||||
required: true
|
required: true
|
||||||
# Add other secrets if necessary
|
# Add other secrets if necessary
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
TELEMETRY_DISABLED: 1
|
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
|
|
||||||
@@ -115,7 +109,7 @@ jobs:
|
|||||||
- name: Start MinIO Server
|
- name: Start MinIO Server
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Start MinIO server in background
|
# Start MinIO server in background
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name minio-server \
|
--name minio-server \
|
||||||
@@ -125,7 +119,7 @@ jobs:
|
|||||||
-e MINIO_ROOT_PASSWORD=devminio123 \
|
-e MINIO_ROOT_PASSWORD=devminio123 \
|
||||||
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
||||||
server /data --console-address :9001
|
server /data --console-address :9001
|
||||||
|
|
||||||
echo "MinIO server started"
|
echo "MinIO server started"
|
||||||
|
|
||||||
- name: Wait for MinIO and create S3 bucket
|
- name: Wait for MinIO and create S3 bucket
|
||||||
@@ -208,32 +202,30 @@ jobs:
|
|||||||
- name: Install Playwright
|
- name: Install Playwright
|
||||||
run: pnpm exec playwright install --with-deps
|
run: pnpm exec playwright install --with-deps
|
||||||
|
|
||||||
- name: Set Azure Secret Variables
|
- name: Determine Playwright execution mode
|
||||||
run: |
|
shell: bash
|
||||||
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@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
|
|
||||||
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:
|
env:
|
||||||
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||||
CI: true
|
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
pnpm test-e2e:azure
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ -n "${PLAYWRIGHT_SERVICE_URL}" && -n "${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}" ]]; then
|
||||||
|
echo "PW_MODE=service" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
echo "PW_MODE=local" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run E2E Tests (Playwright Service)
|
||||||
|
if: env.PW_MODE == 'service'
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||||
|
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
|
||||||
|
CI: true
|
||||||
|
run: pnpm test-e2e:azure
|
||||||
|
|
||||||
- name: Run E2E Tests (Local)
|
- name: Run E2E Tests (Local)
|
||||||
if: env.AZURE_ENABLED == 'false'
|
if: env.PW_MODE == 'local'
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
6
.github/workflows/formbricks-release.yml
vendored
6
.github/workflows/formbricks-release.yml
vendored
@@ -89,7 +89,7 @@ jobs:
|
|||||||
- check-latest-release
|
- check-latest-release
|
||||||
with:
|
with:
|
||||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||||
|
|
||||||
docker-build-cloud:
|
docker-build-cloud:
|
||||||
name: Build & push Formbricks Cloud to ECR
|
name: Build & push Formbricks Cloud to ECR
|
||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
|
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||||
needs:
|
needs:
|
||||||
- check-latest-release
|
- check-latest-release
|
||||||
- docker-build-community
|
- docker-build-community
|
||||||
@@ -154,4 +154,4 @@ jobs:
|
|||||||
release_tag: ${{ github.event.release.tag_name }}
|
release_tag: ${{ github.event.release.tag_name }}
|
||||||
commit_sha: ${{ github.sha }}
|
commit_sha: ${{ github.sha }}
|
||||||
is_prerelease: ${{ github.event.release.prerelease }}
|
is_prerelease: ${{ github.event.release.prerelease }}
|
||||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}
|
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||||
|
|||||||
51
.github/workflows/tolgee-missing-key-check.yml
vendored
51
.github/workflows/tolgee-missing-key-check.yml
vendored
@@ -1,51 +0,0 @@
|
|||||||
name: Check Missing Translations
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
pull_request_target:
|
|
||||||
types: [opened, synchronize, reopened]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check-missing-translations:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.base.ref }}
|
|
||||||
|
|
||||||
- name: Checkout PR
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
|
||||||
with:
|
|
||||||
node-version: 18
|
|
||||||
|
|
||||||
- name: Install Tolgee CLI
|
|
||||||
run: npm install -g @tolgee/cli
|
|
||||||
|
|
||||||
- name: Compare Tolgee Keys
|
|
||||||
id: compare
|
|
||||||
run: |
|
|
||||||
tolgee compare --api-key ${{ secrets.TOLGEE_API_KEY }} > compare_output.txt
|
|
||||||
cat compare_output.txt
|
|
||||||
|
|
||||||
- name: Check for Missing Translations
|
|
||||||
run: |
|
|
||||||
if grep -q "new key found" compare_output.txt; then
|
|
||||||
echo "New keys found that may require translations:"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo "No new keys found."
|
|
||||||
fi
|
|
||||||
95
.github/workflows/tolgee.yml
vendored
95
.github/workflows/tolgee.yml
vendored
@@ -1,95 +0,0 @@
|
|||||||
name: Tolgee Tagging on PR Merge
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request_target:
|
|
||||||
types: [closed]
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
tag-production-keys:
|
|
||||||
name: Tag Production Keys
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event.pull_request.merged == true
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0 # This ensures we get the full git history
|
|
||||||
|
|
||||||
- name: Get source branch name
|
|
||||||
id: branch-name
|
|
||||||
env:
|
|
||||||
RAW_BRANCH: ${{ github.head_ref }}
|
|
||||||
run: |
|
|
||||||
# Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes
|
|
||||||
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
|
|
||||||
|
|
||||||
# Additional validation - ensure branch name is not empty after sanitization
|
|
||||||
if [[ -z "$SOURCE_BRANCH" ]]; then
|
|
||||||
echo "❌ Error: Branch name is empty after sanitization"
|
|
||||||
echo "Original branch: $RAW_BRANCH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Safely add to environment variables using GitHub's recommended method
|
|
||||||
# This prevents environment variable injection attacks
|
|
||||||
echo "SOURCE_BRANCH<<EOF" >> $GITHUB_ENV
|
|
||||||
echo "$SOURCE_BRANCH" >> $GITHUB_ENV
|
|
||||||
echo "EOF" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
echo "Detected source branch: $SOURCE_BRANCH"
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
|
||||||
with:
|
|
||||||
node-version: 18 # Ensure compatibility with your project
|
|
||||||
|
|
||||||
- name: Install Tolgee CLI
|
|
||||||
run: npm install -g @tolgee/cli
|
|
||||||
|
|
||||||
- name: Tag Production Keys
|
|
||||||
run: |
|
|
||||||
npx tolgee tag \
|
|
||||||
--api-key ${{ secrets.TOLGEE_API_KEY }} \
|
|
||||||
--filter-extracted \
|
|
||||||
--filter-tag "draft:${SOURCE_BRANCH}" \
|
|
||||||
--tag production \
|
|
||||||
--untag "draft:${SOURCE_BRANCH}"
|
|
||||||
|
|
||||||
- name: Tag unused production keys as Deprecated
|
|
||||||
run: |
|
|
||||||
npx tolgee tag \
|
|
||||||
--api-key ${{ secrets.TOLGEE_API_KEY }} \
|
|
||||||
--filter-not-extracted --filter-tag production \
|
|
||||||
--tag deprecated --untag production
|
|
||||||
|
|
||||||
- name: Tag unused draft:current-branch keys as Deprecated
|
|
||||||
run: |
|
|
||||||
npx tolgee tag \
|
|
||||||
--api-key ${{ secrets.TOLGEE_API_KEY }} \
|
|
||||||
--filter-not-extracted --filter-tag "draft:${SOURCE_BRANCH}" \
|
|
||||||
--tag deprecated --untag "draft:${SOURCE_BRANCH}"
|
|
||||||
|
|
||||||
- name: Sync with backup
|
|
||||||
run: |
|
|
||||||
npx tolgee sync \
|
|
||||||
--api-key ${{ secrets.TOLGEE_API_KEY }} \
|
|
||||||
--backup ./tolgee-backup \
|
|
||||||
--continue-on-warning \
|
|
||||||
--yes
|
|
||||||
|
|
||||||
- name: Upload backup as artifact
|
|
||||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
|
||||||
with:
|
|
||||||
name: tolgee-backup-${{ github.sha }}
|
|
||||||
path: ./tolgee-backup
|
|
||||||
retention-days: 90
|
|
||||||
63
.github/workflows/translation-check.yml
vendored
Normal file
63
.github/workflows/translation-check.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
name: Translation Validation
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
paths:
|
||||||
|
- "apps/web/**/*.ts"
|
||||||
|
- "apps/web/**/*.tsx"
|
||||||
|
- "apps/web/locales/**/*.json"
|
||||||
|
- "scan-translations.ts"
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "apps/web/**/*.ts"
|
||||||
|
- "apps/web/**/*.tsx"
|
||||||
|
- "apps/web/locales/**/*.json"
|
||||||
|
- "scan-translations.ts"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-translations:
|
||||||
|
name: Validate Translation Keys
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
||||||
|
with:
|
||||||
|
version: 9.15.9
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Validate translation keys
|
||||||
|
run: |
|
||||||
|
echo ""
|
||||||
|
echo "🔍 Validating translation keys..."
|
||||||
|
echo ""
|
||||||
|
pnpm run scan-translations
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
echo ""
|
||||||
|
echo "✅ Translation validation completed successfully!"
|
||||||
|
echo ""
|
||||||
@@ -10,12 +10,34 @@ fi
|
|||||||
|
|
||||||
pnpm lint-staged
|
pnpm lint-staged
|
||||||
|
|
||||||
# Run tolgee-pull if branch.json exists and NEXT_PUBLIC_TOLGEE_API_KEY is not set
|
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
|
||||||
if [ -f branch.json ]; then
|
if [ -n "$LINGODOTDEV_API_KEY" ]; then
|
||||||
if [ -z "$NEXT_PUBLIC_TOLGEE_API_KEY" ]; then
|
echo ""
|
||||||
echo "Skipping tolgee-pull: NEXT_PUBLIC_TOLGEE_API_KEY is not set"
|
echo "🌍 Running Lingo.dev translation workflow..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Run translation generation and validation
|
||||||
|
if pnpm run i18n; then
|
||||||
|
echo ""
|
||||||
|
echo "✅ Translation validation passed"
|
||||||
|
echo ""
|
||||||
|
# Add updated locale files to git
|
||||||
|
git add apps/web/locales/*.json
|
||||||
else
|
else
|
||||||
pnpm run tolgee-pull
|
echo ""
|
||||||
git add apps/web/locales
|
echo "❌ Translation validation failed!"
|
||||||
|
echo ""
|
||||||
|
echo "Please fix the translation issues above before committing:"
|
||||||
|
echo " • Add missing translation keys to your locale files"
|
||||||
|
echo " • Remove unused translation keys"
|
||||||
|
echo ""
|
||||||
|
echo "Or run 'pnpm i18n' to see the detailed report"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
|
||||||
|
echo " (This is expected for community contributors)"
|
||||||
|
echo ""
|
||||||
fi
|
fi
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://docs.tolgee.io/cli-schema.json",
|
|
||||||
"format": "JSON_TOLGEE",
|
|
||||||
"patterns": ["./apps/web/**/*.ts?(x)"],
|
|
||||||
"projectId": 10304,
|
|
||||||
"pull": {
|
|
||||||
"path": "./apps/web/locales"
|
|
||||||
},
|
|
||||||
"push": {
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"language": "en-US",
|
|
||||||
"path": "./apps/web/locales/en-US.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"language": "de-DE",
|
|
||||||
"path": "./apps/web/locales/de-DE.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"language": "fr-FR",
|
|
||||||
"path": "./apps/web/locales/fr-FR.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"language": "pt-BR",
|
|
||||||
"path": "./apps/web/locales/pt-BR.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"language": "zh-Hant-TW",
|
|
||||||
"path": "./apps/web/locales/zh-Hant-TW.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"language": "pt-PT",
|
|
||||||
"path": "./apps/web/locales/pt-PT.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"language": "ro-RO",
|
|
||||||
"path": "./apps/web/locales/ro-RO.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"language": "ja-JP",
|
|
||||||
"path": "./apps/web/locales/ja-JP.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"language": "zh-Hans-CN",
|
|
||||||
"path": "./apps/web/locales/zh-Hans-CN.json"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"forceMode": "OVERRIDE"
|
|
||||||
},
|
|
||||||
"strictNamespace": false
|
|
||||||
}
|
|
||||||
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
||||||
"eslint.workingDirectories": [{ "mode": "auto" }],
|
"eslint.workingDirectories": [
|
||||||
|
{
|
||||||
|
"mode": "auto"
|
||||||
|
}
|
||||||
|
],
|
||||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
"javascript.updateImportsOnFileMove.enabled": "always",
|
||||||
"sonarlint.connectedMode.project": {
|
"sonarlint.connectedMode.project": {
|
||||||
"connectionId": "formbricks",
|
"connectionId": "formbricks",
|
||||||
|
|||||||
28
AGENTS.md
Normal file
28
AGENTS.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
Formbricks runs as a pnpm/turbo monorepo. `apps/web` is the Next.js product surface, with feature modules under `app/` and `modules/`, assets in `public/` and `images/`, and Playwright specs in `apps/web/playwright/`. `apps/storybook` renders reusable UI pieces for review. Shared logic lives in `packages/*`: `database` (Prisma schemas/migrations), `surveys`, `js-core`, `types`, plus linting and TypeScript presets (`config-*`). Deployment collateral is kept in `docs/`, `docker/`, and `helm-chart/`. Unit tests sit next to their source as `*.test.ts` or inside `__tests__`.
|
||||||
|
|
||||||
|
## Build, Test & Development Commands
|
||||||
|
|
||||||
|
- `pnpm install` — install workspace dependencies pinned by `pnpm-lock.yaml`.
|
||||||
|
- `pnpm db:up` / `pnpm db:down` — start/stop the Docker services backing the app.
|
||||||
|
- `pnpm dev` — run all app and worker dev servers in parallel via Turborepo.
|
||||||
|
- `pnpm build` — generate production builds for every package and app.
|
||||||
|
- `pnpm lint` — apply the shared ESLint rules across the workspace.
|
||||||
|
- `pnpm test` / `pnpm test:coverage` — execute Vitest suites with optional coverage.
|
||||||
|
- `pnpm test:e2e` — launch the Playwright browser regression suite.
|
||||||
|
- `pnpm db:migrate:dev` — apply Prisma migrations against the dev database.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
Prefer Vitest with Testing Library for logic in `.ts` files, keeping specs colocated with the code they exercise (`utility.test.ts`). Do not write tests for `.tsx` files—React components are covered by Playwright E2E tests instead. Mock network and storage boundaries through helpers from `@formbricks/*`. Run `pnpm test` before opening a PR and `pnpm test:coverage` when touching critical flows; keep coverage from regressing. End-to-end scenarios belong in `apps/web/playwright`, using descriptive filenames (`billing.spec.ts`) and tagging slow suites with `@slow` when necessary.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { StorybookConfig } from "@storybook/react-vite";
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
import { createRequire } from "module";
|
import { createRequire } from "module";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join, resolve } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function is used to resolve the absolute path of a package.
|
* This function is used to resolve the absolute path of a package.
|
||||||
@@ -13,7 +16,7 @@ function getAbsolutePath(value: string): any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||||
addons: [
|
addons: [
|
||||||
getAbsolutePath("@storybook/addon-onboarding"),
|
getAbsolutePath("@storybook/addon-onboarding"),
|
||||||
getAbsolutePath("@storybook/addon-links"),
|
getAbsolutePath("@storybook/addon-links"),
|
||||||
@@ -25,5 +28,25 @@ const config: StorybookConfig = {
|
|||||||
name: getAbsolutePath("@storybook/react-vite"),
|
name: getAbsolutePath("@storybook/react-vite"),
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
|
async viteFinal(config) {
|
||||||
|
const surveyUiPath = resolve(__dirname, "../../../packages/survey-ui/src");
|
||||||
|
const rootPath = resolve(__dirname, "../../../");
|
||||||
|
|
||||||
|
// Configure server to allow files from outside the storybook directory
|
||||||
|
config.server = config.server || {};
|
||||||
|
config.server.fs = {
|
||||||
|
...config.server.fs,
|
||||||
|
allow: [...(config.server.fs?.allow || []), rootPath],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configure simple alias resolution
|
||||||
|
config.resolve = config.resolve || {};
|
||||||
|
config.resolve.alias = {
|
||||||
|
...config.resolve.alias,
|
||||||
|
"@": surveyUiPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,32 +1,6 @@
|
|||||||
import type { Preview } from "@storybook/react-vite";
|
import type { Preview } from "@storybook/react-vite";
|
||||||
import { TolgeeProvider } from "@tolgee/react";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
// Import translation data for Storybook
|
import "../../../packages/survey-ui/src/styles/globals.css";
|
||||||
import enUSTranslations from "../../web/locales/en-US.json";
|
|
||||||
import "../../web/modules/ui/globals.css";
|
|
||||||
import { TolgeeBase } from "../../web/tolgee/shared";
|
|
||||||
|
|
||||||
// Create a Storybook-specific Tolgee decorator
|
|
||||||
const withTolgee = (Story: any) => {
|
|
||||||
const tolgee = TolgeeBase().init({
|
|
||||||
tagNewKeys: [], // No branch tagging in Storybook
|
|
||||||
});
|
|
||||||
|
|
||||||
return React.createElement(
|
|
||||||
TolgeeProvider,
|
|
||||||
{
|
|
||||||
tolgee,
|
|
||||||
fallback: "Loading",
|
|
||||||
ssr: {
|
|
||||||
language: "en-US",
|
|
||||||
staticData: {
|
|
||||||
"en-US": enUSTranslations,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
React.createElement(Story)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -35,9 +9,23 @@ const preview: Preview = {
|
|||||||
color: /(background|color)$/i,
|
color: /(background|color)$/i,
|
||||||
date: /Date$/i,
|
date: /Date$/i,
|
||||||
},
|
},
|
||||||
|
expanded: true,
|
||||||
|
},
|
||||||
|
backgrounds: {
|
||||||
|
default: "light",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
decorators: [withTolgee],
|
decorators: [
|
||||||
|
(Story) =>
|
||||||
|
React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
id: "fbjs",
|
||||||
|
className: "w-full h-full min-h-screen p-4 bg-background font-sans antialiased text-foreground",
|
||||||
|
},
|
||||||
|
React.createElement(Story)
|
||||||
|
),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default preview;
|
export default preview;
|
||||||
|
|||||||
@@ -11,22 +11,24 @@
|
|||||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eslint-plugin-react-refresh": "0.4.20"
|
"@formbricks/survey-ui": "workspace:*",
|
||||||
|
"eslint-plugin-react-refresh": "0.4.24"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^4.0.1",
|
"@chromatic-com/storybook": "^4.1.3",
|
||||||
"@storybook/addon-a11y": "9.0.15",
|
"@storybook/addon-a11y": "10.0.8",
|
||||||
"@storybook/addon-links": "9.0.15",
|
"@storybook/addon-links": "10.0.8",
|
||||||
"@storybook/addon-onboarding": "9.0.15",
|
"@storybook/addon-onboarding": "10.0.8",
|
||||||
"@storybook/react-vite": "9.0.15",
|
"@storybook/react-vite": "10.0.8",
|
||||||
"@typescript-eslint/eslint-plugin": "8.32.0",
|
"@typescript-eslint/eslint-plugin": "8.48.0",
|
||||||
"@typescript-eslint/parser": "8.32.0",
|
"@tailwindcss/vite": "4.1.17",
|
||||||
"@vitejs/plugin-react": "4.4.1",
|
"@typescript-eslint/parser": "8.48.0",
|
||||||
"esbuild": "0.25.4",
|
"@vitejs/plugin-react": "5.1.1",
|
||||||
"eslint-plugin-storybook": "9.0.15",
|
"esbuild": "0.27.0",
|
||||||
|
"eslint-plugin-storybook": "10.0.8",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"storybook": "9.0.15",
|
"storybook": "10.0.8",
|
||||||
"vite": "6.3.6",
|
"vite": "7.2.4",
|
||||||
"@storybook/addon-docs": "9.0.15"
|
"@storybook/addon-docs": "10.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
import base from "../web/tailwind.config";
|
import surveyUi from "../../packages/survey-ui/tailwind.config";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
...base,
|
content: [
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"],
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
...surveyUi.theme?.extend,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
define: {
|
define: {
|
||||||
"process.env": {},
|
"process.env": {},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "../web"),
|
"@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ ENV NODE_OPTIONS=${NODE_OPTIONS}
|
|||||||
# but needs explicit declaration for some build systems (like Depot)
|
# but needs explicit declaration for some build systems (like Depot)
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
# Base path for the application (optional)
|
||||||
|
ARG BASE_PATH=""
|
||||||
|
ENV BASE_PATH=${BASE_PATH}
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -73,8 +77,8 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
|
|||||||
#
|
#
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
RUN npm install --ignore-scripts -g corepack@latest
|
RUN npm install --ignore-scripts -g corepack@latest && \
|
||||||
RUN corepack enable
|
corepack enable
|
||||||
|
|
||||||
RUN apk add --no-cache curl \
|
RUN apk add --no-cache curl \
|
||||||
&& apk add --no-cache supercronic \
|
&& apk add --no-cache supercronic \
|
||||||
@@ -124,7 +128,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
|||||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||||
RUN chmod -R 755 ./node_modules/zod
|
RUN chmod -R 755 ./node_modules/zod
|
||||||
|
|
||||||
RUN npm install -g prisma
|
RUN npm install -g prisma@6
|
||||||
|
|
||||||
# Create a startup script to handle the conditional logic
|
# Create a startup script to handle the conditional logic
|
||||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
||||||
@@ -134,12 +138,13 @@ EXPOSE 3000
|
|||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
# Prepare volume for uploads
|
# Prepare pnpm as the nextjs user to ensure it's available at runtime
|
||||||
RUN mkdir -p /home/nextjs/apps/web/uploads/
|
# Prepare volumes for uploads and SAML connections
|
||||||
VOLUME /home/nextjs/apps/web/uploads/
|
RUN corepack prepare pnpm@9.15.9 --activate && \
|
||||||
|
mkdir -p /home/nextjs/apps/web/uploads/ && \
|
||||||
|
mkdir -p /home/nextjs/apps/web/saml-connection
|
||||||
|
|
||||||
# Prepare volume for SAML preloaded connection
|
VOLUME /home/nextjs/apps/web/uploads/
|
||||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
|
||||||
VOLUME /home/nextjs/apps/web/saml-connection
|
VOLUME /home/nextjs/apps/web/saml-connection
|
||||||
|
|
||||||
CMD ["/home/nextjs/start.sh"]
|
CMD ["/home/nextjs/start.sh"]
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { ConnectWithFormbricks } from "./ConnectWithFormbricks";
|
|
||||||
|
|
||||||
// Mocks before import
|
|
||||||
const pushMock = vi.fn();
|
|
||||||
const refreshMock = vi.fn();
|
|
||||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
|
||||||
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock, refresh: refreshMock })) }));
|
|
||||||
vi.mock("./OnboardingSetupInstructions", () => ({
|
|
||||||
OnboardingSetupInstructions: () => <div data-testid="instructions" />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ConnectWithFormbricks", () => {
|
|
||||||
const environment = { id: "env1" } as any;
|
|
||||||
const webAppUrl = "http://app";
|
|
||||||
const channel = {} as any;
|
|
||||||
|
|
||||||
test("renders waiting state when appSetupCompleted is false", () => {
|
|
||||||
render(
|
|
||||||
<ConnectWithFormbricks
|
|
||||||
environment={environment}
|
|
||||||
publicDomain={webAppUrl}
|
|
||||||
appSetupCompleted={false}
|
|
||||||
channel={channel}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("instructions")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("environments.connect.waiting_for_your_signal")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders success state when appSetupCompleted is true", () => {
|
|
||||||
render(
|
|
||||||
<ConnectWithFormbricks
|
|
||||||
environment={environment}
|
|
||||||
publicDomain={webAppUrl}
|
|
||||||
appSetupCompleted={true}
|
|
||||||
channel={channel}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(screen.getByText("environments.connect.congrats")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("environments.connect.connection_successful_message")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking finish button navigates to surveys", async () => {
|
|
||||||
render(
|
|
||||||
<ConnectWithFormbricks
|
|
||||||
environment={environment}
|
|
||||||
publicDomain={webAppUrl}
|
|
||||||
appSetupCompleted={true}
|
|
||||||
channel={channel}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
const button = screen.getByRole("button", { name: "environments.connect.finish_onboarding" });
|
|
||||||
await userEvent.click(button);
|
|
||||||
expect(pushMock).toHaveBeenCalledWith(`/environments/${environment.id}/surveys`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("refresh is called on visibilitychange to visible", () => {
|
|
||||||
render(
|
|
||||||
<ConnectWithFormbricks
|
|
||||||
environment={environment}
|
|
||||||
publicDomain={webAppUrl}
|
|
||||||
appSetupCompleted={false}
|
|
||||||
channel={channel}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
|
|
||||||
document.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
expect(refreshMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/cn";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TProjectConfigChannel } from "@formbricks/types/project";
|
import { TProjectConfigChannel } from "@formbricks/types/project";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
||||||
|
|
||||||
interface ConnectWithFormbricksProps {
|
interface ConnectWithFormbricksProps {
|
||||||
@@ -23,7 +23,7 @@ export const ConnectWithFormbricks = ({
|
|||||||
appSetupCompleted,
|
appSetupCompleted,
|
||||||
channel,
|
channel,
|
||||||
}: ConnectWithFormbricksProps) => {
|
}: ConnectWithFormbricksProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const handleFinishOnboarding = async () => {
|
const handleFinishOnboarding = async () => {
|
||||||
router.push(`/environments/${environment.id}/surveys`);
|
router.push(`/environments/${environment.id}/surveys`);
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
|
|
||||||
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
|
||||||
|
|
||||||
// Mock react-hot-toast so we can assert that a success message is shown
|
|
||||||
vi.mock("react-hot-toast", () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: {
|
|
||||||
success: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Set up a spy for navigator.clipboard.writeText so it becomes a ViTest spy.
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(navigator, "clipboard", {
|
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
|
||||||
value: {
|
|
||||||
// Using a mockResolvedValue resolves the promise as writeText is async.
|
|
||||||
writeText: vi.fn().mockResolvedValue(undefined),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("OnboardingSetupInstructions", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Provide some default props for testing
|
|
||||||
const defaultProps = {
|
|
||||||
environmentId: "env-123",
|
|
||||||
publicDomain: "https://example.com",
|
|
||||||
channel: "app" as const, // Assuming channel is either "app" or "website"
|
|
||||||
appSetupCompleted: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
test("renders HTML tab content by default", () => {
|
|
||||||
render(<OnboardingSetupInstructions {...defaultProps} />);
|
|
||||||
|
|
||||||
// Since the default active tab is "html", we check for a unique text
|
|
||||||
expect(
|
|
||||||
screen.getByText(/environments.connect.insert_this_code_into_the_head_tag_of_your_website/i)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
// The HTML snippet contains a marker comment
|
|
||||||
expect(screen.getByText("START")).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Verify the "Copy Code" button is present
|
|
||||||
expect(screen.getByRole("button", { name: /common.copy_code/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders NPM tab content when selected", async () => {
|
|
||||||
render(<OnboardingSetupInstructions {...defaultProps} />);
|
|
||||||
const user = userEvent.setup();
|
|
||||||
|
|
||||||
// Click on the "NPM" tab to switch views.
|
|
||||||
const npmTab = screen.getByText("NPM");
|
|
||||||
await user.click(npmTab);
|
|
||||||
|
|
||||||
// Check that the install commands are present
|
|
||||||
expect(screen.getByText(/npm install @formbricks\/js/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/yarn add @formbricks\/js/)).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Verify the "Read Docs" link has the correct URL (based on channel prop)
|
|
||||||
const readDocsLink = screen.getByRole("link", { name: /common.read_docs/i });
|
|
||||||
expect(readDocsLink).toHaveAttribute("href", "https://formbricks.com/docs/app-surveys/framework-guides");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("copies HTML snippet to clipboard and shows success toast when Copy Code button is clicked", async () => {
|
|
||||||
render(<OnboardingSetupInstructions {...defaultProps} />);
|
|
||||||
const user = userEvent.setup();
|
|
||||||
|
|
||||||
const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText");
|
|
||||||
|
|
||||||
// Click the "Copy Code" button
|
|
||||||
const copyButton = screen.getByRole("button", { name: /common.copy_code/i });
|
|
||||||
await user.click(copyButton);
|
|
||||||
|
|
||||||
// Ensure navigator.clipboard.writeText was called.
|
|
||||||
expect(writeTextSpy).toHaveBeenCalled();
|
|
||||||
const writtenText = (navigator.clipboard.writeText as any).mock.calls[0][0] as string;
|
|
||||||
|
|
||||||
// Check that the pasted snippet contains the expected environment values
|
|
||||||
expect(writtenText).toContain('var appUrl = "https://example.com"');
|
|
||||||
expect(writtenText).toContain('var environmentId = "env-123"');
|
|
||||||
|
|
||||||
// Verify that a success toast was shown
|
|
||||||
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders step-by-step manual link with correct URL in HTML tab", () => {
|
|
||||||
render(<OnboardingSetupInstructions {...defaultProps} />);
|
|
||||||
const manualLink = screen.getByRole("link", { name: /common.step_by_step_manual/i });
|
|
||||||
expect(manualLink).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
"https://formbricks.com/docs/app-surveys/framework-guides#html"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { CodeBlock } from "@/modules/ui/components/code-block";
|
|
||||||
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
|
|
||||||
import { TabBar } from "@/modules/ui/components/tab-bar";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import "prismjs/themes/prism.css";
|
import "prismjs/themes/prism.css";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { TProjectConfigChannel } from "@formbricks/types/project";
|
import { TProjectConfigChannel } from "@formbricks/types/project";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { CodeBlock } from "@/modules/ui/components/code-block";
|
||||||
|
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
|
||||||
|
import { TabBar } from "@/modules/ui/components/tab-bar";
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "html", label: "HTML", icon: <Html5Icon /> },
|
{ id: "html", label: "HTML", icon: <Html5Icon /> },
|
||||||
@@ -29,7 +29,7 @@ export const OnboardingSetupInstructions = ({
|
|||||||
channel,
|
channel,
|
||||||
appSetupCompleted,
|
appSetupCompleted,
|
||||||
}: OnboardingSetupInstructionsProps) => {
|
}: OnboardingSetupInstructionsProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslation();
|
||||||
const [activeTab, setActiveTab] = useState(tabs[0].id);
|
const [activeTab, setActiveTab] = useState(tabs[0].id);
|
||||||
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
|
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import { XIcon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface ConnectPageProps {
|
interface ConnectPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import OnboardingLayout from "./layout";
|
|
||||||
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
|
||||||
IS_FORMBRICKS_CLOUD: false,
|
|
||||||
IS_PRODUCTION: false,
|
|
||||||
IS_DEVELOPMENT: true,
|
|
||||||
E2E_TESTING: false,
|
|
||||||
WEBAPP_URL: "http://localhost:3000",
|
|
||||||
PUBLIC_URL: "http://localhost:3000/survey",
|
|
||||||
ENCRYPTION_KEY: "mock-encryption-key",
|
|
||||||
CRON_SECRET: "mock-cron-secret",
|
|
||||||
DEFAULT_BRAND_COLOR: "#64748b",
|
|
||||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
|
||||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
|
||||||
TERMS_URL: "http://localhost:3000/terms",
|
|
||||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
|
||||||
IMPRINT_ADDRESS: "Mock Address",
|
|
||||||
PASSWORD_RESET_DISABLED: false,
|
|
||||||
EMAIL_VERIFICATION_DISABLED: false,
|
|
||||||
GOOGLE_OAUTH_ENABLED: false,
|
|
||||||
GITHUB_OAUTH_ENABLED: false,
|
|
||||||
AZURE_OAUTH_ENABLED: false,
|
|
||||||
OIDC_OAUTH_ENABLED: false,
|
|
||||||
SAML_OAUTH_ENABLED: false,
|
|
||||||
SAML_XML_DIR: "./mock-saml-connection",
|
|
||||||
SIGNUP_ENABLED: true,
|
|
||||||
EMAIL_AUTH_ENABLED: true,
|
|
||||||
INVITE_DISABLED: false,
|
|
||||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
|
||||||
SLACK_CLIENT_ID: "mock-slack-id",
|
|
||||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
|
||||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
|
||||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
|
||||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
|
||||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
|
||||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
|
||||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
|
||||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
|
||||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
|
||||||
SMTP_HOST: "mock-smtp-host",
|
|
||||||
SMTP_PORT: "587",
|
|
||||||
SMTP_SECURE_ENABLED: false,
|
|
||||||
SMTP_USER: "mock-smtp-user",
|
|
||||||
SMTP_PASSWORD: "mock-smtp-password",
|
|
||||||
SMTP_AUTHENTICATED: true,
|
|
||||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
|
||||||
MAIL_FROM: "mock@mail.com",
|
|
||||||
MAIL_FROM_NAME: "Mock Mail",
|
|
||||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
|
||||||
ITEMS_PER_PAGE: 30,
|
|
||||||
SURVEYS_PER_PAGE: 12,
|
|
||||||
RESPONSES_PER_PAGE: 25,
|
|
||||||
TEXT_RESPONSES_PER_PAGE: 5,
|
|
||||||
INSIGHTS_PER_PAGE: 10,
|
|
||||||
DOCUMENTS_PER_PAGE: 10,
|
|
||||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
|
||||||
MAX_OTHER_OPTION_LENGTH: 250,
|
|
||||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
|
||||||
GITHUB_ID: "mock-github-id",
|
|
||||||
GITHUB_SECRET: "mock-github-secret",
|
|
||||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
|
||||||
AZURE_ID: "mock-azure-id",
|
|
||||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
|
||||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
|
||||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
|
||||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
|
||||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
|
||||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
|
||||||
OIDC_ID: "mock-oidc-id",
|
|
||||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
|
||||||
SAML_ID: "mock-saml-id",
|
|
||||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
|
||||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
|
||||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
|
||||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
|
||||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
|
||||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
|
||||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
|
||||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
|
||||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
|
||||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
|
||||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
|
||||||
SESSION_MAX_AGE: 1000,
|
|
||||||
REDIS_URL: undefined,
|
|
||||||
AUDIT_LOG_ENABLED: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
redirect: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("next-auth", () => ({
|
|
||||||
getServerSession: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/environment/auth", () => ({
|
|
||||||
hasUserEnvironmentAccess: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("OnboardingLayout", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("redirects to login if session is missing", async () => {
|
|
||||||
vi.mocked(getServerSession).mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
await OnboardingLayout({
|
|
||||||
params: { environmentId: "env1" },
|
|
||||||
children: <div>Test Content</div>,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws AuthorizationError if user lacks access", async () => {
|
|
||||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
|
|
||||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
OnboardingLayout({
|
|
||||||
params: { environmentId: "env1" },
|
|
||||||
children: <div>Test Content</div>,
|
|
||||||
})
|
|
||||||
).rejects.toThrow("User is not authorized to access this environment");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders children if user has access", async () => {
|
|
||||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
|
|
||||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
|
|
||||||
|
|
||||||
const result = await OnboardingLayout({
|
|
||||||
params: { environmentId: "env1" },
|
|
||||||
children: <div data-testid="child">Test Content</div>,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(result);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
import { AuthorizationError } from "@formbricks/types/errors";
|
||||||
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
const OnboardingLayout = async (props) => {
|
const OnboardingLayout = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { XMTemplateList } from "./XMTemplateList";
|
|
||||||
|
|
||||||
// Prepare push mock and module mocks before importing component
|
|
||||||
const pushMock = vi.fn();
|
|
||||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
|
||||||
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock })) }));
|
|
||||||
vi.mock("react-hot-toast", () => ({ default: { error: vi.fn() } }));
|
|
||||||
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates", () => ({
|
|
||||||
getXMTemplates: (t: any) => [
|
|
||||||
{ id: 1, name: "tmpl1" },
|
|
||||||
{ id: 2, name: "tmpl2" },
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils", () => ({
|
|
||||||
replacePresetPlaceholders: (template: any, project: any) => ({ ...template, projectId: project.id }),
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/survey/components/template-list/actions", () => ({ createSurveyAction: vi.fn() }));
|
|
||||||
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
|
|
||||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
|
||||||
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
|
|
||||||
<div>
|
|
||||||
{options.map((opt, idx) => (
|
|
||||||
<button key={idx} data-testid={`option-${idx}`} onClick={opt.onClick}>
|
|
||||||
{opt.title}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Reset mocks between tests
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("XMTemplateList component", () => {
|
|
||||||
const project = { id: "proj1" } as any;
|
|
||||||
const user = { id: "user1" } as any;
|
|
||||||
const environmentId = "env1";
|
|
||||||
|
|
||||||
test("creates survey and navigates on success", async () => {
|
|
||||||
// Mock successful survey creation
|
|
||||||
vi.mocked(createSurveyAction).mockResolvedValue({ data: { id: "survey1" } } as any);
|
|
||||||
|
|
||||||
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
|
|
||||||
|
|
||||||
const option0 = screen.getByTestId("option-0");
|
|
||||||
await userEvent.click(option0);
|
|
||||||
|
|
||||||
expect(createSurveyAction).toHaveBeenCalledWith({
|
|
||||||
environmentId,
|
|
||||||
surveyBody: expect.objectContaining({ id: 1, projectId: "proj1", type: "link", createdBy: "user1" }),
|
|
||||||
});
|
|
||||||
expect(pushMock).toHaveBeenCalledWith(`/environments/${environmentId}/surveys/survey1/edit?mode=cx`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error toast on failure", async () => {
|
|
||||||
// Mock failed survey creation
|
|
||||||
vi.mocked(createSurveyAction).mockResolvedValue({ error: "err" } as any);
|
|
||||||
|
|
||||||
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
|
|
||||||
|
|
||||||
const option1 = screen.getByTestId("option-1");
|
|
||||||
await userEvent.click(option1);
|
|
||||||
|
|
||||||
expect(createSurveyAction).toHaveBeenCalled();
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("formatted-error");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { ActivityIcon, ShoppingCartIcon, SmileIcon, StarIcon, ThumbsUpIcon, UsersIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TProject } from "@formbricks/types/project";
|
||||||
|
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||||
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
|
import { TUser } from "@formbricks/types/user";
|
||||||
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
|
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
|
||||||
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
|
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
|
||||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { ActivityIcon, ShoppingCartIcon, SmileIcon, StarIcon, ThumbsUpIcon, UsersIcon } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
|
||||||
|
|
||||||
interface XMTemplateListProps {
|
interface XMTemplateListProps {
|
||||||
project: TProject;
|
project: TProject;
|
||||||
@@ -23,7 +23,7 @@ interface XMTemplateListProps {
|
|||||||
|
|
||||||
export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
|
export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
|
||||||
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
|
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const createSurvey = async (activeTemplate: TXMTemplate) => {
|
const createSurvey = async (activeTemplate: TXMTemplate) => {
|
||||||
|
|||||||
@@ -32,14 +32,22 @@ const mockProject: TProject = {
|
|||||||
};
|
};
|
||||||
const mockTemplate: TXMTemplate = {
|
const mockTemplate: TXMTemplate = {
|
||||||
name: "$[projectName] Survey",
|
name: "$[projectName] Survey",
|
||||||
questions: [
|
blocks: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "block1",
|
||||||
inputType: "text",
|
name: "Block 1",
|
||||||
type: "email" as any,
|
elements: [
|
||||||
headline: { default: "$[projectName] Question" },
|
{
|
||||||
required: false,
|
id: "q1",
|
||||||
charLimit: { enabled: true, min: 400, max: 1000 },
|
type: "openText" as const,
|
||||||
|
inputType: "text" as const,
|
||||||
|
headline: { default: "$[projectName] Question" },
|
||||||
|
subheader: { default: "" },
|
||||||
|
required: false,
|
||||||
|
placeholder: { default: "" },
|
||||||
|
charLimit: 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
endings: [
|
endings: [
|
||||||
@@ -66,9 +74,9 @@ describe("replacePresetPlaceholders", () => {
|
|||||||
expect(result.name).toBe("Test Project Survey");
|
expect(result.name).toBe("Test Project Survey");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replaces projectName placeholder in question headline", () => {
|
test("replaces projectName placeholder in element headline", () => {
|
||||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||||
expect(result.questions[0].headline.default).toBe("Test Project Question");
|
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns a new object without mutating the original template", () => {
|
test("returns a new object without mutating the original template", () => {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { replaceQuestionPresetPlaceholders } from "@/lib/utils/templates";
|
|
||||||
import { TProject } from "@formbricks/types/project";
|
import { TProject } from "@formbricks/types/project";
|
||||||
|
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
|
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
|
||||||
|
|
||||||
// replace all occurences of projectName with the actual project name in the current template
|
// replace all occurences of projectName with the actual project name in the current template
|
||||||
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject) => {
|
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
|
||||||
const survey = structuredClone(template);
|
const survey = structuredClone(template);
|
||||||
survey.name = survey.name.replace("$[projectName]", project.name);
|
|
||||||
survey.questions = survey.questions.map((question) => {
|
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
|
||||||
return replaceQuestionPresetPlaceholders(question, project);
|
...block,
|
||||||
});
|
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
|
||||||
return { ...template, ...survey };
|
}));
|
||||||
|
|
||||||
|
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup } from "@testing-library/preact";
|
import { cleanup } from "@testing-library/preact";
|
||||||
import { TFnType } from "@tolgee/react";
|
import { TFunction } from "i18next";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { getXMSurveyDefault, getXMTemplates } from "./xm-templates";
|
import { getXMSurveyDefault, getXMTemplates } from "./xm-templates";
|
||||||
|
|
||||||
@@ -14,13 +14,13 @@ describe("xm-templates", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("getXMSurveyDefault returns default survey template", () => {
|
test("getXMSurveyDefault returns default survey template", () => {
|
||||||
const tMock = vi.fn((key) => key) as TFnType;
|
const tMock = vi.fn((key) => key) as TFunction;
|
||||||
const result = getXMSurveyDefault(tMock);
|
const result = getXMSurveyDefault(tMock);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
name: "",
|
name: "",
|
||||||
endings: expect.any(Array),
|
endings: expect.any(Array),
|
||||||
questions: [],
|
blocks: [],
|
||||||
styling: {
|
styling: {
|
||||||
overwriteThemeStyling: true,
|
overwriteThemeStyling: true,
|
||||||
},
|
},
|
||||||
@@ -29,7 +29,7 @@ describe("xm-templates", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("getXMTemplates returns all templates", () => {
|
test("getXMTemplates returns all templates", () => {
|
||||||
const tMock = vi.fn((key) => key) as TFnType;
|
const tMock = vi.fn((key) => key) as TFunction;
|
||||||
const result = getXMTemplates(tMock);
|
const result = getXMTemplates(tMock);
|
||||||
|
|
||||||
expect(result).toHaveLength(6);
|
expect(result).toHaveLength(6);
|
||||||
@@ -44,7 +44,7 @@ describe("xm-templates", () => {
|
|||||||
test("getXMTemplates handles errors gracefully", async () => {
|
test("getXMTemplates handles errors gracefully", async () => {
|
||||||
const tMock = vi.fn(() => {
|
const tMock = vi.fn(() => {
|
||||||
throw new Error("Test error");
|
throw new Error("Test error");
|
||||||
}) as TFnType;
|
}) as TFunction;
|
||||||
|
|
||||||
const result = getXMTemplates(tMock);
|
const result = getXMTemplates(tMock);
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { TFnType } from "@tolgee/react";
|
import { TFunction } from "i18next";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
import {
|
import {
|
||||||
buildCTAQuestion,
|
buildBlock,
|
||||||
buildNPSQuestion,
|
buildCTAElement,
|
||||||
buildOpenTextQuestion,
|
buildNPSElement,
|
||||||
buildRatingQuestion,
|
buildOpenTextElement,
|
||||||
getDefaultEndingCard,
|
buildRatingElement,
|
||||||
} from "@/app/lib/survey-builder";
|
createBlockJumpLogic,
|
||||||
|
} from "@/app/lib/survey-block-builder";
|
||||||
|
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
|
||||||
|
|
||||||
export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
|
export const getXMSurveyDefault = (t: TFunction): TXMTemplate => {
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
endings: [getDefaultEndingCard([], t)],
|
endings: [getDefaultEndingCard([], t)],
|
||||||
questions: [],
|
blocks: [],
|
||||||
styling: {
|
styling: {
|
||||||
overwriteThemeStyling: true,
|
overwriteThemeStyling: true,
|
||||||
},
|
},
|
||||||
@@ -26,45 +28,72 @@ export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const npsSurvey = (t: TFnType): TXMTemplate => {
|
const npsSurvey = (t: TFunction): TXMTemplate => {
|
||||||
return {
|
return {
|
||||||
...getXMSurveyDefault(t),
|
...getXMSurveyDefault(t),
|
||||||
name: t("templates.nps_survey_name"),
|
name: t("templates.nps_survey_name"),
|
||||||
questions: [
|
blocks: [
|
||||||
buildNPSQuestion({
|
buildBlock({
|
||||||
headline: t("templates.nps_survey_question_1_headline"),
|
name: "Block 1",
|
||||||
required: true,
|
elements: [
|
||||||
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
|
buildNPSElement({
|
||||||
upperLabel: t("templates.nps_survey_question_1_upper_label"),
|
headline: t("templates.nps_survey_question_1_headline"),
|
||||||
isColorCodingEnabled: true,
|
required: true,
|
||||||
|
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
|
||||||
|
upperLabel: t("templates.nps_survey_question_1_upper_label"),
|
||||||
|
isColorCodingEnabled: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
headline: t("templates.nps_survey_question_2_headline"),
|
name: "Block 2",
|
||||||
required: false,
|
elements: [
|
||||||
inputType: "text",
|
buildOpenTextElement({
|
||||||
|
headline: t("templates.nps_survey_question_2_headline"),
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
headline: t("templates.nps_survey_question_3_headline"),
|
name: "Block 3",
|
||||||
required: false,
|
elements: [
|
||||||
inputType: "text",
|
buildOpenTextElement({
|
||||||
|
headline: t("templates.nps_survey_question_3_headline"),
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
const reusableElementIds = [createId(), createId(), createId()];
|
||||||
|
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
|
||||||
const defaultSurvey = getXMSurveyDefault(t);
|
const defaultSurvey = getXMSurveyDefault(t);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaultSurvey,
|
...defaultSurvey,
|
||||||
name: t("templates.star_rating_survey_name"),
|
name: t("templates.star_rating_survey_name"),
|
||||||
questions: [
|
blocks: [
|
||||||
buildRatingQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[0],
|
name: "Block 1",
|
||||||
|
elements: [
|
||||||
|
buildRatingElement({
|
||||||
|
id: reusableElementIds[0],
|
||||||
|
range: 5,
|
||||||
|
scale: "number",
|
||||||
|
headline: t("templates.star_rating_survey_question_1_headline"),
|
||||||
|
required: true,
|
||||||
|
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
|
||||||
|
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -75,8 +104,8 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableQuestionIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "question",
|
type: "element",
|
||||||
},
|
},
|
||||||
operator: "isLessThanOrEqual",
|
operator: "isLessThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -89,80 +118,72 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
objective: "jumpToQuestion",
|
objective: "jumpToBlock",
|
||||||
target: reusableQuestionIds[2],
|
target: block3Id,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
range: 5,
|
|
||||||
scale: "number",
|
|
||||||
headline: t("templates.star_rating_survey_question_1_headline"),
|
|
||||||
required: true,
|
|
||||||
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
|
|
||||||
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[1],
|
name: "Block 2",
|
||||||
subheader: t("templates.star_rating_survey_question_2_html"),
|
elements: [
|
||||||
logic: [
|
buildCTAElement({
|
||||||
{
|
id: reusableElementIds[1],
|
||||||
id: createId(),
|
subheader: t("templates.star_rating_survey_question_2_html"),
|
||||||
conditions: {
|
headline: t("templates.star_rating_survey_question_2_headline"),
|
||||||
id: createId(),
|
required: false,
|
||||||
connector: "and",
|
buttonUrl: "https://formbricks.com/github",
|
||||||
conditions: [
|
buttonExternal: true,
|
||||||
{
|
ctaButtonLabel: t("templates.star_rating_survey_question_2_button_label"),
|
||||||
id: createId(),
|
}),
|
||||||
leftOperand: {
|
|
||||||
value: reusableQuestionIds[1],
|
|
||||||
type: "question",
|
|
||||||
},
|
|
||||||
operator: "isClicked",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
id: createId(),
|
|
||||||
objective: "jumpToQuestion",
|
|
||||||
target: defaultSurvey.endings[0].id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
headline: t("templates.star_rating_survey_question_2_headline"),
|
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
|
||||||
required: true,
|
|
||||||
buttonUrl: "https://formbricks.com/github",
|
|
||||||
buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
|
|
||||||
buttonExternal: true,
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[2],
|
id: block3Id,
|
||||||
headline: t("templates.star_rating_survey_question_3_headline"),
|
name: "Block 3",
|
||||||
required: true,
|
elements: [
|
||||||
subheader: t("templates.star_rating_survey_question_3_subheader"),
|
buildOpenTextElement({
|
||||||
|
id: reusableElementIds[2],
|
||||||
|
headline: t("templates.star_rating_survey_question_3_headline"),
|
||||||
|
required: true,
|
||||||
|
subheader: t("templates.star_rating_survey_question_3_subheader"),
|
||||||
|
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
|
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
|
||||||
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
|
|
||||||
inputType: "text",
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const csatSurvey = (t: TFnType): TXMTemplate => {
|
const csatSurvey = (t: TFunction): TXMTemplate => {
|
||||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
const reusableElementIds = [createId(), createId(), createId()];
|
||||||
|
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
|
||||||
const defaultSurvey = getXMSurveyDefault(t);
|
const defaultSurvey = getXMSurveyDefault(t);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaultSurvey,
|
...defaultSurvey,
|
||||||
name: t("templates.csat_survey_name"),
|
name: t("templates.csat_survey_name"),
|
||||||
questions: [
|
blocks: [
|
||||||
buildRatingQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[0],
|
name: "Block 1",
|
||||||
|
elements: [
|
||||||
|
buildRatingElement({
|
||||||
|
id: reusableElementIds[0],
|
||||||
|
range: 5,
|
||||||
|
scale: "smiley",
|
||||||
|
headline: t("templates.csat_survey_question_1_headline"),
|
||||||
|
required: true,
|
||||||
|
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
|
||||||
|
upperLabel: t("templates.csat_survey_question_1_upper_label"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -173,8 +194,8 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableQuestionIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "question",
|
type: "element",
|
||||||
},
|
},
|
||||||
operator: "isLessThanOrEqual",
|
operator: "isLessThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -187,101 +208,103 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
objective: "jumpToQuestion",
|
objective: "jumpToBlock",
|
||||||
target: reusableQuestionIds[2],
|
target: block3Id,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
range: 5,
|
|
||||||
scale: "smiley",
|
|
||||||
headline: t("templates.csat_survey_question_1_headline"),
|
|
||||||
required: true,
|
|
||||||
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
|
|
||||||
upperLabel: t("templates.csat_survey_question_1_upper_label"),
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[1],
|
name: "Block 2",
|
||||||
logic: [
|
elements: [
|
||||||
{
|
buildOpenTextElement({
|
||||||
id: createId(),
|
id: reusableElementIds[1],
|
||||||
conditions: {
|
headline: t("templates.csat_survey_question_2_headline"),
|
||||||
id: createId(),
|
required: false,
|
||||||
connector: "and",
|
placeholder: t("templates.csat_survey_question_2_placeholder"),
|
||||||
conditions: [
|
inputType: "text",
|
||||||
{
|
}),
|
||||||
id: createId(),
|
|
||||||
leftOperand: {
|
|
||||||
value: reusableQuestionIds[1],
|
|
||||||
type: "question",
|
|
||||||
},
|
|
||||||
operator: "isSubmitted",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
id: createId(),
|
|
||||||
objective: "jumpToQuestion",
|
|
||||||
target: defaultSurvey.endings[0].id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
headline: t("templates.csat_survey_question_2_headline"),
|
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isSubmitted")],
|
||||||
required: false,
|
|
||||||
placeholder: t("templates.csat_survey_question_2_placeholder"),
|
|
||||||
inputType: "text",
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[2],
|
id: block3Id,
|
||||||
headline: t("templates.csat_survey_question_3_headline"),
|
name: "Block 3",
|
||||||
required: false,
|
elements: [
|
||||||
placeholder: t("templates.csat_survey_question_3_placeholder"),
|
buildOpenTextElement({
|
||||||
inputType: "text",
|
id: reusableElementIds[2],
|
||||||
|
headline: t("templates.csat_survey_question_3_headline"),
|
||||||
|
required: false,
|
||||||
|
placeholder: t("templates.csat_survey_question_3_placeholder"),
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const cessSurvey = (t: TFnType): TXMTemplate => {
|
const cessSurvey = (t: TFunction): TXMTemplate => {
|
||||||
return {
|
return {
|
||||||
...getXMSurveyDefault(t),
|
...getXMSurveyDefault(t),
|
||||||
name: t("templates.cess_survey_name"),
|
name: t("templates.cess_survey_name"),
|
||||||
questions: [
|
blocks: [
|
||||||
buildRatingQuestion({
|
buildBlock({
|
||||||
range: 5,
|
name: "Block 1",
|
||||||
scale: "number",
|
elements: [
|
||||||
headline: t("templates.cess_survey_question_1_headline"),
|
buildRatingElement({
|
||||||
required: true,
|
range: 5,
|
||||||
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
|
scale: "number",
|
||||||
upperLabel: t("templates.cess_survey_question_1_upper_label"),
|
headline: t("templates.cess_survey_question_1_headline"),
|
||||||
|
required: true,
|
||||||
|
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
|
||||||
|
upperLabel: t("templates.cess_survey_question_1_upper_label"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
headline: t("templates.cess_survey_question_2_headline"),
|
name: "Block 2",
|
||||||
required: true,
|
elements: [
|
||||||
placeholder: t("templates.cess_survey_question_2_placeholder"),
|
buildOpenTextElement({
|
||||||
inputType: "text",
|
headline: t("templates.cess_survey_question_2_headline"),
|
||||||
|
required: true,
|
||||||
|
placeholder: t("templates.cess_survey_question_2_placeholder"),
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
const reusableElementIds = [createId(), createId(), createId()];
|
||||||
|
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
|
||||||
const defaultSurvey = getXMSurveyDefault(t);
|
const defaultSurvey = getXMSurveyDefault(t);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaultSurvey,
|
...defaultSurvey,
|
||||||
name: t("templates.smileys_survey_name"),
|
name: t("templates.smileys_survey_name"),
|
||||||
questions: [
|
blocks: [
|
||||||
buildRatingQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[0],
|
name: "Block 1",
|
||||||
|
elements: [
|
||||||
|
buildRatingElement({
|
||||||
|
id: reusableElementIds[0],
|
||||||
|
range: 5,
|
||||||
|
scale: "smiley",
|
||||||
|
headline: t("templates.smileys_survey_question_1_headline"),
|
||||||
|
required: true,
|
||||||
|
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
|
||||||
|
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -292,8 +315,8 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableQuestionIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "question",
|
type: "element",
|
||||||
},
|
},
|
||||||
operator: "isLessThanOrEqual",
|
operator: "isLessThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -306,100 +329,95 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
objective: "jumpToQuestion",
|
objective: "jumpToBlock",
|
||||||
target: reusableQuestionIds[2],
|
target: block3Id,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
range: 5,
|
|
||||||
scale: "smiley",
|
|
||||||
headline: t("templates.smileys_survey_question_1_headline"),
|
|
||||||
required: true,
|
|
||||||
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
|
|
||||||
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[1],
|
name: "Block 2",
|
||||||
subheader: t("templates.smileys_survey_question_2_html"),
|
elements: [
|
||||||
logic: [
|
buildCTAElement({
|
||||||
{
|
id: reusableElementIds[1],
|
||||||
id: createId(),
|
subheader: t("templates.smileys_survey_question_2_html"),
|
||||||
conditions: {
|
headline: t("templates.smileys_survey_question_2_headline"),
|
||||||
id: createId(),
|
required: false,
|
||||||
connector: "and",
|
buttonUrl: "https://formbricks.com/github",
|
||||||
conditions: [
|
buttonExternal: true,
|
||||||
{
|
ctaButtonLabel: t("templates.smileys_survey_question_2_button_label"),
|
||||||
id: createId(),
|
}),
|
||||||
leftOperand: {
|
|
||||||
value: reusableQuestionIds[1],
|
|
||||||
type: "question",
|
|
||||||
},
|
|
||||||
operator: "isClicked",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
id: createId(),
|
|
||||||
objective: "jumpToQuestion",
|
|
||||||
target: defaultSurvey.endings[0].id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
headline: t("templates.smileys_survey_question_2_headline"),
|
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
|
||||||
required: true,
|
|
||||||
buttonUrl: "https://formbricks.com/github",
|
|
||||||
buttonLabel: t("templates.smileys_survey_question_2_button_label"),
|
|
||||||
buttonExternal: true,
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[2],
|
id: block3Id,
|
||||||
headline: t("templates.smileys_survey_question_3_headline"),
|
name: "Block 3",
|
||||||
required: true,
|
elements: [
|
||||||
subheader: t("templates.smileys_survey_question_3_subheader"),
|
buildOpenTextElement({
|
||||||
|
id: reusableElementIds[2],
|
||||||
|
headline: t("templates.smileys_survey_question_3_headline"),
|
||||||
|
required: true,
|
||||||
|
subheader: t("templates.smileys_survey_question_3_subheader"),
|
||||||
|
placeholder: t("templates.smileys_survey_question_3_placeholder"),
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
|
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
|
||||||
placeholder: t("templates.smileys_survey_question_3_placeholder"),
|
|
||||||
inputType: "text",
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const enpsSurvey = (t: TFnType): TXMTemplate => {
|
const enpsSurvey = (t: TFunction): TXMTemplate => {
|
||||||
return {
|
return {
|
||||||
...getXMSurveyDefault(t),
|
...getXMSurveyDefault(t),
|
||||||
name: t("templates.enps_survey_name"),
|
name: t("templates.enps_survey_name"),
|
||||||
questions: [
|
blocks: [
|
||||||
buildNPSQuestion({
|
buildBlock({
|
||||||
headline: t("templates.enps_survey_question_1_headline"),
|
name: "Block 1",
|
||||||
required: false,
|
elements: [
|
||||||
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
|
buildNPSElement({
|
||||||
upperLabel: t("templates.enps_survey_question_1_upper_label"),
|
headline: t("templates.enps_survey_question_1_headline"),
|
||||||
isColorCodingEnabled: true,
|
required: false,
|
||||||
|
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
|
||||||
|
upperLabel: t("templates.enps_survey_question_1_upper_label"),
|
||||||
|
isColorCodingEnabled: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
headline: t("templates.enps_survey_question_2_headline"),
|
name: "Block 2",
|
||||||
required: false,
|
elements: [
|
||||||
inputType: "text",
|
buildOpenTextElement({
|
||||||
|
headline: t("templates.enps_survey_question_2_headline"),
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
headline: t("templates.enps_survey_question_3_headline"),
|
name: "Block 3",
|
||||||
required: false,
|
elements: [
|
||||||
inputType: "text",
|
buildOpenTextElement({
|
||||||
|
headline: t("templates.enps_survey_question_3_headline"),
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getXMTemplates = (t: TFnType): TXMTemplate[] => {
|
export const getXMTemplates = (t: TFunction): TXMTemplate[] => {
|
||||||
try {
|
try {
|
||||||
return [
|
return [
|
||||||
npsSurvey(t),
|
npsSurvey(t),
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import Link from "next/link";
|
||||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import { XIcon } from "lucide-react";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface XMTemplatePageProps {
|
interface XMTemplatePageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding";
|
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { cache as reactCache } from "react";
|
import { cache as reactCache } from "react";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { DatabaseError } from "@formbricks/types/errors";
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
|
import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding";
|
||||||
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
|
|
||||||
export const getTeamsByOrganizationId = reactCache(
|
export const getTeamsByOrganizationId = reactCache(
|
||||||
async (organizationId: string): Promise<TOrganizationTeam[] | null> => {
|
async (organizationId: string): Promise<TOrganizationTeam[] | null> => {
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { LandingSidebar } from "./landing-sidebar";
|
|
||||||
|
|
||||||
// Mock constants that this test needs
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
|
||||||
IS_FORMBRICKS_CLOUD: false,
|
|
||||||
WEBAPP_URL: "http://localhost:3000",
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock server actions that this test needs
|
|
||||||
vi.mock("@/modules/auth/actions/sign-out", () => ({
|
|
||||||
logSignOutAction: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Module mocks must be declared before importing the component
|
|
||||||
vi.mock("@tolgee/react", () => ({
|
|
||||||
useTranslate: () => ({ t: (key: string) => key, isLoading: false }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock our useSignOut hook
|
|
||||||
const mockSignOut = vi.fn();
|
|
||||||
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
|
|
||||||
useSignOut: () => ({
|
|
||||||
signOut: mockSignOut,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
|
||||||
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
|
||||||
CreateOrganizationModal: ({ open }: { open: boolean }) => (
|
|
||||||
<div data-testid={open ? "modal-open" : "modal-closed"} />
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
|
||||||
ProfileAvatar: ({ userId }: { userId: string }) => <div data-testid="avatar">{userId}</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Ensure mocks are reset between tests
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("LandingSidebar component", () => {
|
|
||||||
const user = { id: "u1", name: "Alice", email: "alice@example.com" } as any;
|
|
||||||
const organization = { id: "o1", name: "orgOne" } as any;
|
|
||||||
|
|
||||||
test("renders logo, avatar, and initial modal closed", () => {
|
|
||||||
render(<LandingSidebar user={user} organization={organization} />);
|
|
||||||
|
|
||||||
// Formbricks logo
|
|
||||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
|
||||||
// Profile avatar
|
|
||||||
expect(screen.getByTestId("avatar")).toHaveTextContent("u1");
|
|
||||||
// CreateOrganizationModal should be closed initially
|
|
||||||
expect(screen.getByTestId("modal-closed")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking logout triggers signOut", async () => {
|
|
||||||
render(<LandingSidebar user={user} organization={organization} />);
|
|
||||||
|
|
||||||
// Open user dropdown by clicking on avatar trigger
|
|
||||||
const trigger = screen.getByTestId("avatar").parentElement;
|
|
||||||
if (trigger) await userEvent.click(trigger);
|
|
||||||
|
|
||||||
// Click logout menu item
|
|
||||||
const logoutItem = await screen.findByText("common.logout");
|
|
||||||
await userEvent.click(logoutItem);
|
|
||||||
|
|
||||||
expect(mockSignOut).toHaveBeenCalledWith({
|
|
||||||
reason: "user_initiated",
|
|
||||||
redirectUrl: "/auth/login",
|
|
||||||
organizationId: "o1",
|
|
||||||
redirect: true,
|
|
||||||
callbackUrl: "/auth/login",
|
|
||||||
clearEnvironmentId: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
|
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||||
@@ -27,7 +27,7 @@ interface LandingSidebarProps {
|
|||||||
export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
||||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
|
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
|
||||||
|
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslation();
|
||||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||||
|
|
||||||
const dropdownNavigation = [
|
const dropdownNavigation = [
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
import { getEnvironments } from "@/lib/environment/service";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup } from "@testing-library/preact";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { notFound, redirect } from "next/navigation";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import LandingLayout from "./layout";
|
|
||||||
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
|
||||||
IS_FORMBRICKS_CLOUD: false,
|
|
||||||
IS_PRODUCTION: false,
|
|
||||||
IS_DEVELOPMENT: true,
|
|
||||||
E2E_TESTING: false,
|
|
||||||
WEBAPP_URL: "http://localhost:3000",
|
|
||||||
PUBLIC_URL: "http://localhost:3000/survey",
|
|
||||||
ENCRYPTION_KEY: "mock-encryption-key",
|
|
||||||
CRON_SECRET: "mock-cron-secret",
|
|
||||||
DEFAULT_BRAND_COLOR: "#64748b",
|
|
||||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
|
||||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
|
||||||
TERMS_URL: "http://localhost:3000/terms",
|
|
||||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
|
||||||
IMPRINT_ADDRESS: "Mock Address",
|
|
||||||
PASSWORD_RESET_DISABLED: false,
|
|
||||||
EMAIL_VERIFICATION_DISABLED: false,
|
|
||||||
GOOGLE_OAUTH_ENABLED: false,
|
|
||||||
GITHUB_OAUTH_ENABLED: false,
|
|
||||||
AZURE_OAUTH_ENABLED: false,
|
|
||||||
OIDC_OAUTH_ENABLED: false,
|
|
||||||
SAML_OAUTH_ENABLED: false,
|
|
||||||
SAML_XML_DIR: "./mock-saml-connection",
|
|
||||||
SIGNUP_ENABLED: true,
|
|
||||||
EMAIL_AUTH_ENABLED: true,
|
|
||||||
INVITE_DISABLED: false,
|
|
||||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
|
||||||
SLACK_CLIENT_ID: "mock-slack-id",
|
|
||||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
|
||||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
|
||||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
|
||||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
|
||||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
|
||||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
|
||||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
|
||||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
|
||||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
|
||||||
SMTP_HOST: "mock-smtp-host",
|
|
||||||
SMTP_PORT: "587",
|
|
||||||
SMTP_SECURE_ENABLED: false,
|
|
||||||
SMTP_USER: "mock-smtp-user",
|
|
||||||
SMTP_PASSWORD: "mock-smtp-password",
|
|
||||||
SMTP_AUTHENTICATED: true,
|
|
||||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
|
||||||
MAIL_FROM: "mock@mail.com",
|
|
||||||
MAIL_FROM_NAME: "Mock Mail",
|
|
||||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
|
||||||
ITEMS_PER_PAGE: 30,
|
|
||||||
SURVEYS_PER_PAGE: 12,
|
|
||||||
RESPONSES_PER_PAGE: 25,
|
|
||||||
TEXT_RESPONSES_PER_PAGE: 5,
|
|
||||||
INSIGHTS_PER_PAGE: 10,
|
|
||||||
DOCUMENTS_PER_PAGE: 10,
|
|
||||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
|
||||||
MAX_OTHER_OPTION_LENGTH: 250,
|
|
||||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
|
||||||
GITHUB_ID: "mock-github-id",
|
|
||||||
GITHUB_SECRET: "mock-github-secret",
|
|
||||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
|
||||||
AZURE_ID: "mock-azure-id",
|
|
||||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
|
||||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
|
||||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
|
||||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
|
||||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
|
||||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
|
||||||
OIDC_ID: "mock-oidc-id",
|
|
||||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
|
||||||
SAML_ID: "mock-saml-id",
|
|
||||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
|
||||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
|
||||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
|
||||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
|
||||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
|
||||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
|
||||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
|
||||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
|
||||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
|
||||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
|
||||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
|
||||||
SESSION_MAX_AGE: 1000,
|
|
||||||
REDIS_URL: undefined,
|
|
||||||
AUDIT_LOG_ENABLED: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/environment/service");
|
|
||||||
vi.mock("@/lib/membership/service");
|
|
||||||
vi.mock("@/lib/project/service");
|
|
||||||
vi.mock("next-auth");
|
|
||||||
vi.mock("next/navigation");
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("LandingLayout", () => {
|
|
||||||
test("redirects to login if no session exists", async () => {
|
|
||||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
|
||||||
|
|
||||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
|
||||||
|
|
||||||
await LandingLayout(props);
|
|
||||||
|
|
||||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/auth/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns notFound if no membership is found", async () => {
|
|
||||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
|
|
||||||
|
|
||||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
|
||||||
|
|
||||||
await LandingLayout(props);
|
|
||||||
|
|
||||||
expect(vi.mocked(notFound)).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("redirects to production environment if available", async () => {
|
|
||||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
|
||||||
organizationId: "org-123",
|
|
||||||
userId: "user-123",
|
|
||||||
accepted: true,
|
|
||||||
role: "owner",
|
|
||||||
});
|
|
||||||
vi.mocked(getUserProjects).mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: "proj-123",
|
|
||||||
organizationId: "org-123",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-02"),
|
|
||||||
name: "Project 1",
|
|
||||||
styling: { allowStyleOverwrite: true },
|
|
||||||
recontactDays: 30,
|
|
||||||
inAppSurveyBranding: true,
|
|
||||||
linkSurveyBranding: true,
|
|
||||||
} as any,
|
|
||||||
]);
|
|
||||||
vi.mocked(getEnvironments).mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: "env-123",
|
|
||||||
type: "production",
|
|
||||||
projectId: "proj-123",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-02"),
|
|
||||||
appSetupCompleted: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
|
||||||
|
|
||||||
await LandingLayout(props);
|
|
||||||
|
|
||||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/environments/env-123/");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders children if no projects or production environment exist", async () => {
|
|
||||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
|
||||||
organizationId: "org-123",
|
|
||||||
userId: "user-123",
|
|
||||||
accepted: true,
|
|
||||||
role: "owner",
|
|
||||||
});
|
|
||||||
vi.mocked(getUserProjects).mockResolvedValue([]);
|
|
||||||
|
|
||||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
|
||||||
|
|
||||||
const result = await LandingLayout(props);
|
|
||||||
|
|
||||||
expect(result).toEqual(
|
|
||||||
<>
|
|
||||||
<div>Child Content</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getEnvironments } from "@/lib/environment/service";
|
import { getEnvironments } from "@/lib/environment/service";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { notFound, redirect } from "next/navigation";
|
|
||||||
|
|
||||||
const LandingLayout = async (props) => {
|
const LandingLayout = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
|
||||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
|
||||||
import { getUser } from "@/lib/user/service";
|
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { notFound, redirect } from "next/navigation";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: true,
|
|
||||||
features: { isMultiOrgEnabled: true },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
|
||||||
IS_FORMBRICKS_CLOUD: false,
|
|
||||||
IS_PRODUCTION: false,
|
|
||||||
IS_DEVELOPMENT: true,
|
|
||||||
E2E_TESTING: false,
|
|
||||||
WEBAPP_URL: "http://localhost:3000",
|
|
||||||
ENCRYPTION_KEY: "mock-encryption-key",
|
|
||||||
CRON_SECRET: "mock-cron-secret",
|
|
||||||
DEFAULT_BRAND_COLOR: "#64748b",
|
|
||||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
|
||||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
|
||||||
TERMS_URL: "http://localhost:3000/terms",
|
|
||||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
|
||||||
IMPRINT_ADDRESS: "Mock Address",
|
|
||||||
PASSWORD_RESET_DISABLED: false,
|
|
||||||
EMAIL_VERIFICATION_DISABLED: false,
|
|
||||||
GOOGLE_OAUTH_ENABLED: false,
|
|
||||||
GITHUB_OAUTH_ENABLED: false,
|
|
||||||
AZURE_OAUTH_ENABLED: false,
|
|
||||||
OIDC_OAUTH_ENABLED: false,
|
|
||||||
SAML_OAUTH_ENABLED: false,
|
|
||||||
SAML_XML_DIR: "./mock-saml-connection",
|
|
||||||
SIGNUP_ENABLED: true,
|
|
||||||
EMAIL_AUTH_ENABLED: true,
|
|
||||||
INVITE_DISABLED: false,
|
|
||||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
|
||||||
SLACK_CLIENT_ID: "mock-slack-id",
|
|
||||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
|
||||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
|
||||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
|
||||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
|
||||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
|
||||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
|
||||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
|
||||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
|
||||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
|
||||||
SMTP_HOST: "mock-smtp-host",
|
|
||||||
SMTP_PORT: "587",
|
|
||||||
SMTP_SECURE_ENABLED: false,
|
|
||||||
SMTP_USER: "mock-smtp-user",
|
|
||||||
SMTP_PASSWORD: "mock-smtp-password",
|
|
||||||
SMTP_AUTHENTICATED: true,
|
|
||||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
|
||||||
MAIL_FROM: "mock@mail.com",
|
|
||||||
MAIL_FROM_NAME: "Mock Mail",
|
|
||||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
|
||||||
ITEMS_PER_PAGE: 30,
|
|
||||||
SURVEYS_PER_PAGE: 12,
|
|
||||||
RESPONSES_PER_PAGE: 25,
|
|
||||||
TEXT_RESPONSES_PER_PAGE: 5,
|
|
||||||
INSIGHTS_PER_PAGE: 10,
|
|
||||||
DOCUMENTS_PER_PAGE: 10,
|
|
||||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
|
||||||
MAX_OTHER_OPTION_LENGTH: 250,
|
|
||||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
|
||||||
GITHUB_ID: "mock-github-id",
|
|
||||||
GITHUB_SECRET: "mock-github-secret",
|
|
||||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
|
||||||
AZURE_ID: "mock-azure-id",
|
|
||||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
|
||||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
|
||||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
|
||||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
|
||||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
|
||||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
|
||||||
OIDC_ID: "mock-oidc-id",
|
|
||||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
|
||||||
SAML_ID: "mock-saml-id",
|
|
||||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
|
||||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
|
||||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
|
||||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
|
||||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
|
||||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
|
||||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
|
||||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
|
||||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
|
||||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
|
||||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
|
||||||
SESSION_MAX_AGE: 1000,
|
|
||||||
REDIS_URL: undefined,
|
|
||||||
AUDIT_LOG_ENABLED: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/getPublicUrl", () => ({
|
|
||||||
getPublicDomain: vi.fn().mockReturnValue("http://localhost:3000"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
|
||||||
LandingSidebar: () => <div data-testid="landing-sidebar" />,
|
|
||||||
}));
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/project-and-org-switch", () => ({
|
|
||||||
ProjectAndOrgSwitch: () => <div data-testid="project-and-org-switch" />,
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/organization/lib/utils");
|
|
||||||
vi.mock("@/lib/user/service");
|
|
||||||
vi.mock("@/lib/organization/service");
|
|
||||||
vi.mock("@/lib/membership/service");
|
|
||||||
vi.mock("@/tolgee/server");
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
redirect: vi.fn(() => "REDIRECT_STUB"),
|
|
||||||
notFound: vi.fn(() => "NOT_FOUND_STUB"),
|
|
||||||
usePathname: vi.fn(() => "/organizations/org1"),
|
|
||||||
useRouter: vi.fn(() => ({
|
|
||||||
push: vi.fn(),
|
|
||||||
replace: vi.fn(),
|
|
||||||
back: vi.fn(),
|
|
||||||
forward: vi.fn(),
|
|
||||||
refresh: vi.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the React cache function
|
|
||||||
vi.mock("react", async () => {
|
|
||||||
const actual = await vi.importActual("react");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
cache: (fn: any) => fn,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Page component", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("redirects to login if no user session", async () => {
|
|
||||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {}, organization: {} } as any);
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: true,
|
|
||||||
features: { isMultiOrgEnabled: true },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
|
||||||
}));
|
|
||||||
const { default: Page } = await import("./page");
|
|
||||||
const result = await Page({ params: { organizationId: "org1" } });
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
|
||||||
expect(result).toBe("REDIRECT_STUB");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns notFound if user does not exist", async () => {
|
|
||||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
|
||||||
session: { user: { id: "user1" } },
|
|
||||||
organization: {},
|
|
||||||
} as any);
|
|
||||||
vi.mocked(getUser).mockResolvedValue(null);
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: true,
|
|
||||||
features: { isMultiOrgEnabled: true },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
|
||||||
}));
|
|
||||||
const { default: Page } = await import("./page");
|
|
||||||
const result = await Page({ params: { organizationId: "org1" } });
|
|
||||||
expect(notFound).toHaveBeenCalled();
|
|
||||||
expect(result).toBe("NOT_FOUND_STUB");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders header and sidebar for authenticated user", async () => {
|
|
||||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
|
||||||
session: { user: { id: "user1" } },
|
|
||||||
organization: { id: "org1", billing: { plan: "free" } },
|
|
||||||
} as any);
|
|
||||||
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
|
|
||||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
|
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
|
||||||
organizationId: "org1",
|
|
||||||
userId: "user1",
|
|
||||||
accepted: true,
|
|
||||||
role: "member",
|
|
||||||
} as any);
|
|
||||||
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
|
|
||||||
typeof props === "string" ? props : props.key || ""
|
|
||||||
);
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: true,
|
|
||||||
features: { isMultiOrgEnabled: true },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
|
||||||
}));
|
|
||||||
const { default: Page } = await import("./page");
|
|
||||||
const element = await Page({ params: { organizationId: "org1" } });
|
|
||||||
render(element as React.ReactElement);
|
|
||||||
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("project-and-org-switch")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||||
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import { notFound, redirect } from "next/navigation";
|
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
@@ -24,8 +23,6 @@ const Page = async (props) => {
|
|||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
if (!user) return notFound();
|
if (!user) return notFound();
|
||||||
|
|
||||||
const organizations = await getOrganizationsByUserId(session.user.id);
|
|
||||||
|
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
|
|
||||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||||
@@ -37,11 +34,10 @@ const Page = async (props) => {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{/* we only need to render organization breadcrumb on this page, so we pass some default value without actually calculating them to ProjectAndOrgSwitch component */}
|
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
|
||||||
<ProjectAndOrgSwitch
|
<ProjectAndOrgSwitch
|
||||||
currentOrganizationId={organization.id}
|
currentOrganizationId={organization.id}
|
||||||
organizations={organizations}
|
currentOrganizationName={organization.name}
|
||||||
projects={[]}
|
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
organizationProjectsLimit={0}
|
organizationProjectsLimit={0}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
|
||||||
import { getUser } from "@/lib/user/service";
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import React from "react";
|
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
|
||||||
import ProjectOnboardingLayout from "./layout";
|
|
||||||
|
|
||||||
// Mock all the modules and functions that this layout uses:
|
|
||||||
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
|
||||||
IS_FORMBRICKS_CLOUD: false,
|
|
||||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
|
||||||
POSTHOG_HOST: "mock-posthog-host",
|
|
||||||
IS_POSTHOG_CONFIGURED: true,
|
|
||||||
ENCRYPTION_KEY: "mock-encryption-key",
|
|
||||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
|
||||||
GITHUB_ID: "mock-github-id",
|
|
||||||
GITHUB_SECRET: "test-githubID",
|
|
||||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
|
||||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
|
||||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
|
||||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
|
||||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
|
||||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
|
||||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
|
||||||
OIDC_ISSUER: "test-oidc-issuer",
|
|
||||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
|
||||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
|
||||||
WEBAPP_URL: "test-webapp-url",
|
|
||||||
IS_PRODUCTION: false,
|
|
||||||
SESSION_MAX_AGE: 1000,
|
|
||||||
REDIS_URL: undefined,
|
|
||||||
AUDIT_LOG_ENABLED: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("next-auth", () => ({
|
|
||||||
getServerSession: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
redirect: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/organization/auth", () => ({
|
|
||||||
canUserAccessOrganization: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/organization/service", () => ({
|
|
||||||
getOrganization: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/user/service", () => ({
|
|
||||||
getUser: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/tolgee/server", () => ({
|
|
||||||
getTranslate: vi.fn(() => {
|
|
||||||
// Return a mock translator that just returns the key
|
|
||||||
return (key: string) => key;
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// mock the child components
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({
|
|
||||||
PosthogIdentify: () => <div data-testid="posthog-identify" />,
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ui/components/toaster-client", () => ({
|
|
||||||
ToasterClient: () => <div data-testid="toaster-client" />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("ProjectOnboardingLayout", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("redirects to /auth/login if there is no session", async () => {
|
|
||||||
// Mock no session
|
|
||||||
vi.mocked(getServerSession).mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
const layoutElement = await ProjectOnboardingLayout({
|
|
||||||
params: { organizationId: "org-123" },
|
|
||||||
children: <div data-testid="child-content">Hello!</div>,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
|
||||||
// Layout returns nothing after redirect
|
|
||||||
expect(layoutElement).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws an error if user does not exist", async () => {
|
|
||||||
vi.mocked(getServerSession).mockResolvedValueOnce({
|
|
||||||
user: { id: "user-123" },
|
|
||||||
});
|
|
||||||
vi.mocked(getUser).mockResolvedValueOnce(null); // no user in DB
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
ProjectOnboardingLayout({
|
|
||||||
params: { organizationId: "org-123" },
|
|
||||||
children: <div data-testid="child-content">Hello!</div>,
|
|
||||||
})
|
|
||||||
).rejects.toThrow("common.user_not_found");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws AuthorizationError if user cannot access organization", async () => {
|
|
||||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
|
|
||||||
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
|
|
||||||
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(false);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
ProjectOnboardingLayout({
|
|
||||||
params: { organizationId: "org-123" },
|
|
||||||
children: <div data-testid="child-content">Child</div>,
|
|
||||||
})
|
|
||||||
).rejects.toThrow("common.not_authorized");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws an error if organization does not exist", async () => {
|
|
||||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
|
|
||||||
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
|
|
||||||
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true);
|
|
||||||
vi.mocked(getOrganization).mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
ProjectOnboardingLayout({
|
|
||||||
params: { organizationId: "org-123" },
|
|
||||||
children: <div data-testid="child-content">Hello!</div>,
|
|
||||||
})
|
|
||||||
).rejects.toThrow("common.organization_not_found");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders child content plus PosthogIdentify & ToasterClient if everything is valid", async () => {
|
|
||||||
// Provide valid data
|
|
||||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
|
|
||||||
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", name: "Test User" } as TUser);
|
|
||||||
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true);
|
|
||||||
vi.mocked(getOrganization).mockResolvedValueOnce({
|
|
||||||
id: "org-123",
|
|
||||||
name: "Test Org",
|
|
||||||
billing: {
|
|
||||||
plan: "enterprise",
|
|
||||||
},
|
|
||||||
} as TOrganization);
|
|
||||||
|
|
||||||
let layoutElement: React.ReactNode;
|
|
||||||
// Because it's an async server component, do it in an act
|
|
||||||
await act(async () => {
|
|
||||||
layoutElement = await ProjectOnboardingLayout({
|
|
||||||
params: { organizationId: "org-123" },
|
|
||||||
children: <div data-testid="child-content">Hello!</div>,
|
|
||||||
});
|
|
||||||
render(layoutElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello!");
|
|
||||||
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
|
|
||||||
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
|
|
||||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
|
||||||
import { getUser } from "@/lib/user/service";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
import { AuthorizationError } from "@formbricks/types/errors";
|
||||||
|
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||||
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
|
import { getUser } from "@/lib/user/service";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||||
|
|
||||||
const ProjectOnboardingLayout = async (props) => {
|
const ProjectOnboardingLayout = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
@@ -40,14 +38,6 @@ const ProjectOnboardingLayout = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 bg-slate-50">
|
<div className="flex-1 bg-slate-50">
|
||||||
<PosthogIdentify
|
|
||||||
session={session}
|
|
||||||
user={user}
|
|
||||||
organizationId={organization.id}
|
|
||||||
organizationName={organization.name}
|
|
||||||
organizationBilling={organization.billing}
|
|
||||||
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
|
|
||||||
/>
|
|
||||||
<ToasterClient />
|
<ToasterClient />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import { getUserProjects } from "@/lib/project/service";
|
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import Page from "./page";
|
|
||||||
|
|
||||||
const mockTranslate = vi.fn((key) => key);
|
|
||||||
|
|
||||||
// Module mocks must be declared before importing the component
|
|
||||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
|
||||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
|
||||||
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
|
|
||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn(() => "REDIRECT_STUB") }));
|
|
||||||
vi.mock("@/modules/ui/components/header", () => ({
|
|
||||||
Header: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
|
||||||
<div>
|
|
||||||
<h1>{title}</h1>
|
|
||||||
<p>{subtitle}</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
|
||||||
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
|
|
||||||
<div data-testid="options">{options.map((o) => o.title).join(",")}</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
vi.mock("next/link", () => ({
|
|
||||||
default: ({ href, children }: { href: string; children: React.ReactNode }) => <a href={href}>{children}</a>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Page component", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
const params = Promise.resolve({ organizationId: "org1" });
|
|
||||||
|
|
||||||
test("redirects to login if no user session", async () => {
|
|
||||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {} } as any);
|
|
||||||
|
|
||||||
const result = await Page({ params });
|
|
||||||
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
|
||||||
expect(result).toBe("REDIRECT_STUB");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders header, options, and close button when projects exist", async () => {
|
|
||||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
|
|
||||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
|
||||||
vi.mocked(getUserProjects).mockResolvedValue([{ id: 1 }] as any);
|
|
||||||
|
|
||||||
const element = await Page({ params });
|
|
||||||
render(element as React.ReactElement);
|
|
||||||
|
|
||||||
// Header title and subtitle
|
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
|
||||||
"organizations.projects.new.channel.channel_select_title"
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
screen.getByText("organizations.projects.new.channel.channel_select_subtitle")
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Options container with correct titles
|
|
||||||
expect(screen.getByTestId("options")).toHaveTextContent(
|
|
||||||
"organizations.projects.new.channel.link_and_email_surveys," +
|
|
||||||
"organizations.projects.new.channel.in_product_surveys"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Close button link rendered when projects >=1
|
|
||||||
const closeLink = screen.getByRole("link");
|
|
||||||
expect(closeLink).toHaveAttribute("href", "/");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not render close button when no projects", async () => {
|
|
||||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
|
|
||||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
|
||||||
vi.mocked(getUserProjects).mockResolvedValue([]);
|
|
||||||
|
|
||||||
const element = await Page({ params });
|
|
||||||
render(element as React.ReactElement);
|
|
||||||
|
|
||||||
expect(screen.queryByRole("link")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { Header } from "@/modules/ui/components/header";
|
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
|
import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||||
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { Header } from "@/modules/ui/components/header";
|
||||||
|
|
||||||
interface ChannelPageProps {
|
interface ChannelPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
|
||||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
|
||||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup } from "@testing-library/react";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { notFound, redirect } from "next/navigation";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TMembership } from "@formbricks/types/memberships";
|
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
|
||||||
import OnboardingLayout from "./layout";
|
|
||||||
|
|
||||||
// Mock environment variables
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
|
||||||
IS_FORMBRICKS_CLOUD: false,
|
|
||||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
|
||||||
POSTHOG_HOST: "mock-posthog-host",
|
|
||||||
IS_POSTHOG_CONFIGURED: true,
|
|
||||||
ENCRYPTION_KEY: "mock-encryption-key",
|
|
||||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
|
||||||
GITHUB_ID: "mock-github-id",
|
|
||||||
GITHUB_SECRET: "test-githubID",
|
|
||||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
|
||||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
|
||||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
|
||||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
|
||||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
|
||||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
|
||||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
|
||||||
OIDC_ISSUER: "test-oidc-issuer",
|
|
||||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
|
||||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
|
||||||
WEBAPP_URL: "test-webapp-url",
|
|
||||||
IS_PRODUCTION: false,
|
|
||||||
SESSION_MAX_AGE: 1000,
|
|
||||||
REDIS_URL: undefined,
|
|
||||||
AUDIT_LOG_ENABLED: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
vi.mock("next-auth", () => ({
|
|
||||||
getServerSession: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/membership/service", () => ({
|
|
||||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/organization/service", () => ({
|
|
||||||
getOrganization: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/project/service", () => ({
|
|
||||||
getOrganizationProjectsCount: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
|
||||||
getOrganizationProjectsLimit: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/tolgee/server", () => ({
|
|
||||||
getTranslate: async () => (key: string) => key,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("OnboardingLayout", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("redirects to login if no session", async () => {
|
|
||||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
|
||||||
|
|
||||||
const props = {
|
|
||||||
params: { organizationId: "test-org-id" },
|
|
||||||
children: <div>Test Child</div>,
|
|
||||||
};
|
|
||||||
|
|
||||||
await OnboardingLayout(props);
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns not found if user is member or billing", async () => {
|
|
||||||
const mockSession = {
|
|
||||||
user: { id: "test-user-id" },
|
|
||||||
};
|
|
||||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
|
||||||
|
|
||||||
const mockMembership: TMembership = {
|
|
||||||
organizationId: "test-org-id",
|
|
||||||
userId: "test-user-id",
|
|
||||||
accepted: true,
|
|
||||||
role: "member",
|
|
||||||
};
|
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
|
||||||
|
|
||||||
const props = {
|
|
||||||
params: { organizationId: "test-org-id" },
|
|
||||||
children: <div>Test Child</div>,
|
|
||||||
};
|
|
||||||
|
|
||||||
await OnboardingLayout(props);
|
|
||||||
expect(notFound).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws error if organization is not found", async () => {
|
|
||||||
const mockSession = {
|
|
||||||
user: { id: "test-user-id" },
|
|
||||||
};
|
|
||||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
|
||||||
|
|
||||||
const mockMembership: TMembership = {
|
|
||||||
organizationId: "test-org-id",
|
|
||||||
userId: "test-user-id",
|
|
||||||
accepted: true,
|
|
||||||
role: "owner",
|
|
||||||
};
|
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
|
||||||
vi.mocked(getOrganization).mockResolvedValue(null);
|
|
||||||
|
|
||||||
const props = {
|
|
||||||
params: { organizationId: "test-org-id" },
|
|
||||||
children: <div>Test Child</div>,
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(OnboardingLayout(props)).rejects.toThrow("common.organization_not_found");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("redirects to home if project limit is reached", async () => {
|
|
||||||
const mockSession = {
|
|
||||||
user: { id: "test-user-id" },
|
|
||||||
};
|
|
||||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
|
||||||
|
|
||||||
const mockMembership: TMembership = {
|
|
||||||
organizationId: "test-org-id",
|
|
||||||
userId: "test-user-id",
|
|
||||||
accepted: true,
|
|
||||||
role: "owner",
|
|
||||||
};
|
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
|
||||||
|
|
||||||
const mockOrganization: TOrganization = {
|
|
||||||
id: "test-org-id",
|
|
||||||
name: "Test Org",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
isAIEnabled: false,
|
|
||||||
billing: {
|
|
||||||
stripeCustomerId: null,
|
|
||||||
plan: "free",
|
|
||||||
period: "monthly",
|
|
||||||
limits: {
|
|
||||||
projects: 3,
|
|
||||||
monthly: {
|
|
||||||
responses: 1500,
|
|
||||||
miu: 2000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
periodStart: new Date(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
|
|
||||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
|
|
||||||
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(3);
|
|
||||||
|
|
||||||
const props = {
|
|
||||||
params: { organizationId: "test-org-id" },
|
|
||||||
children: <div>Test Child</div>,
|
|
||||||
};
|
|
||||||
|
|
||||||
await OnboardingLayout(props);
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders children when all conditions are met", async () => {
|
|
||||||
const mockSession = {
|
|
||||||
user: { id: "test-user-id" },
|
|
||||||
};
|
|
||||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
|
||||||
|
|
||||||
const mockMembership: TMembership = {
|
|
||||||
organizationId: "test-org-id",
|
|
||||||
userId: "test-user-id",
|
|
||||||
accepted: true,
|
|
||||||
role: "owner",
|
|
||||||
};
|
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
|
||||||
|
|
||||||
const mockOrganization: TOrganization = {
|
|
||||||
id: "test-org-id",
|
|
||||||
name: "Test Org",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
isAIEnabled: false,
|
|
||||||
billing: {
|
|
||||||
stripeCustomerId: null,
|
|
||||||
plan: "free",
|
|
||||||
period: "monthly",
|
|
||||||
limits: {
|
|
||||||
projects: 3,
|
|
||||||
monthly: {
|
|
||||||
responses: 1500,
|
|
||||||
miu: 2000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
periodStart: new Date(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
|
|
||||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
|
|
||||||
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(2);
|
|
||||||
|
|
||||||
const props = {
|
|
||||||
params: { organizationId: "test-org-id" },
|
|
||||||
children: <div>Test Child</div>,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await OnboardingLayout(props);
|
|
||||||
expect(result).toEqual(<>{props.children}</>);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { notFound, redirect } from "next/navigation";
|
|
||||||
|
|
||||||
const OnboardingLayout = async (props) => {
|
const OnboardingLayout = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import { getUserProjects } from "@/lib/project/service";
|
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import Page from "./page";
|
|
||||||
|
|
||||||
const mockTranslate = vi.fn((key) => key);
|
|
||||||
|
|
||||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
|
||||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
|
||||||
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
|
|
||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
|
||||||
vi.mock("next/link", () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
|
||||||
}));
|
|
||||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
|
||||||
OnboardingOptionsContainer: ({ options }: any) => (
|
|
||||||
<div data-testid="options">{options.map((o: any) => o.title).join(",")}</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ui/components/header", () => ({ Header: ({ title }: any) => <h1>{title}</h1> }));
|
|
||||||
vi.mock("@/modules/ui/components/button", () => ({
|
|
||||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Mode Page", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
const params = Promise.resolve({ organizationId: "org1" });
|
|
||||||
|
|
||||||
test("redirects to login if no session user", async () => {
|
|
||||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
|
|
||||||
await Page({ params });
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders header and options without close link when no projects", async () => {
|
|
||||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
|
|
||||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
|
||||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
|
||||||
|
|
||||||
const element = await Page({ params });
|
|
||||||
render(element as React.ReactElement);
|
|
||||||
|
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
|
||||||
"organizations.projects.new.mode.what_are_you_here_for"
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("options")).toHaveTextContent(
|
|
||||||
"organizations.projects.new.mode.formbricks_surveys," + "organizations.projects.new.mode.formbricks_cx"
|
|
||||||
);
|
|
||||||
expect(screen.queryByRole("link")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders close link when projects exist", async () => {
|
|
||||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
|
|
||||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
|
||||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" } as any]);
|
|
||||||
|
|
||||||
const element = await Page({ params });
|
|
||||||
render(element as React.ReactElement);
|
|
||||||
|
|
||||||
const link = screen.getByRole("link");
|
|
||||||
expect(link).toHaveAttribute("href", "/");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { Header } from "@/modules/ui/components/header";
|
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
|
import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||||
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { Header } from "@/modules/ui/components/header";
|
||||||
|
|
||||||
interface ModePageProps {
|
interface ModePageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { ProjectSettings } from "./ProjectSettings";
|
|
||||||
|
|
||||||
// Mocks before imports
|
|
||||||
const pushMock = vi.fn();
|
|
||||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) }));
|
|
||||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
|
||||||
vi.mock("react-hot-toast", () => ({ toast: { error: vi.fn() } }));
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/actions", () => ({ createProjectAction: vi.fn() }));
|
|
||||||
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
|
|
||||||
vi.mock("@/modules/ui/components/color-picker", () => ({
|
|
||||||
ColorPicker: ({ color, onChange }: any) => (
|
|
||||||
<button data-testid="color-picker" onClick={() => onChange("#000")}>
|
|
||||||
{color}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ui/components/input", () => ({
|
|
||||||
Input: ({ value, onChange, placeholder }: any) => (
|
|
||||||
<input placeholder={placeholder} value={value} onChange={(e) => onChange((e.target as any).value)} />
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ui/components/multi-select", () => ({
|
|
||||||
MultiSelect: ({ value, options, onChange }: any) => (
|
|
||||||
<select
|
|
||||||
data-testid="multi-select"
|
|
||||||
multiple
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(Array.from((e.target as any).selectedOptions).map((o: any) => o.value))}>
|
|
||||||
{options.map((o: any) => (
|
|
||||||
<option key={o.value} value={o.value}>
|
|
||||||
{o.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ui/components/survey", () => ({
|
|
||||||
SurveyInline: () => <div data-testid="survey-inline" />,
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/templates", () => ({ previewSurvey: () => ({}) }));
|
|
||||||
vi.mock("@/modules/ee/teams/team-list/components/create-team-modal", () => ({
|
|
||||||
CreateTeamModal: ({ open }: any) => <div data-testid={open ? "team-modal-open" : "team-modal-closed"} />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Clean up after each test
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ProjectSettings component", () => {
|
|
||||||
const baseProps = {
|
|
||||||
organizationId: "org1",
|
|
||||||
projectMode: "cx",
|
|
||||||
industry: "ind",
|
|
||||||
defaultBrandColor: "#fff",
|
|
||||||
organizationTeams: [],
|
|
||||||
isAccessControlAllowed: false,
|
|
||||||
userProjectsCount: 0,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const fillAndSubmit = async () => {
|
|
||||||
const nameInput = screen.getByPlaceholderText("e.g. Formbricks");
|
|
||||||
await userEvent.clear(nameInput);
|
|
||||||
await userEvent.type(nameInput, "TestProject");
|
|
||||||
const nextButton = screen.getByRole("button", { name: "common.next" });
|
|
||||||
await userEvent.click(nextButton);
|
|
||||||
};
|
|
||||||
|
|
||||||
test("successful createProject for link channel navigates to surveys and clears localStorage", async () => {
|
|
||||||
(createProjectAction as any).mockResolvedValue({
|
|
||||||
data: { environments: [{ id: "env123", type: "production" }] },
|
|
||||||
});
|
|
||||||
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
|
|
||||||
await fillAndSubmit();
|
|
||||||
expect(createProjectAction).toHaveBeenCalledWith({
|
|
||||||
organizationId: "org1",
|
|
||||||
data: expect.objectContaining({ teamIds: [] }),
|
|
||||||
});
|
|
||||||
expect(pushMock).toHaveBeenCalledWith("/environments/env123/surveys");
|
|
||||||
expect(localStorage.getItem("FORMBRICKS_SURVEYS_FILTERS_KEY_LS")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("successful createProject for app channel navigates to connect", async () => {
|
|
||||||
(createProjectAction as any).mockResolvedValue({
|
|
||||||
data: { environments: [{ id: "env456", type: "production" }] },
|
|
||||||
});
|
|
||||||
render(<ProjectSettings {...baseProps} channel="app" projectMode="cx" />);
|
|
||||||
await fillAndSubmit();
|
|
||||||
expect(pushMock).toHaveBeenCalledWith("/environments/env456/connect");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("successful createProject for cx mode navigates to xm-templates when channel is neither link nor app", async () => {
|
|
||||||
(createProjectAction as any).mockResolvedValue({
|
|
||||||
data: { environments: [{ id: "env789", type: "production" }] },
|
|
||||||
});
|
|
||||||
render(<ProjectSettings {...baseProps} channel="unknown" projectMode="cx" />);
|
|
||||||
await fillAndSubmit();
|
|
||||||
expect(pushMock).toHaveBeenCalledWith("/environments/env789/xm-templates");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error toast on createProject error response", async () => {
|
|
||||||
(createProjectAction as any).mockResolvedValue({ error: "err" });
|
|
||||||
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
|
|
||||||
await fillAndSubmit();
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("formatted-error");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error toast on exception", async () => {
|
|
||||||
(createProjectAction as any).mockImplementation(() => {
|
|
||||||
throw new Error("fail");
|
|
||||||
});
|
|
||||||
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
|
|
||||||
await fillAndSubmit();
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("organizations.projects.new.settings.project_creation_failed");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
TProjectConfigChannel,
|
||||||
|
TProjectConfigIndustry,
|
||||||
|
TProjectMode,
|
||||||
|
TProjectUpdateInput,
|
||||||
|
ZProjectUpdateInput,
|
||||||
|
} from "@formbricks/types/project";
|
||||||
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
|
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||||
import { previewSurvey } from "@/app/lib/templates";
|
import { previewSurvey } from "@/app/lib/templates";
|
||||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||||
@@ -20,20 +34,6 @@ import {
|
|||||||
import { Input } from "@/modules/ui/components/input";
|
import { Input } from "@/modules/ui/components/input";
|
||||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||||
import { SurveyInline } from "@/modules/ui/components/survey";
|
import { SurveyInline } from "@/modules/ui/components/survey";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import {
|
|
||||||
TProjectConfigChannel,
|
|
||||||
TProjectConfigIndustry,
|
|
||||||
TProjectMode,
|
|
||||||
TProjectUpdateInput,
|
|
||||||
ZProjectUpdateInput,
|
|
||||||
} from "@formbricks/types/project";
|
|
||||||
|
|
||||||
interface ProjectSettingsProps {
|
interface ProjectSettingsProps {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -44,6 +44,7 @@ interface ProjectSettingsProps {
|
|||||||
organizationTeams: TOrganizationTeam[];
|
organizationTeams: TOrganizationTeam[];
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
userProjectsCount: number;
|
userProjectsCount: number;
|
||||||
|
publicDomain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectSettings = ({
|
export const ProjectSettings = ({
|
||||||
@@ -55,11 +56,12 @@ export const ProjectSettings = ({
|
|||||||
organizationTeams,
|
organizationTeams,
|
||||||
isAccessControlAllowed = false,
|
isAccessControlAllowed = false,
|
||||||
userProjectsCount,
|
userProjectsCount,
|
||||||
|
publicDomain,
|
||||||
}: ProjectSettingsProps) => {
|
}: ProjectSettingsProps) => {
|
||||||
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslation();
|
||||||
const addProject = async (data: TProjectUpdateInput) => {
|
const addProject = async (data: TProjectUpdateInput) => {
|
||||||
try {
|
try {
|
||||||
const createProjectResponse = await createProjectAction({
|
const createProjectResponse = await createProjectAction({
|
||||||
@@ -225,12 +227,13 @@ export const ProjectSettings = ({
|
|||||||
alt="Logo"
|
alt="Logo"
|
||||||
width={256}
|
width={256}
|
||||||
height={56}
|
height={56}
|
||||||
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||||
<div className="z-0 h-3/4 w-3/4">
|
<div className="z-0 h-3/4 w-3/4">
|
||||||
<SurveyInline
|
<SurveyInline
|
||||||
|
appUrl={publicDomain}
|
||||||
isPreviewMode={true}
|
isPreviewMode={true}
|
||||||
survey={previewSurvey(projectName || "my Product", t)}
|
survey={previewSurvey(projectName || "my Product", t)}
|
||||||
styling={{ brandColor: { light: brandColor } }}
|
styling={{ brandColor: { light: brandColor } }}
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
|
||||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import Page from "./page";
|
|
||||||
|
|
||||||
vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
|
|
||||||
// Mocks before component import
|
|
||||||
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
|
|
||||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getAccessControlPermission: vi.fn() }));
|
|
||||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
|
||||||
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
|
|
||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
|
||||||
vi.mock("next/link", () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ui/components/header", () => ({
|
|
||||||
Header: ({ title, subtitle }: any) => (
|
|
||||||
<div>
|
|
||||||
<h1>{title}</h1>
|
|
||||||
<p>{subtitle}</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ui/components/button", () => ({
|
|
||||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
|
||||||
}));
|
|
||||||
vi.mock(
|
|
||||||
"@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings",
|
|
||||||
() => ({
|
|
||||||
ProjectSettings: (props: any) => <div data-testid="project-settings" data-mode={props.projectMode} />,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cleanup after each test
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ProjectSettingsPage", () => {
|
|
||||||
const params = Promise.resolve({ organizationId: "org1" });
|
|
||||||
const searchParams = Promise.resolve({ channel: "link", industry: "other", mode: "cx" } as any);
|
|
||||||
|
|
||||||
test("redirects to login when no session user", async () => {
|
|
||||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
|
|
||||||
await Page({ params, searchParams });
|
|
||||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws when teams not found", async () => {
|
|
||||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
|
||||||
session: { user: { id: "u1" } },
|
|
||||||
organization: { billing: { plan: "basic" } },
|
|
||||||
} as any);
|
|
||||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
|
|
||||||
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(false as any);
|
|
||||||
|
|
||||||
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders header, settings and close link when projects exist", async () => {
|
|
||||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
|
||||||
session: { user: { id: "u1" } },
|
|
||||||
organization: { billing: { plan: "basic" } },
|
|
||||||
} as any);
|
|
||||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
|
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
|
||||||
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
|
|
||||||
|
|
||||||
const element = await Page({ params, searchParams });
|
|
||||||
render(element as React.ReactElement);
|
|
||||||
|
|
||||||
// Header
|
|
||||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
|
||||||
"organizations.projects.new.settings.project_settings_title"
|
|
||||||
);
|
|
||||||
// ProjectSettings stub receives mode prop
|
|
||||||
expect(screen.getByTestId("project-settings")).toHaveAttribute("data-mode", "cx");
|
|
||||||
// Close link for existing projects
|
|
||||||
const link = screen.getByRole("link");
|
|
||||||
expect(link).toHaveAttribute("href", "/");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders without close link when no projects", async () => {
|
|
||||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
|
||||||
session: { user: { id: "u1" } },
|
|
||||||
organization: { billing: { plan: "basic" } },
|
|
||||||
} as any);
|
|
||||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
|
||||||
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
|
|
||||||
|
|
||||||
const element = await Page({ params, searchParams });
|
|
||||||
render(element as React.ReactElement);
|
|
||||||
|
|
||||||
expect(screen.queryByRole("link")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
|
||||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
|
||||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
|
||||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { Header } from "@/modules/ui/components/header";
|
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||||
|
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||||
|
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
||||||
|
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||||
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
|
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { Header } from "@/modules/ui/components/header";
|
||||||
|
|
||||||
interface ProjectSettingsPageProps {
|
interface ProjectSettingsPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -47,6 +48,8 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
throw new Error(t("common.organization_teams_not_found"));
|
throw new Error(t("common.organization_teams_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const publicDomain = getPublicDomain();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header
|
<Header
|
||||||
@@ -62,10 +65,11 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
organizationTeams={organizationTeams}
|
organizationTeams={organizationTeams}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
userProjectsCount={projects.length}
|
userProjectsCount={projects.length}
|
||||||
|
publicDomain={publicDomain}
|
||||||
/>
|
/>
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { Home, Settings } from "lucide-react";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { OnboardingOptionsContainer } from "./OnboardingOptionsContainer";
|
|
||||||
|
|
||||||
describe("OnboardingOptionsContainer", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders options with links", () => {
|
|
||||||
const options = [
|
|
||||||
{
|
|
||||||
title: "Test Option",
|
|
||||||
description: "Test Description",
|
|
||||||
icon: Home,
|
|
||||||
href: "/test",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
render(<OnboardingOptionsContainer options={options} />);
|
|
||||||
expect(screen.getByText("Test Option")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Test Description")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders options with onClick handler", () => {
|
|
||||||
const onClickMock = vi.fn();
|
|
||||||
const options = [
|
|
||||||
{
|
|
||||||
title: "Click Option",
|
|
||||||
description: "Click Description",
|
|
||||||
icon: Home,
|
|
||||||
onClick: onClickMock,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
render(<OnboardingOptionsContainer options={options} />);
|
|
||||||
expect(screen.getByText("Click Option")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Click Description")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders options with iconText", () => {
|
|
||||||
const options = [
|
|
||||||
{
|
|
||||||
title: "Icon Text Option",
|
|
||||||
description: "Icon Text Description",
|
|
||||||
icon: Home,
|
|
||||||
iconText: "Custom Icon Text",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
render(<OnboardingOptionsContainer options={options} />);
|
|
||||||
expect(screen.getByText("Custom Icon Text")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders options with loading state", () => {
|
|
||||||
const options = [
|
|
||||||
{
|
|
||||||
title: "Loading Option",
|
|
||||||
description: "Loading Description",
|
|
||||||
icon: Home,
|
|
||||||
isLoading: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
render(<OnboardingOptionsContainer options={options} />);
|
|
||||||
expect(screen.getByText("Loading Option")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders multiple options", () => {
|
|
||||||
const options = [
|
|
||||||
{
|
|
||||||
title: "First Option",
|
|
||||||
description: "First Description",
|
|
||||||
icon: Home,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Second Option",
|
|
||||||
description: "Second Description",
|
|
||||||
icon: Settings,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
render(<OnboardingOptionsContainer options={options} />);
|
|
||||||
expect(screen.getByText("First Option")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Second Option")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("calls onClick handler when clicking an option", async () => {
|
|
||||||
const onClickMock = vi.fn();
|
|
||||||
const options = [
|
|
||||||
{
|
|
||||||
title: "Click Option",
|
|
||||||
description: "Click Description",
|
|
||||||
icon: Home,
|
|
||||||
onClick: onClickMock,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
render(<OnboardingOptionsContainer options={options} />);
|
|
||||||
await userEvent.click(screen.getByText("Click Option"));
|
|
||||||
expect(onClickMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { OptionCard } from "@/modules/ui/components/option-card";
|
|
||||||
import { LucideProps } from "lucide-react";
|
import { LucideProps } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ForwardRefExoticComponent, RefAttributes } from "react";
|
import { ForwardRefExoticComponent, RefAttributes } from "react";
|
||||||
|
import { OptionCard } from "@/modules/ui/components/option-card";
|
||||||
|
|
||||||
interface OnboardingOptionsContainerProps {
|
interface OnboardingOptionsContainerProps {
|
||||||
options: {
|
options: {
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import { getEnvironment } from "@/lib/environment/service";
|
|
||||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { Session } from "next-auth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
|
||||||
import SurveyEditorEnvironmentLayout from "./layout";
|
|
||||||
|
|
||||||
// Mock sub-components to render identifiable elements
|
|
||||||
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
|
|
||||||
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
|
|
||||||
<div data-testid="EnvironmentIdBaseLayout">
|
|
||||||
{environmentId}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mocks for dependencies
|
|
||||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
|
||||||
environmentIdLayoutChecks: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/environment/service", () => ({
|
|
||||||
getEnvironment: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
redirect: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("SurveyEditorEnvironmentLayout", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders successfully when environment is found", async () => {
|
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
|
||||||
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
|
|
||||||
session: { user: { id: "user1" } } as Session,
|
|
||||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
|
||||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
|
||||||
});
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValueOnce({ id: "env1" } as TEnvironment);
|
|
||||||
|
|
||||||
const result = await SurveyEditorEnvironmentLayout({
|
|
||||||
params: Promise.resolve({ environmentId: "env1" }),
|
|
||||||
children: <div data-testid="child">Survey Editor Content</div>,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(result);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
|
|
||||||
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws an error when environment is not found", async () => {
|
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
|
||||||
t: ((key: string) => key) as any,
|
|
||||||
session: { user: { id: "user1" } } as Session,
|
|
||||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
|
||||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
|
||||||
});
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
SurveyEditorEnvironmentLayout({
|
|
||||||
params: Promise.resolve({ environmentId: "env1" }),
|
|
||||||
children: <div>Content</div>,
|
|
||||||
})
|
|
||||||
).rejects.toThrow("common.environment_not_found");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("calls redirect when session is null", async () => {
|
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
|
||||||
t: ((key: string) => key) as any,
|
|
||||||
session: undefined as unknown as Session,
|
|
||||||
user: undefined as unknown as TUser,
|
|
||||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
|
||||||
});
|
|
||||||
vi.mocked(redirect).mockImplementationOnce(() => {
|
|
||||||
throw new Error("Redirect called");
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
SurveyEditorEnvironmentLayout({
|
|
||||||
params: Promise.resolve({ environmentId: "env1" }),
|
|
||||||
children: <div>Content</div>,
|
|
||||||
})
|
|
||||||
).rejects.toThrow("Redirect called");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws error if user is null", async () => {
|
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
|
||||||
t: ((key: string) => key) as any,
|
|
||||||
session: { user: { id: "user1" } } as Session,
|
|
||||||
user: undefined as unknown as TUser,
|
|
||||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mocked(redirect).mockImplementationOnce(() => {
|
|
||||||
throw new Error("Redirect called");
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
SurveyEditorEnvironmentLayout({
|
|
||||||
params: Promise.resolve({ environmentId: "env1" }),
|
|
||||||
children: <div>Content</div>,
|
|
||||||
})
|
|
||||||
).rejects.toThrow("common.user_not_found");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
const SurveyEditorEnvironmentLayout = async (props) => {
|
const SurveyEditorEnvironmentLayout = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
@@ -25,15 +24,9 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EnvironmentIdBaseLayout
|
<div className="flex h-screen flex-col">
|
||||||
environmentId={params.environmentId}
|
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||||
session={session}
|
</div>
|
||||||
user={user}
|
|
||||||
organization={organization}>
|
|
||||||
<div className="flex h-screen flex-col">
|
|
||||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
|
||||||
</div>
|
|
||||||
</EnvironmentIdBaseLayout>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { Confetti } from "@/modules/ui/components/confetti";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { Confetti } from "@/modules/ui/components/confetti";
|
||||||
|
|
||||||
interface ConfirmationPageProps {
|
interface ConfirmationPageProps {
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
|
export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslation();
|
||||||
const [showConfetti, setShowConfetti] = useState(false);
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowConfetti(true);
|
setShowConfetti(true);
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { SingleContactPage } from "@/modules/ee/contacts/[contactId]/page";
|
|
||||||
import { describe, expect, test, vi } from "vitest";
|
|
||||||
import Page from "./page";
|
|
||||||
|
|
||||||
// mock constants
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
|
||||||
IS_FORMBRICKS_CLOUD: false,
|
|
||||||
ENCRYPTION_KEY: "test",
|
|
||||||
ENTERPRISE_LICENSE_KEY: "test",
|
|
||||||
GITHUB_ID: "test",
|
|
||||||
GITHUB_SECRET: "test",
|
|
||||||
GOOGLE_CLIENT_ID: "test",
|
|
||||||
GOOGLE_CLIENT_SECRET: "test",
|
|
||||||
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
|
|
||||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
|
||||||
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
|
|
||||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
|
||||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
|
||||||
OIDC_ISSUER: "mock-oidc-issuer",
|
|
||||||
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
|
|
||||||
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
|
|
||||||
WEBAPP_URL: "mock-webapp-url",
|
|
||||||
IS_PRODUCTION: true,
|
|
||||||
FB_LOGO_URL: "https://example.com/mock-logo.png",
|
|
||||||
SMTP_HOST: "mock-smtp-host",
|
|
||||||
SMTP_PORT: "mock-smtp-port",
|
|
||||||
IS_POSTHOG_CONFIGURED: true,
|
|
||||||
SESSION_MAX_AGE: 1000,
|
|
||||||
AUDIT_LOG_ENABLED: 1,
|
|
||||||
REDIS_URL: undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/env", () => ({
|
|
||||||
env: {
|
|
||||||
PUBLIC_URL: "https://public-domain.com",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Contact Page Re-export", () => {
|
|
||||||
test("should re-export SingleContactPage", () => {
|
|
||||||
expect(Page).toBe(SingleContactPage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { ContactsPage } from "@/modules/ee/contacts/page";
|
|
||||||
import { describe, expect, test, vi } from "vitest";
|
|
||||||
import Page from "./page";
|
|
||||||
|
|
||||||
// Mock the actual ContactsPage component
|
|
||||||
vi.mock("@/modules/ee/contacts/page", () => ({
|
|
||||||
ContactsPage: () => <div data-testid="contacts-page">Mock Contacts Page</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Contacts Page Re-export", () => {
|
|
||||||
test("should re-export ContactsPage from the EE module", () => {
|
|
||||||
// Assert that the default export 'Page' is the same as the mocked 'ContactsPage'
|
|
||||||
expect(Page).toBe(ContactsPage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import SegmentsPageWrapper from "./page";
|
|
||||||
|
|
||||||
vi.mock("@/modules/ee/contacts/segments/page", () => ({
|
|
||||||
SegmentsPage: vi.fn(() => <div>SegmentsPageMock</div>),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("SegmentsPageWrapper", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders the SegmentsPage component", () => {
|
|
||||||
render(<SegmentsPageWrapper params={{ environmentId: "test-env" } as any} />);
|
|
||||||
expect(screen.getByText("SegmentsPageMock")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ZId } from "@formbricks/types/common";
|
||||||
|
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||||
|
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||||
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||||
import { updateUser } from "@/lib/user/service";
|
import { updateUser } from "@/lib/user/service";
|
||||||
@@ -12,10 +17,8 @@ import {
|
|||||||
getOrganizationProjectsLimit,
|
getOrganizationProjectsLimit,
|
||||||
} from "@/modules/ee/license-check/lib/utils";
|
} from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||||
import { z } from "zod";
|
import { getOrganizationsByUserId } from "./lib/organization";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { getProjectsByUserId } from "./lib/project";
|
||||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
|
||||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
|
||||||
|
|
||||||
const ZCreateProjectAction = z.object({
|
const ZCreateProjectAction = z.object({
|
||||||
organizationId: ZId,
|
organizationId: ZId,
|
||||||
@@ -84,3 +87,59 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ZGetOrganizationsForSwitcherAction = z.object({
|
||||||
|
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches organizations list for switcher dropdown.
|
||||||
|
* Called on-demand when user opens the organization switcher.
|
||||||
|
*/
|
||||||
|
export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
||||||
|
.schema(ZGetOrganizationsForSwitcherAction)
|
||||||
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId: parsedInput.organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager", "member", "billing"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return await getOrganizationsByUserId(ctx.user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZGetProjectsForSwitcherAction = z.object({
|
||||||
|
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches projects list for switcher dropdown.
|
||||||
|
* Called on-demand when user opens the project switcher.
|
||||||
|
*/
|
||||||
|
export const getProjectsForSwitcherAction = authenticatedActionClient
|
||||||
|
.schema(ZGetProjectsForSwitcherAction)
|
||||||
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId: parsedInput.organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager", "member", "billing"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Need membership for getProjectsByUserId (1 DB query)
|
||||||
|
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
|
||||||
|
if (!membership) {
|
||||||
|
throw new Error("Membership not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await getProjectsByUserId(ctx.user.id, membership);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,472 +0,0 @@
|
|||||||
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
|
|
||||||
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
|
|
||||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
|
||||||
import {
|
|
||||||
getMonthlyActiveOrganizationPeopleCount,
|
|
||||||
getMonthlyOrganizationResponseCount,
|
|
||||||
getOrganizationByEnvironmentId,
|
|
||||||
} from "@/lib/organization/service";
|
|
||||||
import { getUser } from "@/lib/user/service";
|
|
||||||
import {
|
|
||||||
getAccessControlPermission,
|
|
||||||
getOrganizationProjectsLimit,
|
|
||||||
} from "@/modules/ee/license-check/lib/utils";
|
|
||||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
|
||||||
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import type { Session } from "next-auth";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { TMembership } from "@formbricks/types/memberships";
|
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
|
||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
|
||||||
|
|
||||||
// Mock services and utils
|
|
||||||
vi.mock("@/lib/environment/service", () => ({
|
|
||||||
getEnvironment: vi.fn(),
|
|
||||||
getEnvironments: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/organization/service", () => ({
|
|
||||||
getOrganizationByEnvironmentId: vi.fn(),
|
|
||||||
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
|
|
||||||
getMonthlyOrganizationResponseCount: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/user/service", () => ({
|
|
||||||
getUser: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/membership/service", () => ({
|
|
||||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/membership/utils", () => ({
|
|
||||||
getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
|
||||||
getOrganizationProjectsLimit: vi.fn(),
|
|
||||||
getAccessControlPermission: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
|
||||||
getProjectPermissionByUserId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
|
|
||||||
getTeamsByOrganizationId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/tolgee/server", () => ({
|
|
||||||
getTranslate: async () => (key: string) => key,
|
|
||||||
}));
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/lib/organization", () => ({
|
|
||||||
getOrganizationsByUserId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/lib/project", () => ({
|
|
||||||
getProjectsByUserId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@formbricks/database", () => ({
|
|
||||||
prisma: {
|
|
||||||
project: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
},
|
|
||||||
organization: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
let mockIsFormbricksCloud = false;
|
|
||||||
let mockIsDevelopment = false;
|
|
||||||
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
|
||||||
get IS_FORMBRICKS_CLOUD() {
|
|
||||||
return mockIsFormbricksCloud;
|
|
||||||
},
|
|
||||||
get IS_DEVELOPMENT() {
|
|
||||||
return mockIsDevelopment;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock components
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
|
|
||||||
MainNavigation: ({ organizationTeams, isAccessControlAllowed }: any) => (
|
|
||||||
<div data-testid="main-navigation">
|
|
||||||
MainNavigation
|
|
||||||
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
|
|
||||||
<div data-testid="is-access-control-allowed">{isAccessControlAllowed?.toString() || "false"}</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
|
|
||||||
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ui/components/limits-reached-banner", () => ({
|
|
||||||
LimitsReachedBanner: () => <div data-testid="limits-banner">LimitsReachedBanner</div>,
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ui/components/pending-downgrade-banner", () => ({
|
|
||||||
PendingDowngradeBanner: ({
|
|
||||||
isPendingDowngrade,
|
|
||||||
active,
|
|
||||||
}: {
|
|
||||||
isPendingDowngrade: boolean;
|
|
||||||
active: boolean;
|
|
||||||
}) =>
|
|
||||||
isPendingDowngrade && active ? <div data-testid="downgrade-banner">PendingDowngradeBanner</div> : null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockUser = {
|
|
||||||
id: "user-1",
|
|
||||||
name: "Test User",
|
|
||||||
email: "test@example.com",
|
|
||||||
emailVerified: new Date(),
|
|
||||||
twoFactorEnabled: false,
|
|
||||||
identityProvider: "email",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
notificationSettings: { alert: {} },
|
|
||||||
} as unknown as TUser;
|
|
||||||
|
|
||||||
const mockOrganization = {
|
|
||||||
id: "org-1",
|
|
||||||
name: "Test Org",
|
|
||||||
billing: {
|
|
||||||
plan: "free",
|
|
||||||
limits: {},
|
|
||||||
},
|
|
||||||
} as unknown as TOrganization;
|
|
||||||
|
|
||||||
const mockEnvironment: TEnvironment = {
|
|
||||||
id: "env-1",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
type: "production",
|
|
||||||
projectId: "proj-1",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockProject: TProject = {
|
|
||||||
id: "proj-1",
|
|
||||||
name: "Test Project",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
organizationId: "org-1",
|
|
||||||
environments: [mockEnvironment],
|
|
||||||
} as unknown as TProject;
|
|
||||||
|
|
||||||
const mockMembership: TMembership = {
|
|
||||||
organizationId: "org-1",
|
|
||||||
userId: "user-1",
|
|
||||||
accepted: true,
|
|
||||||
role: "owner",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockLicense = {
|
|
||||||
plan: "free",
|
|
||||||
active: false,
|
|
||||||
lastChecked: new Date(),
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const mockProjectPermission = {
|
|
||||||
userId: "user-1",
|
|
||||||
projectId: "proj-1",
|
|
||||||
role: "admin",
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const mockOrganizationTeams = [
|
|
||||||
{
|
|
||||||
id: "team-1",
|
|
||||||
name: "Development Team",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "team-2",
|
|
||||||
name: "Marketing Team",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockSession: Session = {
|
|
||||||
user: {
|
|
||||||
id: "user-1",
|
|
||||||
},
|
|
||||||
expires: new Date(Date.now() + 3600 * 1000).toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("EnvironmentLayout", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
|
|
||||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([
|
|
||||||
{ id: mockOrganization.id, name: mockOrganization.name },
|
|
||||||
]);
|
|
||||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
|
||||||
vi.mocked(getProjectsByUserId).mockResolvedValue([{ id: mockProject.id, name: mockProject.name }]);
|
|
||||||
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment]);
|
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
|
||||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
|
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
|
||||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
|
||||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
|
|
||||||
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
|
|
||||||
mockIsDevelopment = false;
|
|
||||||
mockIsFormbricksCloud = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders correctly with default props", async () => {
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: false,
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
render(
|
|
||||||
await EnvironmentLayout({
|
|
||||||
environmentId: "env-1",
|
|
||||||
session: mockSession,
|
|
||||||
children: <div>Child Content</div>,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Child Content")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
|
|
||||||
mockIsFormbricksCloud = true;
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: false,
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
render(
|
|
||||||
await EnvironmentLayout({
|
|
||||||
environmentId: "env-1",
|
|
||||||
session: mockSession,
|
|
||||||
children: <div>Child Content</div>,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
|
|
||||||
expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
|
|
||||||
expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders PendingDowngradeBanner when pending downgrade", async () => {
|
|
||||||
const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
|
|
||||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue(pendingLicense),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
render(
|
|
||||||
await EnvironmentLayout({
|
|
||||||
environmentId: "env-1",
|
|
||||||
session: mockSession,
|
|
||||||
children: <div>Child Content</div>,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles empty organizationTeams array", async () => {
|
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]);
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: false,
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
render(
|
|
||||||
await EnvironmentLayout({
|
|
||||||
environmentId: "env-1",
|
|
||||||
session: mockSession,
|
|
||||||
children: <div>Child Content</div>,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles null organizationTeams", async () => {
|
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(null);
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: false,
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
render(
|
|
||||||
await EnvironmentLayout({
|
|
||||||
environmentId: "env-1",
|
|
||||||
session: mockSession,
|
|
||||||
children: <div>Child Content</div>,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles isAccessControlAllowed false", async () => {
|
|
||||||
vi.mocked(getAccessControlPermission).mockResolvedValue(false);
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: false,
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
render(
|
|
||||||
await EnvironmentLayout({
|
|
||||||
environmentId: "env-1",
|
|
||||||
session: mockSession,
|
|
||||||
children: <div>Child Content</div>,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws error if user not found", async () => {
|
|
||||||
vi.mocked(getUser).mockResolvedValue(null);
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: false,
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
|
||||||
"common.user_not_found"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws error if organization not found", async () => {
|
|
||||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: false,
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
|
||||||
"common.organization_not_found"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws error if environment not found", async () => {
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValue(null);
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: false,
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
|
||||||
"common.environment_not_found"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws error if projects, environments or organizations not found", async () => {
|
|
||||||
vi.mocked(getProjectsByUserId).mockResolvedValue(null as any);
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: false,
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
|
||||||
"environments.projects_environments_organizations_not_found"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws error if member has no project permission", async () => {
|
|
||||||
vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
|
|
||||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: false,
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
|
||||||
"common.project_permission_not_found"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,104 +1,51 @@
|
|||||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||||
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
|
|
||||||
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
|
|
||||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import {
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
getMonthlyActiveOrganizationPeopleCount,
|
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||||
getMonthlyOrganizationResponseCount,
|
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
|
||||||
getOrganizationByEnvironmentId,
|
|
||||||
} from "@/lib/organization/service";
|
|
||||||
import { getUser } from "@/lib/user/service";
|
|
||||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
|
||||||
import {
|
|
||||||
getAccessControlPermission,
|
|
||||||
getOrganizationProjectsLimit,
|
|
||||||
} from "@/modules/ee/license-check/lib/utils";
|
|
||||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
|
||||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import type { Session } from "next-auth";
|
|
||||||
|
|
||||||
interface EnvironmentLayoutProps {
|
interface EnvironmentLayoutProps {
|
||||||
environmentId: string;
|
layoutData: TEnvironmentLayoutData;
|
||||||
session: Session;
|
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EnvironmentLayout = async ({ environmentId, session, children }: EnvironmentLayoutProps) => {
|
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const [user, environment, organizations, organization] = await Promise.all([
|
const publicDomain = getPublicDomain();
|
||||||
getUser(session.user.id),
|
|
||||||
getEnvironment(environmentId),
|
|
||||||
getOrganizationsByUserId(session.user.id),
|
|
||||||
getOrganizationByEnvironmentId(environmentId),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!user) {
|
// Destructure all data from props (NO database queries)
|
||||||
throw new Error(t("common.user_not_found"));
|
const {
|
||||||
}
|
user,
|
||||||
|
environment,
|
||||||
|
organization,
|
||||||
|
membership,
|
||||||
|
project, // Current project details
|
||||||
|
environments, // All project environments (for environment switcher)
|
||||||
|
isAccessControlAllowed,
|
||||||
|
projectPermission,
|
||||||
|
license,
|
||||||
|
peopleCount,
|
||||||
|
responseCount,
|
||||||
|
} = layoutData;
|
||||||
|
|
||||||
if (!organization) {
|
// Calculate derived values (no queries)
|
||||||
throw new Error(t("common.organization_not_found"));
|
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
|
||||||
}
|
|
||||||
|
|
||||||
if (!environment) {
|
const { features, lastChecked, isPendingDowngrade, active } = license;
|
||||||
throw new Error(t("common.environment_not_found"));
|
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||||
}
|
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||||
|
const isOwnerOrManager = isOwner || isManager;
|
||||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
|
||||||
if (!currentUserMembership) {
|
|
||||||
throw new Error(t("common.membership_not_found"));
|
|
||||||
}
|
|
||||||
const membershipRole = currentUserMembership?.role;
|
|
||||||
|
|
||||||
const [projects, environments, isAccessControlAllowed] = await Promise.all([
|
|
||||||
getProjectsByUserId(user.id, currentUserMembership),
|
|
||||||
getEnvironments(environment.projectId),
|
|
||||||
getAccessControlPermission(organization.billing.plan),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!projects || !environments || !organizations) {
|
|
||||||
throw new Error(t("environments.projects_environments_organizations_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const { isMember } = getAccessFlags(membershipRole);
|
|
||||||
|
|
||||||
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
|
||||||
|
|
||||||
const projectPermission = await getProjectPermissionByUserId(session.user.id, environment.projectId);
|
|
||||||
|
|
||||||
|
// Validate that project permission exists for members
|
||||||
if (isMember && !projectPermission) {
|
if (isMember && !projectPermission) {
|
||||||
throw new Error(t("common.project_permission_not_found"));
|
throw new Error(t("common.project_permission_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
|
||||||
|
|
||||||
let peopleCount = 0;
|
|
||||||
let responseCount = 0;
|
|
||||||
|
|
||||||
if (IS_FORMBRICKS_CLOUD) {
|
|
||||||
[peopleCount, responseCount] = await Promise.all([
|
|
||||||
getMonthlyActiveOrganizationPeopleCount(organization.id),
|
|
||||||
getMonthlyOrganizationResponseCount(organization.id),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
|
||||||
|
|
||||||
// Find the current project from the projects array
|
|
||||||
const project = projects.find((p) => p.id === environment.projectId);
|
|
||||||
if (!project) {
|
|
||||||
throw new Error(t("common.project_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const { isManager, isOwner } = getAccessFlags(membershipRole);
|
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
||||||
{IS_FORMBRICKS_CLOUD && (
|
{IS_FORMBRICKS_CLOUD && (
|
||||||
@@ -122,26 +69,25 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
|||||||
<MainNavigation
|
<MainNavigation
|
||||||
environment={environment}
|
environment={environment}
|
||||||
organization={organization}
|
organization={organization}
|
||||||
projects={projects}
|
|
||||||
user={user}
|
user={user}
|
||||||
|
project={{ id: project.id, name: project.name }}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
isDevelopment={IS_DEVELOPMENT}
|
isDevelopment={IS_DEVELOPMENT}
|
||||||
membershipRole={membershipRole}
|
membershipRole={membership.role}
|
||||||
|
publicDomain={publicDomain}
|
||||||
/>
|
/>
|
||||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||||
<TopControlBar
|
<TopControlBar
|
||||||
environments={environments}
|
environments={environments}
|
||||||
currentOrganizationId={organization.id}
|
currentOrganizationId={organization.id}
|
||||||
organizations={organizations}
|
|
||||||
currentProjectId={project.id}
|
currentProjectId={project.id}
|
||||||
projects={projects}
|
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
isLicenseActive={active}
|
isLicenseActive={active}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
membershipRole={membershipRole}
|
membershipRole={membership.role}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
|
||||||
import { render } from "@testing-library/react";
|
|
||||||
import { describe, expect, test, vi } from "vitest";
|
|
||||||
import EnvironmentStorageHandler from "./EnvironmentStorageHandler";
|
|
||||||
|
|
||||||
describe("EnvironmentStorageHandler", () => {
|
|
||||||
test("sets environmentId in localStorage on mount", () => {
|
|
||||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
|
||||||
const testEnvironmentId = "test-env-123";
|
|
||||||
|
|
||||||
render(<EnvironmentStorageHandler environmentId={testEnvironmentId} />);
|
|
||||||
|
|
||||||
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, testEnvironmentId);
|
|
||||||
setItemSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("updates environmentId in localStorage when prop changes", () => {
|
|
||||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
|
||||||
const initialEnvironmentId = "test-env-initial";
|
|
||||||
const updatedEnvironmentId = "test-env-updated";
|
|
||||||
|
|
||||||
const { rerender } = render(<EnvironmentStorageHandler environmentId={initialEnvironmentId} />);
|
|
||||||
|
|
||||||
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, initialEnvironmentId);
|
|
||||||
|
|
||||||
rerender(<EnvironmentStorageHandler environmentId={updatedEnvironmentId} />);
|
|
||||||
|
|
||||||
expect(setItemSpy).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS, updatedEnvironmentId);
|
|
||||||
expect(setItemSpy).toHaveBeenCalledTimes(2); // Called on mount and on rerender with new prop
|
|
||||||
|
|
||||||
setItemSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||||
|
|
||||||
interface EnvironmentStorageHandlerProps {
|
interface EnvironmentStorageHandlerProps {
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { EnvironmentSwitch } from "./EnvironmentSwitch";
|
|
||||||
|
|
||||||
// Mock next/navigation
|
|
||||||
const mockPush = vi.fn();
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: vi.fn(() => ({
|
|
||||||
push: mockPush,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock @tolgee/react
|
|
||||||
vi.mock("@tolgee/react", () => ({
|
|
||||||
useTranslate: () => ({
|
|
||||||
t: (key: string) => key,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockEnvironmentDev: TEnvironment = {
|
|
||||||
id: "dev-env-id",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
type: "development",
|
|
||||||
projectId: "project-id",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEnvironmentProd: TEnvironment = {
|
|
||||||
id: "prod-env-id",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
type: "production",
|
|
||||||
projectId: "project-id",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd];
|
|
||||||
|
|
||||||
describe("EnvironmentSwitch", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders checked when environment is development", () => {
|
|
||||||
render(<EnvironmentSwitch environment={mockEnvironmentDev} environments={mockEnvironments} />);
|
|
||||||
const switchElement = screen.getByRole("switch");
|
|
||||||
expect(switchElement).toBeChecked();
|
|
||||||
expect(screen.getByText("common.dev_env")).toHaveClass("text-orange-800");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders unchecked when environment is production", () => {
|
|
||||||
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
|
|
||||||
const switchElement = screen.getByRole("switch");
|
|
||||||
expect(switchElement).not.toBeChecked();
|
|
||||||
expect(screen.getByText("common.dev_env")).not.toHaveClass("text-orange-800");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("calls router.push with development environment ID when toggled from production", async () => {
|
|
||||||
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
|
|
||||||
const switchElement = screen.getByRole("switch");
|
|
||||||
|
|
||||||
expect(switchElement).not.toBeChecked();
|
|
||||||
await userEvent.click(switchElement);
|
|
||||||
|
|
||||||
// Check loading state (switch disabled)
|
|
||||||
expect(switchElement).toBeDisabled();
|
|
||||||
|
|
||||||
// Check router push call
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check visual state change (though state update happens before navigation)
|
|
||||||
// In a real scenario, the component would re-render with the new environment prop after navigation.
|
|
||||||
// Here, we simulate the state change directly for testing the toggle logic.
|
|
||||||
await waitFor(() => {
|
|
||||||
// Re-render or check internal state if possible, otherwise check mock calls
|
|
||||||
// Since the component manages its own state, we can check the visual state after click
|
|
||||||
expect(switchElement).toBeChecked(); // State updates immediately
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("calls router.push with production environment ID when toggled from development", async () => {
|
|
||||||
render(<EnvironmentSwitch environment={mockEnvironmentDev} environments={mockEnvironments} />);
|
|
||||||
const switchElement = screen.getByRole("switch");
|
|
||||||
|
|
||||||
expect(switchElement).toBeChecked();
|
|
||||||
await userEvent.click(switchElement);
|
|
||||||
|
|
||||||
// Check loading state (switch disabled)
|
|
||||||
expect(switchElement).toBeDisabled();
|
|
||||||
|
|
||||||
// Check router push call
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentProd.id}/`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check visual state change
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(switchElement).not.toBeChecked(); // State updates immediately
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not call router.push if target environment is not found", async () => {
|
|
||||||
const incompleteEnvironments = [mockEnvironmentProd]; // Only production exists
|
|
||||||
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={incompleteEnvironments} />);
|
|
||||||
const switchElement = screen.getByRole("switch");
|
|
||||||
|
|
||||||
await userEvent.click(switchElement); // Try to toggle to development
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(switchElement).toBeDisabled(); // Loading state still set
|
|
||||||
});
|
|
||||||
|
|
||||||
// router.push should not be called because dev env is missing
|
|
||||||
expect(mockPush).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// State still updates visually
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(switchElement).toBeChecked();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("toggles using the label click", async () => {
|
|
||||||
render(<EnvironmentSwitch environment={mockEnvironmentProd} environments={mockEnvironments} />);
|
|
||||||
const labelElement = screen.getByText("common.dev_env");
|
|
||||||
const switchElement = screen.getByRole("switch");
|
|
||||||
|
|
||||||
expect(switchElement).not.toBeChecked();
|
|
||||||
await userEvent.click(labelElement); // Click the label
|
|
||||||
|
|
||||||
// Check loading state (switch disabled)
|
|
||||||
expect(switchElement).toBeDisabled();
|
|
||||||
|
|
||||||
// Check router push call
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check visual state change
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(switchElement).toBeChecked();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { Label } from "@/modules/ui/components/label";
|
import { Label } from "@/modules/ui/components/label";
|
||||||
import { Switch } from "@/modules/ui/components/switch";
|
import { Switch } from "@/modules/ui/components/switch";
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
|
|
||||||
interface EnvironmentSwitchProps {
|
interface EnvironmentSwitchProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
@@ -14,7 +14,7 @@ interface EnvironmentSwitchProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const EnvironmentSwitch = ({ environment, environments }: EnvironmentSwitchProps) => {
|
export const EnvironmentSwitch = ({ environment, environments }: EnvironmentSwitchProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isEnvSwitchChecked, setIsEnvSwitchChecked] = useState(environment?.type === "development");
|
const [isEnvSwitchChecked, setIsEnvSwitchChecked] = useState(environment?.type === "development");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
|
||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
|
||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
|
||||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
|
||||||
import { MainNavigation } from "./MainNavigation";
|
|
||||||
|
|
||||||
// Mock constants that this test needs
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
|
||||||
IS_FORMBRICKS_CLOUD: false,
|
|
||||||
WEBAPP_URL: "http://localhost:3000",
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock server actions that this test needs
|
|
||||||
vi.mock("@/modules/auth/actions/sign-out", () => ({
|
|
||||||
logSignOutAction: vi.fn().mockResolvedValue(undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: vi.fn(() => ({ push: vi.fn() })),
|
|
||||||
usePathname: vi.fn(() => "/environments/env1/surveys"),
|
|
||||||
}));
|
|
||||||
vi.mock("next-auth/react", () => ({
|
|
||||||
signOut: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
|
|
||||||
useSignOut: vi.fn(() => ({ signOut: vi.fn() })),
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/projects/settings/(setup)/app-connection/actions", () => ({
|
|
||||||
getLatestStableFbReleaseAction: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/app/lib/formbricks", () => ({
|
|
||||||
formbricksLogout: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/membership/utils", () => ({
|
|
||||||
getAccessFlags: (role?: string) => ({
|
|
||||||
isAdmin: role === "admin",
|
|
||||||
isOwner: role === "owner",
|
|
||||||
isManager: role === "manager",
|
|
||||||
isMember: role === "member",
|
|
||||||
isBilling: role === "billing",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
|
||||||
CreateOrganizationModal: ({ open }: { open: boolean }) =>
|
|
||||||
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
|
||||||
ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>,
|
|
||||||
}));
|
|
||||||
vi.mock("next/image", () => ({
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
default: (props: any) => <img alt="test" {...props} />,
|
|
||||||
}));
|
|
||||||
vi.mock("../../../../../package.json", () => ({
|
|
||||||
version: "1.0.0",
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock localStorage
|
|
||||||
const localStorageMock = (() => {
|
|
||||||
let store: Record<string, string> = {};
|
|
||||||
return {
|
|
||||||
getItem: (key: string) => store[key] || null,
|
|
||||||
setItem: (key: string, value: string) => {
|
|
||||||
store[key] = value.toString();
|
|
||||||
},
|
|
||||||
removeItem: (key: string) => {
|
|
||||||
delete store[key];
|
|
||||||
},
|
|
||||||
clear: () => {
|
|
||||||
store = {};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
Object.defineProperty(window, "localStorage", { value: localStorageMock });
|
|
||||||
|
|
||||||
// Mock data
|
|
||||||
const mockEnvironment: TEnvironment = {
|
|
||||||
id: "env1",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
type: "production",
|
|
||||||
projectId: "proj1",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
};
|
|
||||||
const mockUser = {
|
|
||||||
id: "user1",
|
|
||||||
name: "Test User",
|
|
||||||
email: "test@example.com",
|
|
||||||
emailVerified: new Date(),
|
|
||||||
twoFactorEnabled: false,
|
|
||||||
identityProvider: "email",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
notificationSettings: { alert: {} },
|
|
||||||
role: "project_manager",
|
|
||||||
objective: "other",
|
|
||||||
} as unknown as TUser;
|
|
||||||
|
|
||||||
const mockOrganization = {
|
|
||||||
id: "org1",
|
|
||||||
name: "Test Org",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
billing: { stripeCustomerId: null, plan: "free", limits: { monthly: { responses: null } } } as any,
|
|
||||||
} as unknown as TOrganization;
|
|
||||||
|
|
||||||
const mockOrganizations: TOrganization[] = [
|
|
||||||
mockOrganization,
|
|
||||||
{ ...mockOrganization, id: "org2", name: "Another Org" },
|
|
||||||
];
|
|
||||||
const mockProject: TProject = {
|
|
||||||
id: "proj1",
|
|
||||||
name: "Test Project",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
organizationId: "org1",
|
|
||||||
environments: [mockEnvironment],
|
|
||||||
config: { channel: "website" },
|
|
||||||
} as unknown as TProject;
|
|
||||||
const mockProjects: TProject[] = [mockProject];
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
environment: mockEnvironment,
|
|
||||||
organizations: mockOrganizations,
|
|
||||||
user: mockUser,
|
|
||||||
organization: mockOrganization,
|
|
||||||
projects: mockProjects,
|
|
||||||
isMultiOrgEnabled: true,
|
|
||||||
isFormbricksCloud: false,
|
|
||||||
isDevelopment: false,
|
|
||||||
membershipRole: "owner" as const,
|
|
||||||
organizationProjectsLimit: 5,
|
|
||||||
isLicenseActive: true,
|
|
||||||
isAccessControlAllowed: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("MainNavigation", () => {
|
|
||||||
let mockRouterPush: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockRouterPush = vi.fn();
|
|
||||||
vi.mocked(useRouter).mockReturnValue({ push: mockRouterPush } as any);
|
|
||||||
vi.mocked(usePathname).mockReturnValue("/environments/env1/surveys");
|
|
||||||
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: null }); // Default: no new version
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders expanded by default and collapses on toggle", async () => {
|
|
||||||
render(<MainNavigation {...defaultProps} />);
|
|
||||||
// Assuming the toggle button is the only one initially without an accessible name
|
|
||||||
// A more specific selector like data-testid would be better if available.
|
|
||||||
const toggleButton = screen.getByRole("button", { name: "" });
|
|
||||||
|
|
||||||
// Check initial state (expanded)
|
|
||||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
|
||||||
// Check localStorage is not set initially after clear()
|
|
||||||
expect(localStorage.getItem("isMainNavCollapsed")).toBeNull();
|
|
||||||
|
|
||||||
// Click to collapse
|
|
||||||
await userEvent.click(toggleButton);
|
|
||||||
|
|
||||||
// Check state after first toggle (collapsed)
|
|
||||||
await waitFor(() => {
|
|
||||||
// Check that the attribute eventually becomes true
|
|
||||||
// Check that localStorage is updated
|
|
||||||
expect(localStorage.getItem("isMainNavCollapsed")).toBe("true");
|
|
||||||
});
|
|
||||||
// Check that the logo is eventually hidden
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByAltText("environments.formbricks_logo")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click to expand
|
|
||||||
await userEvent.click(toggleButton);
|
|
||||||
|
|
||||||
// Check state after second toggle (expanded)
|
|
||||||
await waitFor(() => {
|
|
||||||
// Check that the attribute eventually becomes false
|
|
||||||
// Check that localStorage is updated
|
|
||||||
expect(localStorage.getItem("isMainNavCollapsed")).toBe("false");
|
|
||||||
});
|
|
||||||
// Check that the logo is eventually visible
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders user dropdown and handles logout", async () => {
|
|
||||||
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
|
|
||||||
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
|
||||||
|
|
||||||
// Set up localStorage spy on the mocked localStorage
|
|
||||||
|
|
||||||
render(<MainNavigation {...defaultProps} />);
|
|
||||||
|
|
||||||
// Find the avatar and get its parent div which acts as the trigger
|
|
||||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
|
||||||
expect(userTrigger).toBeInTheDocument(); // Ensure the trigger element is found
|
|
||||||
await userEvent.click(userTrigger);
|
|
||||||
|
|
||||||
// Wait for the dropdown content to appear - using getAllByText to handle multiple instances
|
|
||||||
await waitFor(() => {
|
|
||||||
const accountElements = screen.getAllByText("common.account");
|
|
||||||
expect(accountElements).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText("common.documentation")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.logout")).toBeInTheDocument();
|
|
||||||
|
|
||||||
const logoutButton = screen.getByText("common.logout");
|
|
||||||
await userEvent.click(logoutButton);
|
|
||||||
|
|
||||||
expect(mockSignOut).toHaveBeenCalledWith({
|
|
||||||
reason: "user_initiated",
|
|
||||||
redirectUrl: "/auth/login",
|
|
||||||
organizationId: "org1",
|
|
||||||
redirect: false,
|
|
||||||
callbackUrl: "/auth/login",
|
|
||||||
clearEnvironmentId: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hides new version banner for members or if no new version", async () => {
|
|
||||||
// Test for member
|
|
||||||
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: "v1.1.0" });
|
|
||||||
render(<MainNavigation {...defaultProps} membershipRole="member" />);
|
|
||||||
let toggleButton = screen.getByRole("button", { name: "" });
|
|
||||||
await userEvent.click(toggleButton);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText("common.new_version_available", { exact: false })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
cleanup(); // Clean up before next render
|
|
||||||
|
|
||||||
// Test for no new version
|
|
||||||
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: null });
|
|
||||||
render(<MainNavigation {...defaultProps} membershipRole="owner" />);
|
|
||||||
toggleButton = screen.getByRole("button", { name: "" });
|
|
||||||
await userEvent.click(toggleButton);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText("common.new_version_available", { exact: false })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hides main nav and project switcher if user role is billing", () => {
|
|
||||||
render(<MainNavigation {...defaultProps} membershipRole="billing" />);
|
|
||||||
expect(screen.queryByRole("link", { name: /common.surveys/ })).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("passes isAccessControlAllowed props to ProjectSwitcher", () => {
|
|
||||||
render(<MainNavigation {...defaultProps} />);
|
|
||||||
|
|
||||||
// Test basic navigation structure is rendered (aside element with complementary role)
|
|
||||||
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("profile-avatar")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles no organizationTeams", () => {
|
|
||||||
render(<MainNavigation {...defaultProps} />);
|
|
||||||
|
|
||||||
// Test that navigation renders correctly with no teams
|
|
||||||
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles isAccessControlAllowed false", () => {
|
|
||||||
render(<MainNavigation {...defaultProps} />);
|
|
||||||
|
|
||||||
// Test that navigation renders correctly with access control disabled
|
|
||||||
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import {
|
import {
|
||||||
ArrowUpRightIcon,
|
ArrowUpRightIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
@@ -17,6 +16,7 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
@@ -42,30 +42,31 @@ interface NavigationProps {
|
|||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
user: TUser;
|
user: TUser;
|
||||||
organization: TOrganization;
|
organization: TOrganization;
|
||||||
projects: { id: string; name: string }[];
|
project: { id: string; name: string };
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isDevelopment: boolean;
|
isDevelopment: boolean;
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
|
publicDomain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MainNavigation = ({
|
export const MainNavigation = ({
|
||||||
environment,
|
environment,
|
||||||
organization,
|
organization,
|
||||||
user,
|
user,
|
||||||
projects,
|
project,
|
||||||
membershipRole,
|
membershipRole,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
|
publicDomain,
|
||||||
}: NavigationProps) => {
|
}: NavigationProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslation();
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const [isTextVisible, setIsTextVisible] = useState(true);
|
const [isTextVisible, setIsTextVisible] = useState(true);
|
||||||
const [latestVersion, setLatestVersion] = useState("");
|
const [latestVersion, setLatestVersion] = useState("");
|
||||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||||
|
|
||||||
const project = projects.find((project) => project.id === environment.projectId);
|
|
||||||
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||||
|
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
@@ -184,7 +185,7 @@ export const MainNavigation = ({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||||
)}>
|
)}>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||||
@@ -287,15 +288,16 @@ export const MainNavigation = ({
|
|||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
const loginUrl = `${publicDomain}/auth/login`;
|
||||||
const route = await signOutWithAudit({
|
const route = await signOutWithAudit({
|
||||||
reason: "user_initiated",
|
reason: "user_initiated",
|
||||||
redirectUrl: "/auth/login",
|
redirectUrl: loginUrl,
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
redirect: false,
|
redirect: false,
|
||||||
callbackUrl: "/auth/login",
|
callbackUrl: loginUrl,
|
||||||
clearEnvironmentId: true,
|
clearEnvironmentId: true,
|
||||||
});
|
});
|
||||||
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
|
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
|
||||||
}}
|
}}
|
||||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||||
{t("common.logout")}
|
{t("common.logout")}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
|
||||||
import { NavbarLoading } from "./NavbarLoading";
|
|
||||||
|
|
||||||
describe("NavbarLoading", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders the correct number of skeleton elements", () => {
|
|
||||||
render(<NavbarLoading />);
|
|
||||||
|
|
||||||
// Find all divs with the animate-pulse class
|
|
||||||
const skeletonElements = screen.getAllByText((content, element) => {
|
|
||||||
return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse");
|
|
||||||
});
|
|
||||||
|
|
||||||
// There are 8 skeleton divs in the component
|
|
||||||
expect(skeletonElements).toHaveLength(8);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { NavigationLink } from "./NavigationLink";
|
|
||||||
|
|
||||||
// Mock next/link
|
|
||||||
vi.mock("next/link", () => ({
|
|
||||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock tooltip components
|
|
||||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
|
||||||
Tooltip: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
|
|
||||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<div data-testid="tooltip-content">{children}</div>
|
|
||||||
),
|
|
||||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<div data-testid="tooltip-provider">{children}</div>
|
|
||||||
),
|
|
||||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<div data-testid="tooltip-trigger">{children}</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
href: "/test-link",
|
|
||||||
isActive: false,
|
|
||||||
isCollapsed: false,
|
|
||||||
children: <svg data-testid="icon" />,
|
|
||||||
linkText: "Test Link Text",
|
|
||||||
isTextVisible: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("NavigationLink", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders expanded link correctly (inactive, text visible)", () => {
|
|
||||||
render(<NavigationLink {...defaultProps} />);
|
|
||||||
const linkElement = screen.getByRole("link");
|
|
||||||
const listItem = linkElement.closest("li");
|
|
||||||
const textSpan = screen.getByText(defaultProps.linkText);
|
|
||||||
|
|
||||||
expect(linkElement).toHaveAttribute("href", defaultProps.href);
|
|
||||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
|
||||||
expect(textSpan).toBeInTheDocument();
|
|
||||||
expect(textSpan).toHaveClass("opacity-0");
|
|
||||||
expect(listItem).not.toHaveClass("bg-slate-50"); // inactiveClass check
|
|
||||||
expect(listItem).toHaveClass("hover:bg-slate-50"); // inactiveClass check
|
|
||||||
expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders expanded link correctly (active, text hidden)", () => {
|
|
||||||
render(<NavigationLink {...defaultProps} isActive={true} isTextVisible={false} />);
|
|
||||||
const linkElement = screen.getByRole("link");
|
|
||||||
const listItem = linkElement.closest("li");
|
|
||||||
const textSpan = screen.getByText(defaultProps.linkText);
|
|
||||||
|
|
||||||
expect(linkElement).toHaveAttribute("href", defaultProps.href);
|
|
||||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
|
||||||
expect(textSpan).toBeInTheDocument();
|
|
||||||
expect(textSpan).toHaveClass("opacity-100");
|
|
||||||
expect(listItem).toHaveClass("bg-slate-50"); // activeClass check
|
|
||||||
expect(listItem).toHaveClass("border-brand-dark"); // activeClass check
|
|
||||||
expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders collapsed link correctly (inactive)", () => {
|
|
||||||
render(<NavigationLink {...defaultProps} isCollapsed={true} />);
|
|
||||||
const linkElement = screen.getByRole("link");
|
|
||||||
const listItem = linkElement.closest("li");
|
|
||||||
|
|
||||||
expect(linkElement).toHaveAttribute("href", defaultProps.href);
|
|
||||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
|
||||||
// Check text is NOT directly within the list item
|
|
||||||
expect(within(listItem!).queryByText(defaultProps.linkText)).not.toBeInTheDocument();
|
|
||||||
expect(listItem).not.toHaveClass("bg-slate-50"); // inactiveClass check
|
|
||||||
expect(listItem).toHaveClass("hover:bg-slate-50"); // inactiveClass check
|
|
||||||
|
|
||||||
// Check tooltip elements
|
|
||||||
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("tooltip")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument();
|
|
||||||
// Check text IS within the tooltip content mock
|
|
||||||
expect(screen.getByTestId("tooltip-content")).toHaveTextContent(defaultProps.linkText);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders collapsed link correctly (active)", () => {
|
|
||||||
render(<NavigationLink {...defaultProps} isCollapsed={true} isActive={true} />);
|
|
||||||
const linkElement = screen.getByRole("link");
|
|
||||||
const listItem = linkElement.closest("li");
|
|
||||||
|
|
||||||
expect(linkElement).toHaveAttribute("href", defaultProps.href);
|
|
||||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
|
||||||
// Check text is NOT directly within the list item
|
|
||||||
expect(within(listItem!).queryByText(defaultProps.linkText)).not.toBeInTheDocument();
|
|
||||||
expect(listItem).toHaveClass("bg-slate-50"); // activeClass check
|
|
||||||
expect(listItem).toHaveClass("border-brand-dark"); // activeClass check
|
|
||||||
|
|
||||||
// Check tooltip elements
|
|
||||||
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
|
|
||||||
// Check text IS within the tooltip content mock
|
|
||||||
expect(screen.getByTestId("tooltip-content")).toHaveTextContent(defaultProps.linkText);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { cn } from "@/lib/cn";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
|
|
||||||
interface NavigationLinkProps {
|
interface NavigationLinkProps {
|
||||||
href: string;
|
href: string;
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render } from "@testing-library/react";
|
|
||||||
import { Session } from "next-auth";
|
|
||||||
import { usePostHog } from "posthog-js/react";
|
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
|
||||||
import { PosthogIdentify } from "./PosthogIdentify";
|
|
||||||
|
|
||||||
type PartialPostHog = Partial<ReturnType<typeof usePostHog>>;
|
|
||||||
|
|
||||||
vi.mock("posthog-js/react", () => ({
|
|
||||||
usePostHog: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("PosthogIdentify", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("identifies the user and sets groups when isPosthogEnabled is true", () => {
|
|
||||||
const mockIdentify = vi.fn();
|
|
||||||
const mockGroup = vi.fn();
|
|
||||||
|
|
||||||
const mockPostHog: PartialPostHog = {
|
|
||||||
identify: mockIdentify,
|
|
||||||
group: mockGroup,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<PosthogIdentify
|
|
||||||
session={{ user: { id: "user-123" } } as Session}
|
|
||||||
user={
|
|
||||||
{
|
|
||||||
name: "Test User",
|
|
||||||
email: "test@example.com",
|
|
||||||
} as TUser
|
|
||||||
}
|
|
||||||
environmentId="env-456"
|
|
||||||
organizationId="org-789"
|
|
||||||
organizationName="Test Org"
|
|
||||||
organizationBilling={
|
|
||||||
{
|
|
||||||
plan: "enterprise",
|
|
||||||
limits: { monthly: { responses: 1000, miu: 5000 }, projects: 10 },
|
|
||||||
} as TOrganizationBilling
|
|
||||||
}
|
|
||||||
isPosthogEnabled
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// verify that identify is called with the session user id + extra info
|
|
||||||
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
|
|
||||||
name: "Test User",
|
|
||||||
email: "test@example.com",
|
|
||||||
});
|
|
||||||
|
|
||||||
// environment + organization groups
|
|
||||||
expect(mockGroup).toHaveBeenCalledTimes(2);
|
|
||||||
expect(mockGroup).toHaveBeenCalledWith("environment", "env-456", { name: "env-456" });
|
|
||||||
expect(mockGroup).toHaveBeenCalledWith("organization", "org-789", {
|
|
||||||
name: "Test Org",
|
|
||||||
plan: "enterprise",
|
|
||||||
responseLimit: 1000,
|
|
||||||
miuLimit: 5000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does nothing if isPosthogEnabled is false", () => {
|
|
||||||
const mockIdentify = vi.fn();
|
|
||||||
const mockGroup = vi.fn();
|
|
||||||
|
|
||||||
const mockPostHog: PartialPostHog = {
|
|
||||||
identify: mockIdentify,
|
|
||||||
group: mockGroup,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<PosthogIdentify
|
|
||||||
session={{ user: { id: "user-123" } } as Session}
|
|
||||||
user={{ name: "Test User", email: "test@example.com" } as TUser}
|
|
||||||
isPosthogEnabled={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockIdentify).not.toHaveBeenCalled();
|
|
||||||
expect(mockGroup).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does nothing if session user is missing", () => {
|
|
||||||
const mockIdentify = vi.fn();
|
|
||||||
const mockGroup = vi.fn();
|
|
||||||
|
|
||||||
const mockPostHog: PartialPostHog = {
|
|
||||||
identify: mockIdentify,
|
|
||||||
group: mockGroup,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<PosthogIdentify
|
|
||||||
// no user in session
|
|
||||||
session={{} as any}
|
|
||||||
user={{ name: "Test User", email: "test@example.com" } as TUser}
|
|
||||||
isPosthogEnabled
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Because there's no session.user, we skip identify
|
|
||||||
expect(mockIdentify).not.toHaveBeenCalled();
|
|
||||||
expect(mockGroup).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("identifies user but does not group if environmentId/organizationId not provided", () => {
|
|
||||||
const mockIdentify = vi.fn();
|
|
||||||
const mockGroup = vi.fn();
|
|
||||||
|
|
||||||
const mockPostHog: PartialPostHog = {
|
|
||||||
identify: mockIdentify,
|
|
||||||
group: mockGroup,
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<PosthogIdentify
|
|
||||||
session={{ user: { id: "user-123" } } as Session}
|
|
||||||
user={{ name: "Test User", email: "test@example.com" } as TUser}
|
|
||||||
isPosthogEnabled
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
|
|
||||||
name: "Test User",
|
|
||||||
email: "test@example.com",
|
|
||||||
});
|
|
||||||
// No environmentId or organizationId => no group calls
|
|
||||||
expect(mockGroup).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Session } from "next-auth";
|
|
||||||
import { usePostHog } from "posthog-js/react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
|
||||||
|
|
||||||
interface PosthogIdentifyProps {
|
|
||||||
session: Session;
|
|
||||||
user: TUser;
|
|
||||||
environmentId?: string;
|
|
||||||
organizationId?: string;
|
|
||||||
organizationName?: string;
|
|
||||||
organizationBilling?: TOrganizationBilling;
|
|
||||||
isPosthogEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PosthogIdentify = ({
|
|
||||||
session,
|
|
||||||
user,
|
|
||||||
environmentId,
|
|
||||||
organizationId,
|
|
||||||
organizationName,
|
|
||||||
organizationBilling,
|
|
||||||
isPosthogEnabled,
|
|
||||||
}: PosthogIdentifyProps) => {
|
|
||||||
const posthog = usePostHog();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isPosthogEnabled && session.user && posthog) {
|
|
||||||
posthog.identify(session.user.id, {
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
});
|
|
||||||
if (environmentId) {
|
|
||||||
posthog.group("environment", environmentId, { name: environmentId });
|
|
||||||
}
|
|
||||||
if (organizationId) {
|
|
||||||
posthog.group("organization", organizationId, {
|
|
||||||
name: organizationName,
|
|
||||||
plan: organizationBilling?.plan,
|
|
||||||
responseLimit: organizationBilling?.limits.monthly.responses,
|
|
||||||
miuLimit: organizationBilling?.limits.monthly.miu,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
posthog,
|
|
||||||
session.user,
|
|
||||||
environmentId,
|
|
||||||
organizationId,
|
|
||||||
organizationName,
|
|
||||||
organizationBilling,
|
|
||||||
user.name,
|
|
||||||
user.email,
|
|
||||||
isPosthogEnabled,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { ProjectNavItem } from "./ProjectNavItem";
|
|
||||||
|
|
||||||
describe("ProjectNavItem", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
href: "/test-path",
|
|
||||||
children: <span>Test Child</span>,
|
|
||||||
};
|
|
||||||
|
|
||||||
test("renders correctly when active", () => {
|
|
||||||
render(<ProjectNavItem {...defaultProps} isActive={true} />);
|
|
||||||
|
|
||||||
const linkElement = screen.getByRole("link");
|
|
||||||
const listItem = linkElement.closest("li");
|
|
||||||
|
|
||||||
expect(linkElement).toHaveAttribute("href", "/test-path");
|
|
||||||
expect(screen.getByText("Test Child")).toBeInTheDocument();
|
|
||||||
expect(listItem).toHaveClass("bg-slate-50");
|
|
||||||
expect(listItem).toHaveClass("font-semibold");
|
|
||||||
expect(listItem).not.toHaveClass("hover:bg-slate-50");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders correctly when inactive", () => {
|
|
||||||
render(<ProjectNavItem {...defaultProps} isActive={false} />);
|
|
||||||
|
|
||||||
const linkElement = screen.getByRole("link");
|
|
||||||
const listItem = linkElement.closest("li");
|
|
||||||
|
|
||||||
expect(linkElement).toHaveAttribute("href", "/test-path");
|
|
||||||
expect(screen.getByText("Test Child")).toBeInTheDocument();
|
|
||||||
expect(listItem).not.toHaveClass("bg-slate-50");
|
|
||||||
expect(listItem).not.toHaveClass("font-semibold");
|
|
||||||
expect(listItem).toHaveClass("hover:bg-slate-50");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { QuestionOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
|
||||||
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
|
||||||
import { getTodayDate } from "@/app/lib/surveys/surveys";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { ResponseFilterProvider, useResponseFilter } from "./ResponseFilterContext";
|
|
||||||
|
|
||||||
// Mock the getTodayDate function
|
|
||||||
vi.mock("@/app/lib/surveys/surveys", () => ({
|
|
||||||
getTodayDate: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockToday = new Date("2024-01-15T00:00:00.000Z");
|
|
||||||
const mockFromDate = new Date("2024-01-01T00:00:00.000Z");
|
|
||||||
|
|
||||||
// Test component to use the hook
|
|
||||||
const TestComponent = () => {
|
|
||||||
const {
|
|
||||||
selectedFilter,
|
|
||||||
setSelectedFilter,
|
|
||||||
selectedOptions,
|
|
||||||
setSelectedOptions,
|
|
||||||
dateRange,
|
|
||||||
setDateRange,
|
|
||||||
resetState,
|
|
||||||
} = useResponseFilter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div data-testid="responseStatus">{selectedFilter.responseStatus}</div>
|
|
||||||
<div data-testid="filterLength">{selectedFilter.filter.length}</div>
|
|
||||||
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
|
|
||||||
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
|
|
||||||
<div data-testid="dateFrom">{dateRange.from?.toISOString()}</div>
|
|
||||||
<div data-testid="dateTo">{dateRange.to?.toISOString()}</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setSelectedFilter({
|
|
||||||
filter: [
|
|
||||||
{
|
|
||||||
questionType: { id: "q1", label: "Question 1" },
|
|
||||||
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
responseStatus: "complete",
|
|
||||||
})
|
|
||||||
}>
|
|
||||||
Update Filter
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setSelectedOptions({
|
|
||||||
questionOptions: [{ header: "q1" } as unknown as QuestionOptions],
|
|
||||||
questionFilterOptions: [{ id: "qFilterOpt1" } as unknown as QuestionFilterOptions],
|
|
||||||
})
|
|
||||||
}>
|
|
||||||
Update Options
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setDateRange({ from: mockFromDate, to: mockToday })}>Update Date Range</button>
|
|
||||||
<button onClick={resetState}>Reset State</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("ResponseFilterContext", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(getTodayDate).mockReturnValue(mockToday);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should provide initial state values", () => {
|
|
||||||
render(
|
|
||||||
<ResponseFilterProvider>
|
|
||||||
<TestComponent />
|
|
||||||
</ResponseFilterProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("responseStatus").textContent).toBe("all");
|
|
||||||
expect(screen.getByTestId("filterLength").textContent).toBe("0");
|
|
||||||
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
|
|
||||||
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
|
|
||||||
expect(screen.getByTestId("dateFrom").textContent).toBe("");
|
|
||||||
expect(screen.getByTestId("dateTo").textContent).toBe(mockToday.toISOString());
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should update selectedFilter state", async () => {
|
|
||||||
render(
|
|
||||||
<ResponseFilterProvider>
|
|
||||||
<TestComponent />
|
|
||||||
</ResponseFilterProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateButton = screen.getByText("Update Filter");
|
|
||||||
await userEvent.click(updateButton);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("responseStatus").textContent).toBe("complete");
|
|
||||||
expect(screen.getByTestId("filterLength").textContent).toBe("1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should update selectedOptions state", async () => {
|
|
||||||
render(
|
|
||||||
<ResponseFilterProvider>
|
|
||||||
<TestComponent />
|
|
||||||
</ResponseFilterProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateButton = screen.getByText("Update Options");
|
|
||||||
await userEvent.click(updateButton);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("1");
|
|
||||||
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should update dateRange state", async () => {
|
|
||||||
render(
|
|
||||||
<ResponseFilterProvider>
|
|
||||||
<TestComponent />
|
|
||||||
</ResponseFilterProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateButton = screen.getByText("Update Date Range");
|
|
||||||
await userEvent.click(updateButton);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("dateFrom").textContent).toBe(mockFromDate.toISOString());
|
|
||||||
expect(screen.getByTestId("dateTo").textContent).toBe(mockToday.toISOString());
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw error when useResponseFilter is used outside of Provider", () => {
|
|
||||||
// Hide console error temporarily
|
|
||||||
const consoleErrorMock = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
||||||
expect(() => render(<TestComponent />)).toThrow("useFilterDate must be used within a FilterDateProvider");
|
|
||||||
consoleErrorMock.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
||||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
|
||||||
|
|
||||||
interface TopControlBarProps {
|
interface TopControlBarProps {
|
||||||
environments: TEnvironment[];
|
environments: TEnvironment[];
|
||||||
currentOrganizationId: string;
|
currentOrganizationId: string;
|
||||||
organizations: { id: string; name: string }[];
|
|
||||||
currentProjectId: string;
|
currentProjectId: string;
|
||||||
projects: { id: string; name: string }[];
|
|
||||||
isMultiOrgEnabled: boolean;
|
isMultiOrgEnabled: boolean;
|
||||||
organizationProjectsLimit: number;
|
organizationProjectsLimit: number;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
@@ -24,9 +22,7 @@ interface TopControlBarProps {
|
|||||||
export const TopControlBar = ({
|
export const TopControlBar = ({
|
||||||
environments,
|
environments,
|
||||||
currentOrganizationId,
|
currentOrganizationId,
|
||||||
organizations,
|
|
||||||
currentProjectId,
|
currentProjectId,
|
||||||
projects,
|
|
||||||
isMultiOrgEnabled,
|
isMultiOrgEnabled,
|
||||||
organizationProjectsLimit,
|
organizationProjectsLimit,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
@@ -46,9 +42,7 @@ export const TopControlBar = ({
|
|||||||
currentEnvironmentId={environment.id}
|
currentEnvironmentId={environment.id}
|
||||||
environments={environments}
|
environments={environments}
|
||||||
currentOrganizationId={currentOrganizationId}
|
currentOrganizationId={currentOrganizationId}
|
||||||
organizations={organizations}
|
|
||||||
currentProjectId={currentProjectId}
|
currentProjectId={currentProjectId}
|
||||||
projects={projects}
|
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { WidgetStatusIndicator } from "./WidgetStatusIndicator";
|
|
||||||
|
|
||||||
// Mock next/navigation
|
|
||||||
const mockRefresh = vi.fn();
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: () => ({
|
|
||||||
refresh: mockRefresh,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock lucide-react icons
|
|
||||||
vi.mock("lucide-react", () => ({
|
|
||||||
AlertTriangleIcon: () => <div data-testid="alert-icon">AlertTriangleIcon</div>,
|
|
||||||
CheckIcon: () => <div data-testid="check-icon">CheckIcon</div>,
|
|
||||||
RotateCcwIcon: () => <div data-testid="refresh-icon">RotateCcwIcon</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock Button component
|
|
||||||
vi.mock("@/modules/ui/components/button", () => ({
|
|
||||||
Button: ({ children, onClick, ...props }: any) => (
|
|
||||||
<button onClick={onClick} {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockEnvironmentNotImplemented: TEnvironment = {
|
|
||||||
id: "env-not-implemented",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
type: "development",
|
|
||||||
projectId: "proj1",
|
|
||||||
appSetupCompleted: false, // Not implemented state
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEnvironmentRunning: TEnvironment = {
|
|
||||||
id: "env-running",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
type: "production",
|
|
||||||
projectId: "proj1",
|
|
||||||
appSetupCompleted: true, // Running state
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("WidgetStatusIndicator", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders correctly for 'notImplemented' state", () => {
|
|
||||||
render(<WidgetStatusIndicator environment={mockEnvironmentNotImplemented} />);
|
|
||||||
|
|
||||||
// Check icon
|
|
||||||
expect(screen.getByTestId("alert-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("check-icon")).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
// Check texts
|
|
||||||
expect(
|
|
||||||
screen.getByText("environments.project.app-connection.formbricks_sdk_not_connected")
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText("environments.project.app-connection.formbricks_sdk_not_connected_description")
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Check button
|
|
||||||
const recheckButton = screen.getByRole("button", { name: /environments.project.app-connection.recheck/ });
|
|
||||||
expect(recheckButton).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("refresh-icon")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders correctly for 'running' state", () => {
|
|
||||||
render(<WidgetStatusIndicator environment={mockEnvironmentRunning} />);
|
|
||||||
|
|
||||||
// Check icon
|
|
||||||
expect(screen.getByTestId("check-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("alert-icon")).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
// Check texts
|
|
||||||
expect(screen.getByText("environments.project.app-connection.receiving_data")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText("environments.project.app-connection.formbricks_sdk_connected")
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Check button absence
|
|
||||||
expect(
|
|
||||||
screen.queryByRole("button", { name: /environments.project.app-connection.recheck/ })
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("refresh-icon")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("calls router.refresh when 'Recheck' button is clicked", async () => {
|
|
||||||
render(<WidgetStatusIndicator environment={mockEnvironmentNotImplemented} />);
|
|
||||||
|
|
||||||
const recheckButton = screen.getByRole("button", { name: /environments.project.app-connection.recheck/ });
|
|
||||||
await userEvent.click(recheckButton);
|
|
||||||
|
|
||||||
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/lib/cn";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from "lucide-react";
|
import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
|
||||||
interface WidgetStatusIndicatorProps {
|
interface WidgetStatusIndicatorProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProps) => {
|
export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const stati = {
|
const stati = {
|
||||||
notImplemented: {
|
notImplemented: {
|
||||||
|
|||||||
@@ -1,329 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { EnvironmentBreadcrumb } from "./environment-breadcrumb";
|
|
||||||
|
|
||||||
// Mock the dependencies
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the UI components
|
|
||||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
|
||||||
BreadcrumbItem: ({ children, isActive, isHighlighted, ...props }: any) => (
|
|
||||||
<li data-testid="breadcrumb-item" data-active={isActive} data-highlighted={isHighlighted} {...props}>
|
|
||||||
{children}
|
|
||||||
</li>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
|
||||||
DropdownMenu: ({ children, onOpenChange }: any) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="dropdown-menu"
|
|
||||||
onClick={() => onOpenChange?.(true)}
|
|
||||||
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
DropdownMenuContent: ({ children, ...props }: any) => (
|
|
||||||
<div data-testid="dropdown-content" {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
|
|
||||||
<div
|
|
||||||
data-testid="dropdown-checkbox-item"
|
|
||||||
data-checked={checked}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
|
|
||||||
role="menuitemcheckbox"
|
|
||||||
aria-checked={checked}
|
|
||||||
tabIndex={0}
|
|
||||||
{...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
DropdownMenuTrigger: ({ children, ...props }: any) => (
|
|
||||||
<button data-testid="dropdown-trigger" {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
|
||||||
TooltipProvider: ({ children }: any) => <div data-testid="tooltip-provider">{children}</div>,
|
|
||||||
Tooltip: ({ children }: any) => <div data-testid="tooltip">{children}</div>,
|
|
||||||
TooltipTrigger: ({ children, asChild }: any) => (
|
|
||||||
<div data-testid="tooltip-trigger" data-as-child={asChild}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
TooltipContent: ({ children, className }: any) => (
|
|
||||||
<div data-testid="tooltip-content" className={className}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock Lucide React icons
|
|
||||||
vi.mock("lucide-react", () => ({
|
|
||||||
Code2Icon: ({ className, strokeWidth }: any) => {
|
|
||||||
const isHeader = className?.includes("mr-2");
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
data-testid={isHeader ? "code2-header-icon" : "code2-icon"}
|
|
||||||
className={className}
|
|
||||||
strokeWidth={strokeWidth}>
|
|
||||||
<title>Code2 Icon</title>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
ChevronDownIcon: ({ className, strokeWidth }: any) => (
|
|
||||||
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
|
|
||||||
<title>ChevronDown Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
CircleHelpIcon: ({ className }: any) => (
|
|
||||||
<svg data-testid="circle-help-icon" className={className}>
|
|
||||||
<title>CircleHelp Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Loader2: ({ className }: any) => (
|
|
||||||
<svg data-testid="loader-2-icon" className={className}>
|
|
||||||
<title>Loader2 Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("EnvironmentBreadcrumb", () => {
|
|
||||||
const mockPush = vi.fn();
|
|
||||||
const mockRouter = {
|
|
||||||
push: mockPush,
|
|
||||||
replace: vi.fn(),
|
|
||||||
refresh: vi.fn(),
|
|
||||||
back: vi.fn(),
|
|
||||||
forward: vi.fn(),
|
|
||||||
prefetch: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockProductionEnvironment: TEnvironment = {
|
|
||||||
id: "env-prod-1",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
type: "production",
|
|
||||||
projectId: "project-1",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockDevelopmentEnvironment: TEnvironment = {
|
|
||||||
id: "env-dev-1",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
type: "development",
|
|
||||||
projectId: "project-1",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEnvironments: TEnvironment[] = [mockProductionEnvironment, mockDevelopmentEnvironment];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders environment breadcrumb with production environment", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("code2-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText("production")).toHaveLength(2); // trigger + dropdown option
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders environment breadcrumb with development environment and shows tooltip", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={mockEnvironments}
|
|
||||||
currentEnvironment={mockDevelopmentEnvironment}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getAllByText("development")).toHaveLength(2); // trigger + dropdown option
|
|
||||||
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("circle-help-icon")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("highlights breadcrumb item for development environment", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={mockEnvironments}
|
|
||||||
currentEnvironment={mockDevelopmentEnvironment}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-highlighted", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not highlight breadcrumb item for production environment", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-highlighted", "false");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows chevron down icon when dropdown is open", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getAllByTestId("chevron-down-icon")).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders dropdown content with environment options", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.choose_environment")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders all environment options in dropdown", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
|
||||||
expect(checkboxItems).toHaveLength(2);
|
|
||||||
|
|
||||||
// Check production environment option
|
|
||||||
const productionOption = checkboxItems.find((item) => item.textContent?.includes("production"));
|
|
||||||
expect(productionOption).toBeInTheDocument();
|
|
||||||
expect(productionOption).toHaveAttribute("data-checked", "true");
|
|
||||||
|
|
||||||
// Check development environment option
|
|
||||||
const developmentOption = checkboxItems.find((item) => item.textContent?.includes("development"));
|
|
||||||
expect(developmentOption).toBeInTheDocument();
|
|
||||||
expect(developmentOption).toHaveAttribute("data-checked", "false");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles environment change when clicking dropdown option", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
|
||||||
const developmentOption = checkboxItems.find((item) => item.textContent?.includes("development"));
|
|
||||||
|
|
||||||
expect(developmentOption).toBeInTheDocument();
|
|
||||||
await user.click(developmentOption!);
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith("/environments/env-dev-1/");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("capitalizes environment type in display", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const environmentSpans = screen.getAllByText("production");
|
|
||||||
const triggerSpan = environmentSpans.find((span) => span.className.includes("capitalize"));
|
|
||||||
expect(triggerSpan).toHaveClass("capitalize");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tooltip shows correct content for development environment", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={mockEnvironments}
|
|
||||||
currentEnvironment={mockDevelopmentEnvironment}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const tooltipContent = screen.getByTestId("tooltip-content");
|
|
||||||
expect(tooltipContent).toHaveClass("text-white bg-red-800 border-none mt-2");
|
|
||||||
expect(tooltipContent).toHaveTextContent("common.development_environment_banner");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders without tooltip for production environment", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("circle-help-icon")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sets breadcrumb item as active when dropdown is open", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initially not active
|
|
||||||
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
|
|
||||||
|
|
||||||
// Open dropdown
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
// Should be active when dropdown is open
|
|
||||||
breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles single environment scenario", () => {
|
|
||||||
const singleEnvironment = [mockProductionEnvironment];
|
|
||||||
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={singleEnvironment}
|
|
||||||
currentEnvironment={mockProductionEnvironment}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText("production")).toHaveLength(2); // trigger + dropdown option
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles empty environments array gracefully", () => {
|
|
||||||
render(<EnvironmentBreadcrumb environments={[]} currentEnvironment={mockProductionEnvironment} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("production")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDownIcon, CircleHelpIcon, Code2Icon, Loader2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -9,10 +13,6 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { ChevronDownIcon, CircleHelpIcon, Code2Icon, Loader2 } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export const EnvironmentBreadcrumb = ({
|
export const EnvironmentBreadcrumb = ({
|
||||||
environments,
|
environments,
|
||||||
@@ -21,7 +21,7 @@ export const EnvironmentBreadcrumb = ({
|
|||||||
environments: { id: string; type: string }[];
|
environments: { id: string; type: string }[];
|
||||||
currentEnvironment: { id: string; type: string };
|
currentEnvironment: { id: string; type: string };
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslation();
|
||||||
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
|
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|||||||
@@ -1,560 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
|
|
||||||
import { OrganizationBreadcrumb } from "./organization-breadcrumb";
|
|
||||||
|
|
||||||
// Mock the dependencies
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: vi.fn(),
|
|
||||||
usePathname: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@tolgee/react", () => ({
|
|
||||||
useTranslate: () => ({
|
|
||||||
t: (key: string) => key,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
|
||||||
CreateOrganizationModal: ({ open, setOpen }: any) =>
|
|
||||||
open ? (
|
|
||||||
<div data-testid="create-organization-modal">
|
|
||||||
<button type="button" onClick={() => setOpen(false)}>
|
|
||||||
Close Modal
|
|
||||||
</button>
|
|
||||||
Create Organization Modal
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the UI components
|
|
||||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
|
||||||
BreadcrumbItem: ({ children, isActive, ...props }: any) => (
|
|
||||||
<li data-testid="breadcrumb-item" data-active={isActive} {...props}>
|
|
||||||
{children}
|
|
||||||
</li>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
|
||||||
DropdownMenu: ({ children, onOpenChange }: any) => (
|
|
||||||
<div
|
|
||||||
data-testid="dropdown-menu"
|
|
||||||
onClick={() => onOpenChange?.(true)}
|
|
||||||
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
DropdownMenuContent: ({ children, ...props }: any) => (
|
|
||||||
<div data-testid="dropdown-content" {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
|
|
||||||
<div
|
|
||||||
data-testid="dropdown-checkbox-item"
|
|
||||||
data-checked={checked}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
|
|
||||||
role="menuitemcheckbox"
|
|
||||||
aria-checked={checked}
|
|
||||||
tabIndex={0}
|
|
||||||
{...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
DropdownMenuTrigger: ({ children, ...props }: any) => (
|
|
||||||
<button data-testid="dropdown-trigger" {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
|
|
||||||
DropdownMenuSeparator: () => <div data-testid="dropdown-separator" />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock Lucide React icons
|
|
||||||
vi.mock("lucide-react", () => ({
|
|
||||||
BuildingIcon: ({ className, strokeWidth }: any) => {
|
|
||||||
const isHeader = className?.includes("mr-2");
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
data-testid={isHeader ? "building-header-icon" : "building-icon"}
|
|
||||||
className={className}
|
|
||||||
strokeWidth={strokeWidth}>
|
|
||||||
<title>Building Icon</title>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
ChevronDownIcon: ({ className, strokeWidth }: any) => (
|
|
||||||
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
|
|
||||||
<title>ChevronDown Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
ChevronRightIcon: ({ className, strokeWidth }: any) => (
|
|
||||||
<svg data-testid="chevron-right-icon" className={className} strokeWidth={strokeWidth}>
|
|
||||||
<title>ChevronRight Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
PlusIcon: ({ className }: any) => (
|
|
||||||
<svg data-testid="plus-icon" className={className}>
|
|
||||||
<title>Plus Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
SettingsIcon: ({ className }: any) => (
|
|
||||||
<svg data-testid="settings-icon" className={className}>
|
|
||||||
<title>Settings Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Loader2: ({ className }: any) => (
|
|
||||||
<svg data-testid="loader-2-icon" className={className}>
|
|
||||||
<title>Loader2 Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("OrganizationBreadcrumb", () => {
|
|
||||||
const mockPush = vi.fn();
|
|
||||||
const mockRouter = {
|
|
||||||
push: mockPush,
|
|
||||||
replace: vi.fn(),
|
|
||||||
refresh: vi.fn(),
|
|
||||||
back: vi.fn(),
|
|
||||||
forward: vi.fn(),
|
|
||||||
prefetch: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockOrganization1: TOrganization = {
|
|
||||||
id: "org-1",
|
|
||||||
name: "Test Organization 1",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
billing: {
|
|
||||||
plan: "free",
|
|
||||||
stripeCustomerId: null,
|
|
||||||
} as unknown as TOrganizationBilling,
|
|
||||||
isAIEnabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockOrganization2: TOrganization = {
|
|
||||||
id: "org-2",
|
|
||||||
name: "Test Organization 2",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
billing: {
|
|
||||||
plan: "startup",
|
|
||||||
stripeCustomerId: null,
|
|
||||||
} as unknown as TOrganizationBilling,
|
|
||||||
isAIEnabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockOrganizations = [mockOrganization1, mockOrganization2];
|
|
||||||
const currentEnvironmentId = "env-123";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
|
|
||||||
vi.mocked(usePathname).mockReturnValue("/environments/env-123/");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Single Organization Setup", () => {
|
|
||||||
test("renders organization breadcrumb without dropdown for single org", () => {
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={[mockOrganization1]}
|
|
||||||
isMultiOrgEnabled={false}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("building-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Test Organization 1")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows organization settings without organization switcher", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={[mockOrganization1]}
|
|
||||||
isMultiOrgEnabled={false}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.organization_settings")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("common.choose_organization")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Multi Organization Setup", () => {
|
|
||||||
test("renders organization breadcrumb with dropdown for multi org", () => {
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("building-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText("Test Organization 1")).toHaveLength(2); // trigger + dropdown option
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows chevron icons correctly", () => {
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should show chevron right when closed
|
|
||||||
expect(screen.getByTestId("chevron-right-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows chevron down when dropdown is open", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("chevron-down-icon")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders organization selector in dropdown", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.getByText("common.choose_organization")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
|
|
||||||
|
|
||||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
|
||||||
expect(checkboxItems.length).toBeGreaterThanOrEqual(2); // Organizations + create new option + settings
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles organization change when clicking dropdown option", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
|
||||||
const org2Option = checkboxItems.find((item) => item.textContent?.includes("Test Organization 2"));
|
|
||||||
|
|
||||||
expect(org2Option).toBeInTheDocument();
|
|
||||||
await user.click(org2Option!);
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith("/organizations/org-2/");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows create new organization option when multi org is enabled", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const createOrgOption = screen.getByText("common.create_new_organization");
|
|
||||||
expect(createOrgOption).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens create organization modal when clicking create new option", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const createOrgOption = screen.getByText("common.create_new_organization");
|
|
||||||
await user.click(createOrgOption);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("create-organization-modal")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hides create new organization option when multi org is disabled", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={false}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.queryByText("common.create_new_organization")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Organization Settings", () => {
|
|
||||||
test("renders all organization settings options", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.getByText("common.organization_settings")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("settings-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.general")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.teams")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.api_keys")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.billing")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles navigation to organization settings", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const generalOption = screen.getByText("common.general");
|
|
||||||
await user.click(generalOption);
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${currentEnvironmentId}/settings/general`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("marks current settings page as checked", async () => {
|
|
||||||
vi.mocked(usePathname).mockReturnValue("/environments/env-123/settings/teams");
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
|
||||||
const teamsOption = checkboxItems.find((item) => item.textContent?.includes("common.teams"));
|
|
||||||
|
|
||||||
expect(teamsOption).toBeInTheDocument();
|
|
||||||
expect(teamsOption).toHaveAttribute("data-checked", "true");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Edge Cases", () => {
|
|
||||||
test("handles single organization with multi org enabled", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={[mockOrganization1]}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
// Should still show organization selector since multi org is enabled
|
|
||||||
expect(screen.getByText("common.choose_organization")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.create_new_organization")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows separator between organization switcher and settings", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("dropdown-separator")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sets breadcrumb item as active when dropdown is open", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initially not active
|
|
||||||
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
|
|
||||||
|
|
||||||
// Open dropdown
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
// Should be active when dropdown is open
|
|
||||||
breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("closes create organization modal correctly", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const createOrgOption = screen.getByText("common.create_new_organization");
|
|
||||||
await user.click(createOrgOption);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("create-organization-modal")).toBeInTheDocument();
|
|
||||||
|
|
||||||
const closeButton = screen.getByText("Close Modal");
|
|
||||||
await user.click(closeButton);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("create-organization-modal")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import {
|
||||||
|
BuildingIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
Loader2,
|
||||||
|
PlusIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { getOrganizationsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||||
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||||
import {
|
import {
|
||||||
@@ -10,23 +25,11 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import { useOrganization } from "../context/environment-context";
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import {
|
|
||||||
BuildingIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
Loader2,
|
|
||||||
PlusIcon,
|
|
||||||
SettingsIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
|
|
||||||
interface OrganizationBreadcrumbProps {
|
interface OrganizationBreadcrumbProps {
|
||||||
currentOrganizationId: string;
|
currentOrganizationId: string;
|
||||||
organizations: { id: string; name: string }[];
|
currentOrganizationName?: string; // Optional: pass directly if context not available
|
||||||
isMultiOrgEnabled: boolean;
|
isMultiOrgEnabled: boolean;
|
||||||
currentEnvironmentId?: string;
|
currentEnvironmentId?: string;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
@@ -34,22 +37,71 @@ interface OrganizationBreadcrumbProps {
|
|||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
||||||
|
// Match /settings/{settingId} or /settings/{settingId}/... but exclude account settings
|
||||||
|
// Exclude paths with /(account)/
|
||||||
|
if (pathname.includes("/(account)/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check if path matches /settings/{settingId} (with optional trailing path)
|
||||||
|
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
|
||||||
|
return pattern.test(pathname);
|
||||||
|
};
|
||||||
|
|
||||||
export const OrganizationBreadcrumb = ({
|
export const OrganizationBreadcrumb = ({
|
||||||
currentOrganizationId,
|
currentOrganizationId,
|
||||||
organizations,
|
currentOrganizationName,
|
||||||
isMultiOrgEnabled,
|
isMultiOrgEnabled,
|
||||||
currentEnvironmentId,
|
currentEnvironmentId,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isMember,
|
isMember,
|
||||||
isOwnerOrManager,
|
isOwnerOrManager,
|
||||||
}: OrganizationBreadcrumbProps) => {
|
}: OrganizationBreadcrumbProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslation();
|
||||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
||||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isPending, startTransition] = useTransition();
|
||||||
const currentOrganization = organizations.find((org) => org.id === currentOrganizationId);
|
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
||||||
|
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Get current organization name from context OR prop
|
||||||
|
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||||
|
const { organization: currentOrganization } = useOrganization();
|
||||||
|
const organizationName = currentOrganization?.name || currentOrganizationName || "";
|
||||||
|
|
||||||
|
// Lazy-load organizations when dropdown opens
|
||||||
|
useEffect(() => {
|
||||||
|
// Only fetch when dropdown opened for first time (and no error state)
|
||||||
|
if (isOrganizationDropdownOpen && organizations.length === 0 && !isLoadingOrganizations && !loadError) {
|
||||||
|
setIsLoadingOrganizations(true);
|
||||||
|
setLoadError(null); // Clear any previous errors
|
||||||
|
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||||
|
if (result?.data) {
|
||||||
|
// Sort organizations by name
|
||||||
|
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||||
|
setOrganizations(sorted);
|
||||||
|
} else {
|
||||||
|
// Handle server errors or validation errors
|
||||||
|
const errorMessage = getFormattedErrorMessage(result);
|
||||||
|
const error = new Error(errorMessage);
|
||||||
|
logger.error(error, "Failed to load organizations");
|
||||||
|
Sentry.captureException(error);
|
||||||
|
setLoadError(errorMessage || t("common.failed_to_load_organizations"));
|
||||||
|
}
|
||||||
|
setIsLoadingOrganizations(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isOrganizationDropdownOpen,
|
||||||
|
currentOrganizationId,
|
||||||
|
organizations.length,
|
||||||
|
isLoadingOrganizations,
|
||||||
|
loadError,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
if (!currentOrganization) {
|
if (!currentOrganization) {
|
||||||
const errorMessage = `Organization not found for organization id: ${currentOrganizationId}`;
|
const errorMessage = `Organization not found for organization id: ${currentOrganizationId}`;
|
||||||
@@ -60,13 +112,21 @@ export const OrganizationBreadcrumb = ({
|
|||||||
|
|
||||||
const handleOrganizationChange = (organizationId: string) => {
|
const handleOrganizationChange = (organizationId: string) => {
|
||||||
if (organizationId === currentOrganizationId) return;
|
if (organizationId === currentOrganizationId) return;
|
||||||
setIsLoading(true);
|
startTransition(() => {
|
||||||
router.push(`/organizations/${organizationId}/`);
|
router.push(`/organizations/${organizationId}/`);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hide organization dropdown for single org setups (on-premise)
|
// Hide organization dropdown for single org setups (on-premise)
|
||||||
const showOrganizationDropdown = isMultiOrgEnabled || organizations.length > 1;
|
const showOrganizationDropdown = isMultiOrgEnabled || organizations.length > 1;
|
||||||
|
|
||||||
|
const handleSettingChange = (href: string) => {
|
||||||
|
startTransition(() => {
|
||||||
|
setIsOrganizationDropdownOpen(false);
|
||||||
|
router.push(href);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const organizationSettings = [
|
const organizationSettings = [
|
||||||
{
|
{
|
||||||
id: "general",
|
id: "general",
|
||||||
@@ -75,7 +135,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "teams",
|
id: "teams",
|
||||||
label: t("common.teams"),
|
label: t("common.members_and_teams"),
|
||||||
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -107,8 +167,8 @@ export const OrganizationBreadcrumb = ({
|
|||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
|
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{currentOrganization.name}</span>
|
<span>{organizationName}</span>
|
||||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isOrganizationDropdownOpen ? (
|
{isOrganizationDropdownOpen ? (
|
||||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
) : (
|
) : (
|
||||||
@@ -123,30 +183,52 @@ export const OrganizationBreadcrumb = ({
|
|||||||
<BuildingIcon className="mr-2 inline h-4 w-4" />
|
<BuildingIcon className="mr-2 inline h-4 w-4" />
|
||||||
{t("common.choose_organization")}
|
{t("common.choose_organization")}
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuGroup>
|
{isLoadingOrganizations && (
|
||||||
{organizations.map((org) => (
|
<div className="flex items-center justify-center py-2">
|
||||||
<DropdownMenuCheckboxItem
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
key={org.id}
|
</div>
|
||||||
checked={org.id === currentOrganization.id}
|
)}
|
||||||
onClick={() => handleOrganizationChange(org.id)}
|
{!isLoadingOrganizations && loadError && (
|
||||||
className="cursor-pointer">
|
<div className="px-2 py-4">
|
||||||
{org.name}
|
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
||||||
</DropdownMenuCheckboxItem>
|
<button
|
||||||
))}
|
onClick={() => {
|
||||||
</DropdownMenuGroup>
|
setLoadError(null);
|
||||||
{isMultiOrgEnabled && (
|
setOrganizations([]);
|
||||||
<DropdownMenuCheckboxItem
|
}}
|
||||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||||
className="cursor-pointer">
|
{t("common.try_again")}
|
||||||
<span>{t("common.create_new_organization")}</span>
|
</button>
|
||||||
<PlusIcon className="ml-2 h-4 w-4" />
|
</div>
|
||||||
</DropdownMenuCheckboxItem>
|
)}
|
||||||
|
{!isLoadingOrganizations && !loadError && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={org.id}
|
||||||
|
checked={org.id === currentOrganizationId}
|
||||||
|
onClick={() => handleOrganizationChange(org.id)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
{org.name}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
{isMultiOrgEnabled && (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
<span>{t("common.create_new_organization")}</span>
|
||||||
|
<PlusIcon className="ml-2 h-4 w-4" />
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{currentEnvironmentId && (
|
{currentEnvironmentId && (
|
||||||
<div>
|
<div>
|
||||||
<DropdownMenuSeparator />
|
{showOrganizationDropdown && <DropdownMenuSeparator />}
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<SettingsIcon className="mr-2 inline h-4 w-4" />
|
<SettingsIcon className="mr-2 inline h-4 w-4" />
|
||||||
{t("common.organization_settings")}
|
{t("common.organization_settings")}
|
||||||
@@ -156,9 +238,9 @@ export const OrganizationBreadcrumb = ({
|
|||||||
return setting.hidden ? null : (
|
return setting.hidden ? null : (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={setting.id}
|
key={setting.id}
|
||||||
checked={pathname.includes(setting.id)}
|
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
||||||
hidden={setting.hidden}
|
hidden={setting.hidden}
|
||||||
onClick={() => router.push(setting.href)}
|
onClick={() => handleSettingChange(setting.href)}
|
||||||
className="cursor-pointer">
|
className="cursor-pointer">
|
||||||
{setting.label}
|
{setting.label}
|
||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
|
|||||||
@@ -1,340 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { ProjectAndOrgSwitch } from "./project-and-org-switch";
|
|
||||||
|
|
||||||
// Mock the individual breadcrumb components
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/organization-breadcrumb", () => ({
|
|
||||||
OrganizationBreadcrumb: ({
|
|
||||||
currentOrganizationId,
|
|
||||||
organizations,
|
|
||||||
isMultiOrgEnabled,
|
|
||||||
currentEnvironmentId,
|
|
||||||
}: any) => {
|
|
||||||
const currentOrganization = organizations.find((org: any) => org.id === currentOrganizationId);
|
|
||||||
return (
|
|
||||||
<div data-testid="organization-breadcrumb">
|
|
||||||
<div>Organization: {currentOrganization?.name}</div>
|
|
||||||
<div>Organizations Count: {organizations.length}</div>
|
|
||||||
<div>Multi Org: {isMultiOrgEnabled ? "Enabled" : "Disabled"}</div>
|
|
||||||
<div>Environment ID: {currentEnvironmentId}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/project-breadcrumb", () => ({
|
|
||||||
ProjectBreadcrumb: ({
|
|
||||||
currentProjectId,
|
|
||||||
projects,
|
|
||||||
isOwnerOrManager,
|
|
||||||
organizationProjectsLimit,
|
|
||||||
isFormbricksCloud,
|
|
||||||
isLicenseActive,
|
|
||||||
currentOrganizationId,
|
|
||||||
currentEnvironmentId,
|
|
||||||
isAccessControlAllowed,
|
|
||||||
}: any) => {
|
|
||||||
const currentProject = projects.find((project: any) => project.id === currentProjectId);
|
|
||||||
return (
|
|
||||||
<div data-testid="project-breadcrumb">
|
|
||||||
<div>Project: {currentProject?.name}</div>
|
|
||||||
<div>Projects Count: {projects.length}</div>
|
|
||||||
<div>Owner/Manager: {isOwnerOrManager ? "Yes" : "No"}</div>
|
|
||||||
<div>Project Limit: {organizationProjectsLimit}</div>
|
|
||||||
<div>Formbricks Cloud: {isFormbricksCloud ? "Yes" : "No"}</div>
|
|
||||||
<div>License Active: {isLicenseActive ? "Yes" : "No"}</div>
|
|
||||||
<div>Organization ID: {currentOrganizationId}</div>
|
|
||||||
<div>Environment ID: {currentEnvironmentId}</div>
|
|
||||||
<div>Access Control: {isAccessControlAllowed ? "Allowed" : "Not Allowed"}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/environment-breadcrumb", () => ({
|
|
||||||
EnvironmentBreadcrumb: ({ environments, currentEnvironmentId }: any) => {
|
|
||||||
const currentEnvironment = environments.find((env: any) => env.id === currentEnvironmentId);
|
|
||||||
return (
|
|
||||||
<div data-testid="environment-breadcrumb">
|
|
||||||
<div>Environment: {currentEnvironment?.type}</div>
|
|
||||||
<div>Environments Count: {environments.length}</div>
|
|
||||||
<div>Environment ID: {currentEnvironment?.id}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the UI components
|
|
||||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
|
||||||
Breadcrumb: ({ children }: any) => (
|
|
||||||
<nav data-testid="breadcrumb" aria-label="breadcrumb">
|
|
||||||
{children}
|
|
||||||
</nav>
|
|
||||||
),
|
|
||||||
BreadcrumbList: ({ children, className }: any) => (
|
|
||||||
<ol data-testid="breadcrumb-list" className={className}>
|
|
||||||
{children}
|
|
||||||
</ol>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("ProjectAndOrgSwitch", () => {
|
|
||||||
const mockOrganization1 = {
|
|
||||||
id: "org-1",
|
|
||||||
name: "Test Organization 1",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockOrganization2 = {
|
|
||||||
id: "org-2",
|
|
||||||
name: "Test Organization 2",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockProject1 = {
|
|
||||||
id: "proj-1",
|
|
||||||
name: "Test Project 1",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockProject2 = {
|
|
||||||
id: "proj-2",
|
|
||||||
name: "Test Project 2",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEnvironment1: TEnvironment = {
|
|
||||||
id: "env-1",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
type: "development",
|
|
||||||
projectId: "proj-1",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEnvironment2: TEnvironment = {
|
|
||||||
id: "env-2",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
type: "development",
|
|
||||||
projectId: "proj-1",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
currentOrganizationId: "org-1",
|
|
||||||
organizations: [mockOrganization1, mockOrganization2],
|
|
||||||
currentProjectId: "proj-1",
|
|
||||||
projects: [mockProject1, mockProject2],
|
|
||||||
currentEnvironmentId: "env-1",
|
|
||||||
environments: [mockEnvironment1, mockEnvironment2],
|
|
||||||
isMultiOrgEnabled: true,
|
|
||||||
organizationProjectsLimit: 5,
|
|
||||||
isFormbricksCloud: true,
|
|
||||||
isLicenseActive: false,
|
|
||||||
isOwnerOrManager: true,
|
|
||||||
isAccessControlAllowed: true,
|
|
||||||
isMember: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Basic Rendering", () => {
|
|
||||||
test("renders main breadcrumb structure", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("breadcrumb-list")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("breadcrumb")).toHaveAttribute("aria-label", "breadcrumb");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("applies correct CSS classes to breadcrumb list", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
|
|
||||||
const breadcrumbList = screen.getByTestId("breadcrumb-list");
|
|
||||||
expect(breadcrumbList).toHaveClass("gap-0");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders all three breadcrumb components", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("organization-breadcrumb")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("project-breadcrumb")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Organization Breadcrumb Integration", () => {
|
|
||||||
test("passes correct props to organization breadcrumb", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
|
|
||||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Organization: Test Organization 1");
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Organizations Count: 2");
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Multi Org: Enabled");
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Environment ID: env-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles single organization setup", () => {
|
|
||||||
render(
|
|
||||||
<ProjectAndOrgSwitch
|
|
||||||
{...defaultProps}
|
|
||||||
organizations={[mockOrganization1]}
|
|
||||||
isMultiOrgEnabled={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Organizations Count: 1");
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Multi Org: Disabled");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Project Breadcrumb Integration", () => {
|
|
||||||
test("passes correct props to project breadcrumb", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Project: Test Project 1");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Projects Count: 2");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Owner/Manager: Yes");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Project Limit: 5");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Formbricks Cloud: Yes");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("License Active: No");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Organization ID: org-1");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Environment ID: env-1");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Access Control: Allowed");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles non-owner/manager user", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} isOwnerOrManager={false} />);
|
|
||||||
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Owner/Manager: No");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles self-hosted setup", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} isFormbricksCloud={false} isLicenseActive={true} />);
|
|
||||||
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Formbricks Cloud: No");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("License Active: Yes");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles access control restrictions", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} isAccessControlAllowed={false} />);
|
|
||||||
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Access Control: Not Allowed");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Environment Breadcrumb Integration", () => {
|
|
||||||
test("passes correct props to environment breadcrumb", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
|
|
||||||
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
|
|
||||||
expect(envBreadcrumb).toHaveTextContent("Environments Count: 2");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles single environment", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} environments={[mockEnvironment1]} />);
|
|
||||||
|
|
||||||
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
|
|
||||||
expect(envBreadcrumb).toHaveTextContent("Environments Count: 1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Props Propagation", () => {
|
|
||||||
test("correctly propagates organization limits", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} organizationProjectsLimit={10} />);
|
|
||||||
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Project Limit: 10");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("correctly propagates current organization to project breadcrumb", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} currentOrganizationId="org-2" />);
|
|
||||||
|
|
||||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Organization: Test Organization 2");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Organization ID: org-2");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Edge Cases", () => {
|
|
||||||
test("handles zero project limit", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} organizationProjectsLimit={0} />);
|
|
||||||
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Project Limit: 0");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles all boolean props as false", () => {
|
|
||||||
render(
|
|
||||||
<ProjectAndOrgSwitch
|
|
||||||
{...defaultProps}
|
|
||||||
isMultiOrgEnabled={false}
|
|
||||||
isFormbricksCloud={false}
|
|
||||||
isLicenseActive={false}
|
|
||||||
isOwnerOrManager={false}
|
|
||||||
isAccessControlAllowed={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Multi Org: Disabled");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Owner/Manager: No");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Formbricks Cloud: No");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("License Active: No");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Access Control: Not Allowed");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maintains component order in DOM", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
|
|
||||||
const breadcrumbList = screen.getByTestId("breadcrumb-list");
|
|
||||||
const children = Array.from(breadcrumbList.children);
|
|
||||||
|
|
||||||
expect(children[0]).toHaveAttribute("data-testid", "organization-breadcrumb");
|
|
||||||
expect(children[1]).toHaveAttribute("data-testid", "project-breadcrumb");
|
|
||||||
expect(children[2]).toHaveAttribute("data-testid", "environment-breadcrumb");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("TypeScript Props Interface", () => {
|
|
||||||
test("accepts all required props without error", () => {
|
|
||||||
// This test ensures the component accepts the full interface
|
|
||||||
expect(() => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("works with minimal valid props", () => {
|
|
||||||
const minimalProps = {
|
|
||||||
currentOrganizationId: "org-1",
|
|
||||||
organizations: [mockOrganization1],
|
|
||||||
currentProjectId: "proj-1",
|
|
||||||
projects: [mockProject1],
|
|
||||||
currentEnvironmentId: "env-1",
|
|
||||||
environments: [mockEnvironment1],
|
|
||||||
isMultiOrgEnabled: false,
|
|
||||||
organizationProjectsLimit: 1,
|
|
||||||
isFormbricksCloud: false,
|
|
||||||
isLicenseActive: false,
|
|
||||||
isOwnerOrManager: false,
|
|
||||||
isAccessControlAllowed: false,
|
|
||||||
isMember: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
render(<ProjectAndOrgSwitch {...minimalProps} />);
|
|
||||||
}).not.toThrow();
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,13 +4,12 @@ import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/
|
|||||||
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
||||||
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
|
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
|
||||||
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
interface ProjectAndOrgSwitchProps {
|
interface ProjectAndOrgSwitchProps {
|
||||||
currentOrganizationId: string;
|
currentOrganizationId: string;
|
||||||
organizations: { id: string; name: string }[];
|
currentOrganizationName?: string; // Optional: for pages without context
|
||||||
currentProjectId?: string;
|
currentProjectId?: string;
|
||||||
projects: { id: string; name: string }[];
|
currentProjectName?: string; // Optional: for pages without context
|
||||||
currentEnvironmentId?: string;
|
currentEnvironmentId?: string;
|
||||||
environments: { id: string; type: string }[];
|
environments: { id: string; type: string }[];
|
||||||
isMultiOrgEnabled: boolean;
|
isMultiOrgEnabled: boolean;
|
||||||
@@ -18,15 +17,15 @@ interface ProjectAndOrgSwitchProps {
|
|||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isLicenseActive: boolean;
|
isLicenseActive: boolean;
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
isAccessControlAllowed: boolean;
|
|
||||||
isMember: boolean;
|
isMember: boolean;
|
||||||
|
isAccessControlAllowed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectAndOrgSwitch = ({
|
export const ProjectAndOrgSwitch = ({
|
||||||
currentOrganizationId,
|
currentOrganizationId,
|
||||||
organizations,
|
currentOrganizationName,
|
||||||
currentProjectId,
|
currentProjectId,
|
||||||
projects,
|
currentProjectName,
|
||||||
currentEnvironmentId,
|
currentEnvironmentId,
|
||||||
environments,
|
environments,
|
||||||
isMultiOrgEnabled,
|
isMultiOrgEnabled,
|
||||||
@@ -37,11 +36,6 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
isMember,
|
isMember,
|
||||||
}: ProjectAndOrgSwitchProps) => {
|
}: ProjectAndOrgSwitchProps) => {
|
||||||
const sortedProjects = useMemo(() => projects.toSorted((a, b) => a.name.localeCompare(b.name)), [projects]);
|
|
||||||
const sortedOrganizations = useMemo(
|
|
||||||
() => organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
|
|
||||||
[organizations]
|
|
||||||
);
|
|
||||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||||
|
|
||||||
@@ -50,9 +44,9 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
<BreadcrumbList className="gap-0">
|
<BreadcrumbList className="gap-0">
|
||||||
<OrganizationBreadcrumb
|
<OrganizationBreadcrumb
|
||||||
currentOrganizationId={currentOrganizationId}
|
currentOrganizationId={currentOrganizationId}
|
||||||
organizations={sortedOrganizations}
|
currentOrganizationName={currentOrganizationName}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
currentEnvironmentId={currentEnvironmentId}
|
||||||
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
isMember={isMember}
|
isMember={isMember}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
@@ -60,9 +54,9 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
{currentProjectId && currentEnvironmentId && (
|
{currentProjectId && currentEnvironmentId && (
|
||||||
<ProjectBreadcrumb
|
<ProjectBreadcrumb
|
||||||
currentProjectId={currentProjectId}
|
currentProjectId={currentProjectId}
|
||||||
|
currentProjectName={currentProjectName}
|
||||||
currentOrganizationId={currentOrganizationId}
|
currentOrganizationId={currentOrganizationId}
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
currentEnvironmentId={currentEnvironmentId}
|
||||||
projects={sortedProjects}
|
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
|
|||||||
@@ -1,512 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
|
|
||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
import { ProjectBreadcrumb } from "./project-breadcrumb";
|
|
||||||
|
|
||||||
// Mock the dependencies
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: vi.fn(),
|
|
||||||
usePathname: vi.fn(() => "/environments/env-123/project/general"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@tolgee/react", () => ({
|
|
||||||
useTranslate: () => ({
|
|
||||||
t: (key: string) => key,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/projects/components/project-limit-modal", () => ({
|
|
||||||
ProjectLimitModal: ({ open, setOpen, buttons, projectLimit }: any) =>
|
|
||||||
open ? (
|
|
||||||
<div data-testid="project-limit-modal">
|
|
||||||
<div>Project Limit: {projectLimit}</div>
|
|
||||||
<button onClick={() => setOpen(false)}>Close Limit Modal</button>
|
|
||||||
{buttons.map((button: any) => (
|
|
||||||
<button key={button.text} type="button" onClick={() => button.href && window.open(button.href)}>
|
|
||||||
{button.text}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/projects/components/create-project-modal", () => ({
|
|
||||||
CreateProjectModal: ({ open, setOpen, organizationId, isAccessControlAllowed }: any) =>
|
|
||||||
open ? (
|
|
||||||
<div data-testid="create-project-modal">
|
|
||||||
<div>Organization: {organizationId}</div>
|
|
||||||
<div>Access Control: {isAccessControlAllowed ? "Allowed" : "Not Allowed"}</div>
|
|
||||||
<button onClick={() => setOpen(false)}>Close Create Modal</button>
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the UI components
|
|
||||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
|
||||||
BreadcrumbItem: ({ children, isActive, ...props }: any) => (
|
|
||||||
<li data-testid="breadcrumb-item" data-active={isActive} {...props}>
|
|
||||||
{children}
|
|
||||||
</li>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
|
||||||
DropdownMenu: ({ children, onOpenChange }: any) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="dropdown-menu"
|
|
||||||
onClick={() => onOpenChange?.(true)}
|
|
||||||
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
DropdownMenuContent: ({ children, ...props }: any) => (
|
|
||||||
<div data-testid="dropdown-content" {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
|
|
||||||
<div
|
|
||||||
data-testid="dropdown-checkbox-item"
|
|
||||||
data-checked={checked}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
|
|
||||||
role="menuitemcheckbox"
|
|
||||||
aria-checked={checked}
|
|
||||||
tabIndex={0}
|
|
||||||
{...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
DropdownMenuTrigger: ({ children, ...props }: any) => (
|
|
||||||
<button data-testid="dropdown-trigger" {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
|
|
||||||
DropdownMenuSeparator: () => <div data-testid="dropdown-separator" />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock Lucide React icons
|
|
||||||
vi.mock("lucide-react", () => ({
|
|
||||||
FolderOpenIcon: ({ className, strokeWidth }: any) => {
|
|
||||||
const isHeader = className?.includes("mr-2");
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
data-testid={isHeader ? "folder-open-header-icon" : "folder-open-icon"}
|
|
||||||
className={className}
|
|
||||||
strokeWidth={strokeWidth}>
|
|
||||||
<title>FolderOpen Icon</title>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
ChevronDownIcon: ({ className, strokeWidth }: any) => (
|
|
||||||
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
|
|
||||||
<title>ChevronDown Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
ChevronRightIcon: ({ className, strokeWidth }: any) => (
|
|
||||||
<svg data-testid="chevron-right-icon" className={className} strokeWidth={strokeWidth}>
|
|
||||||
<title>ChevronRight Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
PlusIcon: ({ className }: any) => (
|
|
||||||
<svg data-testid="plus-icon" className={className}>
|
|
||||||
<title>Plus Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Loader2: ({ className }: any) => (
|
|
||||||
<svg data-testid="loader-2-icon" className={className}>
|
|
||||||
<title>Loader2 Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
CogIcon: ({ className }: any) => (
|
|
||||||
<svg data-testid="cog-icon" className={className}>
|
|
||||||
<title>Cog Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
SettingsIcon: ({ className }: any) => (
|
|
||||||
<svg data-testid="settings-icon" className={className}>
|
|
||||||
<title>Settings Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("ProjectBreadcrumb", () => {
|
|
||||||
const mockPush = vi.fn();
|
|
||||||
const mockRouter = {
|
|
||||||
push: mockPush,
|
|
||||||
replace: vi.fn(),
|
|
||||||
refresh: vi.fn(),
|
|
||||||
back: vi.fn(),
|
|
||||||
forward: vi.fn(),
|
|
||||||
prefetch: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockProject1 = {
|
|
||||||
id: "proj-1",
|
|
||||||
name: "Test Project 1",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
organizationId: "org-1",
|
|
||||||
languages: [],
|
|
||||||
} as unknown as TProject;
|
|
||||||
|
|
||||||
const mockProject2 = {
|
|
||||||
id: "proj-2",
|
|
||||||
name: "Test Project 2",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
organizationId: "org-1",
|
|
||||||
languages: [],
|
|
||||||
} as unknown as TProject;
|
|
||||||
|
|
||||||
const mockProjects = [mockProject1, mockProject2];
|
|
||||||
|
|
||||||
const mockOrganization: TOrganization = {
|
|
||||||
id: "org-1",
|
|
||||||
name: "Test Organization",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
billing: {
|
|
||||||
plan: "free",
|
|
||||||
stripeCustomerId: null,
|
|
||||||
} as unknown as TOrganizationBilling,
|
|
||||||
isAIEnabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
currentProjectId: "proj-1",
|
|
||||||
currentOrganizationId: "org-1",
|
|
||||||
projects: mockProjects,
|
|
||||||
isOwnerOrManager: true,
|
|
||||||
organizationProjectsLimit: 3,
|
|
||||||
isFormbricksCloud: true,
|
|
||||||
isLicenseActive: false,
|
|
||||||
currentEnvironmentId: "env-123",
|
|
||||||
isAccessControlAllowed: true,
|
|
||||||
isEnvironmentBreadcrumbVisible: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Basic Rendering", () => {
|
|
||||||
test("renders project breadcrumb correctly", () => {
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("folder-open-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText("Test Project 1")).toHaveLength(2); // trigger + dropdown option
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows chevron icons correctly", () => {
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
// Should show chevron right when closed
|
|
||||||
expect(screen.getByTestId("chevron-right-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows chevron down when dropdown is open", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("chevron-down-icon")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Project Selection", () => {
|
|
||||||
test("renders dropdown content with project options", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.choose_project")).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByTestId("dropdown-group")).toHaveLength(2); // Projects group and settings group
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders all project options in dropdown", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
|
||||||
|
|
||||||
// Find project options (excluding the add new project option)
|
|
||||||
const projectOptions = checkboxItems.filter((item) => item.textContent?.includes("Test Project"));
|
|
||||||
expect(projectOptions).toHaveLength(2);
|
|
||||||
|
|
||||||
// Check current project is marked as selected
|
|
||||||
const currentProjectOption = checkboxItems.find((item) => item.textContent?.includes("Test Project 1"));
|
|
||||||
expect(currentProjectOption).toHaveAttribute("data-checked", "true");
|
|
||||||
|
|
||||||
// Check other project is not selected
|
|
||||||
const otherProjectOption = checkboxItems.find((item) => item.textContent?.includes("Test Project 2"));
|
|
||||||
expect(otherProjectOption).toHaveAttribute("data-checked", "false");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles project change when clicking dropdown option", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
|
||||||
const project2Option = checkboxItems.find((item) => item.textContent?.includes("Test Project 2"));
|
|
||||||
|
|
||||||
expect(project2Option).toBeInTheDocument();
|
|
||||||
await user.click(project2Option!);
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith("/projects/proj-2/");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Add New Project", () => {
|
|
||||||
test("shows add new project option when user is owner or manager", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.getByText("common.add_new_project")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hides add new project option when user is not owner or manager", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} isOwnerOrManager={false} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.queryByText("common.add_new_project")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens create project modal when within project limit", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("create-project-modal")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Organization: org-1")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Access Control: Allowed")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens limit modal when exceeding project limit", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const props = {
|
|
||||||
...defaultProps,
|
|
||||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
|
||||||
organizationProjectsLimit: 3,
|
|
||||||
};
|
|
||||||
render(<ProjectBreadcrumb {...props} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Project Limit: 3")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Project Limit Modal", () => {
|
|
||||||
test("shows correct buttons for Formbricks Cloud with non-enterprise plan", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const props = {
|
|
||||||
...defaultProps,
|
|
||||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
|
||||||
organizationProjectsLimit: 3,
|
|
||||||
isFormbricksCloud: true,
|
|
||||||
isEnvironmentBreadcrumbVisible: true,
|
|
||||||
currentOrganization: {
|
|
||||||
...mockOrganization,
|
|
||||||
billing: { ...mockOrganization.billing, plan: "startup" } as unknown as TOrganizationBilling,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
render(<ProjectBreadcrumb {...props} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
expect(screen.getByText("environments.settings.billing.upgrade")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.cancel")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows correct buttons for self-hosted with active license", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const props = {
|
|
||||||
...defaultProps,
|
|
||||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
|
||||||
organizationProjectsLimit: 3,
|
|
||||||
isFormbricksCloud: false,
|
|
||||||
isLicenseActive: true,
|
|
||||||
isEnvironmentBreadcrumbVisible: true,
|
|
||||||
};
|
|
||||||
render(<ProjectBreadcrumb {...props} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
expect(screen.getByText("environments.settings.billing.upgrade")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.cancel")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("closes limit modal correctly", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const props = {
|
|
||||||
...defaultProps,
|
|
||||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
|
||||||
organizationProjectsLimit: 3,
|
|
||||||
};
|
|
||||||
render(<ProjectBreadcrumb {...props} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
|
||||||
|
|
||||||
const closeButton = screen.getByText("Close Limit Modal");
|
|
||||||
await user.click(closeButton);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("project-limit-modal")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Create Project Modal", () => {
|
|
||||||
test("closes create project modal correctly", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("create-project-modal")).toBeInTheDocument();
|
|
||||||
|
|
||||||
const closeButton = screen.getByText("Close Create Modal");
|
|
||||||
await user.click(closeButton);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("create-project-modal")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("passes correct props to create project modal", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} isAccessControlAllowed={false} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
expect(screen.getByText("Access Control: Not Allowed")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Edge Cases", () => {
|
|
||||||
test("handles single project scenario", () => {
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} projects={[mockProject1]} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText("Test Project 1")).toHaveLength(2); // trigger + dropdown option
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sets breadcrumb item as active when dropdown is open", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
// Initially not active
|
|
||||||
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
|
|
||||||
|
|
||||||
// Open dropdown
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
// Should be active when dropdown is open
|
|
||||||
breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles project limit of zero", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} organizationProjectsLimit={0} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
// Should show limit modal even with 0 projects when limit is 0
|
|
||||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Project Limit: 0")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles enterprise plan on Formbricks Cloud", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const props = {
|
|
||||||
...defaultProps,
|
|
||||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
|
||||||
organizationProjectsLimit: 3,
|
|
||||||
currentOrganization: {
|
|
||||||
...mockOrganization,
|
|
||||||
billing: { ...mockOrganization.billing, plan: "enterprise" } as unknown as TOrganizationBilling,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
render(<ProjectBreadcrumb {...props} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
// Should show self-hosted style buttons for enterprise plan
|
|
||||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||||
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
||||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
||||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||||
@@ -12,16 +20,11 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import { useProject } from "../context/environment-context";
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
|
|
||||||
interface ProjectBreadcrumbProps {
|
interface ProjectBreadcrumbProps {
|
||||||
currentProjectId: string;
|
currentProjectId: string;
|
||||||
projects: { id: string; name: string }[];
|
currentProjectName?: string; // Optional: pass directly if context not available
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
organizationProjectsLimit: number;
|
organizationProjectsLimit: number;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
@@ -32,9 +35,19 @@ interface ProjectBreadcrumbProps {
|
|||||||
isEnvironmentBreadcrumbVisible: boolean;
|
isEnvironmentBreadcrumbVisible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||||
|
// Match /project/{settingId} or /project/{settingId}/... but exclude settings paths
|
||||||
|
if (pathname.includes("/settings/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check if path matches /project/{settingId} (with optional trailing path)
|
||||||
|
const pattern = new RegExp(`/project/${settingId}(?:/|$)`);
|
||||||
|
return pattern.test(pathname);
|
||||||
|
};
|
||||||
|
|
||||||
export const ProjectBreadcrumb = ({
|
export const ProjectBreadcrumb = ({
|
||||||
currentProjectId,
|
currentProjectId,
|
||||||
projects,
|
currentProjectName,
|
||||||
isOwnerOrManager,
|
isOwnerOrManager,
|
||||||
organizationProjectsLimit,
|
organizationProjectsLimit,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
@@ -44,14 +57,46 @@ export const ProjectBreadcrumb = ({
|
|||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
isEnvironmentBreadcrumbVisible,
|
isEnvironmentBreadcrumbVisible,
|
||||||
}: ProjectBreadcrumbProps) => {
|
}: ProjectBreadcrumbProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslation();
|
||||||
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
||||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
||||||
const [openLimitModal, setOpenLimitModal] = useState(false);
|
const [openLimitModal, setOpenLimitModal] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||||
|
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Get current project name from context OR prop
|
||||||
|
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||||
|
const { project: currentProject } = useProject();
|
||||||
|
const projectName = currentProject?.name || currentProjectName || "";
|
||||||
|
|
||||||
|
// Lazy-load projects when dropdown opens
|
||||||
|
useEffect(() => {
|
||||||
|
// Only fetch when dropdown opened for first time (and no error state)
|
||||||
|
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
|
||||||
|
setIsLoadingProjects(true);
|
||||||
|
setLoadError(null); // Clear any previous errors
|
||||||
|
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||||
|
if (result?.data) {
|
||||||
|
// Sort projects by name
|
||||||
|
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||||
|
setProjects(sorted);
|
||||||
|
} else {
|
||||||
|
// Handle server errors or validation errors
|
||||||
|
const errorMessage = getFormattedErrorMessage(result);
|
||||||
|
const error = new Error(errorMessage);
|
||||||
|
logger.error(error, "Failed to load projects");
|
||||||
|
Sentry.captureException(error);
|
||||||
|
setLoadError(errorMessage || t("common.failed_to_load_projects"));
|
||||||
|
}
|
||||||
|
setIsLoadingProjects(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
|
||||||
|
|
||||||
const projectSettings = [
|
const projectSettings = [
|
||||||
{
|
{
|
||||||
id: "general",
|
id: "general",
|
||||||
@@ -90,8 +135,6 @@ export const ProjectBreadcrumb = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const currentProject = projects.find((project) => project.id === currentProjectId);
|
|
||||||
|
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
const errorMessage = `Project not found for project id: ${currentProjectId}`;
|
const errorMessage = `Project not found for project id: ${currentProjectId}`;
|
||||||
logger.error(errorMessage);
|
logger.error(errorMessage);
|
||||||
@@ -101,8 +144,9 @@ export const ProjectBreadcrumb = ({
|
|||||||
|
|
||||||
const handleProjectChange = (projectId: string) => {
|
const handleProjectChange = (projectId: string) => {
|
||||||
if (projectId === currentProjectId) return;
|
if (projectId === currentProjectId) return;
|
||||||
setIsLoading(true);
|
startTransition(() => {
|
||||||
router.push(`/projects/${projectId}/`);
|
router.push(`/projects/${projectId}/`);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddProject = () => {
|
const handleAddProject = () => {
|
||||||
@@ -113,6 +157,12 @@ export const ProjectBreadcrumb = ({
|
|||||||
setOpenCreateProjectModal(true);
|
setOpenCreateProjectModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleProjectSettingsNavigation = (settingId: string) => {
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(`/environments/${currentEnvironmentId}/project/${settingId}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const LimitModalButtons = (): [ModalButton, ModalButton] => {
|
const LimitModalButtons = (): [ModalButton, ModalButton] => {
|
||||||
if (isFormbricksCloud) {
|
if (isFormbricksCloud) {
|
||||||
return [
|
return [
|
||||||
@@ -149,8 +199,8 @@ export const ProjectBreadcrumb = ({
|
|||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
|
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{currentProject.name}</span>
|
<span>{projectName}</span>
|
||||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isProjectDropdownOpen ? (
|
{isProjectDropdownOpen ? (
|
||||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
) : (
|
) : (
|
||||||
@@ -164,26 +214,48 @@ export const ProjectBreadcrumb = ({
|
|||||||
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
{t("common.choose_project")}
|
{t("common.choose_project")}
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuGroup>
|
{isLoadingProjects && (
|
||||||
{projects.map((proj) => (
|
<div className="flex items-center justify-center py-2">
|
||||||
<DropdownMenuCheckboxItem
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
key={proj.id}
|
</div>
|
||||||
checked={proj.id === currentProject.id}
|
)}
|
||||||
onClick={() => handleProjectChange(proj.id)}
|
{!isLoadingProjects && loadError && (
|
||||||
className="cursor-pointer">
|
<div className="px-2 py-4">
|
||||||
<div className="flex items-center gap-2">
|
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
||||||
<span>{proj.name}</span>
|
<button
|
||||||
</div>
|
onClick={() => {
|
||||||
</DropdownMenuCheckboxItem>
|
setLoadError(null);
|
||||||
))}
|
setProjects([]);
|
||||||
</DropdownMenuGroup>
|
}}
|
||||||
{isOwnerOrManager && (
|
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||||
<DropdownMenuCheckboxItem
|
{t("common.try_again")}
|
||||||
onClick={handleAddProject}
|
</button>
|
||||||
className="w-full cursor-pointer justify-between">
|
</div>
|
||||||
<span>{t("common.add_new_project")}</span>
|
)}
|
||||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
{!isLoadingProjects && !loadError && (
|
||||||
</DropdownMenuCheckboxItem>
|
<>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
{projects.map((proj) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={proj.id}
|
||||||
|
checked={proj.id === currentProjectId}
|
||||||
|
onClick={() => handleProjectChange(proj.id)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{proj.name}</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
{isOwnerOrManager && (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
onClick={handleAddProject}
|
||||||
|
className="w-full cursor-pointer justify-between">
|
||||||
|
<span>{t("common.add_new_project")}</span>
|
||||||
|
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -194,8 +266,8 @@ export const ProjectBreadcrumb = ({
|
|||||||
{projectSettings.map((setting) => (
|
{projectSettings.map((setting) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={setting.id}
|
key={setting.id}
|
||||||
checked={pathname.includes(setting.id)}
|
checked={isActiveProjectSetting(pathname, setting.id)}
|
||||||
onClick={() => router.push(setting.href)}
|
onClick={() => handleProjectSettingsNavigation(setting.id)}
|
||||||
className="cursor-pointer">
|
className="cursor-pointer">
|
||||||
{setting.label}
|
{setting.label}
|
||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
import { EnvironmentContextWrapper, useEnvironment } from "./environment-context";
|
|
||||||
|
|
||||||
// Mock environment data
|
|
||||||
const mockEnvironment: TEnvironment = {
|
|
||||||
id: "test-env-id",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
type: "development",
|
|
||||||
projectId: "test-project-id",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock project data
|
|
||||||
const mockProject = {
|
|
||||||
id: "test-project-id",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
organizationId: "test-org-id",
|
|
||||||
config: {
|
|
||||||
channel: "app",
|
|
||||||
industry: "saas",
|
|
||||||
},
|
|
||||||
linkSurveyBranding: true,
|
|
||||||
styling: {
|
|
||||||
allowStyleOverwrite: true,
|
|
||||||
brandColor: {
|
|
||||||
light: "#ffffff",
|
|
||||||
dark: "#000000",
|
|
||||||
},
|
|
||||||
questionColor: {
|
|
||||||
light: "#000000",
|
|
||||||
dark: "#ffffff",
|
|
||||||
},
|
|
||||||
inputColor: {
|
|
||||||
light: "#000000",
|
|
||||||
dark: "#ffffff",
|
|
||||||
},
|
|
||||||
inputBorderColor: {
|
|
||||||
light: "#cccccc",
|
|
||||||
dark: "#444444",
|
|
||||||
},
|
|
||||||
cardBackgroundColor: {
|
|
||||||
light: "#ffffff",
|
|
||||||
dark: "#000000",
|
|
||||||
},
|
|
||||||
cardBorderColor: {
|
|
||||||
light: "#cccccc",
|
|
||||||
dark: "#444444",
|
|
||||||
},
|
|
||||||
isDarkModeEnabled: false,
|
|
||||||
isLogoHidden: false,
|
|
||||||
hideProgressBar: false,
|
|
||||||
roundness: 8,
|
|
||||||
cardArrangement: {
|
|
||||||
linkSurveys: "casual",
|
|
||||||
appSurveys: "casual",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
recontactDays: 30,
|
|
||||||
inAppSurveyBranding: true,
|
|
||||||
logo: {
|
|
||||||
url: "test-logo.png",
|
|
||||||
bgColor: "#ffffff",
|
|
||||||
},
|
|
||||||
placement: "bottomRight",
|
|
||||||
clickOutsideClose: true,
|
|
||||||
} as TProject;
|
|
||||||
|
|
||||||
// Test component that uses the hook
|
|
||||||
const TestComponent = () => {
|
|
||||||
const { environment, project } = useEnvironment();
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div data-testid="environment-id">{environment.id}</div>
|
|
||||||
<div data-testid="environment-type">{environment.type}</div>
|
|
||||||
<div data-testid="project-id">{project.id}</div>
|
|
||||||
<div data-testid="project-organization-id">{project.organizationId}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("EnvironmentContext", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("provides environment and project data to child components", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
|
||||||
<TestComponent />
|
|
||||||
</EnvironmentContextWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
|
|
||||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
|
|
||||||
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
|
|
||||||
expect(screen.getByTestId("project-organization-id")).toHaveTextContent("test-org-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws error when useEnvironment is used outside of provider", () => {
|
|
||||||
const TestComponentWithoutProvider = () => {
|
|
||||||
useEnvironment();
|
|
||||||
return <div>Should not render</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
render(<TestComponentWithoutProvider />);
|
|
||||||
}).toThrow("useEnvironment must be used within an EnvironmentProvider");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("updates context value when environment or project changes", () => {
|
|
||||||
const { rerender } = render(
|
|
||||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
|
||||||
<TestComponent />
|
|
||||||
</EnvironmentContextWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
|
|
||||||
|
|
||||||
const updatedEnvironment = {
|
|
||||||
...mockEnvironment,
|
|
||||||
type: "production" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
rerender(
|
|
||||||
<EnvironmentContextWrapper environment={updatedEnvironment} project={mockProject}>
|
|
||||||
<TestComponent />
|
|
||||||
</EnvironmentContextWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("production");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("memoizes context value correctly", () => {
|
|
||||||
const { rerender } = render(
|
|
||||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
|
||||||
<TestComponent />
|
|
||||||
</EnvironmentContextWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-render with same props
|
|
||||||
rerender(
|
|
||||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
|
||||||
<TestComponent />
|
|
||||||
</EnvironmentContextWrapper>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should still work correctly
|
|
||||||
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
|
|
||||||
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
import { createContext, useContext, useMemo } from "react";
|
import { createContext, useContext, useMemo } from "react";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TProject } from "@formbricks/types/project";
|
import { TProject } from "@formbricks/types/project";
|
||||||
|
|
||||||
export interface EnvironmentContextType {
|
export interface EnvironmentContextType {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
project: TProject;
|
project: TProject;
|
||||||
|
organization: TOrganization;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,25 +22,44 @@ export const useEnvironment = () => {
|
|||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useProject = () => {
|
||||||
|
const context = useContext(EnvironmentContext);
|
||||||
|
if (!context) {
|
||||||
|
return { project: null };
|
||||||
|
}
|
||||||
|
return { project: context.project };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOrganization = () => {
|
||||||
|
const context = useContext(EnvironmentContext);
|
||||||
|
if (!context) {
|
||||||
|
return { organization: null };
|
||||||
|
}
|
||||||
|
return { organization: context.organization };
|
||||||
|
};
|
||||||
|
|
||||||
// Client wrapper component to be used in server components
|
// Client wrapper component to be used in server components
|
||||||
interface EnvironmentContextWrapperProps {
|
interface EnvironmentContextWrapperProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
project: TProject;
|
project: TProject;
|
||||||
|
organization: TOrganization;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EnvironmentContextWrapper = ({
|
export const EnvironmentContextWrapper = ({
|
||||||
environment,
|
environment,
|
||||||
project,
|
project,
|
||||||
|
organization,
|
||||||
children,
|
children,
|
||||||
}: EnvironmentContextWrapperProps) => {
|
}: EnvironmentContextWrapperProps) => {
|
||||||
const environmentContextValue = useMemo(
|
const environmentContextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
environment,
|
environment,
|
||||||
project,
|
project,
|
||||||
|
organization,
|
||||||
organizationId: project.organizationId,
|
organizationId: project.organizationId,
|
||||||
}),
|
}),
|
||||||
[environment, project]
|
[environment, project, organization]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user