mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-29 09:50:10 -06:00
Compare commits
2 Commits
devin/1735
...
switch-sta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8459faed55 | ||
|
|
c4a32bce4f |
@@ -1,216 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Component Migration Automation Rule
|
||||
|
||||
## Overview
|
||||
This rule automates the migration of deprecated components to new component systems in React/TypeScript codebases.
|
||||
|
||||
## Trigger
|
||||
When the user requests component migration (e.g., "migrate [DeprecatedComponent] to [NewComponent]" or "component migration").
|
||||
|
||||
## Process
|
||||
|
||||
### Step 1: Discovery and Planning
|
||||
1. **Identify migration parameters:**
|
||||
- Ask user for deprecated component name (e.g., "Modal")
|
||||
- Ask user for new component name(s) (e.g., "Dialog")
|
||||
- Ask for any components to exclude (e.g., "ModalWithTabs")
|
||||
- Ask for specific import paths if needed
|
||||
|
||||
2. **Scan codebase** for deprecated components:
|
||||
- Search for `import.*[DeprecatedComponent]` patterns
|
||||
- Exclude specified components that should not be migrated
|
||||
- List all found components with file paths
|
||||
- Present numbered list to user for confirmation
|
||||
|
||||
### Step 2: Component-by-Component Migration
|
||||
For each component, follow this exact sequence:
|
||||
|
||||
#### 2.1 Component Migration
|
||||
- **Import changes:**
|
||||
- Ask user to provide the new import structure
|
||||
- Example transformation pattern:
|
||||
```typescript
|
||||
// FROM:
|
||||
import { [DeprecatedComponent] } from "@/components/ui/[DeprecatedComponent]"
|
||||
|
||||
// TO:
|
||||
import {
|
||||
[NewComponent],
|
||||
[NewComponentPart1],
|
||||
[NewComponentPart2],
|
||||
// ... other parts
|
||||
} from "@/components/ui/[NewComponent]"
|
||||
```
|
||||
|
||||
- **Props transformation:**
|
||||
- Ask user for prop mapping rules (e.g., `open` → `open`, `setOpen` → `onOpenChange`)
|
||||
- Ask for props to remove (e.g., `noPadding`, `closeOnOutsideClick`, `size`)
|
||||
- Apply transformations based on user specifications
|
||||
|
||||
- **Structure transformation:**
|
||||
- Ask user for the new component structure pattern
|
||||
- Apply the transformation maintaining all functionality
|
||||
- Preserve all existing logic, state management, and event handlers
|
||||
|
||||
#### 2.2 Wait for User Approval
|
||||
- Present the migration changes
|
||||
- Wait for explicit user approval before proceeding
|
||||
- If rejected, ask for specific feedback and iterate
|
||||
#### 2.3 Re-read and Apply Additional Changes
|
||||
- Re-read the component file to capture any user modifications
|
||||
- Apply any additional improvements the user made
|
||||
- Ensure all changes are incorporated
|
||||
|
||||
#### 2.4 Test File Updates
|
||||
- **Find corresponding test file** (same name with `.test.tsx` or `.test.ts`)
|
||||
- **Update test mocks:**
|
||||
- Ask user for new component mock structure
|
||||
- Replace old component mocks with new ones
|
||||
- Example pattern:
|
||||
```typescript
|
||||
// Add to test setup:
|
||||
jest.mock("@/components/ui/[NewComponent]", () => ({
|
||||
[NewComponent]: ({ children, [props] }: any) => ([mock implementation]),
|
||||
[NewComponentPart1]: ({ children }: any) => <div data-testid="[new-component-part1]">{children}</div>,
|
||||
[NewComponentPart2]: ({ children }: any) => <div data-testid="[new-component-part2]">{children}</div>,
|
||||
// ... other parts
|
||||
}));
|
||||
```
|
||||
- **Update test expectations:**
|
||||
- Change test IDs from old component to new component
|
||||
- Update any component-specific assertions
|
||||
- Ensure all new component parts used in the component are mocked
|
||||
|
||||
#### 2.5 Run Tests and Optimize
|
||||
- Execute `Node package manager test -- ComponentName.test.tsx`
|
||||
- Fix any failing tests
|
||||
- Optimize code quality (imports, formatting, etc.)
|
||||
- Re-run tests until all pass
|
||||
- **Maximum 3 iterations** - if still failing, ask user for guidance
|
||||
|
||||
#### 2.6 Wait for Final Approval
|
||||
- Present test results and any optimizations made
|
||||
- Wait for user approval of the complete migration
|
||||
- If rejected, iterate based on feedback
|
||||
|
||||
#### 2.7 Git Commit
|
||||
- Run: `git add .`
|
||||
- Run: `git commit -m "migrate [ComponentName] from [DeprecatedComponent] to [NewComponent]"`
|
||||
- Confirm commit was successful
|
||||
|
||||
### Step 3: Final Report Generation
|
||||
After all components are migrated, generate a comprehensive GitHub PR report:
|
||||
|
||||
#### PR Title
|
||||
```
|
||||
feat: migrate [DeprecatedComponent] components to [NewComponent] system
|
||||
```
|
||||
|
||||
#### PR Description Template
|
||||
```markdown
|
||||
## 🔄 [DeprecatedComponent] to [NewComponent] Migration
|
||||
|
||||
### Overview
|
||||
Migrated [X] [DeprecatedComponent] components to the new [NewComponent] component system to modernize the UI architecture and improve consistency.
|
||||
|
||||
### Components Migrated
|
||||
[List each component with file path]
|
||||
|
||||
### Technical Changes
|
||||
- **Imports:** Replaced `[DeprecatedComponent]` with `[NewComponent], [NewComponentParts...]`
|
||||
- **Props:** [List prop transformations]
|
||||
- **Structure:** Implemented proper [NewComponent] component hierarchy
|
||||
- **Styling:** [Describe styling changes]
|
||||
- **Tests:** Updated all test mocks and expectations
|
||||
|
||||
### Migration Pattern
|
||||
```typescript
|
||||
// Before
|
||||
<[DeprecatedComponent] [oldProps]>
|
||||
[oldStructure]
|
||||
</[DeprecatedComponent]>
|
||||
|
||||
// After
|
||||
<[NewComponent] [newProps]>
|
||||
[newStructure]
|
||||
</[NewComponent]>
|
||||
```
|
||||
|
||||
### Testing
|
||||
- ✅ All existing tests updated and passing
|
||||
- ✅ Component functionality preserved
|
||||
- ✅ UI/UX behavior maintained
|
||||
|
||||
### How to Test This PR
|
||||
1. **Functional Testing:**
|
||||
- Navigate to each migrated component's usage
|
||||
- Verify [component] opens and closes correctly
|
||||
- Test all interactive elements within [components]
|
||||
- Confirm styling and layout are preserved
|
||||
|
||||
2. **Automated Testing:**
|
||||
```bash
|
||||
Node package manager test
|
||||
```
|
||||
|
||||
3. **Visual Testing:**
|
||||
- Check that all [components] maintain proper styling
|
||||
- Verify responsive behavior
|
||||
- Test keyboard navigation and accessibility
|
||||
|
||||
### Breaking Changes
|
||||
[List any breaking changes or state "None - this is a drop-in replacement maintaining all existing functionality."]
|
||||
|
||||
### Notes
|
||||
- [Any excluded components] were preserved as they already use [NewComponent] internally
|
||||
- All form validation and complex state management preserved
|
||||
- Enhanced code quality with better imports and formatting
|
||||
```
|
||||
|
||||
## Special Considerations
|
||||
|
||||
### Excluded Components
|
||||
- **DO NOT MIGRATE** components specified by user as exclusions
|
||||
- They may already use the new component internally or have other reasons
|
||||
- Inform user these are skipped and why
|
||||
|
||||
### Complex Components
|
||||
- Preserve all existing functionality (forms, validation, state management)
|
||||
- Maintain prop interfaces
|
||||
- Keep all event handlers and callbacks
|
||||
- Preserve accessibility features
|
||||
|
||||
### Test Coverage
|
||||
- Ensure all new component parts are mocked when used
|
||||
- Mock all new component parts that appear in the component
|
||||
- Update test IDs from old component to new component
|
||||
- Maintain all existing test scenarios
|
||||
|
||||
### Error Handling
|
||||
- If tests fail after 3 iterations, stop and ask user for guidance
|
||||
- If component is too complex, ask user for specific guidance
|
||||
- If unsure about functionality preservation, ask for clarification
|
||||
|
||||
### Migration Patterns
|
||||
- Always ask user for specific migration patterns before starting
|
||||
- Confirm import structures, prop mappings, and component hierarchies
|
||||
- Adapt to different component architectures (simple replacements, complex restructuring, etc.)
|
||||
|
||||
## Success Criteria
|
||||
- All deprecated components successfully migrated to new components
|
||||
- All tests passing
|
||||
- No functionality lost
|
||||
- Code quality maintained or improved
|
||||
- User approval on each component
|
||||
- Successful git commits for each migration
|
||||
- Comprehensive PR report generated
|
||||
|
||||
## Usage Examples
|
||||
- "migrate Modal to Dialog"
|
||||
- "migrate Button to NewButton"
|
||||
- "migrate Card to ModernCard"
|
||||
- "component migration" (will prompt for details)
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
||||
|
||||
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
||||
|
||||
---
|
||||
title: "FEATURE NAME"
|
||||
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
||||
icon: "link"
|
||||
---
|
||||
|
||||
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
|
||||
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case
|
||||
- If a feature is part of the Enterprise Edition, use this note:
|
||||
|
||||
<Note>
|
||||
FEATURE NAME is part of the @Enterprise Edition.
|
||||
</Note>
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# Load environment variables from .env files
|
||||
if [ -f "$(git rev-parse --show-toplevel)/.env" ]; then
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
. "$(git rev-parse --show-toplevel)/.env"
|
||||
. .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
@@ -17,4 +18,4 @@ if [ -f branch.json ]; then
|
||||
pnpm run tolgee-pull
|
||||
git add apps/web/locales
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -125,7 +125,7 @@ export const OnboardingSetupInstructions = ({
|
||||
</div>
|
||||
) : activeTab === "html" ? (
|
||||
<div className="prose prose-slate">
|
||||
<p className="-mb-1 mt-6 text-sm text-slate-700">
|
||||
<p className="mt-6 -mb-1 text-sm text-slate-700">
|
||||
{t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")}
|
||||
</p>
|
||||
<div>
|
||||
|
||||
@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
channel={channel}
|
||||
/>
|
||||
<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"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}`}>
|
||||
|
||||
@@ -266,7 +266,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
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 ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
|
||||
@@ -106,7 +106,8 @@ export const ShareEmbedSurvey = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
|
||||
<DialogTitle className="sr-only" />
|
||||
<DialogContent className="w-full max-w-xl bg-white p-0 md:max-w-3xl lg:h-[700px] lg:max-w-5xl">
|
||||
{showView === "start" ? (
|
||||
<div className="h-full max-w-full overflow-hidden">
|
||||
<div className="flex h-[200px] w-full flex-col items-center justify-center space-y-6 p-8 text-center lg:h-2/5">
|
||||
@@ -155,7 +156,7 @@ export const ShareEmbedSurvey = ({
|
||||
<Badge
|
||||
size="tiny"
|
||||
type="success"
|
||||
className="absolute right-3 top-3"
|
||||
className="absolute top-3 right-3"
|
||||
text={t("common.new")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="px-4 text-right md:px-6">{t("environments.surveys.summary.impressions")}</div>
|
||||
<div className="px-4 text-right md:mr-1 md:pl-6 md:pr-6">
|
||||
<div className="px-4 text-right md:mr-1 md:pr-6 md:pl-6">
|
||||
{t("environments.surveys.summary.drop_offs")}
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,10 +63,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap px-4 py-2 text-right font-mono font-medium md:px-6">
|
||||
<div className="px-4 py-2 text-right font-mono font-medium whitespace-pre-wrap md:px-6">
|
||||
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap px-4 py-2 text-right font-mono font-medium md:px-6">
|
||||
<div className="px-4 py-2 text-right font-mono font-medium whitespace-pre-wrap md:px-6">
|
||||
{quesDropOff.impressions}
|
||||
</div>
|
||||
<div className="px-4 py-2 text-right md:px-6">
|
||||
|
||||
@@ -87,7 +87,7 @@ export const ResultsShareButton = ({ survey, publicDomain }: ResultsShareButtonP
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
className="focus:bg-muted cursor-pointer border border-slate-200 outline-none hover:border-slate-300">
|
||||
<div className="min-w-auto h-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[7rem] sm:px-6 sm:py-3">
|
||||
<div className="h-auto min-w-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[7rem] sm:px-6 sm:py-3">
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">
|
||||
{t("environments.surveys.summary.share_results")}
|
||||
|
||||
@@ -63,7 +63,7 @@ export const SurveyStatusDropdown = ({
|
||||
<>
|
||||
{survey.status === "draft" ? (
|
||||
<div className="flex items-center">
|
||||
<p className="text-sm italic text-slate-600">{t("common.draft")}</p>
|
||||
<p className="text-sm text-slate-600 italic">{t("common.draft")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("Survey Builder", () => {
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
shuffleOption: "none",
|
||||
required: false,
|
||||
required: true,
|
||||
});
|
||||
expect(question.choices.length).toBe(3);
|
||||
expect(question.id).toBeDefined();
|
||||
@@ -141,7 +141,7 @@ describe("Survey Builder", () => {
|
||||
inputType: "text",
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: false,
|
||||
required: true,
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
@@ -204,7 +204,7 @@ describe("Survey Builder", () => {
|
||||
range: 5,
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: false,
|
||||
required: true,
|
||||
isColorCodingEnabled: false,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
@@ -265,7 +265,7 @@ describe("Survey Builder", () => {
|
||||
headline: { default: "NPS Question" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: false,
|
||||
required: true,
|
||||
isColorCodingEnabled: false,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
@@ -324,7 +324,7 @@ describe("Survey Builder", () => {
|
||||
label: { default: "I agree to terms" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: false,
|
||||
required: true,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
});
|
||||
@@ -377,7 +377,7 @@ describe("Survey Builder", () => {
|
||||
headline: { default: "CTA Question" },
|
||||
buttonLabel: { default: "common.next" },
|
||||
backButtonLabel: { default: "common.back" },
|
||||
required: false,
|
||||
required: true,
|
||||
buttonExternal: false,
|
||||
});
|
||||
expect(question.id).toBeDefined();
|
||||
|
||||
@@ -66,7 +66,7 @@ export const buildMultipleChoiceQuestion = ({
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
shuffleOption: shuffleOption || "none",
|
||||
required: required ?? false,
|
||||
required: required ?? true,
|
||||
logic,
|
||||
};
|
||||
};
|
||||
@@ -105,7 +105,7 @@ export const buildOpenTextQuestion = ({
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
required: required ?? false,
|
||||
required: required ?? true,
|
||||
longAnswer,
|
||||
logic,
|
||||
charLimit: {
|
||||
@@ -153,7 +153,7 @@ export const buildRatingQuestion = ({
|
||||
range,
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
required: required ?? false,
|
||||
required: required ?? true,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
|
||||
@@ -194,7 +194,7 @@ export const buildNPSQuestion = ({
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
required: required ?? false,
|
||||
required: required ?? true,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
|
||||
@@ -230,7 +230,7 @@ export const buildConsentQuestion = ({
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
required: required ?? false,
|
||||
required: required ?? true,
|
||||
label: createI18nString(label, []),
|
||||
logic,
|
||||
};
|
||||
@@ -269,7 +269,7 @@ export const buildCTAQuestion = ({
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
|
||||
required: required ?? false,
|
||||
required: required ?? true,
|
||||
buttonExternal,
|
||||
buttonUrl,
|
||||
logic,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { TUser } from "@formbricks/types/user";
|
||||
import Page from "./page";
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getUserProjectEnvironmentsByOrganizationIds: vi.fn(),
|
||||
getProjectEnvironmentsByOrganizationIds: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/instance/service", () => ({
|
||||
@@ -152,7 +152,7 @@ describe("Page", () => {
|
||||
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
||||
const { getUser } = await import("@/lib/user/service");
|
||||
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
||||
const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
|
||||
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
|
||||
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
|
||||
const { getAccessFlags } = await import("@/lib/membership/utils");
|
||||
const { redirect } = await import("next/navigation");
|
||||
@@ -220,7 +220,7 @@ describe("Page", () => {
|
||||
} as any);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(
|
||||
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(
|
||||
mockUserProjects as unknown as TProject[]
|
||||
);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||
@@ -241,7 +241,7 @@ describe("Page", () => {
|
||||
const { getServerSession } = await import("next-auth");
|
||||
const { getIsFreshInstance } = await import("@/lib/instance/service");
|
||||
const { getUser } = await import("@/lib/user/service");
|
||||
const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
|
||||
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
|
||||
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
||||
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
|
||||
const { getAccessFlags } = await import("@/lib/membership/utils");
|
||||
@@ -310,7 +310,7 @@ describe("Page", () => {
|
||||
} as any);
|
||||
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(
|
||||
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(
|
||||
mockUserProjects as unknown as TProject[]
|
||||
);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||
@@ -334,7 +334,7 @@ describe("Page", () => {
|
||||
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
|
||||
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
|
||||
const { getAccessFlags } = await import("@/lib/membership/utils");
|
||||
const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
|
||||
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
|
||||
const { render } = await import("@testing-library/react");
|
||||
|
||||
const mockUser: TUser = {
|
||||
@@ -432,7 +432,7 @@ describe("Page", () => {
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects);
|
||||
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects);
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isManager: false,
|
||||
isOwner: false,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getIsFreshInstance } from "@/lib/instance/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUserProjectEnvironmentsByOrganizationIds } from "@/lib/project/service";
|
||||
import { getProjectEnvironmentsByOrganizationIds } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||
@@ -34,10 +34,7 @@ const Page = async () => {
|
||||
return redirect("/setup/organization/create");
|
||||
}
|
||||
|
||||
const projectsByOrg = await getUserProjectEnvironmentsByOrganizationIds(
|
||||
userOrganizations.map((org) => org.id),
|
||||
user.id
|
||||
);
|
||||
const projectsByOrg = await getProjectEnvironmentsByOrganizationIds(userOrganizations.map((org) => org.id));
|
||||
|
||||
// Flatten all environments from all projects across all organizations
|
||||
const allEnvironments = projectsByOrg.flatMap((project) => project.environments);
|
||||
|
||||
@@ -7,8 +7,8 @@ import { ITEMS_PER_PAGE } from "../constants";
|
||||
import {
|
||||
getProject,
|
||||
getProjectByEnvironmentId,
|
||||
getProjectEnvironmentsByOrganizationIds,
|
||||
getProjects,
|
||||
getUserProjectEnvironmentsByOrganizationIds,
|
||||
getUserProjects,
|
||||
} from "./service";
|
||||
|
||||
@@ -21,7 +21,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
membership: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -489,7 +488,6 @@ describe("Project Service", () => {
|
||||
test("getProjectsByOrganizationIds should return projects for given organization IDs", async () => {
|
||||
const organizationId1 = createId();
|
||||
const organizationId2 = createId();
|
||||
const userId = createId();
|
||||
const mockProjects = [
|
||||
{
|
||||
environments: [],
|
||||
@@ -499,34 +497,16 @@ describe("Project Service", () => {
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.membership.findMany).mockResolvedValue([
|
||||
{
|
||||
userId,
|
||||
organizationId: organizationId1,
|
||||
role: "owner" as any,
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
},
|
||||
{
|
||||
userId,
|
||||
organizationId: organizationId2,
|
||||
role: "owner" as any,
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
||||
|
||||
const result = await getUserProjectEnvironmentsByOrganizationIds(
|
||||
[organizationId1, organizationId2],
|
||||
userId
|
||||
);
|
||||
const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]);
|
||||
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [{ organizationId: organizationId1 }, { organizationId: organizationId2 }],
|
||||
organizationId: {
|
||||
in: [organizationId1, organizationId2],
|
||||
},
|
||||
},
|
||||
select: { environments: true },
|
||||
});
|
||||
@@ -535,36 +515,17 @@ describe("Project Service", () => {
|
||||
test("getProjectsByOrganizationIds should return empty array when no projects are found", async () => {
|
||||
const organizationId1 = createId();
|
||||
const organizationId2 = createId();
|
||||
const userId = createId();
|
||||
|
||||
vi.mocked(prisma.membership.findMany).mockResolvedValue([
|
||||
{
|
||||
userId,
|
||||
organizationId: organizationId1,
|
||||
role: "owner" as any,
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
},
|
||||
{
|
||||
userId,
|
||||
organizationId: organizationId2,
|
||||
role: "owner" as any,
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getUserProjectEnvironmentsByOrganizationIds(
|
||||
[organizationId1, organizationId2],
|
||||
userId
|
||||
);
|
||||
const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [{ organizationId: organizationId1 }, { organizationId: organizationId2 }],
|
||||
organizationId: {
|
||||
in: [organizationId1, organizationId2],
|
||||
},
|
||||
},
|
||||
select: { environments: true },
|
||||
});
|
||||
@@ -573,111 +534,18 @@ describe("Project Service", () => {
|
||||
test("getProjectsByOrganizationIds should throw DatabaseError when prisma throws", async () => {
|
||||
const organizationId1 = createId();
|
||||
const organizationId2 = createId();
|
||||
const userId = createId();
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.membership.findMany).mockResolvedValue([
|
||||
{
|
||||
userId,
|
||||
organizationId: organizationId1,
|
||||
role: "owner" as any,
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(
|
||||
getUserProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2], userId)
|
||||
).rejects.toThrow(DatabaseError);
|
||||
await expect(getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2])).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
|
||||
test("getProjectsByOrganizationIds should throw ValidationError with wrong input", async () => {
|
||||
const userId = createId();
|
||||
await expect(getUserProjectEnvironmentsByOrganizationIds(["wrong-id"], userId)).rejects.toThrow(
|
||||
ValidationError
|
||||
);
|
||||
});
|
||||
|
||||
test("getProjectsByOrganizationIds should return empty array when user has no memberships", async () => {
|
||||
const organizationId1 = createId();
|
||||
const organizationId2 = createId();
|
||||
const userId = createId();
|
||||
|
||||
// Mock no memberships found
|
||||
vi.mocked(prisma.membership.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getUserProjectEnvironmentsByOrganizationIds(
|
||||
[organizationId1, organizationId2],
|
||||
userId
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(prisma.membership.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId,
|
||||
organizationId: {
|
||||
in: [organizationId1, organizationId2],
|
||||
},
|
||||
},
|
||||
});
|
||||
// Should not call project.findMany when no memberships
|
||||
expect(prisma.project.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("getProjectsByOrganizationIds should handle member role with team access", async () => {
|
||||
const organizationId1 = createId();
|
||||
const organizationId2 = createId();
|
||||
const userId = createId();
|
||||
const mockProjects = [
|
||||
{
|
||||
environments: [],
|
||||
},
|
||||
];
|
||||
|
||||
// Mock membership where user is a member
|
||||
vi.mocked(prisma.membership.findMany).mockResolvedValue([
|
||||
{
|
||||
userId,
|
||||
organizationId: organizationId1,
|
||||
role: "member" as any,
|
||||
accepted: true,
|
||||
deprecatedRole: null,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
||||
|
||||
const result = await getUserProjectEnvironmentsByOrganizationIds(
|
||||
[organizationId1, organizationId2],
|
||||
userId
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
organizationId: organizationId1,
|
||||
projectTeams: {
|
||||
some: {
|
||||
team: {
|
||||
teamUsers: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { environments: true },
|
||||
});
|
||||
await expect(getProjectEnvironmentsByOrganizationIds(["wrong-id"])).rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,56 +171,20 @@ export const getOrganizationProjectsCount = reactCache(async (organizationId: st
|
||||
}
|
||||
});
|
||||
|
||||
export const getUserProjectEnvironmentsByOrganizationIds = reactCache(
|
||||
async (organizationIds: string[], userId: string): Promise<Pick<TProject, "environments">[]> => {
|
||||
validateInputs([organizationIds, ZId.array()], [userId, ZId]);
|
||||
export const getProjectEnvironmentsByOrganizationIds = reactCache(
|
||||
async (organizationIds: string[]): Promise<Pick<TProject, "environments">[]> => {
|
||||
validateInputs([organizationIds, ZId.array()]);
|
||||
try {
|
||||
if (organizationIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const memberships = await prisma.membership.findMany({
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
userId,
|
||||
organizationId: {
|
||||
in: organizationIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (memberships.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const whereConditions: Prisma.ProjectWhereInput[] = memberships.map((membership) => {
|
||||
let projectWhereClause: Prisma.ProjectWhereInput = {
|
||||
organizationId: membership.organizationId,
|
||||
};
|
||||
|
||||
if (membership.role === "member") {
|
||||
projectWhereClause = {
|
||||
...projectWhereClause,
|
||||
projectTeams: {
|
||||
some: {
|
||||
team: {
|
||||
teamUsers: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return projectWhereClause;
|
||||
});
|
||||
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
OR: whereConditions,
|
||||
},
|
||||
select: { environments: true },
|
||||
});
|
||||
|
||||
|
||||
@@ -309,6 +309,7 @@
|
||||
"project_not_found": "Projekt nicht gefunden",
|
||||
"project_permission_not_found": "Projekt-Berechtigung nicht gefunden",
|
||||
"projects": "Projekte",
|
||||
"projects_limit_reached": "Projektlimit erreicht",
|
||||
"question": "Frage",
|
||||
"question_id": "Frage-ID",
|
||||
"questions": "Fragen",
|
||||
|
||||
@@ -309,6 +309,7 @@
|
||||
"project_not_found": "Project not found",
|
||||
"project_permission_not_found": "Project permission not found",
|
||||
"projects": "Projects",
|
||||
"projects_limit_reached": "Projects limit reached",
|
||||
"question": "Question",
|
||||
"question_id": "Question ID",
|
||||
"questions": "Questions",
|
||||
|
||||
@@ -309,6 +309,7 @@
|
||||
"project_not_found": "Projet non trouvé",
|
||||
"project_permission_not_found": "Autorisation de projet non trouvée",
|
||||
"projects": "Projets",
|
||||
"projects_limit_reached": "Limite de projets atteinte",
|
||||
"question": "Question",
|
||||
"question_id": "ID de la question",
|
||||
"questions": "Questions",
|
||||
|
||||
@@ -309,6 +309,7 @@
|
||||
"project_not_found": "Projeto não encontrado",
|
||||
"project_permission_not_found": "Permissão do projeto não encontrada",
|
||||
"projects": "Projetos",
|
||||
"projects_limit_reached": "Limites de projetos atingidos",
|
||||
"question": "Pergunta",
|
||||
"question_id": "ID da Pergunta",
|
||||
"questions": "Perguntas",
|
||||
|
||||
@@ -309,6 +309,7 @@
|
||||
"project_not_found": "Projeto não encontrado",
|
||||
"project_permission_not_found": "Permissão do projeto não encontrada",
|
||||
"projects": "Projetos",
|
||||
"projects_limit_reached": "Limite de projetos atingido",
|
||||
"question": "Pergunta",
|
||||
"question_id": "ID da pergunta",
|
||||
"questions": "Perguntas",
|
||||
|
||||
@@ -309,6 +309,7 @@
|
||||
"project_not_found": "找不到專案",
|
||||
"project_permission_not_found": "找不到專案權限",
|
||||
"projects": "專案",
|
||||
"projects_limit_reached": "已達到專案上限",
|
||||
"question": "問題",
|
||||
"question_id": "問題 ID",
|
||||
"questions": "問題",
|
||||
|
||||
@@ -10,7 +10,7 @@ export const SignupWithoutVerificationSuccessPage = async ({ searchParams }) =>
|
||||
|
||||
return (
|
||||
<FormWrapper>
|
||||
<h1 className="leading-2 mb-4 text-center font-bold">
|
||||
<h1 className="mb-4 text-center leading-2 font-bold">
|
||||
{t("auth.signup_without_verification_success.user_successfully_created")}
|
||||
</h1>
|
||||
<p className="text-center text-sm">
|
||||
|
||||
@@ -15,7 +15,7 @@ export const VerificationRequestedPage = async ({ searchParams }) => {
|
||||
return (
|
||||
<FormWrapper>
|
||||
<>
|
||||
<h1 className="leading-2 mb-4 text-center text-lg font-semibold text-slate-900">
|
||||
<h1 className="mb-4 text-center text-lg leading-2 font-semibold text-slate-900">
|
||||
{t("auth.verification-requested.please_confirm_your_email_address")}
|
||||
</h1>
|
||||
<p className="text-center text-sm text-slate-700">
|
||||
|
||||
@@ -9,7 +9,7 @@ vi.mock("../../../ee/license-check/lib/utils", () => ({
|
||||
getIsAuditLogsEnabled: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn() },
|
||||
logger: { audit: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const validEvent = {
|
||||
@@ -37,7 +37,7 @@ describe("logAuditEvent", () => {
|
||||
test("logs event if access is granted and event is valid", async () => {
|
||||
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||
await logAuditEvent(validEvent);
|
||||
expect(logger.info).toHaveBeenCalledWith(validEvent, "Audit event logged");
|
||||
expect(logger.audit).toHaveBeenCalledWith(validEvent);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("logAuditEvent", () => {
|
||||
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||
const invalidEvent = { ...validEvent, action: "invalid.action" };
|
||||
await logAuditEvent(invalidEvent as any);
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
expect(logger.audit).not.toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -53,12 +53,12 @@ describe("logAuditEvent", () => {
|
||||
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||
const event = { ...validEvent, organizationId: UNKNOWN_DATA };
|
||||
await logAuditEvent(event);
|
||||
expect(logger.info).toHaveBeenCalledWith(event, "Audit event logged");
|
||||
expect(logger.audit).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
test("does not throw if logger.info throws", async () => {
|
||||
test("does not throw if logger.audit throws", async () => {
|
||||
getIsAuditLogsEnabled.mockResolvedValue(true);
|
||||
logger.info.mockImplementation(() => {
|
||||
logger.audit.mockImplementation(() => {
|
||||
throw new Error("fail");
|
||||
});
|
||||
await logAuditEvent(validEvent);
|
||||
|
||||
@@ -11,7 +11,7 @@ const validateEvent = (event: TAuditLogEvent): void => {
|
||||
export const logAuditEvent = async (event: TAuditLogEvent): Promise<void> => {
|
||||
try {
|
||||
validateEvent(event);
|
||||
logger.info(event, "Audit event logged");
|
||||
logger.audit(event);
|
||||
} catch (error) {
|
||||
// Log error to application logger but don't throw
|
||||
// This ensures audit logging failures don't break the application
|
||||
|
||||
@@ -6,70 +6,13 @@ import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
const bulkContactEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "uploadBulkContacts",
|
||||
summary: "Upload Bulk Contacts",
|
||||
description:
|
||||
"Uploads contacts in bulk. Each contact in the payload must have an 'email' attribute present in their attributes array. The email attribute is mandatory and must be a valid email format. Without a valid email, the contact will be skipped during processing.",
|
||||
description: "Uploads contacts in bulk",
|
||||
requestBody: {
|
||||
required: true,
|
||||
description:
|
||||
"The contacts to upload. Each contact must include an 'email' attribute in their attributes array. The email is used as the unique identifier for the contact.",
|
||||
description: "The contacts to upload",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactBulkUploadRequest,
|
||||
example: {
|
||||
environmentId: "env_01h2xce9q8p3w4x5y6z7a8b9c0",
|
||||
contacts: [
|
||||
{
|
||||
attributes: [
|
||||
{
|
||||
attributeKey: {
|
||||
key: "email",
|
||||
name: "Email Address",
|
||||
},
|
||||
value: "john.doe@example.com",
|
||||
},
|
||||
{
|
||||
attributeKey: {
|
||||
key: "firstName",
|
||||
name: "First Name",
|
||||
},
|
||||
value: "John",
|
||||
},
|
||||
{
|
||||
attributeKey: {
|
||||
key: "lastName",
|
||||
name: "Last Name",
|
||||
},
|
||||
value: "Doe",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
attributes: [
|
||||
{
|
||||
attributeKey: {
|
||||
key: "email",
|
||||
name: "Email Address",
|
||||
},
|
||||
value: "jane.smith@example.com",
|
||||
},
|
||||
{
|
||||
attributeKey: {
|
||||
key: "firstName",
|
||||
name: "First Name",
|
||||
},
|
||||
value: "Jane",
|
||||
},
|
||||
{
|
||||
attributeKey: {
|
||||
key: "lastName",
|
||||
name: "Last Name",
|
||||
},
|
||||
value: "Smith",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -314,7 +314,7 @@ function AttributeSegmentFilter({
|
||||
}}
|
||||
value={attrKeyValue}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className={cn("flex items-center gap-2", !isCapitalized(attrKeyValue ?? "") && "lowercase")}>
|
||||
@@ -496,7 +496,7 @@ function PersonSegmentFilter({
|
||||
}}
|
||||
value={personIdentifier}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
|
||||
hideArrow>
|
||||
<SelectValue>
|
||||
<div className="flex items-center gap-1 lowercase">
|
||||
@@ -647,7 +647,7 @@ function SegmentSegmentFilter({
|
||||
}}
|
||||
value={currentSegment?.id}>
|
||||
<SelectTrigger
|
||||
className="flex w-auto items-center justify-center whitespace-nowrap bg-white capitalize"
|
||||
className="flex w-auto items-center justify-center bg-white whitespace-nowrap capitalize"
|
||||
hideArrow>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users2Icon className="h-4 w-4 text-sm" />
|
||||
|
||||
@@ -232,7 +232,7 @@ export function EditLanguage({
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm italic text-slate-500">
|
||||
<p className="text-sm text-slate-500 italic">
|
||||
{t("environments.project.languages.no_language_found")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -60,11 +60,7 @@ const getRatingContent = (scale: string, i: number, range: number, isColorCoding
|
||||
);
|
||||
}
|
||||
if (scale === "number") {
|
||||
return (
|
||||
<Text className="m-0 h-[44px] text-center text-[14px] leading-[44px]">
|
||||
{i + 1}
|
||||
</Text>
|
||||
);
|
||||
return <Text className="m-0 h-[44px] text-center text-[14px] leading-[44px]">{i + 1}</Text>;
|
||||
}
|
||||
if (scale === "star") {
|
||||
return <Text className="m-auto text-3xl">⭐</Text>;
|
||||
@@ -98,8 +94,8 @@ export async function PreviewEmailTemplate({
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
|
||||
<Container className="text-question-color m-0 text-sm font-normal leading-6">
|
||||
<Text className="text-question-color m-0 block text-base leading-6 font-semibold">{headline}</Text>
|
||||
<Container className="text-question-color m-0 text-sm leading-6 font-normal">
|
||||
<div
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -185,8 +181,8 @@ export async function PreviewEmailTemplate({
|
||||
case TSurveyQuestionTypeEnum.CTA:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<Text className="text-question-color m-0 block text-base font-semibold leading-6">{headline}</Text>
|
||||
<Container className="text-question-color ml-0 mt-2 text-sm font-normal leading-6">
|
||||
<Text className="text-question-color m-0 block text-base leading-6 font-semibold">{headline}</Text>
|
||||
<Container className="text-question-color mt-2 ml-0 text-sm leading-6 font-normal">
|
||||
<div
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{
|
||||
@@ -232,8 +228,8 @@ export async function PreviewEmailTemplate({
|
||||
{ "rounded-l-lg border-l": i === 0 },
|
||||
{ "rounded-r-lg": i === firstQuestion.range - 1 },
|
||||
firstQuestion.isColorCodingEnabled &&
|
||||
firstQuestion.scale === "number" &&
|
||||
`border border-t-[6px] border-t-${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`,
|
||||
firstQuestion.scale === "number" &&
|
||||
`border border-t-[6px] border-t-${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`,
|
||||
firstQuestion.scale === "star" && "border-transparent"
|
||||
)}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${(i + 1).toString()}`}
|
||||
@@ -325,13 +321,13 @@ export async function PreviewEmailTemplate({
|
||||
{firstQuestion.choices.map((choice) =>
|
||||
firstQuestion.allowMulti ? (
|
||||
<Img
|
||||
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
|
||||
className="rounded-custom mr-1 mb-1 inline-block h-[140px] w-[220px]"
|
||||
key={choice.id}
|
||||
src={choice.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
|
||||
className="rounded-custom mr-1 mb-1 inline-block h-[140px] w-[220px]"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
|
||||
key={choice.id}
|
||||
target="_blank">
|
||||
@@ -379,11 +375,11 @@ export async function PreviewEmailTemplate({
|
||||
<Container className="mx-0">
|
||||
<Section className="w-full table-auto">
|
||||
<Row>
|
||||
<Column className="w-40 break-words px-4 py-2" />
|
||||
<Column className="w-40 px-4 py-2 break-words" />
|
||||
{firstQuestion.columns.map((column) => {
|
||||
return (
|
||||
<Column
|
||||
className="text-question-color max-w-40 break-words px-4 py-2 text-center"
|
||||
className="text-question-color max-w-40 px-4 py-2 text-center break-words"
|
||||
key={getLocalizedValue(column, "default")}>
|
||||
{getLocalizedValue(column, "default")}
|
||||
</Column>
|
||||
@@ -395,7 +391,7 @@ export async function PreviewEmailTemplate({
|
||||
<Row
|
||||
className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`}
|
||||
key={getLocalizedValue(row, "default")}>
|
||||
<Column className="w-40 break-words px-4 py-2">
|
||||
<Column className="w-40 px-4 py-2 break-words">
|
||||
{getLocalizedValue(row, "default")}
|
||||
</Column>
|
||||
{firstQuestion.columns.map((_) => {
|
||||
|
||||
@@ -44,6 +44,8 @@ describe("ProjectLimitModal", () => {
|
||||
test("renders dialog and upgrade prompt with correct props", () => {
|
||||
render(<ProjectLimitModal open={true} setOpen={setOpen} projectLimit={3} buttons={buttons} />);
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-content")).toHaveClass("bg-white");
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("common.projects_limit_reached");
|
||||
expect(screen.getByTestId("upgrade-prompt")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.unlock_more_projects_with_a_higher_plan")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.you_have_reached_your_limit_of_project_limit")).toBeInTheDocument();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Dialog, DialogContent } from "@/modules/ui/components/dialog";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
@@ -16,7 +16,8 @@ export const ProjectLimitModal = ({ open, setOpen, projectLimit, buttons }: Proj
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent className="w-full max-w-[564px] bg-white">
|
||||
<DialogTitle>{t("common.projects_limit_reached")}</DialogTitle>
|
||||
<UpgradePrompt
|
||||
title={t("common.unlock_more_projects_with_a_higher_plan")}
|
||||
description={t("common.you_have_reached_your_limit_of_project_limit", { projectLimit })}
|
||||
|
||||
@@ -220,7 +220,7 @@ export const RecallWrapper = ({
|
||||
}
|
||||
parts.push(
|
||||
<span
|
||||
className="z-30 flex h-fit cursor-pointer justify-center whitespace-pre rounded-md bg-slate-100 text-sm text-transparent"
|
||||
className="z-30 flex h-fit cursor-pointer justify-center rounded-md bg-slate-100 text-sm whitespace-pre text-transparent"
|
||||
key={`recall-${parts.length}`}>
|
||||
{"@" + label}
|
||||
</span>
|
||||
@@ -255,7 +255,7 @@ export const RecallWrapper = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="absolute right-2 top-full z-[1] flex h-6 cursor-pointer items-center rounded-b-lg rounded-t-none bg-slate-100 px-2.5 py-0 text-xs hover:bg-slate-200"
|
||||
className="absolute top-full right-2 z-[1] flex h-6 cursor-pointer items-center rounded-t-none rounded-b-lg bg-slate-100 px-2.5 py-0 text-xs hover:bg-slate-200"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowFallbackInput(true);
|
||||
|
||||
@@ -13,7 +13,7 @@ export const AddEndingCardButton = ({ localSurvey, addEndingCard }: AddEndingCar
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<button
|
||||
className="group inline-flex items-stretch rounded-lg border border-slate-300 bg-slate-50 hover:cursor-pointer hover:bg-white"
|
||||
className="group inline-flex rounded-lg border border-slate-300 bg-slate-50 hover:cursor-pointer hover:bg-white"
|
||||
onClick={() => addEndingCard(localSurvey.endings.length)}>
|
||||
<div className="flex w-10 items-center justify-center rounded-l-lg bg-slate-400 transition-all duration-300 ease-in-out group-hover:bg-slate-500 group-aria-expanded:rounded-bl-none group-aria-expanded:rounded-br">
|
||||
<PlusIcon className="h-6 w-6 text-white" />
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyLanguage,
|
||||
TSurveyMatrixQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { MatrixLabelChoice } from "./matrix-label-choice";
|
||||
|
||||
// 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",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
|
||||
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,
|
||||
}));
|
||||
|
||||
// Mock tolgee
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string, params?: any) => {
|
||||
if (key === "environments.surveys.edit.row_idx") {
|
||||
return `Row ${params?.rowIndex}`;
|
||||
}
|
||||
if (key === "environments.surveys.edit.column_idx") {
|
||||
return `Column ${params?.columnIndex}`;
|
||||
}
|
||||
if (key === "environments.surveys.edit.delete_row") {
|
||||
return "Delete row";
|
||||
}
|
||||
if (key === "environments.surveys.edit.delete_column") {
|
||||
return "Delete column";
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock QuestionFormInput component
|
||||
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||
QuestionFormInput: vi.fn(({ id, updateMatrixLabel, value, onKeyDown }) => (
|
||||
<div data-testid={`question-input-${id}`}>
|
||||
<input
|
||||
data-testid={`input-${id}`}
|
||||
onChange={(e) => {
|
||||
if (updateMatrixLabel) {
|
||||
const type = id.startsWith("row") ? "row" : "column";
|
||||
const index = parseInt(id.split("-")[1]);
|
||||
updateMatrixLabel(index, type, { default: e.target.value });
|
||||
}
|
||||
}}
|
||||
value={value?.default || ""}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock TooltipRenderer component
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipRenderer: vi.fn(({ children }) => <div data-testid="tooltip-renderer">{children}</div>),
|
||||
}));
|
||||
|
||||
// Mock validation
|
||||
vi.mock("../lib/validation", () => ({
|
||||
isLabelValidForAllLanguages: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
// Mock survey languages
|
||||
const mockSurveyLanguages: TSurveyLanguage[] = [
|
||||
{
|
||||
default: true,
|
||||
enabled: true,
|
||||
language: {
|
||||
id: "en",
|
||||
code: "en",
|
||||
alias: "English",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "project-1",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Mock matrix question
|
||||
const mockQuestion: TSurveyMatrixQuestion = {
|
||||
id: "matrix-1",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: createI18nString("Matrix Question", ["en"]),
|
||||
required: false,
|
||||
logic: [],
|
||||
rows: [
|
||||
createI18nString("Row 1", ["en"]),
|
||||
createI18nString("Row 2", ["en"]),
|
||||
createI18nString("Row 3", ["en"]),
|
||||
],
|
||||
columns: [
|
||||
createI18nString("Column 1", ["en"]),
|
||||
createI18nString("Column 2", ["en"]),
|
||||
createI18nString("Column 3", ["en"]),
|
||||
],
|
||||
shuffleOption: "none",
|
||||
};
|
||||
|
||||
// Mock survey
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey-1",
|
||||
name: "Test Survey",
|
||||
questions: [mockQuestion],
|
||||
languages: mockSurveyLanguages,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const defaultProps = {
|
||||
labelIdx: 0,
|
||||
type: "row" as const,
|
||||
questionIdx: 0,
|
||||
updateMatrixLabel: vi.fn(),
|
||||
handleDeleteLabel: vi.fn(),
|
||||
handleKeyDown: vi.fn(),
|
||||
isInvalid: false,
|
||||
localSurvey: mockSurvey,
|
||||
selectedLanguageCode: "en",
|
||||
setSelectedLanguageCode: vi.fn(),
|
||||
question: mockQuestion,
|
||||
locale: "en-US" as TUserLocale,
|
||||
};
|
||||
|
||||
const renderWithDndContext = (props = {}) => {
|
||||
const finalProps = { ...defaultProps, ...props };
|
||||
return render(
|
||||
<DndContext>
|
||||
<SortableContext
|
||||
items={[`${finalProps.type}-${finalProps.labelIdx}`]}
|
||||
strategy={verticalListSortingStrategy}>
|
||||
<MatrixLabelChoice {...finalProps} />
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
describe("MatrixLabelChoice", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Row type", () => {
|
||||
test("renders the row choice with drag handle and input", () => {
|
||||
renderWithDndContext({ type: "row" });
|
||||
|
||||
expect(screen.getByDisplayValue("Row 1")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows delete button when there are more than 2 rows", () => {
|
||||
renderWithDndContext({ type: "row" });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides delete button when there are only 2 rows", () => {
|
||||
const questionWith2Rows = {
|
||||
...mockQuestion,
|
||||
rows: [createI18nString("Row 1", ["en"]), createI18nString("Row 2", ["en"])],
|
||||
};
|
||||
|
||||
renderWithDndContext({ type: "row", question: questionWith2Rows });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeUndefined();
|
||||
});
|
||||
|
||||
test("calls handleDeleteLabel when delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleDeleteLabel = vi.fn();
|
||||
|
||||
renderWithDndContext({ type: "row", handleDeleteLabel });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeDefined();
|
||||
|
||||
await user.click(deleteButton!);
|
||||
|
||||
expect(handleDeleteLabel).toHaveBeenCalledWith("row", 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column type", () => {
|
||||
test("renders the column choice with drag handle and input", () => {
|
||||
renderWithDndContext({ type: "column" });
|
||||
|
||||
expect(screen.getByDisplayValue("Column 1")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows delete button when there are more than 2 columns", () => {
|
||||
renderWithDndContext({ type: "column" });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides delete button when there are only 2 columns", () => {
|
||||
const questionWith2Columns = {
|
||||
...mockQuestion,
|
||||
columns: [createI18nString("Column 1", ["en"]), createI18nString("Column 2", ["en"])],
|
||||
};
|
||||
|
||||
renderWithDndContext({ type: "column", question: questionWith2Columns });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeUndefined();
|
||||
});
|
||||
|
||||
test("calls handleDeleteLabel when delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleDeleteLabel = vi.fn();
|
||||
|
||||
renderWithDndContext({ type: "column", handleDeleteLabel });
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const deleteButton = buttons.find((button) => button.querySelector('svg[class*="lucide-trash"]'));
|
||||
expect(deleteButton).toBeDefined();
|
||||
|
||||
await user.click(deleteButton!);
|
||||
|
||||
expect(handleDeleteLabel).toHaveBeenCalledWith("column", 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Common functionality", () => {
|
||||
test("calls updateMatrixLabel when input value changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateMatrixLabel = vi.fn();
|
||||
|
||||
renderWithDndContext({ updateMatrixLabel });
|
||||
|
||||
const input = screen.getByDisplayValue("Row 1");
|
||||
await user.clear(input);
|
||||
await user.type(input, "Updated Row");
|
||||
|
||||
expect(updateMatrixLabel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls handleKeyDown when Enter key is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleKeyDown = vi.fn();
|
||||
|
||||
renderWithDndContext({ handleKeyDown });
|
||||
|
||||
const input = screen.getByDisplayValue("Row 1");
|
||||
await user.type(input, "{Enter}");
|
||||
|
||||
expect(handleKeyDown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("applies invalid styling when isInvalid is true", () => {
|
||||
renderWithDndContext({ isInvalid: true });
|
||||
|
||||
const input = screen.getByDisplayValue("Row 1");
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { GripVerticalIcon, TrashIcon } from "lucide-react";
|
||||
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { isLabelValidForAllLanguages } from "../lib/validation";
|
||||
|
||||
interface MatrixLabelChoiceProps {
|
||||
labelIdx: number;
|
||||
type: "row" | "column";
|
||||
questionIdx: number;
|
||||
updateMatrixLabel: (index: number, type: "row" | "column", data: TI18nString) => void;
|
||||
handleDeleteLabel: (type: "row" | "column", index: number) => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent, type: "row" | "column") => void;
|
||||
isInvalid: boolean;
|
||||
localSurvey: TSurvey;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
question: TSurveyMatrixQuestion;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const MatrixLabelChoice = ({
|
||||
labelIdx,
|
||||
type,
|
||||
questionIdx,
|
||||
updateMatrixLabel,
|
||||
handleDeleteLabel,
|
||||
handleKeyDown,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
question,
|
||||
locale,
|
||||
}: MatrixLabelChoiceProps) => {
|
||||
const { t } = useTranslate();
|
||||
const labels = type === "row" ? question.rows : question.columns;
|
||||
const surveyLanguages = localSurvey.languages ?? [];
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: `${type}-${labelIdx}`,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transition: transition ?? "transform 100ms ease",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center gap-2" ref={setNodeRef} style={style}>
|
||||
{/* drag handle */}
|
||||
<div {...listeners} {...attributes}>
|
||||
<GripVerticalIcon className="h-4 w-4 cursor-move text-slate-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex w-full space-x-2">
|
||||
<QuestionFormInput
|
||||
key={`${type}-${labelIdx}`}
|
||||
id={`${type}-${labelIdx}`}
|
||||
placeholder={t(`environments.surveys.edit.${type}_idx`, {
|
||||
[`${type}Index`]: labelIdx + 1,
|
||||
})}
|
||||
label=""
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
value={labels[labelIdx]}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid && !isLabelValidForAllLanguages(labels[labelIdx], surveyLanguages)}
|
||||
onKeyDown={(e) => handleKeyDown(e, type)}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{labels.length > 2 && (
|
||||
<TooltipRenderer tooltipContent={t(`environments.surveys.edit.delete_${type}`)}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label={`Delete ${type}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteLabel(type, labelIdx);
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MatrixLabelChoice } from "@/modules/survey/editor/components/matrix-label-choice";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { DndContext } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
interface MatrixLabelSectionProps {
|
||||
type: "row" | "column";
|
||||
labels: TI18nString[];
|
||||
question: TSurveyMatrixQuestion;
|
||||
questionIdx: number;
|
||||
updateMatrixLabel: (index: number, type: "row" | "column", data: TI18nString) => void;
|
||||
handleDeleteLabel: (type: "row" | "column", index: number) => void;
|
||||
handleKeyDown: (e: React.KeyboardEvent, type: "row" | "column") => void;
|
||||
handleAddLabel: (type: "row" | "column") => void;
|
||||
onDragEnd: (event: any) => void;
|
||||
isInvalid: boolean;
|
||||
localSurvey: TSurvey;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
locale: TUserLocale;
|
||||
parent: any;
|
||||
}
|
||||
|
||||
export const MatrixLabelSection = ({
|
||||
type,
|
||||
labels,
|
||||
question,
|
||||
questionIdx,
|
||||
updateMatrixLabel,
|
||||
handleDeleteLabel,
|
||||
handleKeyDown,
|
||||
handleAddLabel,
|
||||
onDragEnd,
|
||||
isInvalid,
|
||||
localSurvey,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
parent,
|
||||
}: MatrixLabelSectionProps) => {
|
||||
const { t } = useTranslate();
|
||||
const labelKey = type === "row" ? "rows" : "columns";
|
||||
const addKey = type === "row" ? "add_row" : "add_column";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={labelKey}>{t(`environments.surveys.edit.${labelKey}`)}</Label>
|
||||
<div className="mt-2" id={labelKey}>
|
||||
<DndContext id={`matrix-${labelKey}`} onDragEnd={onDragEnd}>
|
||||
<SortableContext
|
||||
items={labels.map((_, idx) => `${type}-${idx}`)}
|
||||
strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-2" ref={parent}>
|
||||
{labels.map((_, index) => (
|
||||
<MatrixLabelChoice
|
||||
key={`${type}-${index}`}
|
||||
labelIdx={index}
|
||||
type={type}
|
||||
questionIdx={questionIdx}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
handleDeleteLabel={handleDeleteLabel}
|
||||
handleKeyDown={handleKeyDown}
|
||||
isInvalid={isInvalid}
|
||||
localSurvey={localSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
question={question}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-2 w-fit"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddLabel(type);
|
||||
}}>
|
||||
<PlusIcon />
|
||||
{t(`environments.surveys.edit.${addKey}`)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2,17 +2,19 @@
|
||||
|
||||
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
|
||||
import { QuestionFormInput } from "@/modules/survey/components/question-form-input";
|
||||
import { MatrixLabelSection } from "@/modules/survey/editor/components/matrix-label-section";
|
||||
import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { ShuffleOptionSelect } from "@/modules/ui/components/shuffle-option-select";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import type { JSX } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TI18nString, TSurvey, TSurveyMatrixQuestion } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { isLabelValidForAllLanguages } from "../lib/validation";
|
||||
|
||||
interface MatrixQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -107,42 +109,6 @@ export const MatrixQuestionForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRowDragEnd = (event: any) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!active || !over) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIndex = question.rows.findIndex((_, idx) => `row-${idx}` === active.id);
|
||||
const overIndex = question.rows.findIndex((_, idx) => `row-${idx}` === over.id);
|
||||
|
||||
if (activeIndex !== overIndex) {
|
||||
const newRows = [...question.rows];
|
||||
const [reorderedItem] = newRows.splice(activeIndex, 1);
|
||||
newRows.splice(overIndex, 0, reorderedItem);
|
||||
updateQuestion(questionIdx, { rows: newRows });
|
||||
}
|
||||
};
|
||||
|
||||
const handleColumnDragEnd = (event: any) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!active || !over) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeIndex = question.columns.findIndex((_, idx) => `column-${idx}` === active.id);
|
||||
const overIndex = question.columns.findIndex((_, idx) => `column-${idx}` === over.id);
|
||||
|
||||
if (activeIndex !== overIndex) {
|
||||
const newColumns = [...question.columns];
|
||||
const [reorderedItem] = newColumns.splice(activeIndex, 1);
|
||||
newColumns.splice(overIndex, 0, reorderedItem);
|
||||
updateQuestion(questionIdx, { columns: newColumns });
|
||||
}
|
||||
};
|
||||
|
||||
const shuffleOptionsTypes = {
|
||||
none: {
|
||||
id: "none",
|
||||
@@ -212,41 +178,105 @@ export const MatrixQuestionForm = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-4">
|
||||
<MatrixLabelSection
|
||||
type="row"
|
||||
labels={question.rows}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
handleDeleteLabel={handleDeleteLabel}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleAddLabel={handleAddLabel}
|
||||
onDragEnd={handleRowDragEnd}
|
||||
isInvalid={isInvalid}
|
||||
localSurvey={localSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
parent={parent}
|
||||
/>
|
||||
<div>
|
||||
<MatrixLabelSection
|
||||
type="column"
|
||||
labels={question.columns}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
handleDeleteLabel={handleDeleteLabel}
|
||||
handleKeyDown={handleKeyDown}
|
||||
handleAddLabel={handleAddLabel}
|
||||
onDragEnd={handleColumnDragEnd}
|
||||
isInvalid={isInvalid}
|
||||
localSurvey={localSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
parent={parent}
|
||||
/>
|
||||
{/* Rows section */}
|
||||
<Label htmlFor="rows">{t("environments.surveys.edit.rows")}</Label>
|
||||
<div className="mt-2 flex flex-col gap-2" ref={parent}>
|
||||
{question.rows.map((row, index) => (
|
||||
<div className="flex items-center" key={`${row}-${index}`}>
|
||||
<QuestionFormInput
|
||||
id={`row-${index}`}
|
||||
label={""}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
value={question.rows[index]}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={
|
||||
isInvalid && !isLabelValidForAllLanguages(question.rows[index], localSurvey.languages)
|
||||
}
|
||||
locale={locale}
|
||||
onKeyDown={(e) => handleKeyDown(e, "row")}
|
||||
/>
|
||||
{question.rows.length > 2 && (
|
||||
<TooltipRenderer data-testid="tooltip-renderer" tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteLabel("row", index);
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddLabel("row");
|
||||
}}>
|
||||
<PlusIcon />
|
||||
{t("environments.surveys.edit.add_row")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{/* Columns section */}
|
||||
<Label htmlFor="columns">{t("environments.surveys.edit.columns")}</Label>
|
||||
<div className="mt-2 flex flex-col gap-2" ref={parent}>
|
||||
{question.columns.map((column, index) => (
|
||||
<div className="flex items-center" key={`${column}-${index}`}>
|
||||
<QuestionFormInput
|
||||
id={`column-${index}`}
|
||||
label={""}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={questionIdx}
|
||||
value={question.columns[index]}
|
||||
updateMatrixLabel={updateMatrixLabel}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={
|
||||
isInvalid && !isLabelValidForAllLanguages(question.columns[index], localSurvey.languages)
|
||||
}
|
||||
locale={locale}
|
||||
onKeyDown={(e) => handleKeyDown(e, "column")}
|
||||
/>
|
||||
{question.columns.length > 2 && (
|
||||
<TooltipRenderer data-testid="tooltip-renderer" tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteLabel("column", index);
|
||||
}}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddLabel("column");
|
||||
}}>
|
||||
<PlusIcon />
|
||||
{t("environments.surveys.edit.add_column")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-1 items-center justify-end gap-2">
|
||||
<ShuffleOptionSelect
|
||||
shuffleOptionsTypes={shuffleOptionsTypes}
|
||||
|
||||
@@ -336,7 +336,7 @@ export const getCXQuestionNameMap = (t: TFnType) =>
|
||||
) as Record<TSurveyQuestionTypeEnum, string>;
|
||||
|
||||
export const universalQuestionPresets = {
|
||||
required: false,
|
||||
required: true,
|
||||
};
|
||||
|
||||
export const getQuestionDefaults = (id: string, project: any, t: TFnType) => {
|
||||
|
||||
@@ -82,7 +82,7 @@ export const LinkSurveyWrapper = ({
|
||||
{!styling.isLogoHidden && project.logo?.url && <ClientLogo projectLogo={project.logo} />}
|
||||
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
|
||||
{isPreview && (
|
||||
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
|
||||
<div className="fixed top-0 left-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
|
||||
<div />
|
||||
Survey Preview 👀
|
||||
<ResetProgressButton onClick={handleResetSurvey} />
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock("@/modules/ui/components/checkbox", () => ({
|
||||
id={id}
|
||||
data-testid={id}
|
||||
name={props.name}
|
||||
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
|
||||
className="focus:ring-opacity-50 mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500"
|
||||
onChange={(e) => {
|
||||
// Call onCheckedChange with the checked state
|
||||
onCheckedChange && onCheckedChange(e.target.checked);
|
||||
|
||||
@@ -105,11 +105,11 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
|
||||
field.onChange([...field.value, environment.id]);
|
||||
}
|
||||
}}
|
||||
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
|
||||
className="focus:ring-opacity-50 mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500"
|
||||
id={environment.id}
|
||||
/>
|
||||
<Label htmlFor={environment.id}>
|
||||
<p className="text-sm font-medium capitalize text-slate-900">
|
||||
<p className="text-sm font-medium text-slate-900 capitalize">
|
||||
{environment.type}
|
||||
</p>
|
||||
</Label>
|
||||
@@ -128,8 +128,8 @@ export const CopySurveyForm = ({ defaultProjects, survey, onCancel, setOpen }: I
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="fixed bottom-0 left-0 right-0 z-10 flex w-full justify-end space-x-2 bg-white">
|
||||
<div className="flex w-full justify-end pb-4 pr-4">
|
||||
<div className="fixed right-0 bottom-0 left-0 z-10 flex w-full justify-end space-x-2 bg-white">
|
||||
<div className="flex w-full justify-end pr-4 pb-4">
|
||||
<Button type="button" onClick={onCancel} variant="ghost">
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
|
||||
@@ -72,7 +72,7 @@ export const SurveyCard = ({
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"col-span-1 flex w-fit items-center gap-2 whitespace-nowrap rounded-full py-1 pl-1 pr-2 text-sm text-slate-800",
|
||||
"col-span-1 flex w-fit items-center gap-2 rounded-full py-1 pr-2 pl-1 text-sm whitespace-nowrap text-slate-800",
|
||||
surveyStatusLabel === "Scheduled" && "bg-slate-200",
|
||||
surveyStatusLabel === "In Progress" && "bg-emerald-50",
|
||||
surveyStatusLabel === "Completed" && "bg-slate-200",
|
||||
@@ -81,23 +81,23 @@ export const SurveyCard = ({
|
||||
)}>
|
||||
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}{" "}
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-sm text-ellipsis whitespace-nowrap text-slate-600">
|
||||
{survey.responseCount}
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-between">
|
||||
<SurveyTypeIndicator type={survey.type} />
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-sm text-ellipsis whitespace-nowrap text-slate-600">
|
||||
{convertDateString(survey.createdAt.toString())}
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-sm text-ellipsis whitespace-nowrap text-slate-600">
|
||||
{timeSince(survey.updatedAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
<div className="col-span-1 max-w-full overflow-hidden text-sm text-ellipsis whitespace-nowrap text-slate-600">
|
||||
{survey.creator ? survey.creator.name : "-"}
|
||||
</div>
|
||||
</div>
|
||||
<button className="absolute right-3 top-3.5" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="absolute top-3.5 right-3" onClick={(e) => e.stopPropagation()}>
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
// Import the actions to access mocked functions
|
||||
import { deleteSurveyAction } from "@/modules/survey/list/actions";
|
||||
import { TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { SurveyDropDownMenu } from "./survey-dropdown-menu";
|
||||
|
||||
// Cast to mocked functions
|
||||
const mockDeleteSurveyAction = vi.mocked(deleteSurveyAction);
|
||||
const mockToast = vi.mocked(toast);
|
||||
|
||||
// Mock translation
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({ t: (key: string) => key }),
|
||||
@@ -50,24 +43,6 @@ vi.mock("@/modules/survey/list/actions", () => ({
|
||||
getSurveyAction: vi.fn(() =>
|
||||
Promise.resolve({ data: { id: "duplicatedSurveyId", name: "Duplicated Survey" } })
|
||||
),
|
||||
deleteSurveyAction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock next/navigation
|
||||
const mockRouterRefresh = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
refresh: mockRouterRefresh,
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock react-hot-toast
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("SurveyDropDownMenu", () => {
|
||||
@@ -265,245 +240,4 @@ describe("SurveyDropDownMenu", () => {
|
||||
expect(mockDuplicateSurvey).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDeleteSurvey", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("successfully deletes survey - calls all expected functions and shows success toast", async () => {
|
||||
const mockDeleteSurvey = vi.fn();
|
||||
mockDeleteSurveyAction.mockResolvedValueOnce({ data: true });
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open dropdown and click delete
|
||||
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
|
||||
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
|
||||
await userEvent.click(triggerElement);
|
||||
|
||||
const deleteButton = screen.getByText("common.delete");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Confirm deletion in dialog
|
||||
const confirmDeleteButton = screen.getByText("common.delete");
|
||||
await userEvent.click(confirmDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteSurveyAction).toHaveBeenCalledWith({ surveyId: "testSurvey" });
|
||||
expect(mockDeleteSurvey).toHaveBeenCalledWith("testSurvey");
|
||||
expect(mockToast.success).toHaveBeenCalledWith("environments.surveys.survey_deleted_successfully");
|
||||
expect(mockRouterRefresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles deletion error - shows error toast and resets loading state", async () => {
|
||||
const mockDeleteSurvey = vi.fn();
|
||||
const deletionError = new Error("Deletion failed");
|
||||
mockDeleteSurveyAction.mockRejectedValueOnce(deletionError);
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open dropdown and click delete
|
||||
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
|
||||
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
|
||||
await userEvent.click(triggerElement);
|
||||
|
||||
const deleteButton = screen.getByText("common.delete");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Confirm deletion in dialog
|
||||
const confirmDeleteButton = screen.getByText("common.delete");
|
||||
await userEvent.click(confirmDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteSurveyAction).toHaveBeenCalledWith({ surveyId: "testSurvey" });
|
||||
expect(mockDeleteSurvey).not.toHaveBeenCalled();
|
||||
expect(mockToast.error).toHaveBeenCalledWith("environments.surveys.error_deleting_survey");
|
||||
expect(mockRouterRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("manages loading state correctly during successful deletion", async () => {
|
||||
const mockDeleteSurvey = vi.fn();
|
||||
mockDeleteSurveyAction.mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve({ data: true }), 100))
|
||||
);
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open dropdown and click delete
|
||||
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
|
||||
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
|
||||
await userEvent.click(triggerElement);
|
||||
|
||||
const deleteButton = screen.getByText("common.delete");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Confirm deletion in dialog using a more reliable selector
|
||||
const confirmDeleteButton = screen.getByText("common.delete");
|
||||
await userEvent.click(confirmDeleteButton);
|
||||
|
||||
// Wait for the deletion process to complete
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteSurveyAction).toHaveBeenCalled();
|
||||
expect(mockDeleteSurvey).toHaveBeenCalled();
|
||||
expect(mockToast.success).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("manages loading state correctly during failed deletion", async () => {
|
||||
const mockDeleteSurvey = vi.fn();
|
||||
mockDeleteSurveyAction.mockImplementation(
|
||||
() => new Promise((_, reject) => setTimeout(() => reject(new Error("Network error")), 100))
|
||||
);
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open dropdown and click delete
|
||||
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
|
||||
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
|
||||
await userEvent.click(triggerElement);
|
||||
|
||||
const deleteButton = screen.getByText("common.delete");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Confirm deletion in dialog using a more reliable selector
|
||||
const confirmDeleteButton = screen.getByText("common.delete");
|
||||
await userEvent.click(confirmDeleteButton);
|
||||
|
||||
// Wait for the error to occur
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteSurveyAction).toHaveBeenCalled();
|
||||
expect(mockToast.error).toHaveBeenCalledWith("environments.surveys.error_deleting_survey");
|
||||
});
|
||||
|
||||
// Verify that deleteSurvey callback was not called due to error
|
||||
expect(mockDeleteSurvey).not.toHaveBeenCalled();
|
||||
expect(mockRouterRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not call router.refresh or success toast when deleteSurveyAction throws", async () => {
|
||||
const mockDeleteSurvey = vi.fn();
|
||||
mockDeleteSurveyAction.mockRejectedValueOnce(new Error("API Error"));
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open dropdown and click delete
|
||||
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
|
||||
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
|
||||
await userEvent.click(triggerElement);
|
||||
|
||||
const deleteButton = screen.getByText("common.delete");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Confirm deletion in dialog
|
||||
const confirmDeleteButton = screen.getByText("common.delete");
|
||||
await userEvent.click(confirmDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteSurveyAction).toHaveBeenCalled();
|
||||
expect(mockToast.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify success-path functions are not called
|
||||
expect(mockDeleteSurvey).not.toHaveBeenCalled();
|
||||
expect(mockToast.success).not.toHaveBeenCalled();
|
||||
expect(mockRouterRefresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls functions in correct order during successful deletion", async () => {
|
||||
const mockDeleteSurvey = vi.fn();
|
||||
const callOrder: string[] = [];
|
||||
|
||||
mockDeleteSurveyAction.mockImplementation(async () => {
|
||||
callOrder.push("deleteSurveyAction");
|
||||
return { data: true };
|
||||
});
|
||||
|
||||
mockDeleteSurvey.mockImplementation(() => {
|
||||
callOrder.push("deleteSurvey");
|
||||
});
|
||||
|
||||
(mockToast.success as any).mockImplementation(() => {
|
||||
callOrder.push("toast.success");
|
||||
});
|
||||
|
||||
mockRouterRefresh.mockImplementation(() => {
|
||||
callOrder.push("router.refresh");
|
||||
});
|
||||
|
||||
render(
|
||||
<SurveyDropDownMenu
|
||||
environmentId="env123"
|
||||
survey={fakeSurvey}
|
||||
publicDomain="http://survey.test"
|
||||
refreshSingleUseId={vi.fn()}
|
||||
duplicateSurvey={vi.fn()}
|
||||
deleteSurvey={mockDeleteSurvey}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open dropdown and click delete
|
||||
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
|
||||
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
|
||||
await userEvent.click(triggerElement);
|
||||
|
||||
const deleteButton = screen.getByText("common.delete");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
// Confirm deletion in dialog
|
||||
const confirmDeleteButton = screen.getByText("common.delete");
|
||||
await userEvent.click(confirmDeleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(callOrder).toEqual(["deleteSurveyAction", "deleteSurvey", "toast.success", "router.refresh"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,13 +71,13 @@ export const SurveyDropDownMenu = ({
|
||||
try {
|
||||
await deleteSurveyAction({ surveyId });
|
||||
deleteSurvey(surveyId);
|
||||
toast.success(t("environments.surveys.survey_deleted_successfully"));
|
||||
router.refresh();
|
||||
setDeleteDialogOpen(false);
|
||||
toast.success(t("environments.surveys.survey_deleted_successfully"));
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.error_deleting_survey"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleCopyLink = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
@@ -242,7 +242,6 @@ export const SurveyDropDownMenu = ({
|
||||
setOpen={setDeleteDialogOpen}
|
||||
onDelete={() => handleDeleteSurvey(survey.id)}
|
||||
text={t("environments.surveys.delete_survey_and_responses_warning")}
|
||||
isDeleting={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TProjectConfigChannel } from "@formbricks/types/project";
|
||||
import { TSurveyFilters } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { SurveyCard } from "./survey-card";
|
||||
import { SurveyFilters } from "./survey-filters";
|
||||
import { SurveysList, initialFilters as surveyFiltersInitialFiltersFromModule } from "./survey-list";
|
||||
import { SurveyLoading } from "./survey-loading";
|
||||
|
||||
@@ -323,24 +324,6 @@ describe("SurveysList", () => {
|
||||
expect(screen.getByText("Survey Two")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handleDeleteSurvey shows loading state when the last survey is deleted", async () => {
|
||||
const surveysData = [{ ...surveyMock, id: "s1", name: "Last Survey" }];
|
||||
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: surveysData });
|
||||
const user = userEvent.setup();
|
||||
render(<SurveysList {...defaultProps} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Last Survey")).toBeInTheDocument());
|
||||
expect(screen.queryByTestId("survey-loading")).not.toBeInTheDocument();
|
||||
|
||||
const deleteButtonS1 = screen.getByTestId("delete-s1");
|
||||
await user.click(deleteButtonS1);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Last Survey")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("survey-loading")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("handleDuplicateSurvey adds the duplicated survey to the beginning of the list", async () => {
|
||||
const initialSurvey = { ...surveyMock, id: "s1", name: "Original Survey" };
|
||||
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: [initialSurvey] });
|
||||
|
||||
@@ -123,7 +123,6 @@ export const SurveysList = ({
|
||||
const handleDeleteSurvey = async (surveyId: string) => {
|
||||
const newSurveys = surveys.filter((survey) => survey.id !== surveyId);
|
||||
setSurveys(newSurveys);
|
||||
if (newSurveys.length === 0) setIsFetching(true);
|
||||
};
|
||||
|
||||
const handleDuplicateSurvey = async (survey: TSurvey) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from ".";
|
||||
} from "./index";
|
||||
|
||||
// Mock Radix UI Dialog components
|
||||
vi.mock("@radix-ui/react-dialog", () => {
|
||||
@@ -120,7 +120,7 @@ describe("Dialog Components", () => {
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("dialog-close")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dialog-close")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("x-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -11,19 +11,19 @@ const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = ({ children, ...props }: DialogPrimitive.DialogPortalProps) => (
|
||||
<DialogPrimitive.Portal {...props}>
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center md:items-center">{children}</div>
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center">{children}</div>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
DialogPortal.displayName = DialogPrimitive.Portal.displayName;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in fixed inset-0 z-50 bg-black/80 backdrop-blur-sm transition-all duration-100",
|
||||
"bg-background/80 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in fixed inset-0 z-50 backdrop-blur-sm transition-all duration-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -31,43 +31,29 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface DialogContentProps {
|
||||
hideCloseButton?: boolean;
|
||||
disableCloseOnOutsideClick?: boolean;
|
||||
width?: "default" | "wide";
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & DialogContentProps
|
||||
>(
|
||||
(
|
||||
{ className, children, hideCloseButton, disableCloseOnOutsideClick, width = "default", ...props },
|
||||
ref
|
||||
) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 md:zoom-in-90 data-[state=open]:md:slide-in-from-bottom-0 fixed z-50 flex max-h-[90dvh] w-full flex-col space-y-4 rounded-t-lg border bg-white p-4 shadow-lg md:overflow-hidden md:rounded-lg",
|
||||
width === "default" ? "md:w-[720px]" : "md:w-[720px] lg:w-[960px]",
|
||||
className
|
||||
)}
|
||||
onPointerDownOutside={disableCloseOnOutsideClick ? (e) => e.preventDefault() : undefined}
|
||||
onEscapeKeyDown={disableCloseOnOutsideClick ? (e) => e.preventDefault() : undefined}
|
||||
{...props}>
|
||||
{children}
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent absolute right-3 top-[-0.25rem] z-10 rounded-sm bg-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:text-slate-500">
|
||||
<X className="size-4 text-slate-500" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
hideCloseButton?: boolean;
|
||||
}
|
||||
>(({ className, children, hideCloseButton, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid gap-4 rounded-b-lg border p-6 shadow-lg sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
|
||||
{!hideCloseButton ? <X className="h-4 w-4" /> : null}
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
type DialogHeaderProps = Omit<React.HTMLAttributes<HTMLDivElement>, "dangerouslySetInnerHTML"> & {
|
||||
@@ -77,14 +63,7 @@ type DialogHeaderProps = Omit<React.HTMLAttributes<HTMLDivElement>, "dangerously
|
||||
};
|
||||
|
||||
const DialogHeader = ({ className, ...props }: DialogHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky top-[-32px] z-10 flex flex-shrink-0 flex-col gap-y-1 bg-white text-left",
|
||||
"[&>svg]:text-primary [&>svg]:absolute [&>svg]:size-4 [&>svg~*]:items-center [&>svg~*]:pl-6 md:[&>svg~*]:flex",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
@@ -96,56 +75,35 @@ type DialogFooterProps = Omit<React.HTMLAttributes<HTMLDivElement>, "dangerously
|
||||
|
||||
const DialogFooter = ({ className, ...props }: DialogFooterProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex flex-shrink-0 flex-col-reverse bg-white md:sticky md:flex-row md:justify-end",
|
||||
className
|
||||
)}
|
||||
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogBody = ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
|
||||
<section
|
||||
className={cn("flex-1 overflow-y-auto text-sm", className)}
|
||||
aria-label="Dialog content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogBody.displayName = "DialogBody";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-primary min-h-4 text-sm font-medium leading-none tracking-tight", className)}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("font-regular text-sm text-slate-500", className)}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogBody,
|
||||
};
|
||||
export { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger };
|
||||
|
||||
@@ -1,460 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "../button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "./index";
|
||||
|
||||
// Story options separate from component props
|
||||
interface StoryOptions {
|
||||
triggerText: string;
|
||||
showHeader: boolean;
|
||||
showIcon: boolean;
|
||||
title: string;
|
||||
showDescription: boolean;
|
||||
description: string;
|
||||
bodyContent?: React.ReactNode;
|
||||
showFooter: boolean;
|
||||
footerButtonConfiguration: "1" | "2" | "3";
|
||||
primaryButtonText: string;
|
||||
secondaryButtonText: string;
|
||||
tertiaryButtonText: string;
|
||||
bodyElementCount: number;
|
||||
}
|
||||
|
||||
type StoryProps = React.ComponentProps<typeof DialogContent> & StoryOptions;
|
||||
|
||||
const DefaultBodyContent = (elementCount: number): React.ReactNode => {
|
||||
return (
|
||||
<div>
|
||||
{Array(elementCount)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<p key={i}>Scrollable content line {i + 1}</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI/Modal",
|
||||
component: DialogContent,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: {
|
||||
sort: "requiredFirst",
|
||||
exclude: [],
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
// Component Props - Behavior Category
|
||||
hideCloseButton: {
|
||||
control: "boolean",
|
||||
description: "Whether to hide the close button (X)",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
disableCloseOnOutsideClick: {
|
||||
control: "boolean",
|
||||
description: "Whether to disable closing when clicking outside",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
|
||||
// Story Options - Appearance Category
|
||||
width: {
|
||||
control: "select",
|
||||
options: ["default", "wide"],
|
||||
description: "Width of the modal",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
showHeader: {
|
||||
control: "boolean",
|
||||
description: "Whether to show the header section",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "boolean" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
showIcon: {
|
||||
control: "boolean",
|
||||
description: "Whether to show an icon in the header",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "boolean" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
showDescription: {
|
||||
control: "boolean",
|
||||
description: "Whether to show a description in the header",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "boolean" },
|
||||
},
|
||||
order: 4,
|
||||
},
|
||||
showFooter: {
|
||||
control: "boolean",
|
||||
description: "Whether to show the footer section",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "boolean" },
|
||||
},
|
||||
order: 5,
|
||||
},
|
||||
footerButtonConfiguration: {
|
||||
control: "select",
|
||||
options: ["1", "2", "3"],
|
||||
description: "Number of buttons to show in footer",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 6,
|
||||
},
|
||||
|
||||
// Story Options - Content Category
|
||||
triggerText: {
|
||||
control: "text",
|
||||
description: "Text for the trigger button",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
title: {
|
||||
control: "text",
|
||||
description: "Modal title text",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
description: {
|
||||
control: "text",
|
||||
description: "Modal description text",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
primaryButtonText: {
|
||||
control: "text",
|
||||
description: "Text for the primary button",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 4,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
control: "text",
|
||||
description: "Text for the secondary button",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 5,
|
||||
},
|
||||
tertiaryButtonText: {
|
||||
control: "text",
|
||||
description: "Text for the tertiary button",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 6,
|
||||
},
|
||||
bodyElementCount: {
|
||||
control: { type: "number", min: 1, max: 100, step: 1 },
|
||||
description: "Number of elements in the body content",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "number" },
|
||||
},
|
||||
order: 7,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof DialogContent> & { args: StoryOptions };
|
||||
|
||||
// Create a common render function to reduce duplication
|
||||
const renderModal = (args: StoryProps) => {
|
||||
// Extract component props
|
||||
const {
|
||||
hideCloseButton = false,
|
||||
disableCloseOnOutsideClick = false,
|
||||
width = "default",
|
||||
className = "",
|
||||
} = args;
|
||||
|
||||
// Extract story content options
|
||||
const {
|
||||
triggerText = "Open Modal",
|
||||
showHeader = true,
|
||||
showIcon = false,
|
||||
title = "Modal Title",
|
||||
showDescription = true,
|
||||
description = "Modal description",
|
||||
showFooter = true,
|
||||
footerButtonConfiguration = "3",
|
||||
primaryButtonText = "Confirm",
|
||||
secondaryButtonText = "Cancel",
|
||||
tertiaryButtonText = "Learn more",
|
||||
bodyElementCount = 5,
|
||||
} = args as StoryOptions;
|
||||
|
||||
const bodyContent = DefaultBodyContent(bodyElementCount);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">{triggerText}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
hideCloseButton={hideCloseButton}
|
||||
disableCloseOnOutsideClick={disableCloseOnOutsideClick}
|
||||
width={width}
|
||||
className={className}>
|
||||
{showHeader && (
|
||||
<DialogHeader>
|
||||
{showIcon && <AlertCircle />}
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{showDescription && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
)}
|
||||
<DialogBody>{bodyContent}</DialogBody>
|
||||
{showFooter && footerButtonConfiguration === "3" && (
|
||||
<DialogFooter className="md:justify-between">
|
||||
<div className="flex w-full flex-col space-y-2 md:hidden">
|
||||
<Button className="w-full">{primaryButtonText}</Button>
|
||||
<Button className="w-full" variant="secondary">
|
||||
{secondaryButtonText}
|
||||
</Button>
|
||||
<Button className="w-full" variant="ghost">
|
||||
{tertiaryButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<Button className="justify-self-start" variant="ghost">
|
||||
{tertiaryButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="hidden md:flex md:space-x-2">
|
||||
<Button variant="secondary">{secondaryButtonText}</Button>
|
||||
<Button>{primaryButtonText}</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
{showFooter && footerButtonConfiguration !== "3" && (
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-col space-y-2 md:hidden">
|
||||
<Button className="w-full">{primaryButtonText}</Button>
|
||||
{footerButtonConfiguration !== "1" && (
|
||||
<Button className="w-full" variant="secondary">
|
||||
{secondaryButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden md:flex md:space-x-2">
|
||||
{footerButtonConfiguration !== "1" && (
|
||||
<Button variant="secondary">{secondaryButtonText}</Button>
|
||||
)}
|
||||
<Button>{primaryButtonText}</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
render: renderModal,
|
||||
args: {
|
||||
triggerText: "Open Modal",
|
||||
showHeader: true,
|
||||
showIcon: true,
|
||||
title: "Modal Title",
|
||||
showDescription: true,
|
||||
description: "This is a description of what this modal is for.",
|
||||
showFooter: true,
|
||||
footerButtonConfiguration: "3",
|
||||
primaryButtonText: "Confirm",
|
||||
secondaryButtonText: "Cancel",
|
||||
tertiaryButtonText: "Learn more",
|
||||
bodyElementCount: 5,
|
||||
hideCloseButton: false,
|
||||
disableCloseOnOutsideClick: false,
|
||||
width: "default",
|
||||
},
|
||||
};
|
||||
|
||||
export const OnlyBody: Story = {
|
||||
render: renderModal,
|
||||
args: {
|
||||
triggerText: "Open Modal - Body Only",
|
||||
showHeader: false,
|
||||
showIcon: false,
|
||||
title: "",
|
||||
showDescription: false,
|
||||
description: "",
|
||||
showFooter: false,
|
||||
footerButtonConfiguration: "1",
|
||||
primaryButtonText: "",
|
||||
secondaryButtonText: "",
|
||||
tertiaryButtonText: "",
|
||||
bodyElementCount: 50,
|
||||
hideCloseButton: false,
|
||||
disableCloseOnOutsideClick: false,
|
||||
width: "default",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "A minimal modal with only body content, useful for simple content display.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoFooter: Story = {
|
||||
render: renderModal,
|
||||
args: {
|
||||
triggerText: "Open Modal - No Footer",
|
||||
showHeader: true,
|
||||
showIcon: true,
|
||||
title: "Modal Without Footer",
|
||||
showDescription: false,
|
||||
description: "This modal has a header and body but no footer buttons.",
|
||||
showFooter: false,
|
||||
footerButtonConfiguration: "1",
|
||||
primaryButtonText: "",
|
||||
secondaryButtonText: "",
|
||||
tertiaryButtonText: "",
|
||||
bodyElementCount: 10,
|
||||
hideCloseButton: false,
|
||||
disableCloseOnOutsideClick: false,
|
||||
width: "default",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Modal with header and body content but no footer actions.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoHeader: Story = {
|
||||
render: renderModal,
|
||||
args: {
|
||||
triggerText: "Open Modal - No Header",
|
||||
showHeader: false,
|
||||
showIcon: false,
|
||||
title: "",
|
||||
showDescription: false,
|
||||
description: "",
|
||||
showFooter: true,
|
||||
footerButtonConfiguration: "2",
|
||||
primaryButtonText: "Confirm",
|
||||
secondaryButtonText: "Cancel",
|
||||
tertiaryButtonText: "",
|
||||
bodyElementCount: 8,
|
||||
hideCloseButton: false,
|
||||
disableCloseOnOutsideClick: false,
|
||||
width: "default",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Modal without header, useful when you want to focus on content and actions.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const RestrictClose: Story = {
|
||||
render: renderModal,
|
||||
args: {
|
||||
triggerText: "Open Modal - Restrict Close",
|
||||
showHeader: true,
|
||||
showIcon: true,
|
||||
title: "Modal with Restricted Close",
|
||||
showDescription: false,
|
||||
description: "This modal hides the close button and prevents closing on outside click.",
|
||||
showFooter: true,
|
||||
footerButtonConfiguration: "2",
|
||||
primaryButtonText: "Save",
|
||||
secondaryButtonText: "Cancel",
|
||||
tertiaryButtonText: "",
|
||||
bodyElementCount: 5,
|
||||
hideCloseButton: true,
|
||||
disableCloseOnOutsideClick: true,
|
||||
width: "default",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you need to force user interaction with the modal content before closing.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WideModal: Story = {
|
||||
render: renderModal,
|
||||
args: {
|
||||
triggerText: "Open Modal - Wide Modal",
|
||||
showHeader: true,
|
||||
showIcon: true,
|
||||
title: "Modal with more width",
|
||||
showDescription: false,
|
||||
description: "This modal has more width than the default modal.",
|
||||
showFooter: true,
|
||||
footerButtonConfiguration: "2",
|
||||
primaryButtonText: "Save",
|
||||
secondaryButtonText: "Cancel",
|
||||
tertiaryButtonText: "",
|
||||
bodyElementCount: 5,
|
||||
hideCloseButton: false,
|
||||
disableCloseOnOutsideClick: false,
|
||||
width: "wide",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you need to force user interaction with the modal content before closing.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
106
apps/web/modules/ui/components/modal/stories.tsx
Normal file
106
apps/web/modules/ui/components/modal/stories.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { fn } from "@storybook/test";
|
||||
import { Modal } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "UI/Modal",
|
||||
component: Modal,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component: "Modal component for displaying content in an overlay.",
|
||||
},
|
||||
story: {
|
||||
inline: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
open: { control: "boolean" },
|
||||
setOpen: { action: "setOpen" },
|
||||
title: { control: "text" },
|
||||
noPadding: { control: "boolean" },
|
||||
blur: { control: "boolean" },
|
||||
closeOnOutsideClick: { control: "boolean" },
|
||||
size: { control: { type: "select", options: ["md", "lg"] } },
|
||||
hideCloseButton: { control: "boolean" },
|
||||
restrictOverflow: { control: "boolean" },
|
||||
},
|
||||
args: { setOpen: fn() },
|
||||
} satisfies Meta<typeof Modal>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
open: true,
|
||||
children: <div>Default Modal Content</div>,
|
||||
title: "Default Modal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
primary: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeSize: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
size: "lg",
|
||||
title: "Large Modal",
|
||||
},
|
||||
};
|
||||
|
||||
export const NoPadding: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
noPadding: true,
|
||||
title: "Modal without Padding",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithBlur: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
blur: true,
|
||||
title: "Modal with Blur",
|
||||
},
|
||||
};
|
||||
|
||||
export const HideCloseButton: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
hideCloseButton: true,
|
||||
title: "Modal without Close Button",
|
||||
},
|
||||
};
|
||||
|
||||
export const PreventCloseOnOutsideClick: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
closeOnOutsideClick: false,
|
||||
title: "Modal that doesn't close on outside click",
|
||||
},
|
||||
};
|
||||
|
||||
export const RestrictOverflow: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
restrictOverflow: true,
|
||||
title: "Modal with Restricted Overflow",
|
||||
children: (
|
||||
<div style={{ height: "500px", overflowY: "auto" }}>
|
||||
{Array(50)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<p key={i}>Scrollable content line {i + 1}</p>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -23,7 +23,7 @@ export const UpgradePrompt = ({ title, description, buttons }: UpgradePromptProp
|
||||
<KeyIcon className="h-6 w-6 text-slate-900" />
|
||||
</div>
|
||||
<div className="flex max-w-[80%] flex-col items-center gap-2 text-center">
|
||||
<h2 className="text-xl font-semibold text-slate-900">{title}</h2>
|
||||
<p className="text-xl font-semibold text-slate-900">{title}</p>
|
||||
<p className="text-sm text-slate-500">{description}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
|
||||
@@ -103,7 +103,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
page.locator("#questionCard-3").getByText(surveys.createAndSubmit.ratingQuestion.highLabel)
|
||||
).toBeVisible();
|
||||
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
|
||||
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).not.toBeVisible();
|
||||
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.locator("path").nth(3).click();
|
||||
|
||||
@@ -115,7 +115,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
await expect(
|
||||
page.locator("#questionCard-4").getByText(surveys.createAndSubmit.npsQuestion.highLabel)
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).not.toBeVisible();
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
|
||||
for (let i = 0; i < 11; i++) {
|
||||
@@ -135,7 +135,7 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
await expect(page.getByText(surveys.createAndSubmit.consentQuestion.checkboxLabel)).toBeVisible();
|
||||
await expect(page.locator("#questionCard-6").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-6").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByLabel(surveys.createAndSubmit.consentQuestion.checkboxLabel).check();
|
||||
await page.getByText(surveys.createAndSubmit.consentQuestion.checkboxLabel).check();
|
||||
await page.locator("#questionCard-6").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Picture Select Question
|
||||
@@ -760,7 +760,7 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
page.locator("#questionCard-4").getByText(surveys.createWithLogicAndSubmit.ratingQuestion.highLabel)
|
||||
).toBeVisible();
|
||||
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).not.toBeVisible();
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByRole("group", { name: "Choices" }).locator("path").nth(3).click();
|
||||
|
||||
@@ -772,7 +772,7 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
await expect(
|
||||
page.locator("#questionCard-5").getByText(surveys.createWithLogicAndSubmit.npsQuestion.highLabel)
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Next" })).not.toBeVisible();
|
||||
await expect(page.locator("#questionCard-5").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
|
||||
for (let i = 0; i < 11; i++) {
|
||||
@@ -831,7 +831,7 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByLabel(surveys.createWithLogicAndSubmit.consentQuestion.checkboxLabel).check();
|
||||
await page.getByText(surveys.createWithLogicAndSubmit.consentQuestion.checkboxLabel).check();
|
||||
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// File Upload Question
|
||||
|
||||
@@ -418,6 +418,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByPlaceholder("Option 1").fill(params.singleSelectQuestion.options[0]);
|
||||
await page.getByPlaceholder("Option 2").fill(params.singleSelectQuestion.options[1]);
|
||||
await page.getByRole("button", { name: 'Add "Other"', exact: true }).click();
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Multi Select Question
|
||||
await page
|
||||
@@ -462,6 +463,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
},
|
||||
]);
|
||||
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Rating Question
|
||||
await page
|
||||
.locator("div")
|
||||
@@ -507,6 +510,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByRole("button", { name: "Add option" }).click();
|
||||
await page.getByPlaceholder("Option 5").click();
|
||||
await page.getByPlaceholder("Option 5").fill(params.ranking.choices[4]);
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Matrix Question
|
||||
await page
|
||||
@@ -545,6 +549,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByRole("button", { name: "Statement (Call to Action)" }).click();
|
||||
await page.getByPlaceholder("Your question here. Recall").fill(params.ctaQuestion.question);
|
||||
await page.getByPlaceholder("Finish").fill(params.ctaQuestion.buttonLabel);
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Consent Question
|
||||
await page
|
||||
@@ -573,6 +578,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Date" }).click();
|
||||
await page.getByLabel("Question*").fill(params.date.question);
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Cal Question
|
||||
await page
|
||||
@@ -582,6 +588,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Schedule a meeting" }).click();
|
||||
await page.getByLabel("Question*").fill(params.cal.question);
|
||||
await page.getByLabel("Required").click();
|
||||
|
||||
// Fill Address Question
|
||||
await page
|
||||
@@ -626,8 +633,8 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith
|
||||
await page.getByRole("option", { name: "secret" }).click();
|
||||
await page.locator("#action-2-operator").click();
|
||||
await page.getByRole("option", { name: "Assign =" }).click();
|
||||
await page.locator("#action-2-value-input").click();
|
||||
await page.locator("#action-2-value-input").fill("1");
|
||||
await page.getByRole("textbox", { name: "Value" }).click();
|
||||
await page.getByRole("textbox", { name: "Value" }).fill("This ");
|
||||
|
||||
// Single Select Question
|
||||
await page.getByRole("heading", { name: params.singleSelectQuestion.question }).click();
|
||||
|
||||
@@ -4,19 +4,11 @@ import { logger } from "@formbricks/logger";
|
||||
|
||||
// Define the v1 (now v2) client endpoints to be merged
|
||||
const v1ClientEndpoints = {
|
||||
"/client/{environmentId}/responses/{responseId}": {
|
||||
"/responses/{responseId}": {
|
||||
put: {
|
||||
security: [],
|
||||
description:
|
||||
"Update an existing response for example when you want to mark a response as finished or you want to change an existing response's value.",
|
||||
parameters: [
|
||||
{
|
||||
in: "path",
|
||||
name: "environmentId",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "The ID of the environment.",
|
||||
},
|
||||
{
|
||||
in: "path",
|
||||
name: "responseId",
|
||||
@@ -65,15 +57,14 @@ const v1ClientEndpoints = {
|
||||
tags: ["Client API > Response"],
|
||||
servers: [
|
||||
{
|
||||
url: "https://app.formbricks.com/api/v2",
|
||||
url: "https://app.formbricks.com/api/v2/client",
|
||||
description: "Formbricks Client",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"/client/{environmentId}/responses": {
|
||||
"/{environmentId}/responses": {
|
||||
post: {
|
||||
security: [],
|
||||
description:
|
||||
"Create a response for a survey and its fields with the user's responses. The userId & meta here is optional",
|
||||
requestBody: {
|
||||
@@ -98,15 +89,14 @@ const v1ClientEndpoints = {
|
||||
tags: ["Client API > Response"],
|
||||
servers: [
|
||||
{
|
||||
url: "https://app.formbricks.com/api/v2",
|
||||
url: "https://app.formbricks.com/api/v2/client",
|
||||
description: "Formbricks Client",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"/client/{environmentId}/contacts/{userId}/attributes": {
|
||||
"/{environmentId}/contacts/{userId}/attributes": {
|
||||
put: {
|
||||
security: [],
|
||||
description:
|
||||
"Update a contact's attributes in Formbricks to keep them in sync with your app or when you want to set a custom attribute in Formbricks.",
|
||||
parameters: [
|
||||
@@ -148,15 +138,14 @@ const v1ClientEndpoints = {
|
||||
tags: ["Client API > Contacts"],
|
||||
servers: [
|
||||
{
|
||||
url: "https://app.formbricks.com/api/v2",
|
||||
url: "https://app.formbricks.com/api/v2/client",
|
||||
description: "Formbricks Client",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"/client/{environmentId}/identify/contacts/{userId}": {
|
||||
"/{environmentId}/identify/contacts/{userId}": {
|
||||
get: {
|
||||
security: [],
|
||||
description:
|
||||
"Retrieves a contact's state including their segments, displays, responses and other tracking information. If the contact doesn't exist, it will be created.",
|
||||
parameters: [
|
||||
@@ -178,15 +167,14 @@ const v1ClientEndpoints = {
|
||||
tags: ["Client API > Contacts"],
|
||||
servers: [
|
||||
{
|
||||
url: "https://app.formbricks.com/api/v2",
|
||||
url: "https://app.formbricks.com/api/v2/client",
|
||||
description: "Formbricks Client",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"/client/{environmentId}/displays": {
|
||||
"/{environmentId}/displays": {
|
||||
post: {
|
||||
security: [],
|
||||
description:
|
||||
"Create a new display for a valid survey ID. If a userId is passed, the display is linked to the user.",
|
||||
requestBody: {
|
||||
@@ -211,25 +199,48 @@ const v1ClientEndpoints = {
|
||||
tags: ["Client API > Display"],
|
||||
servers: [
|
||||
{
|
||||
url: "https://app.formbricks.com/api/v2",
|
||||
url: "https://app.formbricks.com/api/v2/client",
|
||||
description: "Formbricks Client",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"/client/{environmentId}/environment": {
|
||||
get: {
|
||||
security: [],
|
||||
description: "Retrieves the environment state to be used in Formbricks SDKs",
|
||||
parameters: [
|
||||
"/{environmentId}/displays/{displayId}": {
|
||||
put: {
|
||||
description:
|
||||
"Update a Display for a user. A use case can be when a user submits a response & you want to link it to an existing display.",
|
||||
parameters: [{ in: "path", name: "displayId", required: true, schema: { type: "string" } }],
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { example: { responseId: "response123" }, type: "object" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
content: {
|
||||
"application/json": {
|
||||
example: { displayId: "display123" },
|
||||
schema: { type: "object" },
|
||||
},
|
||||
},
|
||||
description: "OK",
|
||||
},
|
||||
},
|
||||
summary: "Update Display",
|
||||
tags: ["Client API > Display"],
|
||||
servers: [
|
||||
{
|
||||
in: "path",
|
||||
name: "environmentId",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "The ID of the environment.",
|
||||
url: "https://app.formbricks.com/api/v2/client",
|
||||
description: "Formbricks Client",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"/{environmentId}/environment": {
|
||||
get: {
|
||||
description: "Retrieves the environment state to be used in Formbricks SDKs",
|
||||
responses: {
|
||||
"200": {
|
||||
content: {
|
||||
@@ -245,15 +256,14 @@ const v1ClientEndpoints = {
|
||||
tags: ["Client API > Environment"],
|
||||
servers: [
|
||||
{
|
||||
url: "https://app.formbricks.com/api/v2",
|
||||
url: "https://app.formbricks.com/api/v2/client",
|
||||
description: "Formbricks Client",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"/client/{environmentId}/user": {
|
||||
"/{environmentId}/user": {
|
||||
post: {
|
||||
security: [],
|
||||
description:
|
||||
"Endpoint for creating or identifying a user within the specified environment. If the user already exists, this will identify them and potentially update user attributes. If they don't exist, it will create a new user.",
|
||||
requestBody: {
|
||||
@@ -278,15 +288,14 @@ const v1ClientEndpoints = {
|
||||
tags: ["Client API > User"],
|
||||
servers: [
|
||||
{
|
||||
url: "https://app.formbricks.com/api/v2",
|
||||
url: "https://app.formbricks.com/api/v2/client",
|
||||
description: "Formbricks Client",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"/client/{environmentId}/storage": {
|
||||
"/{environmentId}/storage": {
|
||||
post: {
|
||||
security: [],
|
||||
summary: "Upload Private File",
|
||||
description:
|
||||
"API endpoint for uploading private files. Uploaded files are kept private so that only users with access to the specified environment can retrieve them. The endpoint validates the survey ID, file name, and file type from the request body, and returns a signed URL for S3 uploads along with a local upload URL.",
|
||||
@@ -433,15 +442,14 @@ const v1ClientEndpoints = {
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: "https://app.formbricks.com/api/v2",
|
||||
url: "https://app.formbricks.com/api/v2/client",
|
||||
description: "Formbricks API Server",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"/client/{environmentId}/storage/local": {
|
||||
"/{environmentId}/storage/local": {
|
||||
post: {
|
||||
security: [],
|
||||
summary: "Upload Private File to Local Storage",
|
||||
description:
|
||||
'API endpoint for uploading private files to local storage. The request must include a valid signature, UUID, and timestamp to verify the upload. The file is provided as a Base64 encoded string in the request body. The "Content-Type" header must be set to a valid MIME type, and the file data must be a valid file object (buffer).',
|
||||
@@ -470,8 +478,7 @@ const v1ClientEndpoints = {
|
||||
},
|
||||
fileName: {
|
||||
type: "string",
|
||||
description:
|
||||
"This must be the `fileName` returned from the [Upload Private File](/api-v2-reference/client-api->-file-upload/upload-private-file) endpoint (Step 1).",
|
||||
description: "The URI encoded file name.",
|
||||
},
|
||||
fileType: {
|
||||
type: "string",
|
||||
|
||||
@@ -197,7 +197,6 @@ tls:
|
||||
alpnProtocols:
|
||||
- h2
|
||||
- http/1.1
|
||||
- acme-tls/1
|
||||
EOT
|
||||
|
||||
echo "💡 Created traefik.yaml and traefik-dynamic.yaml file."
|
||||
|
||||
@@ -34,18 +34,11 @@ tags:
|
||||
security:
|
||||
- apiKeyAuth: []
|
||||
paths:
|
||||
/client/{environmentId}/responses/{responseId}:
|
||||
/responses/{responseId}:
|
||||
put:
|
||||
security: []
|
||||
description: Update an existing response for example when you want to mark a
|
||||
response as finished or you want to change an existing response's value.
|
||||
parameters:
|
||||
- in: path
|
||||
name: environmentId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The ID of the environment.
|
||||
- in: path
|
||||
name: responseId
|
||||
required: true
|
||||
@@ -84,11 +77,10 @@ paths:
|
||||
tags:
|
||||
- Client API > Response
|
||||
servers:
|
||||
- url: https://app.formbricks.com/api/v2
|
||||
- url: https://app.formbricks.com/api/v2/client
|
||||
description: Formbricks Client
|
||||
/client/{environmentId}/responses:
|
||||
/{environmentId}/responses:
|
||||
post:
|
||||
security: []
|
||||
description: Create a response for a survey and its fields with the user's
|
||||
responses. The userId & meta here is optional
|
||||
requestBody:
|
||||
@@ -112,11 +104,10 @@ paths:
|
||||
tags:
|
||||
- Client API > Response
|
||||
servers:
|
||||
- url: https://app.formbricks.com/api/v2
|
||||
- url: https://app.formbricks.com/api/v2/client
|
||||
description: Formbricks Client
|
||||
/client/{environmentId}/contacts/{userId}/attributes:
|
||||
/{environmentId}/contacts/{userId}/attributes:
|
||||
put:
|
||||
security: []
|
||||
description: Update a contact's attributes in Formbricks to keep them in sync
|
||||
with your app or when you want to set a custom attribute in Formbricks.
|
||||
parameters:
|
||||
@@ -161,11 +152,10 @@ paths:
|
||||
tags:
|
||||
- Client API > Contacts
|
||||
servers:
|
||||
- url: https://app.formbricks.com/api/v2
|
||||
- url: https://app.formbricks.com/api/v2/client
|
||||
description: Formbricks Client
|
||||
/client/{environmentId}/identify/contacts/{userId}:
|
||||
/{environmentId}/identify/contacts/{userId}:
|
||||
get:
|
||||
security: []
|
||||
description: Retrieves a contact's state including their segments, displays,
|
||||
responses and other tracking information. If the contact doesn't exist,
|
||||
it will be created.
|
||||
@@ -194,11 +184,10 @@ paths:
|
||||
tags:
|
||||
- Client API > Contacts
|
||||
servers:
|
||||
- url: https://app.formbricks.com/api/v2
|
||||
- url: https://app.formbricks.com/api/v2/client
|
||||
description: Formbricks Client
|
||||
/client/{environmentId}/displays:
|
||||
/{environmentId}/displays:
|
||||
post:
|
||||
security: []
|
||||
description: Create a new display for a valid survey ID. If a userId is passed,
|
||||
the display is linked to the user.
|
||||
requestBody:
|
||||
@@ -222,19 +211,43 @@ paths:
|
||||
tags:
|
||||
- Client API > Display
|
||||
servers:
|
||||
- url: https://app.formbricks.com/api/v2
|
||||
- url: https://app.formbricks.com/api/v2/client
|
||||
description: Formbricks Client
|
||||
/client/{environmentId}/environment:
|
||||
get:
|
||||
security: []
|
||||
description: Retrieves the environment state to be used in Formbricks SDKs
|
||||
/{environmentId}/displays/{displayId}:
|
||||
put:
|
||||
description: Update a Display for a user. A use case can be when a user submits
|
||||
a response & you want to link it to an existing display.
|
||||
parameters:
|
||||
- in: path
|
||||
name: environmentId
|
||||
name: displayId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The ID of the environment.
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
example:
|
||||
responseId: response123
|
||||
type: object
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
example:
|
||||
displayId: display123
|
||||
schema:
|
||||
type: object
|
||||
description: OK
|
||||
summary: Update Display
|
||||
tags:
|
||||
- Client API > Display
|
||||
servers:
|
||||
- url: https://app.formbricks.com/api/v2/client
|
||||
description: Formbricks Client
|
||||
/{environmentId}/environment:
|
||||
get:
|
||||
description: Retrieves the environment state to be used in Formbricks SDKs
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
@@ -249,11 +262,10 @@ paths:
|
||||
tags:
|
||||
- Client API > Environment
|
||||
servers:
|
||||
- url: https://app.formbricks.com/api/v2
|
||||
- url: https://app.formbricks.com/api/v2/client
|
||||
description: Formbricks Client
|
||||
/client/{environmentId}/user:
|
||||
/{environmentId}/user:
|
||||
post:
|
||||
security: []
|
||||
description: Endpoint for creating or identifying a user within the specified
|
||||
environment. If the user already exists, this will identify them and
|
||||
potentially update user attributes. If they don't exist, it will create
|
||||
@@ -280,11 +292,10 @@ paths:
|
||||
tags:
|
||||
- Client API > User
|
||||
servers:
|
||||
- url: https://app.formbricks.com/api/v2
|
||||
- url: https://app.formbricks.com/api/v2/client
|
||||
description: Formbricks Client
|
||||
/client/{environmentId}/storage:
|
||||
/{environmentId}/storage:
|
||||
post:
|
||||
security: []
|
||||
summary: Upload Private File
|
||||
description: API endpoint for uploading private files. Uploaded files are kept
|
||||
private so that only users with access to the specified environment can
|
||||
@@ -391,11 +402,10 @@ paths:
|
||||
example:
|
||||
error: Survey survey123 not found
|
||||
servers:
|
||||
- url: https://app.formbricks.com/api/v2
|
||||
- url: https://app.formbricks.com/api/v2/client
|
||||
description: Formbricks API Server
|
||||
/client/{environmentId}/storage/local:
|
||||
/{environmentId}/storage/local:
|
||||
post:
|
||||
security: []
|
||||
summary: Upload Private File to Local Storage
|
||||
description: API endpoint for uploading private files to local storage. The
|
||||
request must include a valid signature, UUID, and timestamp to verify
|
||||
@@ -423,9 +433,7 @@ paths:
|
||||
description: The ID of the survey associated with the file.
|
||||
fileName:
|
||||
type: string
|
||||
description: This must be the `fileName` returned from the [Upload Private
|
||||
File](/api-v2-reference/client-api->-file-upload/upload-private-file)
|
||||
endpoint (Step 1).
|
||||
description: The URI encoded file name.
|
||||
fileType:
|
||||
type: string
|
||||
description: The MIME type of the file.
|
||||
@@ -1523,15 +1531,12 @@ paths:
|
||||
put:
|
||||
operationId: uploadBulkContacts
|
||||
summary: Upload Bulk Contacts
|
||||
description: Uploads contacts in bulk. Each contact in the payload must have an
|
||||
'email' attribute present in their attributes array. The email attribute
|
||||
is mandatory and must be a valid email format.
|
||||
description: Uploads contacts in bulk
|
||||
tags:
|
||||
- Management API > Contacts
|
||||
requestBody:
|
||||
required: true
|
||||
description: The contacts to upload. Each contact **must include an 'email'
|
||||
attribute** in their attributes array.
|
||||
description: The contacts to upload
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -1566,39 +1571,10 @@ paths:
|
||||
- value
|
||||
required:
|
||||
- attributes
|
||||
maxItems: 250
|
||||
maxItems: 1000
|
||||
required:
|
||||
- environmentId
|
||||
- contacts
|
||||
example:
|
||||
environmentId: env_01h2xce9q8p3w4x5y6z7a8b9c0
|
||||
contacts:
|
||||
- attributes:
|
||||
- attributeKey:
|
||||
key: email
|
||||
name: Email Address
|
||||
value: john.doe@example.com
|
||||
- attributeKey:
|
||||
key: firstName
|
||||
name: First Name
|
||||
value: John
|
||||
- attributeKey:
|
||||
key: lastName
|
||||
name: Last Name
|
||||
value: Doe
|
||||
- attributes:
|
||||
- attributeKey:
|
||||
key: email
|
||||
name: Email Address
|
||||
value: jane.smith@example.com
|
||||
- attributeKey:
|
||||
key: firstName
|
||||
name: First Name
|
||||
value: Jane
|
||||
- attributeKey:
|
||||
key: lastName
|
||||
name: Last Name
|
||||
value: Smith
|
||||
responses:
|
||||
"200":
|
||||
description: Contacts uploaded successfully.
|
||||
@@ -4407,22 +4383,6 @@ components:
|
||||
- enabled
|
||||
- message
|
||||
description: Email verification configuration (deprecated)
|
||||
recaptcha:
|
||||
type:
|
||||
- object
|
||||
- "null"
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
threshold:
|
||||
type: number
|
||||
multipleOf: 0.1
|
||||
minimum: 0.1
|
||||
maximum: 0.9
|
||||
required:
|
||||
- enabled
|
||||
- threshold
|
||||
description: Google reCAPTCHA configuration
|
||||
displayPercentage:
|
||||
type:
|
||||
- number
|
||||
@@ -4471,7 +4431,6 @@ components:
|
||||
- inlineTriggers
|
||||
- isBackButtonHidden
|
||||
- verifyEmail
|
||||
- recaptcha
|
||||
- displayPercentage
|
||||
- questions
|
||||
webhook:
|
||||
|
||||
@@ -74,8 +74,6 @@ cronJob:
|
||||
|
||||
## Deployment & Autoscaling
|
||||
deployment:
|
||||
image:
|
||||
pullPolicy: Always
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video-upload";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
@@ -27,7 +26,7 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="fb-group/image fb-relative fb-mb-6 fb-block fb-min-h-40 fb-rounded-md">
|
||||
<div className="fb-group/image fb-relative fb-mb-4 fb-block fb-min-h-40 fb-rounded-md">
|
||||
{isLoading ? (
|
||||
<div className="fb-absolute fb-inset-auto fb-flex fb-h-full fb-w-full fb-animate-pulse fb-items-center fb-justify-center fb-rounded-md fb-bg-slate-200" />
|
||||
) : null}
|
||||
@@ -36,16 +35,10 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
|
||||
key={imgUrl}
|
||||
src={imgUrl}
|
||||
alt={altText}
|
||||
className={cn(
|
||||
"fb-rounded-custom fb-max-h-[40dvh] fb-mx-auto fb-object-contain",
|
||||
isLoading ? "fb-opacity-0" : ""
|
||||
)}
|
||||
className="fb-rounded-custom"
|
||||
onLoad={() => {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
onError={() => {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{videoUrlWithParams ? (
|
||||
@@ -55,13 +48,10 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
|
||||
src={videoUrlWithParams}
|
||||
title="Question Video"
|
||||
frameBorder="0"
|
||||
className={cn("fb-rounded-custom fb-aspect-video fb-w-full", isLoading ? "fb-opacity-0" : "")}
|
||||
className="fb-rounded-custom fb-aspect-video fb-w-full"
|
||||
onLoad={() => {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
onError={() => {
|
||||
setIsLoading(false);
|
||||
}}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function AddressQuestion({
|
||||
currentQuestionId,
|
||||
autoFocusEnabled,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<AddressQuestionProps>) {
|
||||
}: AddressQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
@@ -40,7 +40,7 @@ export function CalQuestion({
|
||||
setTtc,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<CalQuestionProps>) {
|
||||
}: CalQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ConsentQuestion({
|
||||
currentQuestionId,
|
||||
autoFocusEnabled,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<ConsentQuestionProps>) {
|
||||
}: ConsentQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const isCurrent = question.id === currentQuestionId;
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ContactInfoQuestion({
|
||||
currentQuestionId,
|
||||
autoFocusEnabled,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<ContactInfoQuestionProps>) {
|
||||
}: ContactInfoQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
@@ -41,7 +41,7 @@ export function CTAQuestion({
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
onOpenExternalURL,
|
||||
}: Readonly<CTAQuestionProps>) {
|
||||
}: CTAQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const isCurrent = question.id === currentQuestionId;
|
||||
|
||||
@@ -94,7 +94,7 @@ export function DateQuestion({
|
||||
ttc,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<DateQuestionProps>) {
|
||||
}: DateQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
|
||||
@@ -14,21 +14,21 @@ import { FileInput } from "../general/file-input";
|
||||
import { Subheader } from "../general/subheader";
|
||||
|
||||
interface FileUploadQuestionProps {
|
||||
question: TSurveyFileUploadQuestion;
|
||||
value: string[];
|
||||
onChange: (responseData: TResponseData) => void;
|
||||
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
|
||||
onBack: () => void;
|
||||
onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
|
||||
isFirstQuestion: boolean;
|
||||
isLastQuestion: boolean;
|
||||
surveyId: string;
|
||||
languageCode: string;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
autoFocusEnabled: boolean;
|
||||
currentQuestionId: TSurveyQuestionId;
|
||||
isBackButtonHidden: boolean;
|
||||
readonly question: TSurveyFileUploadQuestion;
|
||||
readonly value: string[];
|
||||
readonly onChange: (responseData: TResponseData) => void;
|
||||
readonly onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
|
||||
readonly onBack: () => void;
|
||||
readonly onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
|
||||
readonly isFirstQuestion: boolean;
|
||||
readonly isLastQuestion: boolean;
|
||||
readonly surveyId: string;
|
||||
readonly languageCode: string;
|
||||
readonly ttc: TResponseTtc;
|
||||
readonly setTtc: (ttc: TResponseTtc) => void;
|
||||
readonly autoFocusEnabled: boolean;
|
||||
readonly currentQuestionId: TSurveyQuestionId;
|
||||
readonly isBackButtonHidden: boolean;
|
||||
}
|
||||
|
||||
export function FileUploadQuestion({
|
||||
@@ -46,7 +46,7 @@ export function FileUploadQuestion({
|
||||
setTtc,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<FileUploadQuestionProps>) {
|
||||
}: FileUploadQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
|
||||
|
||||
@@ -40,7 +40,7 @@ export function MatrixQuestion({
|
||||
setTtc,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<MatrixQuestionProps>) {
|
||||
}: MatrixQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
|
||||
|
||||
@@ -41,7 +41,7 @@ export function MultipleChoiceMultiQuestion({
|
||||
autoFocusEnabled,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<MultipleChoiceMultiProps>) {
|
||||
}: MultipleChoiceMultiProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, question.id === currentQuestionId);
|
||||
|
||||
@@ -41,7 +41,7 @@ export function MultipleChoiceSingleQuestion({
|
||||
autoFocusEnabled,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<MultipleChoiceSingleProps>) {
|
||||
}: MultipleChoiceSingleProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [otherSelected, setOtherSelected] = useState(false);
|
||||
const otherSpecify = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -40,7 +40,7 @@ export function NPSQuestion({
|
||||
setTtc,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<NPSQuestionProps>) {
|
||||
}: NPSQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [hoveredNumber, setHoveredNumber] = useState(-1);
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
|
||||
@@ -43,7 +43,7 @@ export function OpenTextQuestion({
|
||||
autoFocusEnabled,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<OpenTextQuestionProps>) {
|
||||
}: OpenTextQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [currentLength, setCurrentLength] = useState(value.length || 0);
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
|
||||
@@ -171,7 +171,7 @@ describe("PictureSelectionQuestion", () => {
|
||||
render(<PictureSelectionQuestion {...mockProps} />);
|
||||
|
||||
const images = screen.getAllByRole("img");
|
||||
const label = images[0].closest("button");
|
||||
const label = images[0].closest("label");
|
||||
|
||||
fireEvent.keyDown(label!, { key: " " });
|
||||
|
||||
|
||||
@@ -41,15 +41,8 @@ export function PictureSelectionQuestion({
|
||||
setTtc,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<PictureSelectionProps>) {
|
||||
}: PictureSelectionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>(() => {
|
||||
const initialLoadingState: Record<string, boolean> = {};
|
||||
question.choices.forEach((choice) => {
|
||||
initialLoadingState[choice.id] = true;
|
||||
});
|
||||
return initialLoadingState;
|
||||
});
|
||||
const isMediaAvailable = question.imageUrl || question.videoUrl;
|
||||
const isCurrent = question.id === currentQuestionId;
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime, isCurrent);
|
||||
@@ -122,75 +115,35 @@ export function PictureSelectionQuestion({
|
||||
<div className="fb-mt-4">
|
||||
<fieldset>
|
||||
<legend className="fb-sr-only">Options</legend>
|
||||
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-1 sm:fb-grid-cols-2 fb-gap-4">
|
||||
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-2 fb-gap-4">
|
||||
{questionChoices.map((choice) => (
|
||||
<div className="fb-relative" key={choice.id}>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
onKeyDown={(e) => {
|
||||
// Accessibility: if spacebar was pressed pass this down to the input
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.click();
|
||||
e.currentTarget.focus();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
handleChange(choice.id);
|
||||
}}
|
||||
className={cn(
|
||||
"fb-relative fb-w-full fb-cursor-pointer fb-overflow-hidden fb-border fb-rounded-custom focus-visible:fb-outline-none focus-visible:fb-ring-2 focus-visible:fb-ring-brand focus-visible:fb-ring-offset-2 fb-aspect-[4/3] fb-min-h-[7rem] fb-max-h-[50vh] group/image",
|
||||
Array.isArray(value) && value.includes(choice.id)
|
||||
? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm"
|
||||
: ""
|
||||
)}>
|
||||
{loadingImages[choice.id] && (
|
||||
<div className="fb-absolute fb-inset-0 fb-flex fb-h-full fb-w-full fb-animate-pulse fb-items-center fb-justify-center fb-rounded-md fb-bg-slate-200" />
|
||||
)}
|
||||
<img
|
||||
src={choice.imageUrl}
|
||||
id={choice.id}
|
||||
alt={getOriginalFileNameFromUrl(choice.imageUrl)}
|
||||
className={cn(
|
||||
"fb-h-full fb-w-full fb-object-cover",
|
||||
loadingImages[choice.id] ? "fb-opacity-0" : ""
|
||||
)}
|
||||
onLoad={() => {
|
||||
setLoadingImages((prev) => ({ ...prev, [choice.id]: false }));
|
||||
}}
|
||||
onError={() => {
|
||||
setLoadingImages((prev) => ({ ...prev, [choice.id]: false }));
|
||||
}}
|
||||
/>
|
||||
{question.allowMulti ? (
|
||||
<input
|
||||
id={`${choice.id}-checked`}
|
||||
name={`${choice.id}-checkbox`}
|
||||
type="checkbox"
|
||||
tabIndex={-1}
|
||||
checked={value.includes(choice.id)}
|
||||
className={cn(
|
||||
"fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border",
|
||||
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
|
||||
)}
|
||||
required={question.required && value.length ? false : question.required}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={`${choice.id}-radio`}
|
||||
name={`${question.id}`}
|
||||
type="radio"
|
||||
tabIndex={-1}
|
||||
checked={value.includes(choice.id)}
|
||||
className={cn(
|
||||
"fb-border-border fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border",
|
||||
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
|
||||
)}
|
||||
required={question.required && value.length ? false : question.required}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<label
|
||||
key={choice.id}
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
htmlFor={choice.id}
|
||||
onKeyDown={(e) => {
|
||||
// Accessibility: if spacebar was pressed pass this down to the input
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
document.getElementById(choice.id)?.click();
|
||||
document.getElementById(choice.id)?.focus();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
handleChange(choice.id);
|
||||
}}
|
||||
className={cn(
|
||||
"fb-relative fb-w-full fb-cursor-pointer fb-overflow-hidden fb-border fb-rounded-custom focus:fb-outline-none fb-aspect-[4/3] fb-min-h-[7rem] fb-max-h-[50vh] focus:fb-border-brand focus:fb-border-4 group/image",
|
||||
Array.isArray(value) && value.includes(choice.id)
|
||||
? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm"
|
||||
: ""
|
||||
)}>
|
||||
<img
|
||||
src={choice.imageUrl}
|
||||
id={choice.id}
|
||||
alt={getOriginalFileNameFromUrl(choice.imageUrl)}
|
||||
className="fb-h-full fb-w-full fb-object-cover"
|
||||
/>
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href={choice.imageUrl}
|
||||
@@ -200,25 +153,52 @@ export function PictureSelectionQuestion({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="fb-absolute fb-bottom-4 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100 fb-z-20">
|
||||
className="fb-absolute fb-bottom-2 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-opacity-0 fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="lucide lucide-image-down-icon lucide-image-down">
|
||||
<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21" />
|
||||
<path d="m14 19 3 3v-5.5" />
|
||||
<path d="m17 22 3-3" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
className="lucide lucide-expand">
|
||||
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
|
||||
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
|
||||
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
|
||||
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{question.allowMulti ? (
|
||||
<input
|
||||
id={`${choice.id}-checked`}
|
||||
name={`${choice.id}-checkbox`}
|
||||
type="checkbox"
|
||||
tabIndex={-1}
|
||||
checked={value.includes(choice.id)}
|
||||
className={cn(
|
||||
"fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border",
|
||||
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
|
||||
)}
|
||||
required={question.required && value.length ? false : question.required}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={`${choice.id}-radio`}
|
||||
name={`${choice.id}-radio`}
|
||||
type="radio"
|
||||
tabIndex={-1}
|
||||
checked={value.includes(choice.id)}
|
||||
className={cn(
|
||||
"fb-border-border fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border",
|
||||
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
|
||||
)}
|
||||
required={question.required && value.length ? false : question.required}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -46,7 +46,7 @@ export function RankingQuestion({
|
||||
autoFocusEnabled,
|
||||
currentQuestionId,
|
||||
isBackButtonHidden,
|
||||
}: Readonly<RankingQuestionProps>) {
|
||||
}: RankingQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const isCurrent = question.id === currentQuestionId;
|
||||
const shuffledChoicesIds = useMemo(() => {
|
||||
|
||||
@@ -6,7 +6,7 @@ interface ScrollableContainerProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export function ScrollableContainer({ children }: Readonly<ScrollableContainerProps>) {
|
||||
export function ScrollableContainer({ children }: ScrollableContainerProps) {
|
||||
const [isAtBottom, setIsAtBottom] = useState(false);
|
||||
const [isAtTop, setIsAtTop] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -30,7 +30,7 @@ export function StackedCardsContainer({
|
||||
setQuestionId,
|
||||
shouldResetQuestionId = true,
|
||||
fullSizeCards = false,
|
||||
}: Readonly<StackedCardsContainerProps>) {
|
||||
}: StackedCardsContainerProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const highlightBorderColor = survey.styling?.overwriteThemeStyling
|
||||
? survey.styling?.highlightBorderColor?.light
|
||||
|
||||
@@ -20,7 +20,7 @@ export function SurveyContainer({
|
||||
onClose,
|
||||
clickOutside,
|
||||
isOpen = true,
|
||||
}: Readonly<SurveyContainerProps>) {
|
||||
}: SurveyContainerProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const isCenter = placement === "center";
|
||||
const isModal = mode === "modal";
|
||||
|
||||
Reference in New Issue
Block a user