mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 14:10:45 -06:00
Compare commits
38 Commits
harsh/plai
...
feat/s3-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09494905fc | ||
|
|
855e7c78ce | ||
|
|
6c506d90c7 | ||
|
|
53f6e02ca1 | ||
|
|
14de2eab42 | ||
|
|
ad1f80331a | ||
|
|
3527ac337b | ||
|
|
23c2d3dce9 | ||
|
|
da652bd860 | ||
|
|
6f88dde1a0 | ||
|
|
3b90223101 | ||
|
|
e29a67b1f6 | ||
|
|
78f5de2f35 | ||
|
|
b1a35d4a69 | ||
|
|
2166c44470 | ||
|
|
080cf741e9 | ||
|
|
8881691509 | ||
|
|
3045f4437f | ||
|
|
91ace0e821 | ||
|
|
6ef281647a | ||
|
|
0aaaaa54ee | ||
|
|
b1f78e7bf2 | ||
|
|
7086ce2ca3 | ||
|
|
8f8b549b1d | ||
|
|
28514487e0 | ||
|
|
ee20af54c3 | ||
|
|
d08ec4c9ab | ||
|
|
891c83e232 | ||
|
|
0b02b00b72 | ||
|
|
a217cdd501 | ||
|
|
ebe50a4821 | ||
|
|
cb68d9defc | ||
|
|
c42a706789 | ||
|
|
3803111b19 | ||
|
|
30fdcff737 | ||
|
|
e83cfa85a4 | ||
|
|
eee9ee8995 | ||
|
|
ed89f12af8 |
@@ -18,7 +18,6 @@ apps/web/
|
||||
│ ├── (app)/ # Main application routes
|
||||
│ ├── (auth)/ # Authentication routes
|
||||
│ ├── api/ # API routes
|
||||
│ └── share/ # Public sharing routes
|
||||
├── components/ # Shared components
|
||||
├── lib/ # Utility functions and services
|
||||
└── modules/ # Feature-specific modules
|
||||
@@ -43,7 +42,6 @@ The application uses Next.js 13+ app router with route groups:
|
||||
### Dynamic Routes
|
||||
- `[environmentId]` - Environment-specific routes
|
||||
- `[surveyId]` - Survey-specific routes
|
||||
- `[sharingKey]` - Public sharing routes
|
||||
|
||||
## Service Layer Pattern
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ When testing hooks that use React Context:
|
||||
vi.mocked(useResponseFilter).mockReturnValue({
|
||||
selectedFilter: {
|
||||
filter: [],
|
||||
onlyComplete: false,
|
||||
responseStatus: "all",
|
||||
},
|
||||
setSelectedFilter: vi.fn(),
|
||||
selectedOptions: {
|
||||
@@ -291,11 +291,6 @@ test("handles different modes", async () => {
|
||||
expect(vi.mocked(regularApi)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test sharing mode
|
||||
vi.mocked(useParams).mockReturnValue({
|
||||
surveyId: "123",
|
||||
sharingKey: "share-123"
|
||||
});
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -194,9 +194,6 @@ REDIS_URL=redis://localhost:6379
|
||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||
# REDIS_HTTP_URL:
|
||||
|
||||
# The below is used for Rate Limiting for management API
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Questions
|
||||
url: https://github.com/formbricks/formbricks/discussions
|
||||
|
||||
101
.github/workflows/docker-build-validation.yml
vendored
101
.github/workflows/docker-build-validation.yml
vendored
@@ -59,18 +59,32 @@ jobs:
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
|
||||
- name: Verify PostgreSQL Connection
|
||||
- name: Verify and Initialize PostgreSQL
|
||||
run: |
|
||||
echo "Verifying PostgreSQL connection..."
|
||||
# Install PostgreSQL client to test connection
|
||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||
|
||||
# Test connection using psql
|
||||
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
|
||||
# Test connection using psql with timeout and proper error handling
|
||||
echo "Testing PostgreSQL connection with 30 second timeout..."
|
||||
if timeout 30 bash -c 'until PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" >/dev/null 2>&1; do
|
||||
echo "Waiting for PostgreSQL to be ready..."
|
||||
sleep 2
|
||||
done'; then
|
||||
echo "✅ PostgreSQL connection successful"
|
||||
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "SELECT version();"
|
||||
|
||||
# Enable necessary extensions that might be required by migrations
|
||||
echo "Enabling required PostgreSQL extensions..."
|
||||
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "CREATE EXTENSION IF NOT EXISTS vector;" || echo "Vector extension already exists or not available"
|
||||
|
||||
else
|
||||
echo "❌ PostgreSQL connection failed after 30 seconds"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Show network configuration
|
||||
echo "Network configuration:"
|
||||
ip addr show
|
||||
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
|
||||
|
||||
- name: Test Docker Image with Health Check
|
||||
@@ -89,26 +103,9 @@ jobs:
|
||||
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
|
||||
-d formbricks-test:${{ github.sha }}
|
||||
|
||||
# Give it more time to start up
|
||||
echo "Waiting 45 seconds for application to start..."
|
||||
sleep 45
|
||||
|
||||
# Check if the container is running
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
|
||||
echo "❌ Container failed to start properly!"
|
||||
docker logs formbricks-test
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Container started successfully!"
|
||||
fi
|
||||
|
||||
# Try connecting to PostgreSQL from inside the container
|
||||
echo "Testing PostgreSQL connection from inside container..."
|
||||
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
|
||||
|
||||
# Try to access the health endpoint
|
||||
echo "🏥 Testing /health endpoint..."
|
||||
MAX_RETRIES=10
|
||||
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
|
||||
echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..."
|
||||
MAX_RETRIES=60 # 60 attempts × 5 seconds = 5 minutes
|
||||
RETRY_COUNT=0
|
||||
HEALTH_CHECK_SUCCESS=false
|
||||
|
||||
@@ -116,38 +113,32 @@ jobs:
|
||||
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
|
||||
|
||||
# Show container logs before each attempt to help debugging
|
||||
if [ $RETRY_COUNT -gt 1 ]; then
|
||||
echo "📋 Current container logs:"
|
||||
docker logs --tail 20 formbricks-test
|
||||
|
||||
# Check if container is still running
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test 2>/dev/null)" != "true" ]; then
|
||||
echo "❌ Container stopped running after $((RETRY_COUNT * 5)) seconds!"
|
||||
echo "📋 Container logs:"
|
||||
docker logs formbricks-test
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get detailed curl output for debugging
|
||||
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
|
||||
CURL_EXIT_CODE=$?
|
||||
|
||||
echo "Curl exit code: $CURL_EXIT_CODE"
|
||||
echo "Curl output: $HTTP_OUTPUT"
|
||||
|
||||
if [ $CURL_EXIT_CODE -eq 0 ]; then
|
||||
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
|
||||
echo "Status code detected: $STATUS_CODE"
|
||||
|
||||
if [ "$STATUS_CODE" = "200" ]; then
|
||||
echo "✅ Health check successful!"
|
||||
HEALTH_CHECK_SUCCESS=true
|
||||
break
|
||||
else
|
||||
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
|
||||
fi
|
||||
else
|
||||
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
|
||||
|
||||
# Show progress and diagnostic info every 12 attempts (1 minute intervals)
|
||||
if [ $((RETRY_COUNT % 12)) -eq 0 ] || [ $RETRY_COUNT -eq 1 ]; then
|
||||
echo "Health check attempt $RETRY_COUNT of $MAX_RETRIES ($(($RETRY_COUNT * 5)) seconds elapsed)..."
|
||||
echo "📋 Recent container logs:"
|
||||
docker logs --tail 10 formbricks-test
|
||||
fi
|
||||
|
||||
echo "Waiting 15 seconds before next attempt..."
|
||||
sleep 15
|
||||
|
||||
# Try health endpoint with shorter timeout for faster polling
|
||||
# Use -f flag to make curl fail on HTTP error status codes (4xx, 5xx)
|
||||
if curl -f -s -m 10 http://localhost:3000/health >/dev/null 2>&1; then
|
||||
echo "✅ Health check successful after $((RETRY_COUNT * 5)) seconds!"
|
||||
HEALTH_CHECK_SUCCESS=true
|
||||
break
|
||||
fi
|
||||
|
||||
# Wait 5 seconds before next attempt
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# Show full container logs for debugging
|
||||
@@ -160,7 +151,7 @@ jobs:
|
||||
|
||||
# Exit with failure if health check did not succeed
|
||||
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
|
||||
echo "❌ Health check failed after $MAX_RETRIES attempts"
|
||||
echo "❌ Health check failed after $((MAX_RETRIES * 5)) seconds (5 minutes)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -76,12 +76,9 @@ jobs:
|
||||
echo "Generated SemVer version: $VERSION"
|
||||
|
||||
- name: Update package.json version
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
run: |
|
||||
cd ./apps/web
|
||||
npm version $VERSION --no-git-tag-version
|
||||
echo "Updated version to: $(npm pkg get version)"
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep version
|
||||
|
||||
- name: Set Sentry environment in .env
|
||||
run: |
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
import { createRequire } from "module";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* This function is used to resolve the absolute path of a package.
|
||||
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
||||
*/
|
||||
const getAbsolutePath = (value: string) => {
|
||||
function getAbsolutePath(value: string): any {
|
||||
return dirname(require.resolve(join(value, "package.json")));
|
||||
};
|
||||
}
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
|
||||
@@ -71,10 +71,6 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
||||
alert: {
|
||||
...user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...user.notificationSettings?.weeklySummary,
|
||||
[project.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(user.id, {
|
||||
|
||||
@@ -24,14 +24,17 @@ export const ActionClassesTable = ({
|
||||
otherEnvActionClasses,
|
||||
otherEnvironment,
|
||||
}: ActionClassesTableProps) => {
|
||||
const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false);
|
||||
const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false);
|
||||
|
||||
const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
|
||||
|
||||
const handleOpenActionDetailModalClick = (e, actionClass: TActionClass) => {
|
||||
const handleOpenActionDetailModalClick = (
|
||||
e: React.MouseEvent<HTMLButtonElement>,
|
||||
actionClass: TActionClass
|
||||
) => {
|
||||
e.preventDefault();
|
||||
setActiveActionClass(actionClass);
|
||||
setActionDetailModalOpen(true);
|
||||
setIsActionDetailModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -42,7 +45,7 @@ export const ActionClassesTable = ({
|
||||
{actionClasses.length > 0 ? (
|
||||
actionClasses.map((actionClass, index) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
handleOpenActionDetailModalClick(e, actionClass);
|
||||
}}
|
||||
className="w-full"
|
||||
@@ -63,7 +66,7 @@ export const ActionClassesTable = ({
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
open={isActionDetailModalOpen}
|
||||
setOpen={setActionDetailModalOpen}
|
||||
setOpen={setIsActionDetailModalOpen}
|
||||
actionClasses={actionClasses}
|
||||
actionClass={activeActionClass}
|
||||
isReadOnly={isReadOnly}
|
||||
|
||||
@@ -70,15 +70,13 @@ export const ActionDetailModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalWithTabs
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
tabs={tabs}
|
||||
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
label={actionClass.name}
|
||||
description={typeDescription()}
|
||||
/>
|
||||
</>
|
||||
<ModalWithTabs
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
tabs={tabs}
|
||||
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
label={actionClass.name}
|
||||
description={typeDescription()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,21 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
|
||||
updateActionClassAction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock action utils
|
||||
vi.mock("@/modules/survey/editor/lib/action-utils", () => ({
|
||||
useActionClassKeys: vi.fn(() => ["existing-key"]),
|
||||
createActionClassZodResolver: vi.fn(() => vi.fn()),
|
||||
validatePermissions: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock action builder
|
||||
vi.mock("@/modules/survey/editor/lib/action-builder", () => ({
|
||||
buildActionObject: vi.fn((data, environmentId, t) => ({
|
||||
...data,
|
||||
environmentId,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock utils
|
||||
vi.mock("@/app/lib/actionClass/actionClass", () => ({
|
||||
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
|
||||
@@ -24,6 +39,7 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/code-action-form", () => ({
|
||||
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
||||
<div data-testid="code-action-form" data-readonly={isReadOnly}>
|
||||
@@ -31,6 +47,7 @@ vi.mock("@/modules/ui/components/code-action-form", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
|
||||
open ? (
|
||||
@@ -43,6 +60,26 @@ vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/action-name-description-fields", () => ({
|
||||
ActionNameDescriptionFields: ({ isReadOnly, nameInputId, descriptionInputId }: any) => (
|
||||
<div data-testid="action-name-description-fields">
|
||||
<input
|
||||
data-testid={`name-input-${nameInputId}`}
|
||||
placeholder="environments.actions.eg_clicked_download"
|
||||
disabled={isReadOnly}
|
||||
defaultValue="Test Action"
|
||||
/>
|
||||
<input
|
||||
data-testid={`description-input-${descriptionInputId}`}
|
||||
placeholder="environments.actions.user_clicked_download_button"
|
||||
disabled={isReadOnly}
|
||||
defaultValue="Test Description"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
|
||||
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
||||
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
|
||||
@@ -56,6 +93,23 @@ vi.mock("lucide-react", () => ({
|
||||
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
|
||||
}));
|
||||
|
||||
// Mock react-hook-form
|
||||
const mockHandleSubmit = vi.fn();
|
||||
const mockForm = {
|
||||
handleSubmit: mockHandleSubmit,
|
||||
control: {},
|
||||
formState: { errors: {} },
|
||||
};
|
||||
|
||||
vi.mock("react-hook-form", async () => {
|
||||
const actual = await vi.importActual("react-hook-form");
|
||||
return {
|
||||
...actual,
|
||||
useForm: vi.fn(() => mockForm),
|
||||
FormProvider: ({ children }: any) => <div>{children}</div>,
|
||||
};
|
||||
});
|
||||
|
||||
const mockSetOpen = vi.fn();
|
||||
const mockActionClasses: TActionClass[] = [
|
||||
{
|
||||
@@ -88,6 +142,7 @@ const createMockActionClass = (id: string, type: TActionClassType, name: string)
|
||||
describe("ActionSettingsTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHandleSubmit.mockImplementation((fn) => fn);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -105,13 +160,9 @@ describe("ActionSettingsTab", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
|
||||
actionClass.name
|
||||
);
|
||||
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
|
||||
actionClass.description
|
||||
);
|
||||
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
|
||||
@@ -131,18 +182,104 @@ describe("ActionSettingsTab", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
|
||||
actionClass.name
|
||||
);
|
||||
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
|
||||
actionClass.description
|
||||
);
|
||||
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly for other action types (fallback)", () => {
|
||||
const actionClass = {
|
||||
...createMockActionClass("auto1", "noCode", "Auto Action"),
|
||||
type: "automatic" as any,
|
||||
};
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls utility functions on initialization", async () => {
|
||||
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
|
||||
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
|
||||
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles successful form submission", async () => {
|
||||
const { updateActionClassAction } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/actions/actions"
|
||||
);
|
||||
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
|
||||
|
||||
vi.mocked(updateActionClassAction).mockResolvedValue({ data: {} } as any);
|
||||
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check that utility functions were called during component initialization
|
||||
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
|
||||
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles permission validation error", async () => {
|
||||
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
|
||||
vi.mocked(actionUtilsMock.validatePermissions).mockImplementation(() => {
|
||||
throw new Error("Not authorized");
|
||||
});
|
||||
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "common.save_changes" });
|
||||
|
||||
mockHandleSubmit.mockImplementation((fn) => (e) => {
|
||||
e.preventDefault();
|
||||
return fn({ name: "Test", type: "noCode" });
|
||||
});
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Not authorized");
|
||||
});
|
||||
});
|
||||
|
||||
test("handles successful deletion", async () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
const { deleteActionClassAction } = await import(
|
||||
@@ -209,17 +346,16 @@ describe("ActionSettingsTab", () => {
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={true} // Set to read-only
|
||||
isReadOnly={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toBeDisabled();
|
||||
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeDisabled();
|
||||
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeDisabled();
|
||||
expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true");
|
||||
expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); // Docs link still visible
|
||||
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("prevents delete when read-only", async () => {
|
||||
@@ -228,7 +364,6 @@ describe("ActionSettingsTab", () => {
|
||||
"@/app/(app)/environments/[environmentId]/actions/actions"
|
||||
);
|
||||
|
||||
// Render with isReadOnly=true, but simulate a delete attempt
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
@@ -238,12 +373,6 @@ describe("ActionSettingsTab", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Try to open and confirm delete dialog (buttons won't exist, so we simulate the flow)
|
||||
// This test primarily checks the logic within handleDeleteAction if it were called.
|
||||
// A better approach might be to export handleDeleteAction for direct testing,
|
||||
// but for now, we assume the UI prevents calling it.
|
||||
|
||||
// We can assert that the delete button isn't there to prevent the flow
|
||||
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
||||
expect(deleteActionClassAction).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -262,4 +391,19 @@ describe("ActionSettingsTab", () => {
|
||||
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
|
||||
expect(docsLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
test("uses correct input IDs for ActionNameDescriptionFields", () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,14 +4,17 @@ import {
|
||||
deleteActionClassAction,
|
||||
updateActionClassAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/actions/actions";
|
||||
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
|
||||
import { buildActionObject } from "@/modules/survey/editor/lib/action-builder";
|
||||
import {
|
||||
createActionClassZodResolver,
|
||||
useActionClassKeys,
|
||||
validatePermissions,
|
||||
} from "@/modules/survey/editor/lib/action-utils";
|
||||
import { ActionNameDescriptionFields } from "@/modules/ui/components/action-name-description-fields";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { CodeActionForm } from "@/modules/ui/components/code-action-form";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -19,8 +22,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
|
||||
|
||||
interface ActionSettingsTabProps {
|
||||
actionClass: TActionClass;
|
||||
@@ -48,63 +50,51 @@ export const ActionSettingsTab = ({
|
||||
[actionClass.id, actionClasses]
|
||||
);
|
||||
|
||||
const actionClassKeys = useActionClassKeys(actionClasses);
|
||||
|
||||
const form = useForm<TActionClassInput>({
|
||||
defaultValues: {
|
||||
...restActionClass,
|
||||
},
|
||||
resolver: zodResolver(
|
||||
ZActionClassInput.superRefine((data, ctx) => {
|
||||
if (data.name && actionClassNames.includes(data.name)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["name"],
|
||||
message: t("environments.actions.action_with_name_already_exists", { name: data.name }),
|
||||
});
|
||||
}
|
||||
})
|
||||
),
|
||||
resolver: createActionClassZodResolver(actionClassNames, actionClassKeys, t),
|
||||
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const { handleSubmit, control } = form;
|
||||
|
||||
const renderActionForm = () => {
|
||||
if (actionClass.type === "code") {
|
||||
return (
|
||||
<>
|
||||
<CodeActionForm form={form} isReadOnly={true} />
|
||||
<p className="text-sm text-slate-600">
|
||||
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (actionClass.type === "noCode") {
|
||||
return <NoCodeActionForm form={form} isReadOnly={isReadOnly} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="text-sm text-slate-600">
|
||||
{t("environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it")}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: TActionClassInput) => {
|
||||
try {
|
||||
if (isReadOnly) {
|
||||
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
|
||||
}
|
||||
setIsUpdatingAction(true);
|
||||
validatePermissions(isReadOnly, t);
|
||||
const updatedAction = buildActionObject(data, actionClass.environmentId, t);
|
||||
|
||||
if (data.name && actionClassNames.includes(data.name)) {
|
||||
throw new Error(t("environments.actions.action_with_name_already_exists", { name: data.name }));
|
||||
}
|
||||
|
||||
if (
|
||||
data.type === "noCode" &&
|
||||
data.noCodeConfig?.type === "click" &&
|
||||
data.noCodeConfig.elementSelector.cssSelector &&
|
||||
!isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector)
|
||||
) {
|
||||
throw new Error(t("environments.actions.invalid_css_selector"));
|
||||
}
|
||||
|
||||
const updatedData: TActionClassInput = {
|
||||
...data,
|
||||
...(data.type === "noCode" &&
|
||||
data.noCodeConfig?.type === "click" && {
|
||||
noCodeConfig: {
|
||||
...data.noCodeConfig,
|
||||
elementSelector: {
|
||||
cssSelector: data.noCodeConfig.elementSelector.cssSelector,
|
||||
innerHtml: data.noCodeConfig.elementSelector.innerHtml,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
await updateActionClassAction({
|
||||
actionClassId: actionClass.id,
|
||||
updatedAction: updatedData,
|
||||
updatedAction: updatedAction,
|
||||
});
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
@@ -123,7 +113,7 @@ export const ActionSettingsTab = ({
|
||||
router.refresh();
|
||||
toast.success(t("environments.actions.action_deleted_successfully"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsDeletingAction(false);
|
||||
@@ -135,79 +125,14 @@ export const ActionSettingsTab = ({
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
|
||||
<div className="grid w-full grid-cols-2 gap-x-4">
|
||||
<div className="col-span-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
disabled={isReadOnly}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="actionNameSettingsInput">
|
||||
{actionClass.type === "noCode"
|
||||
? t("environments.actions.what_did_your_user_do")
|
||||
: t("environments.actions.display_name")}
|
||||
</FormLabel>
|
||||
<ActionNameDescriptionFields
|
||||
control={control}
|
||||
isReadOnly={isReadOnly}
|
||||
nameInputId="actionNameSettingsInput"
|
||||
descriptionInputId="actionDescriptionSettingsInput"
|
||||
/>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
id="actionNameSettingsInput"
|
||||
{...field}
|
||||
placeholder={t("environments.actions.eg_clicked_download")}
|
||||
isInvalid={!!error?.message}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="actionDescriptionSettingsInput">
|
||||
{t("common.description")}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
id="actionDescriptionSettingsInput"
|
||||
{...field}
|
||||
placeholder={t("environments.actions.user_clicked_download_button")}
|
||||
value={field.value ?? ""}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionClass.type === "code" ? (
|
||||
<>
|
||||
<CodeActionForm form={form} isReadOnly={true} />
|
||||
<p className="text-sm text-slate-600">
|
||||
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
|
||||
</p>
|
||||
</>
|
||||
) : actionClass.type === "noCode" ? (
|
||||
<NoCodeActionForm form={form} isReadOnly={isReadOnly} />
|
||||
) : (
|
||||
<p className="text-sm text-slate-600">
|
||||
{t(
|
||||
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{renderActionForm()}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
|
||||
|
||||
@@ -9,8 +9,12 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
getOrganizationProjectsLimit,
|
||||
getRoleManagementPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { Session } from "next-auth";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -49,10 +53,14 @@ vi.mock("@/lib/membership/utils", () => ({
|
||||
}));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
getRoleManagementPermission: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||
getProjectPermissionByUserId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
|
||||
getTeamsByOrganizationId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
@@ -71,7 +79,13 @@ vi.mock("@/lib/constants", () => ({
|
||||
|
||||
// Mock components
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
|
||||
MainNavigation: () => <div data-testid="main-navigation">MainNavigation</div>,
|
||||
MainNavigation: ({ organizationTeams, canDoRoleManagement }: any) => (
|
||||
<div data-testid="main-navigation">
|
||||
MainNavigation
|
||||
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
|
||||
<div data-testid="can-do-role-management">{canDoRoleManagement?.toString() || "false"}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
|
||||
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
||||
@@ -104,7 +118,7 @@ const mockUser = {
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
||||
notificationSettings: { alert: {} },
|
||||
} as unknown as TUser;
|
||||
|
||||
const mockOrganization = {
|
||||
@@ -156,6 +170,17 @@ const mockProjectPermission = {
|
||||
role: "admin",
|
||||
} as any;
|
||||
|
||||
const mockOrganizationTeams = [
|
||||
{
|
||||
id: "team-1",
|
||||
name: "Development Team",
|
||||
},
|
||||
{
|
||||
id: "team-2",
|
||||
name: "Marketing Team",
|
||||
},
|
||||
];
|
||||
|
||||
const mockSession: Session = {
|
||||
user: {
|
||||
id: "user-1",
|
||||
@@ -176,6 +201,8 @@ describe("EnvironmentLayout", () => {
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
mockIsDevelopment = false;
|
||||
mockIsFormbricksCloud = false;
|
||||
});
|
||||
@@ -288,6 +315,110 @@ describe("EnvironmentLayout", () => {
|
||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("passes canDoRoleManagement props to MainNavigation", async () => {
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
|
||||
expect(vi.mocked(getRoleManagementPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
|
||||
});
|
||||
|
||||
test("handles empty organizationTeams array", async () => {
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
|
||||
});
|
||||
|
||||
test("handles null organizationTeams", async () => {
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
|
||||
});
|
||||
|
||||
test("handles canDoRoleManagement false", async () => {
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
|
||||
});
|
||||
|
||||
test("throws error if user not found", async () => {
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
getOrganizationProjectsLimit,
|
||||
getRoleManagementPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
@@ -48,9 +51,10 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const [projects, environments] = await Promise.all([
|
||||
const [projects, environments, canDoRoleManagement] = await Promise.all([
|
||||
getUserProjects(user.id, organization.id),
|
||||
getEnvironments(environment.projectId),
|
||||
getRoleManagementPermission(organization.billing.plan),
|
||||
]);
|
||||
|
||||
if (!projects || !environments || !organizations) {
|
||||
@@ -117,6 +121,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
membershipRole={membershipRole}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
isLicenseActive={active}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
/>
|
||||
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
|
||||
<TopControlBar
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
@@ -52,9 +53,19 @@ vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
||||
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
|
||||
}));
|
||||
vi.mock("@/modules/projects/components/project-switcher", () => ({
|
||||
ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
|
||||
ProjectSwitcher: ({
|
||||
isCollapsed,
|
||||
organizationTeams,
|
||||
canDoRoleManagement,
|
||||
}: {
|
||||
isCollapsed: boolean;
|
||||
organizationTeams: TOrganizationTeam[];
|
||||
canDoRoleManagement: boolean;
|
||||
}) => (
|
||||
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
|
||||
Project Switcher
|
||||
<div data-testid="organization-teams-count">{organizationTeams?.length || 0}</div>
|
||||
<div data-testid="can-do-role-management">{canDoRoleManagement.toString()}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
@@ -106,7 +117,7 @@ const mockUser = {
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
||||
notificationSettings: { alert: {} },
|
||||
role: "project_manager",
|
||||
objective: "other",
|
||||
} as unknown as TUser;
|
||||
@@ -146,6 +157,7 @@ const defaultProps = {
|
||||
membershipRole: "owner" as const,
|
||||
organizationProjectsLimit: 5,
|
||||
isLicenseActive: true,
|
||||
canDoRoleManagement: true,
|
||||
};
|
||||
|
||||
describe("MainNavigation", () => {
|
||||
@@ -334,4 +346,23 @@ describe("MainNavigation", () => {
|
||||
});
|
||||
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("passes canDoRoleManagement props to ProjectSwitcher", () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
|
||||
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
|
||||
});
|
||||
|
||||
test("handles no organizationTeams", () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
|
||||
});
|
||||
|
||||
test("handles canDoRoleManagement false", () => {
|
||||
render(<MainNavigation {...defaultProps} canDoRoleManagement={false} />);
|
||||
|
||||
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ interface NavigationProps {
|
||||
membershipRole?: TOrganizationRole;
|
||||
organizationProjectsLimit: number;
|
||||
isLicenseActive: boolean;
|
||||
canDoRoleManagement: boolean;
|
||||
}
|
||||
|
||||
export const MainNavigation = ({
|
||||
@@ -80,6 +81,7 @@ export const MainNavigation = ({
|
||||
organizationProjectsLimit,
|
||||
isLicenseActive,
|
||||
isDevelopment,
|
||||
canDoRoleManagement,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -323,6 +325,7 @@ export const MainNavigation = ({
|
||||
isTextVisible={isTextVisible}
|
||||
organization={organization}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ const TestComponent = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="onlyComplete">{selectedFilter.onlyComplete.toString()}</div>
|
||||
<div data-testid="responseStatus">{selectedFilter.responseStatus}</div>
|
||||
<div data-testid="filterLength">{selectedFilter.filter.length}</div>
|
||||
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
|
||||
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
|
||||
@@ -44,7 +44,7 @@ const TestComponent = () => {
|
||||
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
|
||||
},
|
||||
],
|
||||
onlyComplete: true,
|
||||
responseStatus: "complete",
|
||||
})
|
||||
}>
|
||||
Update Filter
|
||||
@@ -81,7 +81,7 @@ describe("ResponseFilterContext", () => {
|
||||
</ResponseFilterProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("onlyComplete").textContent).toBe("false");
|
||||
expect(screen.getByTestId("responseStatus").textContent).toBe("all");
|
||||
expect(screen.getByTestId("filterLength").textContent).toBe("0");
|
||||
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
|
||||
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
|
||||
@@ -99,7 +99,7 @@ describe("ResponseFilterContext", () => {
|
||||
const updateButton = screen.getByText("Update Filter");
|
||||
await userEvent.click(updateButton);
|
||||
|
||||
expect(screen.getByTestId("onlyComplete").textContent).toBe("true");
|
||||
expect(screen.getByTestId("responseStatus").textContent).toBe("complete");
|
||||
expect(screen.getByTestId("filterLength").textContent).toBe("1");
|
||||
});
|
||||
|
||||
|
||||
@@ -16,9 +16,11 @@ export interface FilterValue {
|
||||
};
|
||||
}
|
||||
|
||||
export type TResponseStatus = "all" | "complete" | "partial";
|
||||
|
||||
export interface SelectedFilterValue {
|
||||
filter: FilterValue[];
|
||||
onlyComplete: boolean;
|
||||
responseStatus: TResponseStatus;
|
||||
}
|
||||
|
||||
interface SelectedFilterOptions {
|
||||
@@ -47,7 +49,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
// state holds the filter selected value
|
||||
const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({
|
||||
filter: [],
|
||||
onlyComplete: false,
|
||||
responseStatus: "all",
|
||||
});
|
||||
// state holds all the options of the responses fetched
|
||||
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
|
||||
@@ -67,7 +69,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
});
|
||||
setSelectedFilter({
|
||||
filter: [],
|
||||
onlyComplete: false,
|
||||
responseStatus: "all",
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TProject } from "@formbricks/types/project";
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
|
||||
@@ -35,6 +36,7 @@ export const EnvironmentContextWrapper = ({
|
||||
() => ({
|
||||
environment,
|
||||
project,
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
[environment, project]
|
||||
);
|
||||
|
||||
@@ -30,16 +30,16 @@ interface ManageIntegrationProps {
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
const tableHeaders = [
|
||||
"common.survey",
|
||||
"environments.integrations.airtable.table_name",
|
||||
"common.questions",
|
||||
"common.updated_at",
|
||||
];
|
||||
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { t } = useTranslate();
|
||||
|
||||
const tableHeaders = [
|
||||
t("common.survey"),
|
||||
t("environments.integrations.airtable.table_name"),
|
||||
t("common.questions"),
|
||||
t("common.updated_at"),
|
||||
];
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
|
||||
@@ -100,7 +100,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
{tableHeaders.map((header) => (
|
||||
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
|
||||
{t(header)}
|
||||
{header}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import React from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
|
||||
interface Column<T> {
|
||||
/** Header text rendered in the table head */
|
||||
header: React.ReactNode;
|
||||
/** Cell renderer for an item */
|
||||
render: (item: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface ActionButtonProps {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
/** Optional Lucide Icon */
|
||||
icon?: React.ReactNode;
|
||||
/** Tooltip content */
|
||||
tooltip?: string;
|
||||
/** Variant override */
|
||||
variant?: "default" | "outline" | "secondary" | "destructive" | "ghost";
|
||||
}
|
||||
|
||||
interface IntegrationListPanelProps<T> {
|
||||
readonly environment: TEnvironment;
|
||||
readonly statusNode: React.ReactNode;
|
||||
readonly reconnectAction: ActionButtonProps;
|
||||
readonly addNewAction: ActionButtonProps;
|
||||
readonly emptyMessage: string;
|
||||
readonly items: T[];
|
||||
readonly columns: Column<T>[];
|
||||
readonly onRowClick: (index: number) => void;
|
||||
readonly getRowKey?: (item: T, index: number) => string | number;
|
||||
}
|
||||
|
||||
export function IntegrationListPanel<T>({
|
||||
environment,
|
||||
statusNode,
|
||||
reconnectAction,
|
||||
addNewAction,
|
||||
emptyMessage,
|
||||
items,
|
||||
columns,
|
||||
onRowClick,
|
||||
getRowKey,
|
||||
}: IntegrationListPanelProps<T>) {
|
||||
return (
|
||||
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
||||
{/* Toolbar */}
|
||||
<div className="flex w-full justify-end space-x-2">
|
||||
<div className="mr-6 flex items-center">{statusNode}</div>
|
||||
|
||||
{/* Re-connect */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant={reconnectAction.variant ?? "outline"} onClick={reconnectAction.onClick}>
|
||||
{reconnectAction.icon}
|
||||
{reconnectAction.label}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
{reconnectAction.tooltip && <TooltipContent>{reconnectAction.tooltip}</TooltipContent>}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Add new */}
|
||||
<Button variant={addNewAction.variant ?? "default"} onClick={addNewAction.onClick}>
|
||||
{addNewAction.icon}
|
||||
{addNewAction.label}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Empty table view */}
|
||||
{!items || items.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={emptyMessage}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
<div className="mt-6 w-full rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-6 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
{columns.map((col) => (
|
||||
<div key={`hdr-${String(col.header)}`} className="col-span-2 hidden text-center sm:block">
|
||||
{col.header}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{items.map((item, index) => {
|
||||
const key = getRowKey ? getRowKey(item, index) : index;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
onClick={() => onRowClick(index)}>
|
||||
{columns.map((col) => (
|
||||
<div key={`cell-${String(col.header)}`} className="col-span-2 text-center">
|
||||
{col.render(item)}
|
||||
</div>
|
||||
))}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -220,7 +220,6 @@ const surveys: TSurvey[] = [
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
@@ -258,7 +257,6 @@ const surveys: TSurvey[] = [
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
@@ -119,7 +119,6 @@ const mockSurveys: TSurvey[] = [
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
export interface QuestionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TSurveyQuestionTypeEnum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a flat list of selectable question / metadata items for integrations.
|
||||
* Extracted to avoid duplication between integration modals.
|
||||
*/
|
||||
export const buildQuestionItems = (
|
||||
selectedSurvey: TSurvey | null | undefined,
|
||||
t: TFnType
|
||||
): QuestionItem[] => {
|
||||
const questions: QuestionItem[] = selectedSurvey
|
||||
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
|
||||
id: q.id,
|
||||
name: getLocalizedValue(q.headline, "default"),
|
||||
type: q.type,
|
||||
})) || []
|
||||
: [];
|
||||
|
||||
const variables: QuestionItem[] =
|
||||
selectedSurvey?.variables.map((variable) => ({
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
})) || [];
|
||||
|
||||
const hiddenFields: QuestionItem[] = selectedSurvey?.hiddenFields.enabled
|
||||
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
|
||||
id: fId,
|
||||
name: `${t("common.hidden_field")} : ${fId}`,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
})) || []
|
||||
: [];
|
||||
|
||||
const metadata: QuestionItem[] = [
|
||||
{
|
||||
id: "metadata",
|
||||
name: t("common.metadata"),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
},
|
||||
];
|
||||
|
||||
const createdAt: QuestionItem[] = [
|
||||
{
|
||||
id: "createdAt",
|
||||
name: t("common.created_at"),
|
||||
type: TSurveyQuestionTypeEnum.Date,
|
||||
},
|
||||
];
|
||||
|
||||
return [...questions, ...variables, ...hiddenFields, ...metadata, ...createdAt];
|
||||
};
|
||||
@@ -236,7 +236,6 @@ const surveys: TSurvey[] = [
|
||||
languages: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
@@ -272,7 +271,6 @@ const surveys: TSurvey[] = [
|
||||
languages: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { buildQuestionItems } from "@/app/(app)/environments/[environmentId]/integrations/lib/questionItems";
|
||||
import {
|
||||
ERRORS,
|
||||
TYPE_MAPPING,
|
||||
UNSUPPORTED_TYPES_BY_NOTION,
|
||||
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
|
||||
import NotionLogo from "@/images/notion.png";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
TIntegrationNotionConfigData,
|
||||
TIntegrationNotionDatabase,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface AddIntegrationModalProps {
|
||||
environmentId: string;
|
||||
@@ -117,7 +118,47 @@ export const AddIntegrationModal = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDatabase?.id]);
|
||||
|
||||
const questionItems = useMemo(() => buildQuestionItems(selectedSurvey, t), [selectedSurvey?.id, t]);
|
||||
const questionItems = useMemo(() => {
|
||||
const questions = selectedSurvey
|
||||
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
|
||||
id: q.id,
|
||||
name: getLocalizedValue(q.headline, "default"),
|
||||
type: q.type,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const variables =
|
||||
selectedSurvey?.variables.map((variable) => ({
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
})) || [];
|
||||
|
||||
const hiddenFields = selectedSurvey?.hiddenFields.enabled
|
||||
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
|
||||
id: fId,
|
||||
name: `${t("common.hidden_field")} : ${fId}`,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
})) || []
|
||||
: [];
|
||||
const Metadata = [
|
||||
{
|
||||
id: "metadata",
|
||||
name: t("common.metadata"),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
},
|
||||
];
|
||||
const createdAt = [
|
||||
{
|
||||
id: "createdAt",
|
||||
name: t("common.created_at"),
|
||||
type: TSurveyQuestionTypeEnum.Date,
|
||||
},
|
||||
];
|
||||
|
||||
return [...questions, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedSurvey?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIntegration) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
@@ -12,7 +14,6 @@ import toast from "react-hot-toast";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { IntegrationListPanel } from "../../components/IntegrationListPanel";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
environment: TEnvironment;
|
||||
@@ -69,58 +70,78 @@ export const ManageIntegration = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegrationListPanel
|
||||
environment={environment}
|
||||
statusNode={
|
||||
<>
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="text-slate-500">
|
||||
{t("environments.integrations.notion.connected_with_workspace", {
|
||||
workspace: notionIntegration.config.key.workspace_name,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
reconnectAction={{
|
||||
label: t("environments.integrations.notion.update_connection"),
|
||||
onClick: handleNotionAuthorization,
|
||||
icon: <RefreshCcwIcon className="mr-2 h-4 w-4" />,
|
||||
tooltip: t("environments.integrations.notion.update_connection_tooltip"),
|
||||
variant: "outline",
|
||||
}}
|
||||
addNewAction={{
|
||||
label: t("environments.integrations.notion.link_new_database"),
|
||||
onClick: () => {
|
||||
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
||||
<div className="flex w-full justify-end space-x-2">
|
||||
<div className="mr-6 flex items-center">
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="text-slate-500">
|
||||
{t("environments.integrations.notion.connected_with_workspace", {
|
||||
workspace: notionIntegration.config.key.workspace_name,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" onClick={handleNotionAuthorization}>
|
||||
<RefreshCcwIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.integrations.notion.update_connection")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("environments.integrations.notion.update_connection_tooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedIntegration(null);
|
||||
setOpenAddIntegrationModal(true);
|
||||
},
|
||||
}}
|
||||
emptyMessage={t("environments.integrations.notion.no_databases_found")}
|
||||
items={integrationArray}
|
||||
columns={[
|
||||
{
|
||||
header: t("common.survey"),
|
||||
render: (item: TIntegrationNotionConfigData) => item.surveyName,
|
||||
},
|
||||
{
|
||||
header: t("environments.integrations.notion.database_name"),
|
||||
render: (item: TIntegrationNotionConfigData) => item.databaseName,
|
||||
},
|
||||
{
|
||||
header: t("common.updated_at"),
|
||||
render: (item: TIntegrationNotionConfigData) => timeSince(item.createdAt.toString(), locale),
|
||||
},
|
||||
]}
|
||||
onRowClick={editIntegration}
|
||||
getRowKey={(item: TIntegrationNotionConfigData, idx) => `${idx}-${item.databaseId}`}
|
||||
/>
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)}>
|
||||
<Trash2Icon />
|
||||
{t("environments.integrations.delete_integration")}
|
||||
}}>
|
||||
{t("environments.integrations.notion.link_new_database")}
|
||||
</Button>
|
||||
</div>
|
||||
{!integrationArray || integrationArray.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environment={environment}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage={t("environments.integrations.notion.no_databases_found")}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
<div className="mt-6 w-full rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-6 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.survey")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">
|
||||
{t("environments.integrations.notion.database_name")}
|
||||
</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
|
||||
</div>
|
||||
{integrationArray &&
|
||||
integrationArray.map((data, index) => {
|
||||
return (
|
||||
<button
|
||||
key={`${index}-${data.databaseId}`}
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
editIntegration(index);
|
||||
}}>
|
||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||
<div className="col-span-2 text-center">{data.databaseName}</div>
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), locale)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
|
||||
<Trash2Icon />
|
||||
{t("environments.integrations.delete_integration")}
|
||||
</Button>
|
||||
|
||||
<DeleteDialog
|
||||
open={isDeleteIntegrationModalOpen}
|
||||
@@ -130,6 +151,6 @@ export const ManageIntegration = ({
|
||||
text={t("environments.integrations.delete_integration_confirmation")}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -128,7 +128,6 @@ const mockSurveys: TSurvey[] = [
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
|
||||
@@ -6,7 +6,6 @@ import JsLogo from "@/images/jslogo.png";
|
||||
import MakeLogo from "@/images/make-small.png";
|
||||
import n8nLogo from "@/images/n8n.png";
|
||||
import notionLogo from "@/images/notion.png";
|
||||
import PlainCom from "@/images/plain.webp";
|
||||
import SlackLogo from "@/images/slacklogo.png";
|
||||
import WebhookLogo from "@/images/webhook.png";
|
||||
import ZapierLogo from "@/images/zapier-small.png";
|
||||
@@ -51,7 +50,6 @@ const Page = async (props) => {
|
||||
|
||||
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
|
||||
const isNotionIntegrationConnected = isIntegrationConnected("notion");
|
||||
const isPlainIntegrationConnected = isIntegrationConnected("plain");
|
||||
const isAirtableIntegrationConnected = isIntegrationConnected("airtable");
|
||||
const isN8nIntegrationConnected = isIntegrationConnected("n8n");
|
||||
const isSlackIntegrationConnected = isIntegrationConnected("slack");
|
||||
@@ -209,20 +207,6 @@ const Page = async (props) => {
|
||||
: `${activePiecesWebhookCount} ${t("common.integrations")}`,
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/activepieces",
|
||||
docsText: t("common.docs"),
|
||||
docsNewTab: true,
|
||||
connectHref: `/environments/${params.environmentId}/integrations/plain`,
|
||||
connectText: `${isPlainIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||
connectNewTab: false,
|
||||
label: "Plain",
|
||||
description: t("environments.integrations.plain.plain_integration_description"),
|
||||
icon: <Image src={PlainCom} alt="Plain.com Logo" />,
|
||||
connected: isPlainIntegrationConnected,
|
||||
statusText: isPlainIntegrationConnected ? t("common.connected") : t("common.not_connected"),
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
];
|
||||
|
||||
integrationCards.unshift({
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricEncrypt } from "@/lib/crypto";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import type { TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
|
||||
|
||||
const ZConnectPlainIntegration = z.object({
|
||||
environmentId: ZId,
|
||||
key: z.string().min(1),
|
||||
});
|
||||
|
||||
export const connectPlainIntegrationAction = authenticatedActionClient
|
||||
.schema(ZConnectPlainIntegration)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const { environmentId, key } = parsedInput;
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
const projectId = await getProjectIdFromEnvironmentId(environmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const encryptedAccessToken = symmetricEncrypt(key, ENCRYPTION_KEY);
|
||||
|
||||
const existingIntegration = await getIntegrationByType(environmentId, "plain");
|
||||
const plainData: TIntegrationPlainConfigData[] =
|
||||
existingIntegration?.type === "plain"
|
||||
? (existingIntegration.config.data as TIntegrationPlainConfigData[])
|
||||
: [];
|
||||
|
||||
const integration = await createOrUpdateIntegration(environmentId, {
|
||||
type: "plain",
|
||||
config: {
|
||||
key: encryptedAccessToken,
|
||||
data: plainData,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
integration,
|
||||
};
|
||||
});
|
||||
@@ -1,567 +0,0 @@
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/plain/components/AddIntegrationModal";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TIntegrationPlain,
|
||||
TIntegrationPlainConfigData,
|
||||
TPlainFieldType,
|
||||
} from "@formbricks/types/integration/plain";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock actions and utilities
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
createOrUpdateIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
|
||||
}));
|
||||
vi.mock("@/lib/pollyfills/structuredClone", () => ({
|
||||
structuredClone: (obj: any) => JSON.parse(JSON.stringify(obj)),
|
||||
}));
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceHeadlineRecall: (survey: any) => survey,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, loading, variant, type = "button" }: any) => (
|
||||
<button onClick={onClick} disabled={loading} data-variant={variant} type={type}>
|
||||
{loading ? "Loading..." : children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
DropdownSelector: ({ label, items, selectedItem, setSelectedItem, placeholder, disabled }: any) => {
|
||||
// Ensure the selected item is always available as an option
|
||||
const allOptions = [...items];
|
||||
if (selectedItem && !items.some((item: any) => item.id === selectedItem.id)) {
|
||||
// Use a simple object structure consistent with how options are likely used
|
||||
allOptions.push({ id: selectedItem.id, name: selectedItem.name });
|
||||
}
|
||||
// Remove duplicates just in case
|
||||
const uniqueOptions = Array.from(new Map(allOptions.map((item) => [item.id, item])).values());
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
<select
|
||||
data-testid={`dropdown-${label?.toLowerCase().replace(/\s+/g, "-") || placeholder?.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
value={selectedItem?.id || ""} // Still set value based on selectedItem prop
|
||||
onChange={(e) => {
|
||||
const selected = uniqueOptions.find((item: any) => item.id === e.target.value);
|
||||
setSelectedItem(selected);
|
||||
}}
|
||||
disabled={disabled}>
|
||||
<option value="">{placeholder || "Select..."}</option>
|
||||
{/* Render options from the potentially augmented list */}
|
||||
{uniqueOptions.map((item: any) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="dialog">{children}</div> : null,
|
||||
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="dialog-content" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="dialog-header" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<p data-testid="dialog-description" className={className}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<h2 data-testid="dialog-title">{children}</h2>
|
||||
),
|
||||
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="dialog-body" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="dialog-footer" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("lucide-react", () => ({
|
||||
PlusIcon: () => <span data-testid="plus-icon">+</span>,
|
||||
TrashIcon: () => <span data-testid="trash-icon">🗑️</span>,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
|
||||
}));
|
||||
vi.mock("react-hook-form", () => ({
|
||||
useForm: () => ({
|
||||
handleSubmit: (callback: any) => (event: any) => {
|
||||
event.preventDefault();
|
||||
return callback();
|
||||
},
|
||||
register: vi.fn(),
|
||||
setValue: vi.fn(),
|
||||
watch: vi.fn(),
|
||||
formState: { errors: {} },
|
||||
}),
|
||||
}));
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@tolgee/react", async () => {
|
||||
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
const useTranslate = () => ({
|
||||
t: (key: string) => {
|
||||
// Simple mock translation function
|
||||
if (key === "common.warning") return "Warning";
|
||||
if (key === "common.metadata") return "Metadata";
|
||||
if (key === "common.created_at") return "Created at";
|
||||
if (key === "common.hidden_field") return "Hidden Field";
|
||||
if (key === "common.first_name") return "First Name";
|
||||
if (key === "common.last_name") return "Last Name";
|
||||
if (key === "common.email") return "Email";
|
||||
if (key === "common.select_survey") return "Select survey";
|
||||
if (key === "common.delete") return "Delete";
|
||||
if (key === "common.cancel") return "Cancel";
|
||||
if (key === "common.update") return "Update";
|
||||
if (key === "environments.integrations.plain.configure_plain_integration")
|
||||
return "Configure Plain Integration";
|
||||
if (key === "environments.integrations.plain.plain_integration_description")
|
||||
return "Connect your Plain account to send survey responses as threads.";
|
||||
if (key === "environments.integrations.plain.plain_logo") return "Plain logo";
|
||||
if (key === "environments.integrations.plain.map_formbricks_fields_to_plain")
|
||||
return "Map Formbricks fields to Plain";
|
||||
if (key === "environments.integrations.plain.select_a_survey_question")
|
||||
return "Select a survey question";
|
||||
if (key === "environments.integrations.plain.select_a_field_to_map") return "Select a field to map";
|
||||
if (key === "environments.integrations.plain.enter_label_id") return "Enter Label ID";
|
||||
if (key === "environments.integrations.plain.connect") return "Connect";
|
||||
if (key === "environments.integrations.plain.no_contact_info_question")
|
||||
return "No contact info question found in survey";
|
||||
if (key === "environments.integrations.plain.contact_info_missing_fields")
|
||||
return "Contact info question is missing required fields:";
|
||||
if (key === "environments.integrations.plain.contact_info_warning") return "Contact Info Warning";
|
||||
if (key === "environments.integrations.plain.contact_info_missing_fields_description")
|
||||
return "The following fields are missing";
|
||||
if (key === "environments.integrations.plain.please_select_at_least_one_mapping")
|
||||
return "Please select at least one mapping.";
|
||||
if (key === "environments.integrations.plain.please_resolve_mapping_errors")
|
||||
return "Please resolve mapping errors.";
|
||||
if (key === "environments.integrations.plain.please_complete_mapping_fields")
|
||||
return "Please complete mapping fields.";
|
||||
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
|
||||
if (key === "environments.integrations.create_survey_warning")
|
||||
return "You need to create a survey first.";
|
||||
if (key === "environments.integrations.integration_updated_successfully")
|
||||
return "Integration updated successfully.";
|
||||
if (key === "environments.integrations.integration_added_successfully")
|
||||
return "Integration added successfully.";
|
||||
if (key === "environments.integrations.integration_removed_successfully")
|
||||
return "Integration removed successfully.";
|
||||
|
||||
return key; // Return key if no translation is found
|
||||
},
|
||||
});
|
||||
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
|
||||
});
|
||||
|
||||
// Mock dependencies
|
||||
const createOrUpdateIntegrationAction = vi.mocked(
|
||||
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
|
||||
.createOrUpdateIntegrationAction
|
||||
);
|
||||
const toast = vi.mocked((await import("react-hot-toast")).default);
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
// Create a mock survey with a ContactInfo question
|
||||
const surveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 1",
|
||||
type: "app",
|
||||
environmentId: environmentId,
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question 1?" },
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyQuestionTypeEnum.ContactInfo,
|
||||
headline: { default: "Contact Info" },
|
||||
required: true,
|
||||
firstName: { show: true },
|
||||
lastName: { show: true },
|
||||
email: { show: true },
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
variables: [{ id: "var1", name: "Variable 1" }],
|
||||
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
id: "survey2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Survey 2",
|
||||
type: "link",
|
||||
environmentId: environmentId,
|
||||
status: "draft",
|
||||
questions: [
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyQuestionTypeEnum.ContactInfo,
|
||||
headline: { default: "Partial Contact Info" },
|
||||
required: true,
|
||||
firstName: { show: true },
|
||||
lastName: { show: false }, // Missing lastName
|
||||
email: { show: true },
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
variables: [],
|
||||
hiddenFields: { enabled: false },
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
const mockPlainIntegration: TIntegrationPlain = {
|
||||
id: "integration1",
|
||||
type: "plain",
|
||||
environmentId: environmentId,
|
||||
config: {
|
||||
key: "test-api-key",
|
||||
data: [], // Initially empty
|
||||
},
|
||||
};
|
||||
|
||||
const mockSelectedIntegration: TIntegrationPlainConfigData & { index: number } = {
|
||||
surveyId: surveys[0].id,
|
||||
surveyName: surveys[0].name,
|
||||
mapping: [
|
||||
{
|
||||
plainField: { id: "threadTitle", name: "Thread Title", type: "title" as TPlainFieldType },
|
||||
question: { id: "q1", name: "Question 1?", type: TSurveyQuestionTypeEnum.OpenText },
|
||||
},
|
||||
{
|
||||
plainField: { id: "componentText", name: "Component Text", type: "componentText" as TPlainFieldType },
|
||||
question: { id: "var1", name: "Variable 1", type: TSurveyQuestionTypeEnum.OpenText },
|
||||
},
|
||||
],
|
||||
includeCreatedAt: true,
|
||||
includeComponents: true,
|
||||
labelId: "custom-label",
|
||||
createdAt: new Date(),
|
||||
index: 0,
|
||||
};
|
||||
|
||||
describe("AddIntegrationModal (Plain)", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset integration data before each test if needed
|
||||
mockPlainIntegration.config.data = [
|
||||
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
|
||||
];
|
||||
});
|
||||
|
||||
test("renders correctly when open (create mode)", () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
plainIntegration={{
|
||||
...mockPlainIntegration,
|
||||
config: { ...mockPlainIntegration.config, data: [] },
|
||||
}}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText("Configure Plain Integration")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly when open (update mode)", async () => {
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
plainIntegration={mockPlainIntegration}
|
||||
selectedIntegration={mockSelectedIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
|
||||
expect(screen.getByText("Map Formbricks fields to Plain")).toBeInTheDocument();
|
||||
|
||||
// Check if mapping rows are rendered
|
||||
await waitFor(() => {
|
||||
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
|
||||
expect(questionDropdowns).toHaveLength(2); // Expecting two rows based on mockSelectedIntegration
|
||||
});
|
||||
|
||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Update" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows survey selection and enables mapping when survey is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
plainIntegration={{
|
||||
...mockPlainIntegration,
|
||||
config: { ...mockPlainIntegration.config, data: [] },
|
||||
}}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Select a survey
|
||||
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
|
||||
await user.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
// Check if mapping section appears
|
||||
expect(screen.getByText("Map Formbricks fields to Plain")).toBeInTheDocument();
|
||||
|
||||
// Check if default mapping rows are present
|
||||
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
|
||||
expect(questionDropdowns).toHaveLength(2); // Two default mapping rows
|
||||
});
|
||||
|
||||
test("adds and removes mapping rows", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
plainIntegration={mockPlainIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Select a survey first
|
||||
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
|
||||
await user.selectOptions(surveyDropdown, surveys[0].id);
|
||||
|
||||
// Initial mapping rows
|
||||
let plusButtons = screen.getAllByTestId("plus-icon");
|
||||
expect(plusButtons).toHaveLength(2); // Two default rows
|
||||
|
||||
// Add a new row
|
||||
await user.click(plusButtons[0]);
|
||||
|
||||
// Check if a new row was added
|
||||
plusButtons = screen.getAllByTestId("plus-icon");
|
||||
expect(plusButtons).toHaveLength(3); // Now three rows
|
||||
|
||||
// Try to remove a row (not the mandatory ones)
|
||||
const trashButtons = screen.getAllByTestId("trash-icon");
|
||||
expect(trashButtons).toHaveLength(1); // Only the new row should be removable
|
||||
|
||||
await user.click(trashButtons[0]);
|
||||
|
||||
// Check if row was removed
|
||||
plusButtons = screen.getAllByTestId("plus-icon");
|
||||
expect(plusButtons).toHaveLength(2); // Back to two rows
|
||||
});
|
||||
|
||||
test("shows warning for survey with incomplete contact info", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
plainIntegration={mockPlainIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Select survey with incomplete contact info
|
||||
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
|
||||
await user.selectOptions(surveyDropdown, surveys[1].id);
|
||||
|
||||
// Check if warning appears
|
||||
expect(screen.getByText("Contact Info Warning")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Last Name/)).toBeInTheDocument(); // Missing field
|
||||
});
|
||||
|
||||
test("handles form submission with validation errors", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
plainIntegration={mockPlainIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Try to submit without selecting a survey
|
||||
const connectButton = screen.getByRole("button", { name: "Connect" });
|
||||
await user.click(connectButton);
|
||||
|
||||
// Check if error toast was shown
|
||||
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
|
||||
});
|
||||
|
||||
test("handles successful integration update", async () => {
|
||||
const user = userEvent.setup();
|
||||
createOrUpdateIntegrationAction.mockResolvedValue({});
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
plainIntegration={mockPlainIntegration}
|
||||
selectedIntegration={mockSelectedIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
// Change a mapping
|
||||
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
|
||||
await user.selectOptions(questionDropdowns[0], "q2"); // Change to Contact Info question
|
||||
|
||||
// Submit the form
|
||||
const updateButton = screen.getByRole("button", { name: "Update" });
|
||||
await user.click(updateButton);
|
||||
|
||||
// Check if integration was updated
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalled();
|
||||
expect(toast.success).toHaveBeenCalledWith("Integration updated successfully.");
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("handles integration deletion", async () => {
|
||||
const user = userEvent.setup();
|
||||
createOrUpdateIntegrationAction.mockResolvedValue({});
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
plainIntegration={mockPlainIntegration}
|
||||
selectedIntegration={mockSelectedIntegration}
|
||||
/>
|
||||
);
|
||||
|
||||
// Click delete button
|
||||
const deleteButton = screen.getByRole("button", { name: "Delete" });
|
||||
await user.click(deleteButton);
|
||||
|
||||
// Check if integration was deleted
|
||||
await waitFor(() => {
|
||||
expect(createOrUpdateIntegrationAction).toHaveBeenCalled();
|
||||
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("calls setOpen(false) and resets form on cancel", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<AddIntegrationModal
|
||||
environmentId={environmentId}
|
||||
open={true}
|
||||
surveys={surveys}
|
||||
setOpen={mockSetOpen}
|
||||
plainIntegration={mockPlainIntegration}
|
||||
selectedIntegration={null}
|
||||
/>
|
||||
);
|
||||
|
||||
// Click cancel button
|
||||
const cancelButton = screen.getByRole("button", { name: "Cancel" });
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Check if modal was closed
|
||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -1,627 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { buildQuestionItems } from "@/app/(app)/environments/[environmentId]/integrations/lib/questionItems";
|
||||
import PlainLogo from "@/images/plain.webp";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { TIntegrationInput } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationPlain,
|
||||
TIntegrationPlainConfigData,
|
||||
TPlainFieldType,
|
||||
TPlainMapping,
|
||||
} from "@formbricks/types/integration/plain";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { INITIAL_MAPPING, PLAIN_FIELD_TYPES } from "../constants";
|
||||
|
||||
interface AddIntegrationModalProps {
|
||||
environmentId: string;
|
||||
surveys: TSurvey[];
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
plainIntegration: TIntegrationPlain;
|
||||
selectedIntegration: (TIntegrationPlainConfigData & { index: number }) | null;
|
||||
}
|
||||
|
||||
export const AddIntegrationModal = ({
|
||||
environmentId,
|
||||
surveys,
|
||||
open,
|
||||
setOpen,
|
||||
plainIntegration,
|
||||
selectedIntegration,
|
||||
}: AddIntegrationModalProps) => {
|
||||
const { t } = useTranslate();
|
||||
const { handleSubmit } = useForm();
|
||||
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
|
||||
const [mapping, setMapping] = useState<
|
||||
{
|
||||
plainField: { id: string; name: string; type: TPlainFieldType; config?: Record<string, any> };
|
||||
question: { id: string; name: string; type: string };
|
||||
error?: {
|
||||
type: string;
|
||||
msg: React.ReactNode | string;
|
||||
} | null;
|
||||
isMandatory?: boolean;
|
||||
}[]
|
||||
>(INITIAL_MAPPING.map((m) => ({ ...m })));
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
const [isLinkingIntegration, setIsLinkingIntegration] = useState(false);
|
||||
|
||||
const plainFieldTypes = PLAIN_FIELD_TYPES;
|
||||
|
||||
// State to track custom label ID values
|
||||
const [labelIdValues, setLabelIdValues] = useState<Record<string, string>>({});
|
||||
|
||||
const plainIntegrationData: TIntegrationInput = {
|
||||
type: "plain",
|
||||
config: {
|
||||
key: plainIntegration?.config?.key,
|
||||
data: plainIntegration.config?.data || [],
|
||||
},
|
||||
};
|
||||
|
||||
const questionItems = useMemo(() => buildQuestionItems(selectedSurvey, t), [selectedSurvey?.id, t]);
|
||||
|
||||
const checkContactInfoQuestion = (survey: TSurvey | null) => {
|
||||
if (!survey) return { hasContactInfo: false, missingFields: [] };
|
||||
|
||||
// Find ContactInfo questions in the survey
|
||||
const contactInfoQuestions = survey.questions.filter(
|
||||
(q) => q.type === TSurveyQuestionTypeEnum.ContactInfo
|
||||
);
|
||||
|
||||
if (contactInfoQuestions.length === 0) {
|
||||
return { hasContactInfo: false, missingFields: [] };
|
||||
}
|
||||
|
||||
// Check if any ContactInfo question has all required fields enabled
|
||||
for (const question of contactInfoQuestions) {
|
||||
const contactQuestion = question as any; // Type assertion to access fields
|
||||
const missingFields: string[] = [];
|
||||
|
||||
if (!contactQuestion.firstName?.show) {
|
||||
missingFields.push("firstName");
|
||||
}
|
||||
|
||||
if (!contactQuestion.lastName?.show) {
|
||||
missingFields.push("lastName");
|
||||
}
|
||||
|
||||
if (!contactQuestion.email?.show) {
|
||||
missingFields.push("email");
|
||||
}
|
||||
|
||||
// If this question has all required fields, return success
|
||||
if (missingFields.length === 0) {
|
||||
return {
|
||||
hasContactInfo: true,
|
||||
missingFields: [],
|
||||
questionId: question.id,
|
||||
question: contactQuestion,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise continue checking other questions
|
||||
}
|
||||
|
||||
// If we get here, we found ContactInfo questions but none with all required fields
|
||||
return {
|
||||
hasContactInfo: true,
|
||||
missingFields: ["firstName", "lastName", "email"],
|
||||
partialMatch: true,
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIntegration) {
|
||||
setSelectedSurvey(
|
||||
surveys.find((survey) => {
|
||||
return survey.id === selectedIntegration.surveyId;
|
||||
})!
|
||||
);
|
||||
// Ensure mandatory fields remain protected from deletion when editing
|
||||
setMapping(
|
||||
selectedIntegration.mapping.map((m) => ({
|
||||
...m,
|
||||
// Re-apply mandatory flag based on field id
|
||||
isMandatory: m.plainField.id === "threadTitle" || m.plainField.id === "componentText",
|
||||
}))
|
||||
);
|
||||
|
||||
// Initialize labelIdValues from existing mapping
|
||||
const newLabelIdValues: Record<string, string> = {};
|
||||
selectedIntegration.mapping.forEach((m, idx) => {
|
||||
if (m.plainField.id === "labelTypeId") {
|
||||
newLabelIdValues[idx] = m.question.id;
|
||||
}
|
||||
});
|
||||
setLabelIdValues(newLabelIdValues);
|
||||
|
||||
return;
|
||||
}
|
||||
resetForm();
|
||||
}, [selectedIntegration, surveys]);
|
||||
|
||||
// State to track contact info validation results
|
||||
const [contactInfoValidation, setContactInfoValidation] = useState<{
|
||||
hasContactInfo: boolean;
|
||||
missingFields: string[];
|
||||
partialMatch?: boolean;
|
||||
questionId?: string;
|
||||
question?: any;
|
||||
}>({ hasContactInfo: false, missingFields: [] });
|
||||
|
||||
// Check for ContactInfo question when survey is selected
|
||||
useEffect(() => {
|
||||
if (selectedSurvey) {
|
||||
const contactCheck = checkContactInfoQuestion(selectedSurvey);
|
||||
setContactInfoValidation(contactCheck);
|
||||
} else {
|
||||
setContactInfoValidation({ hasContactInfo: false, missingFields: [] });
|
||||
}
|
||||
}, [selectedSurvey]);
|
||||
|
||||
const linkIntegration = async () => {
|
||||
try {
|
||||
if (!selectedSurvey) {
|
||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||
}
|
||||
|
||||
const contactCheck = checkContactInfoQuestion(selectedSurvey);
|
||||
if (!contactCheck.hasContactInfo) {
|
||||
toast.error(t("environments.integrations.plain.no_contact_info_question"));
|
||||
return;
|
||||
} else if (contactCheck.partialMatch || contactCheck.missingFields.length > 0) {
|
||||
const missingFieldsFormatted = contactCheck.missingFields
|
||||
.map((field) => {
|
||||
switch (field) {
|
||||
case "firstName":
|
||||
return t("common.first_name");
|
||||
case "lastName":
|
||||
return t("common.last_name");
|
||||
case "email":
|
||||
return t("common.email");
|
||||
default:
|
||||
return field;
|
||||
}
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
toast.error(
|
||||
`${t("environments.integrations.plain.contact_info_missing_fields")} ${missingFieldsFormatted}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mapping.length === 0 || (mapping.length === 1 && !mapping[0].question.id)) {
|
||||
throw new Error(t("environments.integrations.plain.please_select_at_least_one_mapping"));
|
||||
}
|
||||
|
||||
if (mapping.filter((m) => m.error).length > 0) {
|
||||
throw new Error(t("environments.integrations.plain.please_resolve_mapping_errors"));
|
||||
}
|
||||
|
||||
if (mapping.filter((m) => !m.question.id).length >= 1) {
|
||||
throw new Error(t("environments.integrations.plain.please_complete_mapping_fields"));
|
||||
}
|
||||
|
||||
setIsLinkingIntegration(true);
|
||||
|
||||
// Find Label ID mapping if it exists
|
||||
const labelIdMapping = mapping.find((m) => m.plainField.id === "labelTypeId");
|
||||
const labelId = labelIdMapping?.question.id || "";
|
||||
|
||||
const integrationData: TIntegrationPlainConfigData = {
|
||||
surveyId: selectedSurvey.id,
|
||||
surveyName: selectedSurvey.name,
|
||||
mapping: mapping.map((m) => {
|
||||
const { error, ...rest } = m;
|
||||
return rest as TPlainMapping;
|
||||
}),
|
||||
includeCreatedAt: true,
|
||||
includeComponents: true,
|
||||
labelId: labelId, // Add the Label ID from the mapping
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
if (selectedIntegration) {
|
||||
// update action
|
||||
plainIntegrationData.config.data[selectedIntegration.index] = integrationData;
|
||||
} else {
|
||||
// create action
|
||||
plainIntegrationData.config.data.push(integrationData);
|
||||
}
|
||||
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: plainIntegrationData });
|
||||
if (selectedIntegration) {
|
||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||
} else {
|
||||
toast.success(t("environments.integrations.integration_added_successfully"));
|
||||
}
|
||||
resetForm();
|
||||
setOpen(false);
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setIsLinkingIntegration(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteLink = async () => {
|
||||
plainIntegrationData.config.data.splice(selectedIntegration!.index, 1);
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: plainIntegrationData });
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setIsLinkingIntegration(false);
|
||||
setSelectedSurvey(null);
|
||||
setLabelIdValues({});
|
||||
setMapping(INITIAL_MAPPING.map((m) => ({ ...m })));
|
||||
};
|
||||
|
||||
const getFilteredQuestionItems = (selectedIdx) => {
|
||||
const selectedQuestionIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.question.id);
|
||||
return questionItems.filter((q) => !selectedQuestionIds.includes(q.id));
|
||||
};
|
||||
|
||||
const createCopy = (item) => structuredClone(item);
|
||||
|
||||
const getFilteredPlainFieldTypes = (selectedIdx: number) => {
|
||||
const selectedPlainFieldIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.plainField.id);
|
||||
|
||||
return plainFieldTypes.filter((field) => !selectedPlainFieldIds.includes(field.id));
|
||||
};
|
||||
|
||||
const MappingRow = ({ idx }: { idx: number }) => {
|
||||
const filteredQuestionItems = getFilteredQuestionItems(idx);
|
||||
const filteredPlainFields = getFilteredPlainFieldTypes(idx);
|
||||
|
||||
const addRow = () => {
|
||||
const usedFieldIds = mapping.map((m) => m.plainField.id);
|
||||
const availableField = plainFieldTypes.find((field) => !usedFieldIds.includes(field.id)) || {
|
||||
id: "threadField",
|
||||
name: "Thread Field",
|
||||
type: "threadField" as TPlainFieldType,
|
||||
};
|
||||
|
||||
setMapping((prev) => [
|
||||
...prev,
|
||||
{
|
||||
plainField: availableField,
|
||||
question: { id: "", name: "", type: "" },
|
||||
isMandatory: false,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const deleteRow = () => {
|
||||
if (mapping[idx].isMandatory) return;
|
||||
|
||||
setMapping((prev) => {
|
||||
return prev.filter((_, i) => i !== idx);
|
||||
});
|
||||
};
|
||||
|
||||
interface ErrorMsgProps {
|
||||
error:
|
||||
| {
|
||||
type: string;
|
||||
msg: React.ReactNode | string;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
field?: { id: string; name: string; type: TPlainFieldType; config?: Record<string, any> };
|
||||
ques?: { id: string; name: string; type: string };
|
||||
}
|
||||
|
||||
const ErrorMsg = ({ error }: ErrorMsgProps) => {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
|
||||
<span className="mb-2 block">{error.type}</span>
|
||||
{error.msg}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ErrorMsg
|
||||
key={idx}
|
||||
error={mapping[idx]?.error}
|
||||
field={mapping[idx].plainField}
|
||||
ques={mapping[idx].question}
|
||||
/>
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<div className="flex w-full items-center">
|
||||
{mapping[idx].plainField.id === "labelTypeId" ? (
|
||||
<div className="max-w-full flex-1">
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-200"
|
||||
placeholder={t("environments.integrations.plain.enter_label_id")}
|
||||
value={labelIdValues[idx] || ""}
|
||||
onChange={(e) => {
|
||||
setLabelIdValues((prev) => ({
|
||||
...prev,
|
||||
[idx]: e.target.value,
|
||||
}));
|
||||
setMapping((prev) => {
|
||||
const copy = createCopy(prev);
|
||||
copy[idx] = {
|
||||
...copy[idx],
|
||||
question: {
|
||||
id: e.target.value,
|
||||
name: "Label ID",
|
||||
type: "labelTypeId",
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
return copy;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Regular question dropdown for non-Label ID fields
|
||||
<div className="max-w-full flex-1">
|
||||
<DropdownSelector
|
||||
placeholder={t("environments.integrations.plain.select_a_survey_question")}
|
||||
items={filteredQuestionItems}
|
||||
selectedItem={mapping?.[idx]?.question}
|
||||
setSelectedItem={(item) => {
|
||||
setMapping((prev) => {
|
||||
const copy = createCopy(prev);
|
||||
copy[idx] = {
|
||||
...copy[idx],
|
||||
question: item,
|
||||
error: null,
|
||||
};
|
||||
return copy;
|
||||
});
|
||||
}}
|
||||
disabled={questionItems.length === 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-px w-4 border-t border-t-slate-300" />
|
||||
<div className="max-w-full flex-1">
|
||||
<DropdownSelector
|
||||
placeholder={t("environments.integrations.plain.select_a_field_to_map")}
|
||||
items={filteredPlainFields}
|
||||
selectedItem={mapping?.[idx]?.plainField}
|
||||
disabled={filteredPlainFields.length === 0}
|
||||
setSelectedItem={(item) => {
|
||||
setMapping((prev) => {
|
||||
const copy = createCopy(prev);
|
||||
copy[idx] = {
|
||||
...copy[idx],
|
||||
plainField: item,
|
||||
error: null,
|
||||
};
|
||||
return copy;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{!mapping[idx].isMandatory && (
|
||||
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="mb-4 flex items-start space-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={PlainLogo}
|
||||
alt={t("environments.integrations.plain.plain_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.plain.configure_plain_integration")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.plain.plain_integration_description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(linkIntegration)} className="contents space-y-4">
|
||||
<DialogBody>
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<DropdownSelector
|
||||
label={t("common.select_survey")}
|
||||
items={surveys}
|
||||
selectedItem={selectedSurvey}
|
||||
setSelectedItem={setSelectedSurvey}
|
||||
disabled={surveys.length === 0}
|
||||
/>
|
||||
<p className="m-1 text-xs text-slate-500">
|
||||
{surveys.length === 0 && t("environments.integrations.create_survey_warning")}
|
||||
</p>
|
||||
|
||||
{/* Contact Info Validation Alerts */}
|
||||
{selectedSurvey && (
|
||||
<>
|
||||
{/* Success – all required fields present */}
|
||||
{contactInfoValidation.hasContactInfo &&
|
||||
contactInfoValidation.missingFields.length === 0 && (
|
||||
<div className="my-4 rounded-md bg-green-50 p-3 text-sm text-green-800">
|
||||
<p className="font-medium">
|
||||
{t("environments.integrations.plain.contact_info_success_title", {
|
||||
defaultValue: "Contact-Info question found",
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
{t("environments.integrations.plain.contact_info_all_present", {
|
||||
defaultValue:
|
||||
"This survey contains a complete Contact-Info question (first name, last name & email).",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error – no contact info question */}
|
||||
{!contactInfoValidation.hasContactInfo && (
|
||||
<div className="mt-2 rounded-md bg-red-50 p-3 text-sm text-red-800">
|
||||
<p className="font-medium">
|
||||
{t("environments.integrations.plain.contact_info_missing_title", {
|
||||
defaultValue: "No Contact-Info question",
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
{t("environments.integrations.plain.no_contact_info_question", {
|
||||
defaultValue:
|
||||
"This survey does not include a Contact-Info question. Please add one with first name, last name and email enabled to use Plain.",
|
||||
})}
|
||||
</p>
|
||||
<a
|
||||
href="https://formbricks.com/docs/integrations/plain#contact-info"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-block text-xs font-medium underline">
|
||||
{t("common.learn_more", { defaultValue: "Learn more" })}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning – partial match (retain existing implementation) */}
|
||||
{contactInfoValidation.hasContactInfo && contactInfoValidation.partialMatch && (
|
||||
<div className="mt-2 rounded-md bg-red-50 p-3 text-sm text-red-800">
|
||||
<p className="font-medium">
|
||||
{t("environments.integrations.plain.contact_info_warning")}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
{t("environments.integrations.plain.contact_info_missing_fields_description")}:{" "}
|
||||
{contactInfoValidation.missingFields
|
||||
.map((field) => {
|
||||
switch (field) {
|
||||
case "firstName":
|
||||
return t("common.first_name");
|
||||
case "lastName":
|
||||
return t("common.last_name");
|
||||
case "email":
|
||||
return t("common.email");
|
||||
default:
|
||||
return field;
|
||||
}
|
||||
})
|
||||
.join(", ")}
|
||||
</p>
|
||||
<a
|
||||
href="https://docs.formbricks.com/integrations/plain#contact-info"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-2 inline-block text-xs font-medium underline">
|
||||
{t("common.learn_more", { defaultValue: "Learn more" })}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedSurvey && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>{t("environments.integrations.plain.map_formbricks_fields_to_plain")}</Label>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{t("environments.integrations.plain.mandatory_mapping_note", {
|
||||
defaultValue:
|
||||
"Thread Title and Component Text are mandatory mappings and cannot be removed.",
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-1 space-y-2 overflow-y-auto">
|
||||
{mapping.map((_, idx) => (
|
||||
<MappingRow idx={idx} key={idx} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isLinkingIntegration}
|
||||
disabled={mapping.filter((m) => m.error).length > 0}>
|
||||
{selectedIntegration ? t("common.update") : t("environments.integrations.plain.connect")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import { AddKeyModal } from "@/app/(app)/environments/[environmentId]/integrations/plain/components/AddKeyModal";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { connectPlainIntegrationAction } from "../actions";
|
||||
|
||||
vi.mock("../actions", () => ({
|
||||
connectPlainIntegrationAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast");
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("AddKeyModal", () => {
|
||||
const environmentId = "test-environment-id";
|
||||
const setOpen = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should disable the connect button when the API key is empty", () => {
|
||||
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
|
||||
const connectButton = screen.getByRole("button", { name: "common.connect" });
|
||||
expect(connectButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("should enable the connect button when the API key is not empty", async () => {
|
||||
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
|
||||
const apiKeyInput = screen.getByLabelText("environments.integrations.plain.api_key_label");
|
||||
await userEvent.type(apiKeyInput, "test-api-key", { pointerEventsCheck: 0 });
|
||||
const connectButton = screen.getByRole("button", { name: "common.connect" });
|
||||
expect(connectButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("should call the connect action and show a success toast on successful connection", async () => {
|
||||
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
|
||||
const apiKeyInput = screen.getByLabelText("environments.integrations.plain.api_key_label");
|
||||
await userEvent.type(apiKeyInput, "test-api-key", { pointerEventsCheck: 0 });
|
||||
const connectButton = screen.getByRole("button", { name: "common.connect" });
|
||||
await userEvent.click(connectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(connectPlainIntegrationAction).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
key: "test-api-key",
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.integrations.plain.connection_success");
|
||||
expect(setOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("should show an error toast on a failed connection", async () => {
|
||||
(connectPlainIntegrationAction as Mock).mockRejectedValue(new Error("Connection error"));
|
||||
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
|
||||
const apiKeyInput = screen.getByLabelText("environments.integrations.plain.api_key_label");
|
||||
await userEvent.type(apiKeyInput, "test-api-key", { pointerEventsCheck: 0 });
|
||||
const connectButton = screen.getByRole("button", { name: "common.connect" });
|
||||
await userEvent.click(connectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.integrations.plain.connection_error");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { KeyIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { connectPlainIntegrationAction } from "../actions";
|
||||
|
||||
interface AddKeyModalProps {
|
||||
environmentId: string;
|
||||
open?: boolean;
|
||||
setOpen?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const AddKeyModal = ({
|
||||
environmentId,
|
||||
open: externalOpen,
|
||||
setOpen: externalSetOpen,
|
||||
}: AddKeyModalProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [keyLabel, setKeyLabel] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const open = externalOpen ?? internalOpen;
|
||||
const setOpen = externalSetOpen || setInternalOpen;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<KeyIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">{t("environments.integrations.plain.add_key")}</span>
|
||||
<p className="text-sm font-normal text-slate-500">
|
||||
{t("environments.integrations.plain.add_key_description")}
|
||||
</p>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="keyLabel" className="mb-2 block text-sm font-medium text-slate-700">
|
||||
{t("environments.integrations.plain.api_key_label")}
|
||||
</label>
|
||||
<Input
|
||||
id="keyLabel"
|
||||
name="keyLabel"
|
||||
placeholder="plainApiKey_123"
|
||||
value={keyLabel}
|
||||
onChange={(e) => setKeyLabel(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!keyLabel.trim() || isSubmitting}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await connectPlainIntegrationAction({
|
||||
environmentId,
|
||||
key: keyLabel.trim(),
|
||||
});
|
||||
toast.success(t("environments.integrations.plain.connection_success"));
|
||||
setOpen(false);
|
||||
} catch {
|
||||
toast.error(t("environments.integrations.plain.connection_error"));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}}>
|
||||
{isSubmitting ? <LoadingSpinner className="h-4 w-4" /> : t("common.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,186 +0,0 @@
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationPlain, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: vi.fn((time) => `mock-time-since-${time}`),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn((err) => err?.message || "An error occurred"),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
DeleteDialog: ({ open, setOpen, onDelete, text, isDeleting }) =>
|
||||
open ? (
|
||||
<div>
|
||||
<span>{text}</span>
|
||||
<button onClick={() => onDelete()}>{isDeleting ? "Deleting..." : "Delete"}</button>
|
||||
<button onClick={() => setOpen(false)}>Cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
|
||||
EmptySpaceFiller: ({ emptyMessage }) => <div>{emptyMessage}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => {
|
||||
const base = {
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
ENCRYPTION_KEY: "12345678901234567890123456789012",
|
||||
REDIS_URL: undefined,
|
||||
ENTERPRISE_LICENSE_KEY: undefined,
|
||||
POSTHOG_API_KEY: undefined,
|
||||
POSTHOG_HOST: undefined,
|
||||
IS_POSTHOG_CONFIGURED: false,
|
||||
GITHUB_ID: undefined,
|
||||
GITHUB_SECRET: undefined,
|
||||
GOOGLE_CLIENT_ID: undefined,
|
||||
GOOGLE_CLIENT_SECRET: undefined,
|
||||
AZUREAD_CLIENT_ID: undefined,
|
||||
AZUREAD_CLIENT_SECRET: undefined,
|
||||
AZUREAD_TENANT_ID: undefined,
|
||||
OIDC_DISPLAY_NAME: undefined,
|
||||
OIDC_CLIENT_ID: undefined,
|
||||
OIDC_ISSUER: undefined,
|
||||
OIDC_CLIENT_SECRET: undefined,
|
||||
OIDC_SIGNING_ALGORITHM: undefined,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
WEBAPP_URL: undefined,
|
||||
SENTRY_DSN: undefined,
|
||||
SENTRY_RELEASE: undefined,
|
||||
SENTRY_ENVIRONMENT: undefined,
|
||||
};
|
||||
return new Proxy(base, {
|
||||
get(target, prop) {
|
||||
return prop in target ? target[prop as keyof typeof target] : undefined;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockEnvironment = { id: "test-env-id" } as TEnvironment;
|
||||
const mockIntegrationData: TIntegrationPlainConfigData[] = [
|
||||
{
|
||||
surveyId: "survey-1",
|
||||
surveyName: "Survey One",
|
||||
createdAt: new Date(),
|
||||
mapping: [],
|
||||
includeMetadata: true,
|
||||
includeHiddenFields: true,
|
||||
includeComponents: false,
|
||||
},
|
||||
{
|
||||
surveyId: "survey-2",
|
||||
surveyName: "Survey Two",
|
||||
createdAt: new Date(),
|
||||
mapping: [],
|
||||
includeMetadata: true,
|
||||
includeHiddenFields: true,
|
||||
includeComponents: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockPlainIntegration: TIntegrationPlain = {
|
||||
id: "integration-id",
|
||||
type: "plain",
|
||||
environmentId: "test-env-id",
|
||||
config: {
|
||||
key: "test-key",
|
||||
data: mockIntegrationData,
|
||||
},
|
||||
};
|
||||
|
||||
describe("ManageIntegration", () => {
|
||||
let setOpenAddIntegrationModal: (isOpen: boolean) => void;
|
||||
let setIsConnected: (isConnected: boolean) => void;
|
||||
let setSelectedIntegration: (integration: (TIntegrationPlainConfigData & { index: number }) | null) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
setOpenAddIntegrationModal = vi.fn();
|
||||
setIsConnected = vi.fn();
|
||||
setSelectedIntegration = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders empty state when no integrations are configured", () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
environment={mockEnvironment}
|
||||
plainIntegration={{ ...mockPlainIntegration, config: { ...mockPlainIntegration.config, data: [] } }}
|
||||
setOpenAddIntegrationModal={setOpenAddIntegrationModal}
|
||||
setIsConnected={setIsConnected}
|
||||
setSelectedIntegration={setSelectedIntegration}
|
||||
locale={"en-US"}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.integrations.plain.no_databases_found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders a list of integrations when configured", () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
environment={mockEnvironment}
|
||||
plainIntegration={mockPlainIntegration}
|
||||
setOpenAddIntegrationModal={setOpenAddIntegrationModal}
|
||||
setIsConnected={setIsConnected}
|
||||
setSelectedIntegration={setSelectedIntegration}
|
||||
locale={"en-US"}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByText("Survey One")[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Survey Two")[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles successful deletion of an integration", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: mockPlainIntegration });
|
||||
|
||||
render(
|
||||
<ManageIntegration
|
||||
environment={mockEnvironment}
|
||||
plainIntegration={mockPlainIntegration}
|
||||
setOpenAddIntegrationModal={setOpenAddIntegrationModal}
|
||||
setIsConnected={setIsConnected}
|
||||
setSelectedIntegration={setSelectedIntegration}
|
||||
locale={"en-US"}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getAllByText("environments.integrations.delete_integration")[0]);
|
||||
expect(screen.getByText("environments.integrations.delete_integration_confirmation")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("Delete"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: mockPlainIntegration.id });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationPlain, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { IntegrationListPanel } from "../../components/IntegrationListPanel";
|
||||
import { AddKeyModal } from "./AddKeyModal";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
environment: TEnvironment;
|
||||
plainIntegration: TIntegrationPlain;
|
||||
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setSelectedIntegration: React.Dispatch<
|
||||
React.SetStateAction<(TIntegrationPlainConfigData & { index: number }) | null>
|
||||
>;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
environment,
|
||||
plainIntegration,
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
setSelectedIntegration,
|
||||
locale,
|
||||
}: ManageIntegrationProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
|
||||
let integrationArray: TIntegrationPlainConfigData[] = [];
|
||||
if (plainIntegration?.config.data) {
|
||||
integrationArray = plainIntegration.config.data;
|
||||
}
|
||||
|
||||
const handleDeleteIntegration = async () => {
|
||||
setisDeleting(true);
|
||||
|
||||
const deleteIntegrationActionResult = await deleteIntegrationAction({
|
||||
integrationId: plainIntegration.id,
|
||||
});
|
||||
|
||||
if (deleteIntegrationActionResult?.data) {
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
setIsConnected(false);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
setisDeleting(false);
|
||||
setIsDeleteIntegrationModalOpen(false);
|
||||
};
|
||||
|
||||
const editIntegration = (index: number) => {
|
||||
setSelectedIntegration({ ...plainIntegration.config.data[index], index });
|
||||
setOpenAddIntegrationModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegrationListPanel
|
||||
environment={environment}
|
||||
statusNode={
|
||||
<>
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="text-slate-500">{t("common.connected")}</span>
|
||||
</>
|
||||
}
|
||||
reconnectAction={{
|
||||
label: t("environments.integrations.plain.update_connection"),
|
||||
onClick: () => setIsKeyModalOpen(true),
|
||||
icon: <RefreshCcwIcon className="mr-2 h-4 w-4" />,
|
||||
tooltip: t("environments.integrations.plain.update_connection_tooltip"),
|
||||
variant: "outline",
|
||||
}}
|
||||
addNewAction={{
|
||||
label: t("environments.integrations.plain.link_new_database"),
|
||||
onClick: () => {
|
||||
setSelectedIntegration(null);
|
||||
setOpenAddIntegrationModal(true);
|
||||
},
|
||||
}}
|
||||
emptyMessage={t("environments.integrations.plain.no_databases_found")}
|
||||
items={integrationArray}
|
||||
columns={[
|
||||
{
|
||||
header: t("common.survey"),
|
||||
render: (item: TIntegrationPlainConfigData) => item.surveyName,
|
||||
},
|
||||
{
|
||||
header: t("common.survey_id"),
|
||||
render: (item: TIntegrationPlainConfigData) => item.surveyId,
|
||||
},
|
||||
{
|
||||
header: t("common.updated_at"),
|
||||
render: (item: TIntegrationPlainConfigData) => timeSince(item.createdAt.toString(), locale),
|
||||
},
|
||||
]}
|
||||
onRowClick={editIntegration}
|
||||
getRowKey={(item: TIntegrationPlainConfigData, idx) => `${idx}-${item.surveyId}`}
|
||||
/>
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)}>
|
||||
<Trash2Icon />
|
||||
{t("environments.integrations.delete_integration")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AddKeyModal environmentId={environment.id} open={isKeyModalOpen} setOpen={setIsKeyModalOpen} />
|
||||
|
||||
<DeleteDialog
|
||||
open={isDeleteIntegrationModalOpen}
|
||||
setOpen={setIsDeleteIntegrationModalOpen}
|
||||
deleteWhat={t("environments.integrations.plain.plain_integration")}
|
||||
onDelete={handleDeleteIntegration}
|
||||
text={t("environments.integrations.delete_integration_confirmation")}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationPlain } from "@formbricks/types/integration/plain";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { PlainWrapper } from "./PlainWrapper";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/ui/components/connect-integration", () => ({
|
||||
ConnectIntegration: vi.fn(() => <div>Mocked ConnectIntegration</div>),
|
||||
}));
|
||||
|
||||
vi.mock("./AddIntegrationModal", () => ({
|
||||
AddIntegrationModal: vi.fn(() => <div>Mocked AddIntegrationModal</div>),
|
||||
}));
|
||||
|
||||
vi.mock("./AddKeyModal", () => ({
|
||||
AddKeyModal: vi.fn(() => <div>Mocked AddKeyModal</div>),
|
||||
}));
|
||||
|
||||
vi.mock("./ManageIntegration", () => ({
|
||||
ManageIntegration: vi.fn(() => <div>Mocked ManageIntegration</div>),
|
||||
}));
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "test-env-id",
|
||||
name: "Test Environment",
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockSurveys: TSurvey[] = [];
|
||||
|
||||
const mockPlainIntegration: TIntegrationPlain = {
|
||||
id: "integration-id",
|
||||
type: "plain",
|
||||
environmentId: "test-env-id",
|
||||
config: {
|
||||
key: "test-key",
|
||||
data: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe("PlainWrapper", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders ConnectIntegration when not connected", () => {
|
||||
render(
|
||||
<PlainWrapper
|
||||
plainIntegration={undefined}
|
||||
enabled={true}
|
||||
environment={mockEnvironment}
|
||||
webAppUrl="http://localhost:3000"
|
||||
surveys={mockSurveys}
|
||||
databasesArray={[]}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Mocked ConnectIntegration")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Mocked ManageIntegration")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders ManageIntegration when connected", () => {
|
||||
render(
|
||||
<PlainWrapper
|
||||
plainIntegration={mockPlainIntegration}
|
||||
enabled={true}
|
||||
environment={mockEnvironment}
|
||||
webAppUrl="http://localhost:3000"
|
||||
surveys={mockSurveys}
|
||||
databasesArray={[]}
|
||||
locale="en-US"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Mocked ManageIntegration")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Mocked ConnectIntegration")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import PlainLogo from "@/images/plain.webp";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
import { useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationPlain, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AddIntegrationModal } from "./AddIntegrationModal";
|
||||
import { AddKeyModal } from "./AddKeyModal";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
interface PlainWrapperProps {
|
||||
plainIntegration: TIntegrationPlain | undefined;
|
||||
enabled: boolean;
|
||||
environment: TEnvironment;
|
||||
webAppUrl: string;
|
||||
surveys: TSurvey[];
|
||||
databasesArray: any[];
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const PlainWrapper = ({
|
||||
plainIntegration,
|
||||
enabled,
|
||||
environment,
|
||||
|
||||
surveys,
|
||||
locale,
|
||||
}: PlainWrapperProps) => {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(plainIntegration ? plainIntegration.config.key : false);
|
||||
const [selectedIntegration, setSelectedIntegration] = useState<
|
||||
(TIntegrationPlainConfigData & { index: number }) | null
|
||||
>(null);
|
||||
|
||||
const handlePlainAuthorization = async () => {
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isConnected && plainIntegration ? (
|
||||
<>
|
||||
<AddIntegrationModal
|
||||
environmentId={environment.id}
|
||||
surveys={surveys}
|
||||
open={isModalOpen}
|
||||
setOpen={setModalOpen}
|
||||
plainIntegration={plainIntegration}
|
||||
selectedIntegration={selectedIntegration}
|
||||
/>
|
||||
<ManageIntegration
|
||||
environment={environment}
|
||||
plainIntegration={plainIntegration}
|
||||
setOpenAddIntegrationModal={setModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
setSelectedIntegration={setSelectedIntegration}
|
||||
locale={locale}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AddKeyModal environmentId={environment.id} open={open} setOpen={setOpen} />
|
||||
<ConnectIntegration
|
||||
isEnabled={enabled}
|
||||
integrationType={"plain"}
|
||||
handleAuthorization={handlePlainAuthorization}
|
||||
integrationLogoSrc={PlainLogo}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { TPlainFieldType } from "@formbricks/types/integration/plain";
|
||||
|
||||
export const PLAIN_FIELD_TYPES: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TPlainFieldType;
|
||||
}[] = [
|
||||
{ id: "threadTitle", name: "Thread Title", type: "threadField" as TPlainFieldType },
|
||||
{ id: "componentText", name: "Component Text", type: "componentText" as TPlainFieldType },
|
||||
{ id: "labelTypeId", name: "Label ID", type: "labelTypeId" as TPlainFieldType },
|
||||
];
|
||||
|
||||
export const INITIAL_MAPPING = [
|
||||
{
|
||||
plainField: { id: "threadTitle", name: "Thread Title", type: "title" as TPlainFieldType },
|
||||
question: { id: "", name: "", type: "" },
|
||||
isMandatory: true,
|
||||
},
|
||||
{
|
||||
plainField: { id: "componentText", name: "Component Text", type: "componentText" as TPlainFieldType },
|
||||
question: { id: "", name: "", type: "" },
|
||||
isMandatory: true,
|
||||
},
|
||||
] as const;
|
||||
@@ -1,210 +0,0 @@
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationPlain } from "@formbricks/types/integration/plain";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import Page from "./page";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/plain/components/PlainWrapper", () => ({
|
||||
PlainWrapper: vi.fn(
|
||||
({ enabled, surveys, environment, plainIntegration, webAppUrl, databasesArray, locale }) => (
|
||||
<div>
|
||||
<span>Mocked PlainWrapper</span>
|
||||
<span data-testid="enabled">{enabled.toString()}</span>
|
||||
<span data-testid="environmentId">{environment.id}</span>
|
||||
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
|
||||
<span data-testid="integrationId">{plainIntegration?.id}</span>
|
||||
<span data-testid="webAppUrl">{webAppUrl}</span>
|
||||
<span data-testid="databasesArray">{databasesArray?.length ?? 0}</span>
|
||||
<span data-testid="locale">{locale}</span>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
|
||||
getSurveys: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/integration/service", () => ({
|
||||
getIntegrationByType: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/utils/locale", () => ({
|
||||
findMatchingLocale: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/go-back-button", () => ({
|
||||
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back">{url}</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key) => key,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "https://app.formbricks.com",
|
||||
}));
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
appSetupCompleted: true,
|
||||
type: "development",
|
||||
projectId: "project-id",
|
||||
project: {
|
||||
id: "project-id",
|
||||
name: "Test Project",
|
||||
environments: [],
|
||||
people: [],
|
||||
surveys: [],
|
||||
tags: [],
|
||||
webhooks: [],
|
||||
apiKey: {
|
||||
id: "api-key",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
hashedKey: "hashed",
|
||||
label: "api",
|
||||
},
|
||||
logo: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: "org-id",
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: false,
|
||||
linkSurveyBranding: false,
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
},
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockSurveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Survey 1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "test-env-id",
|
||||
status: "inProgress",
|
||||
type: "app",
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
const mockPlainIntegration = {
|
||||
id: "integration1",
|
||||
type: "plain",
|
||||
environmentId: "test-env-id",
|
||||
config: {
|
||||
key: "plain-key",
|
||||
data: [],
|
||||
},
|
||||
} as unknown as TIntegrationPlain;
|
||||
|
||||
const mockProps = {
|
||||
params: { environmentId: "test-env-id" },
|
||||
};
|
||||
|
||||
describe("PlainIntegrationPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
project: {} as any,
|
||||
organization: {} as any,
|
||||
session: {} as any,
|
||||
currentUserMembership: {} as any,
|
||||
projectPermission: {} as any,
|
||||
isMember: true,
|
||||
isOwner: false,
|
||||
isManager: false,
|
||||
isBilling: false,
|
||||
hasReadAccess: true,
|
||||
hasReadWriteAccess: true,
|
||||
hasManageAccess: false,
|
||||
isReadOnly: false,
|
||||
});
|
||||
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
|
||||
vi.mocked(getIntegrationByType).mockResolvedValue(mockPlainIntegration);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
|
||||
});
|
||||
|
||||
test("renders the page with PlainWrapper when enabled and not read-only", async () => {
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("environments.integrations.plain.plain_integration")).toBeInTheDocument();
|
||||
expect(screen.getByText("Mocked PlainWrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("enabled")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id);
|
||||
expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString());
|
||||
expect(screen.getByTestId("integrationId")).toHaveTextContent(mockPlainIntegration.id);
|
||||
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("https://app.formbricks.com");
|
||||
expect(screen.getByTestId("databasesArray")).toHaveTextContent("0");
|
||||
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
|
||||
expect(screen.getByTestId("go-back")).toHaveTextContent(
|
||||
`https://app.formbricks.com/environments/${mockProps.params.environmentId}/integrations`
|
||||
);
|
||||
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls redirect when user is read-only", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
project: {} as any,
|
||||
organization: {} as any,
|
||||
session: {} as any,
|
||||
currentUserMembership: {} as any,
|
||||
projectPermission: {} as any,
|
||||
isMember: true,
|
||||
isOwner: false,
|
||||
isManager: false,
|
||||
isBilling: false,
|
||||
hasReadAccess: true,
|
||||
hasReadWriteAccess: false,
|
||||
hasManageAccess: false,
|
||||
isReadOnly: true,
|
||||
});
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { PlainWrapper } from "@/app/(app)/environments/[environmentId]/integrations/plain/components/PlainWrapper";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationPlain } from "@formbricks/types/integration/plain";
|
||||
|
||||
const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [surveys, plainIntegration] = await Promise.all([
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrationByType(params.environmentId, "plain"),
|
||||
]);
|
||||
|
||||
const databasesArray = [];
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
redirect("./");
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
|
||||
<PageHeader pageTitle={t("environments.integrations.plain.plain_integration") || "Plain Integration"} />
|
||||
<PlainWrapper
|
||||
enabled={true}
|
||||
surveys={surveys}
|
||||
environment={environment}
|
||||
plainIntegration={plainIntegration as TIntegrationPlain}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
databasesArray={databasesArray}
|
||||
locale={locale}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -226,7 +226,6 @@ const surveys: TSurvey[] = [
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
@@ -264,7 +263,6 @@ const surveys: TSurvey[] = [
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
@@ -114,7 +114,6 @@ const mockSurveys: TSurvey[] = [
|
||||
languages: [],
|
||||
styling: null,
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
closeOnDate: null,
|
||||
runOnDate: null,
|
||||
|
||||
@@ -49,7 +49,6 @@ const mockUser = {
|
||||
email: "test@example.com",
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
role: "project_manager",
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { Membership } from "../types";
|
||||
import { EditWeeklySummary } from "./EditWeeklySummary";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
UsersIcon: () => <div data-testid="users-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href} data-testid="link">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockNotificationSwitch = vi.fn();
|
||||
vi.mock("./NotificationSwitch", () => ({
|
||||
NotificationSwitch: (props: any) => {
|
||||
mockNotificationSwitch(props);
|
||||
return (
|
||||
<div data-testid={`notification-switch-${props.surveyOrProjectOrOrganizationId}`}>
|
||||
NotificationSwitch
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const mockT = vi.fn((key) => key);
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: mockT,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUser = {
|
||||
id: "user1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {
|
||||
proj1: true,
|
||||
proj3: false,
|
||||
},
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
role: "project_manager",
|
||||
objective: "other",
|
||||
emailVerified: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
identityProvider: "email",
|
||||
twoFactorEnabled: false,
|
||||
} as unknown as TUser;
|
||||
|
||||
const mockMemberships: Membership[] = [
|
||||
{
|
||||
organization: {
|
||||
id: "org1",
|
||||
name: "Organization 1",
|
||||
projects: [
|
||||
{ id: "proj1", name: "Project 1", environments: [] },
|
||||
{ id: "proj2", name: "Project 2", environments: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
organization: {
|
||||
id: "org2",
|
||||
name: "Organization 2",
|
||||
projects: [{ id: "proj3", name: "Project 3", environments: [] }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
|
||||
describe("EditWeeklySummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders correctly with multiple memberships and projects", () => {
|
||||
render(<EditWeeklySummary memberships={mockMemberships} user={mockUser} environmentId={environmentId} />);
|
||||
|
||||
expect(screen.getByText("Organization 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Organization 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project 3")).toBeInTheDocument();
|
||||
|
||||
expect(mockNotificationSwitch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
surveyOrProjectOrOrganizationId: "proj1",
|
||||
notificationSettings: mockUser.notificationSettings,
|
||||
notificationType: "weeklySummary",
|
||||
})
|
||||
);
|
||||
expect(screen.getByTestId("notification-switch-proj1")).toBeInTheDocument();
|
||||
|
||||
expect(mockNotificationSwitch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
surveyOrProjectOrOrganizationId: "proj2",
|
||||
notificationSettings: mockUser.notificationSettings,
|
||||
notificationType: "weeklySummary",
|
||||
})
|
||||
);
|
||||
expect(screen.getByTestId("notification-switch-proj2")).toBeInTheDocument();
|
||||
|
||||
expect(mockNotificationSwitch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
surveyOrProjectOrOrganizationId: "proj3",
|
||||
notificationSettings: mockUser.notificationSettings,
|
||||
notificationType: "weeklySummary",
|
||||
})
|
||||
);
|
||||
expect(screen.getByTestId("notification-switch-proj3")).toBeInTheDocument();
|
||||
|
||||
const inviteLinks = screen.getAllByTestId("link");
|
||||
expect(inviteLinks.length).toBe(mockMemberships.length);
|
||||
inviteLinks.forEach((link) => {
|
||||
expect(link).toHaveAttribute("href", `/environments/${environmentId}/settings/general`);
|
||||
expect(link).toHaveTextContent("common.invite_them");
|
||||
});
|
||||
|
||||
expect(screen.getAllByTestId("users-icon").length).toBe(mockMemberships.length);
|
||||
|
||||
expect(screen.getAllByText("common.project")[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText("common.weekly_summary")[0]).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByText("environments.settings.notifications.want_to_loop_in_organization_mates?").length
|
||||
).toBe(mockMemberships.length);
|
||||
});
|
||||
|
||||
test("renders correctly with no memberships", () => {
|
||||
render(<EditWeeklySummary memberships={[]} user={mockUser} environmentId={environmentId} />);
|
||||
expect(screen.queryByText("Organization 1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("users-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly when an organization has no projects", () => {
|
||||
const membershipsWithNoProjects: Membership[] = [
|
||||
{
|
||||
organization: {
|
||||
id: "org3",
|
||||
name: "Organization No Projects",
|
||||
projects: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
render(
|
||||
<EditWeeklySummary
|
||||
memberships={membershipsWithNoProjects}
|
||||
user={mockUser}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Organization No Projects")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Project 1")).not.toBeInTheDocument(); // Check that no projects are listed under it
|
||||
expect(mockNotificationSwitch).not.toHaveBeenCalled(); // No projects, so no switches for projects
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { Membership } from "../types";
|
||||
import { NotificationSwitch } from "./NotificationSwitch";
|
||||
|
||||
interface EditAlertsProps {
|
||||
memberships: Membership[];
|
||||
user: TUser;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const EditWeeklySummary = ({ memberships, user, environmentId }: EditAlertsProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<>
|
||||
{memberships.map((membership) => (
|
||||
<div key={membership.organization.id}>
|
||||
<div className="mb-5 flex items-center space-x-3 text-sm font-medium">
|
||||
<UsersIcon className="h-6 w-7 text-slate-600" />
|
||||
|
||||
<p className="text-slate-800">{membership.organization.name}</p>
|
||||
</div>
|
||||
<div className="mb-6 rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-3 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-2">{t("common.project")}</div>
|
||||
<div className="col-span-1 text-center">{t("common.weekly_summary")}</div>
|
||||
</div>
|
||||
<div className="space-y-1 p-2">
|
||||
{membership.organization.projects.map((project) => (
|
||||
<div
|
||||
className="grid h-auto w-full cursor-pointer grid-cols-3 place-content-center justify-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
|
||||
key={project.id}>
|
||||
<div className="col-span-2">{project?.name}</div>
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<NotificationSwitch
|
||||
surveyOrProjectOrOrganizationId={project.id}
|
||||
notificationSettings={user.notificationSettings!}
|
||||
notificationType={"weeklySummary"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="pb-3 pl-4 text-xs text-slate-400">
|
||||
{t("environments.settings.notifications.want_to_loop_in_organization_mates")}?{" "}
|
||||
<Link className="font-semibold" href={`/environments/${environmentId}/settings/general`}>
|
||||
{t("common.invite_them")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -29,7 +29,6 @@ const organizationId = "org1";
|
||||
|
||||
const baseNotificationSettings: TUserNotificationSettings = {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [],
|
||||
};
|
||||
|
||||
@@ -68,19 +67,6 @@ describe("NotificationSwitch", () => {
|
||||
expect(switchInput.checked).toBe(false);
|
||||
});
|
||||
|
||||
test("renders with initial checked state for 'weeklySummary' (true)", () => {
|
||||
const settings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
|
||||
renderSwitch({
|
||||
surveyOrProjectOrOrganizationId: projectId,
|
||||
notificationSettings: settings,
|
||||
notificationType: "weeklySummary",
|
||||
});
|
||||
const switchInput = screen.getByLabelText(
|
||||
"toggle notification settings for weeklySummary"
|
||||
) as HTMLInputElement;
|
||||
expect(switchInput.checked).toBe(true);
|
||||
});
|
||||
|
||||
test("renders with initial checked state for 'unsubscribedOrganizationIds' (subscribed initially, so checked is true)", () => {
|
||||
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
|
||||
renderSwitch({
|
||||
@@ -268,31 +254,6 @@ describe("NotificationSwitch", () => {
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction fails for 'weeklySummary' type", async () => {
|
||||
const mockErrorResponse = { serverError: "Database connection failed" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
const initialSettings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
|
||||
renderSwitch({
|
||||
surveyOrProjectOrOrganizationId: projectId,
|
||||
notificationSettings: initialSettings,
|
||||
notificationType: "weeklySummary",
|
||||
});
|
||||
const switchInput = screen.getByLabelText("toggle notification settings for weeklySummary");
|
||||
|
||||
await act(async () => {
|
||||
await user.click(switchInput);
|
||||
});
|
||||
|
||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
||||
notificationSettings: { ...initialSettings, weeklySummary: { [projectId]: false } },
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("Database connection failed", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => {
|
||||
const mockErrorResponse = { serverError: "Permission denied" };
|
||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { updateNotificationSettingsAction } from "../actions";
|
||||
interface NotificationSwitchProps {
|
||||
surveyOrProjectOrOrganizationId: string;
|
||||
notificationSettings: TUserNotificationSettings;
|
||||
notificationType: "alert" | "weeklySummary" | "unsubscribedOrganizationIds";
|
||||
notificationType: "alert" | "unsubscribedOrganizationIds";
|
||||
autoDisableNotificationType?: string;
|
||||
autoDisableNotificationElementId?: string;
|
||||
}
|
||||
|
||||
@@ -34,17 +34,5 @@ describe("Loading Notifications Settings", () => {
|
||||
.getByText("environments.settings.notifications.email_alerts_surveys")
|
||||
.closest("div[class*='rounded-xl']"); // Find parent card
|
||||
expect(alertsCard).toBeInTheDocument();
|
||||
|
||||
// Check for Weekly Summary LoadingCard
|
||||
expect(
|
||||
screen.getByText("environments.settings.notifications.weekly_summary_projects")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")
|
||||
).toBeInTheDocument();
|
||||
const weeklySummaryCard = screen
|
||||
.getByText("environments.settings.notifications.weekly_summary_projects")
|
||||
.closest("div[class*='rounded-xl']"); // Find parent card
|
||||
expect(weeklySummaryCard).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,11 +14,6 @@ const Loading = () => {
|
||||
description: t("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"),
|
||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
|
||||
},
|
||||
{
|
||||
title: t("environments.settings.notifications.weekly_summary_projects"),
|
||||
description: t("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday"),
|
||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { EditAlerts } from "./components/EditAlerts";
|
||||
import { EditWeeklySummary } from "./components/EditWeeklySummary";
|
||||
import Page from "./page";
|
||||
import { Membership } from "./types";
|
||||
|
||||
@@ -58,9 +57,7 @@ vi.mock("@formbricks/database", () => ({
|
||||
vi.mock("./components/EditAlerts", () => ({
|
||||
EditAlerts: vi.fn(() => <div>EditAlertsComponent</div>),
|
||||
}));
|
||||
vi.mock("./components/EditWeeklySummary", () => ({
|
||||
EditWeeklySummary: vi.fn(() => <div>EditWeeklySummaryComponent</div>),
|
||||
}));
|
||||
|
||||
vi.mock("./components/IntegrationsTip", () => ({
|
||||
IntegrationsTip: () => <div>IntegrationsTipComponent</div>,
|
||||
}));
|
||||
@@ -71,7 +68,6 @@ const mockUser: Partial<TUser> = {
|
||||
email: "test@example.com",
|
||||
notificationSettings: {
|
||||
alert: { "survey-old": true },
|
||||
weeklySummary: { "project-old": true },
|
||||
unsubscribedOrganizationIds: ["org-unsubscribed"],
|
||||
},
|
||||
};
|
||||
@@ -137,13 +133,6 @@ describe("NotificationsPage", () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
|
||||
expect(screen.getByText("IntegrationsTipComponent")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.settings.notifications.weekly_summary_projects")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
|
||||
|
||||
// The actual `user.notificationSettings` passed to EditAlerts will be a new object
|
||||
// after `setCompleteNotificationSettings` processes it.
|
||||
@@ -157,16 +146,12 @@ describe("NotificationsPage", () => {
|
||||
// It iterates memberships, then projects, then environments, then surveys.
|
||||
// `newNotificationSettings.alert[survey.id] = notificationSettings[survey.id]?.responseFinished || (notificationSettings.alert && notificationSettings.alert[survey.id]) || false;`
|
||||
// This means only survey IDs found in memberships will be in the new `alert` object.
|
||||
// `newNotificationSettings.weeklySummary[project.id]` also only adds project IDs from memberships.
|
||||
|
||||
const finalExpectedSettings = {
|
||||
alert: {
|
||||
"survey-1": false,
|
||||
"survey-2": false,
|
||||
},
|
||||
weeklySummary: {
|
||||
"project-1": false,
|
||||
},
|
||||
unsubscribedOrganizationIds: ["org-unsubscribed"],
|
||||
};
|
||||
|
||||
@@ -175,11 +160,6 @@ describe("NotificationsPage", () => {
|
||||
expect(editAlertsCall.environmentId).toBe(mockParams.environmentId);
|
||||
expect(editAlertsCall.autoDisableNotificationType).toBe(mockSearchParams.type);
|
||||
expect(editAlertsCall.autoDisableNotificationElementId).toBe(mockSearchParams.elementId);
|
||||
|
||||
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
|
||||
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(finalExpectedSettings);
|
||||
expect(editWeeklySummaryCall.memberships).toEqual(mockMemberships);
|
||||
expect(editWeeklySummaryCall.environmentId).toBe(mockParams.environmentId);
|
||||
});
|
||||
|
||||
test("throws error if session is not found", async () => {
|
||||
@@ -207,21 +187,15 @@ describe("NotificationsPage", () => {
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
|
||||
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
|
||||
|
||||
const expectedEmptySettings = {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [],
|
||||
};
|
||||
|
||||
const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0];
|
||||
expect(editAlertsCall.user.notificationSettings).toEqual(expectedEmptySettings);
|
||||
expect(editAlertsCall.memberships).toEqual([]);
|
||||
|
||||
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
|
||||
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(expectedEmptySettings);
|
||||
expect(editWeeklySummaryCall.memberships).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles legacy notification settings correctly", async () => {
|
||||
@@ -229,7 +203,6 @@ describe("NotificationsPage", () => {
|
||||
id: "user-legacy",
|
||||
notificationSettings: {
|
||||
"survey-1": { responseFinished: true }, // Legacy alert for survey-1
|
||||
weeklySummary: { "project-1": true },
|
||||
unsubscribedOrganizationIds: [],
|
||||
} as any, // To allow legacy structure
|
||||
};
|
||||
@@ -246,9 +219,6 @@ describe("NotificationsPage", () => {
|
||||
"survey-1": true, // Should be true due to legacy setting
|
||||
"survey-2": false, // Default for other surveys in membership
|
||||
},
|
||||
weeklySummary: {
|
||||
"project-1": true, // From user's weeklySummary
|
||||
},
|
||||
unsubscribedOrganizationIds: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { getServerSession } from "next-auth";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
import { EditAlerts } from "./components/EditAlerts";
|
||||
import { EditWeeklySummary } from "./components/EditWeeklySummary";
|
||||
import { IntegrationsTip } from "./components/IntegrationsTip";
|
||||
import type { Membership } from "./types";
|
||||
|
||||
@@ -19,14 +18,10 @@ const setCompleteNotificationSettings = (
|
||||
): TUserNotificationSettings => {
|
||||
const newNotificationSettings = {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
||||
};
|
||||
for (const membership of memberships) {
|
||||
for (const project of membership.organization.projects) {
|
||||
// set default values for weekly summary
|
||||
newNotificationSettings.weeklySummary[project.id] =
|
||||
(notificationSettings.weeklySummary && notificationSettings.weeklySummary[project.id]) || false;
|
||||
// set default values for alerts
|
||||
for (const environment of project.environments) {
|
||||
for (const survey of environment.surveys) {
|
||||
@@ -183,11 +178,6 @@ const Page = async (props) => {
|
||||
/>
|
||||
</SettingsCard>
|
||||
<IntegrationsTip environmentId={params.environmentId} />
|
||||
<SettingsCard
|
||||
title={t("environments.settings.notifications.weekly_summary_projects")}
|
||||
description={t("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")}>
|
||||
<EditWeeklySummary memberships={memberships} user={user} environmentId={params.environmentId} />
|
||||
</SettingsCard>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,24 +10,19 @@ import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
OperationNotAllowedError,
|
||||
TooManyRequestsError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
|
||||
|
||||
const limiter = rateLimit({
|
||||
interval: 60 * 60, // 1 hour
|
||||
allowedPerInterval: 3, // max 3 calls for email verification per hour
|
||||
});
|
||||
TUserPersonalInfoUpdateInput,
|
||||
TUserUpdateInput,
|
||||
ZUserPersonalInfoUpdateInput,
|
||||
} from "@formbricks/types/user";
|
||||
|
||||
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
|
||||
return {
|
||||
@@ -41,18 +36,15 @@ async function handleEmailUpdate({
|
||||
parsedInput,
|
||||
payload,
|
||||
}: {
|
||||
ctx: any;
|
||||
parsedInput: any;
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: TUserPersonalInfoUpdateInput;
|
||||
payload: TUserUpdateInput;
|
||||
}) {
|
||||
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
||||
if (!inputEmail || ctx.user.email === inputEmail) return payload;
|
||||
|
||||
try {
|
||||
await limiter(ctx.user.id);
|
||||
} catch {
|
||||
throw new TooManyRequestsError("Too many requests");
|
||||
}
|
||||
await applyRateLimit(rateLimitConfigs.actions.emailUpdate, ctx.user.id);
|
||||
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
|
||||
}
|
||||
@@ -75,41 +67,35 @@ async function handleEmailUpdate({
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const updateUserAction = authenticatedActionClient
|
||||
.schema(
|
||||
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
|
||||
password: ZUserPassword.optional(),
|
||||
})
|
||||
)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
let payload = buildUserUpdatePayload(parsedInput);
|
||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: TUserPersonalInfoUpdateInput;
|
||||
}) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
let payload = buildUserUpdatePayload(parsedInput);
|
||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
let newObject = oldObject;
|
||||
if (Object.keys(payload).length > 0) {
|
||||
newObject = await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = newObject;
|
||||
|
||||
return true;
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
let newObject = oldObject;
|
||||
if (Object.keys(payload).length > 0) {
|
||||
newObject = await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = newObject;
|
||||
|
||||
return true;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateAvatarAction = z.object({
|
||||
avatarUrl: z.string(),
|
||||
|
||||
@@ -20,7 +20,7 @@ const mockUser = {
|
||||
email: "test@example.com",
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
twoFactorEnabled: false,
|
||||
|
||||
@@ -15,7 +15,7 @@ const mockUser = {
|
||||
id: "user1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
|
||||
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -13,7 +13,7 @@ const mockUser = {
|
||||
locale: "en-US",
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
twoFactorEnabled: false,
|
||||
|
||||
@@ -76,7 +76,7 @@ const mockUser = {
|
||||
imageUrl: "http://example.com/avatar.png",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
|
||||
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: "project_manager",
|
||||
|
||||
@@ -129,7 +129,7 @@ const mockUser = {
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
||||
notificationSettings: { alert: {} },
|
||||
role: "project_manager",
|
||||
objective: "other",
|
||||
} as unknown as TUser;
|
||||
|
||||
@@ -58,7 +58,6 @@ vi.mock("@/lib/env", () => ({
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
|
||||
vi.mock("@/app/lib/surveys/surveys");
|
||||
vi.mock("@/app/share/[sharingKey]/actions");
|
||||
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
|
||||
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
|
||||
}));
|
||||
@@ -112,7 +111,6 @@ const mockSurvey = {
|
||||
surveyClosedMessage: null,
|
||||
welcomeCard: { enabled: false, headline: { default: "" } } as unknown as TSurvey["welcomeCard"],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
@@ -171,22 +169,6 @@ describe("SurveyAnalysisNavigation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("renders navigation correctly for sharing page", () => {
|
||||
mockUsePathname.mockReturnValue(
|
||||
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/summary`
|
||||
);
|
||||
mockUseParams.mockReturnValue({ sharingKey: "test-sharing-key" });
|
||||
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
|
||||
mockGetFormattedFilters.mockReturnValue([] as any);
|
||||
mockGetResponseCountAction.mockResolvedValue({ data: 5 });
|
||||
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} />);
|
||||
|
||||
expect(MockSecondaryNavigation).toHaveBeenCalled();
|
||||
const lastCallArgs = MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
|
||||
expect(lastCallArgs.navigation[0].href).toContain("/share/test-sharing-key");
|
||||
});
|
||||
|
||||
test("displays correct response count string in label for various scenarios", async () => {
|
||||
mockUsePathname.mockReturnValue(
|
||||
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses`
|
||||
|
||||
@@ -4,7 +4,7 @@ import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { InboxIcon, PresentationIcon } from "lucide-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface SurveyAnalysisNavigationProps {
|
||||
@@ -20,11 +20,8 @@ export const SurveyAnalysisNavigation = ({
|
||||
}: SurveyAnalysisNavigationProps) => {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslate();
|
||||
const params = useParams();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const url = isSharingPage ? `/share/${sharingKey}` : `/environments/${environmentId}/surveys/${survey.id}`;
|
||||
const url = `/environments/${environmentId}/surveys/${survey.id}`;
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
|
||||
@@ -50,7 +50,6 @@ const mockSurvey = {
|
||||
isBackButtonHidden: false,
|
||||
pin: null,
|
||||
recontactDays: null,
|
||||
resultShareKey: null,
|
||||
runOnDate: null,
|
||||
showLanguageSwitch: false,
|
||||
singleUse: null,
|
||||
|
||||
@@ -113,7 +113,6 @@ const mockSurvey = {
|
||||
singleUse: null,
|
||||
triggers: [],
|
||||
languages: [],
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
welcomeCard: { enabled: false, headline: { default: "Welcome!" } } as unknown as TSurvey["welcomeCard"],
|
||||
styling: null,
|
||||
@@ -139,7 +138,7 @@ const mockUser = {
|
||||
updatedAt: new Date(),
|
||||
role: "project_manager",
|
||||
objective: "increase_conversion",
|
||||
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
|
||||
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
|
||||
} as unknown as TUser;
|
||||
|
||||
const mockEnvironmentTags: TTag[] = [
|
||||
|
||||
@@ -88,7 +88,6 @@ const mockSurvey = {
|
||||
surveyClosedMessage: null,
|
||||
triggers: [],
|
||||
languages: [],
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
|
||||
@@ -28,19 +28,10 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/
|
||||
CustomFilter: vi.fn(() => <div data-testid="custom-filter">CustomFilter</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton", () => ({
|
||||
ResultsShareButton: vi.fn(() => <div data-testid="results-share-button">ResultsShareButton</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/surveys/surveys", () => ({
|
||||
getFormattedFilters: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
||||
getResponseCountBySurveySharingKeyAction: vi.fn(),
|
||||
getResponsesBySurveySharingKeyAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceHeadlineRecall: vi.fn((survey) => survey),
|
||||
}));
|
||||
@@ -64,12 +55,6 @@ const mockGetResponseCountAction = vi.mocked(
|
||||
(await import("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"))
|
||||
.getResponseCountAction
|
||||
);
|
||||
const mockGetResponsesBySurveySharingKeyAction = vi.mocked(
|
||||
(await import("@/app/share/[sharingKey]/actions")).getResponsesBySurveySharingKeyAction
|
||||
);
|
||||
const mockGetResponseCountBySurveySharingKeyAction = vi.mocked(
|
||||
(await import("@/app/share/[sharingKey]/actions")).getResponseCountBySurveySharingKeyAction
|
||||
);
|
||||
const mockUseParams = vi.mocked((await import("next/navigation")).useParams);
|
||||
const mockUseSearchParams = vi.mocked((await import("next/navigation")).useSearchParams);
|
||||
const mockGetFormattedFilters = vi.mocked((await import("@/app/lib/surveys/surveys")).getFormattedFilters);
|
||||
@@ -150,8 +135,6 @@ describe("ResponsePage", () => {
|
||||
mockUseResponseFilter.mockReturnValue(mockResponseFilterState);
|
||||
mockGetResponsesAction.mockResolvedValue({ data: mockResponses });
|
||||
mockGetResponseCountAction.mockResolvedValue({ data: 20 });
|
||||
mockGetResponsesBySurveySharingKeyAction.mockResolvedValue({ data: mockResponses });
|
||||
mockGetResponseCountBySurveySharingKeyAction.mockResolvedValue({ data: 20 });
|
||||
mockGetFormattedFilters.mockReturnValue({});
|
||||
});
|
||||
|
||||
@@ -159,28 +142,11 @@ describe("ResponsePage", () => {
|
||||
render(<ResponsePage {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
|
||||
});
|
||||
expect(mockGetResponsesAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not render ResultsShareButton when isReadOnly is true", async () => {
|
||||
render(<ResponsePage {...defaultProps} isReadOnly={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("does not render ResultsShareButton when on sharing page", async () => {
|
||||
mockUseParams.mockReturnValue({ sharingKey: "share123" });
|
||||
render(<ResponsePage {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(mockGetResponsesBySurveySharingKeyAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fetches next page of responses", async () => {
|
||||
const { rerender } = render(<ResponsePage {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -4,11 +4,9 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/comp
|
||||
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { getResponsesBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
@@ -20,7 +18,6 @@ interface ResponsePageProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
surveyId: string;
|
||||
publicDomain: string;
|
||||
user?: TUser;
|
||||
environmentTags: TTag[];
|
||||
responsesPerPage: number;
|
||||
@@ -32,17 +29,12 @@ export const ResponsePage = ({
|
||||
environment,
|
||||
survey,
|
||||
surveyId,
|
||||
publicDomain,
|
||||
user,
|
||||
environmentTags,
|
||||
responsesPerPage,
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: ResponsePageProps) => {
|
||||
const params = useParams();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const [responses, setResponses] = useState<TResponse[]>([]);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
@@ -63,30 +55,20 @@ export const ResponsePage = ({
|
||||
|
||||
let newResponses: TResponse[] = [];
|
||||
|
||||
if (isSharingPage) {
|
||||
const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({
|
||||
sharingKey: sharingKey,
|
||||
limit: responsesPerPage,
|
||||
offset: (newPage - 1) * responsesPerPage,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
newResponses = getResponsesActionResponse?.data || [];
|
||||
} else {
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
surveyId,
|
||||
limit: responsesPerPage,
|
||||
offset: (newPage - 1) * responsesPerPage,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
newResponses = getResponsesActionResponse?.data || [];
|
||||
}
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
surveyId,
|
||||
limit: responsesPerPage,
|
||||
offset: (newPage - 1) * responsesPerPage,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
newResponses = getResponsesActionResponse?.data || [];
|
||||
|
||||
if (newResponses.length === 0 || newResponses.length < responsesPerPage) {
|
||||
setHasMore(false);
|
||||
}
|
||||
setResponses([...responses, ...newResponses]);
|
||||
setPage(newPage);
|
||||
}, [filters, isSharingPage, page, responses, responsesPerPage, sharingKey, surveyId]);
|
||||
}, [filters, page, responses, responsesPerPage, surveyId]);
|
||||
|
||||
const deleteResponses = (responseIds: string[]) => {
|
||||
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
|
||||
@@ -114,25 +96,14 @@ export const ResponsePage = ({
|
||||
setFetchingFirstPage(true);
|
||||
let responses: TResponse[] = [];
|
||||
|
||||
if (isSharingPage) {
|
||||
const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
limit: responsesPerPage,
|
||||
offset: 0,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
surveyId,
|
||||
limit: responsesPerPage,
|
||||
offset: 0,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
|
||||
responses = getResponsesActionResponse?.data || [];
|
||||
} else {
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
surveyId,
|
||||
limit: responsesPerPage,
|
||||
offset: 0,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
|
||||
responses = getResponsesActionResponse?.data || [];
|
||||
}
|
||||
responses = getResponsesActionResponse?.data || [];
|
||||
|
||||
if (responses.length < responsesPerPage) {
|
||||
setHasMore(false);
|
||||
@@ -143,7 +114,7 @@ export const ResponsePage = ({
|
||||
}
|
||||
};
|
||||
fetchInitialResponses();
|
||||
}, [surveyId, filters, responsesPerPage, sharingKey, isSharingPage]);
|
||||
}, [surveyId, filters, responsesPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
@@ -155,7 +126,6 @@ export const ResponsePage = ({
|
||||
<>
|
||||
<div className="flex gap-1.5">
|
||||
<CustomFilter survey={surveyMemoized} />
|
||||
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} publicDomain={publicDomain} />}
|
||||
</div>
|
||||
<ResponseDataView
|
||||
survey={survey}
|
||||
|
||||
@@ -156,7 +156,6 @@ const mockSurvey = {
|
||||
projectOverwrites: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
surveyClosedMessage: null,
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -73,6 +74,10 @@ vi.mock("@/lib/response/service", () => ({
|
||||
getResponseCountBySurveyId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/display/service", () => ({
|
||||
getDisplayCountBySurveyId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
}));
|
||||
@@ -115,7 +120,6 @@ vi.mock("next/navigation", () => ({
|
||||
useParams: () => ({
|
||||
environmentId: "test-env-id",
|
||||
surveyId: "test-survey-id",
|
||||
sharingKey: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -178,6 +182,7 @@ describe("ResponsesPage", () => {
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
|
||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(5);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
|
||||
});
|
||||
@@ -206,6 +211,8 @@ describe("ResponsesPage", () => {
|
||||
isReadOnly: false,
|
||||
user: mockUser,
|
||||
publicDomain: mockPublicDomain,
|
||||
responseCount: 10,
|
||||
displayCount: 5,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
@@ -224,12 +231,10 @@ describe("ResponsesPage", () => {
|
||||
environment: mockEnvironment,
|
||||
survey: mockSurvey,
|
||||
surveyId: mockSurveyId,
|
||||
publicDomain: mockPublicDomain,
|
||||
environmentTags: mockTags,
|
||||
user: mockUser,
|
||||
responsesPerPage: 10,
|
||||
locale: mockLocale,
|
||||
isReadOnly: false,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -40,6 +41,7 @@ const Page = async (props) => {
|
||||
|
||||
// Get response count for the CTA component
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
const publicDomain = getPublicDomain();
|
||||
@@ -56,6 +58,7 @@ const Page = async (props) => {
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
displayCount={displayCount}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
@@ -67,7 +70,6 @@ const Page = async (props) => {
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={params.surveyId}
|
||||
publicDomain={publicDomain}
|
||||
environmentTags={tags}
|
||||
user={user}
|
||||
responsesPerPage={RESPONSES_PER_PAGE}
|
||||
|
||||
@@ -14,10 +14,10 @@ import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
|
||||
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
|
||||
|
||||
const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
||||
surveyId: ZId,
|
||||
@@ -64,143 +64,60 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
||||
);
|
||||
});
|
||||
|
||||
const ZGenerateResultShareUrlAction = z.object({
|
||||
const ZResetSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
organizationId: ZId,
|
||||
projectId: ZId,
|
||||
});
|
||||
|
||||
export const generateResultShareUrlAction = authenticatedActionClient
|
||||
.schema(ZGenerateResultShareUrlAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZResetSurveyAction>;
|
||||
}) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: parsedInput.projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = null;
|
||||
|
||||
const resultShareKey = customAlphabet(
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
|
||||
20
|
||||
)();
|
||||
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
|
||||
parsedInput.surveyId
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = survey;
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
deletedResponsesCount: deletedResponsesCount,
|
||||
deletedDisplaysCount: deletedDisplaysCount,
|
||||
};
|
||||
|
||||
const newSurvey = await updateSurvey({ ...survey, resultShareKey });
|
||||
ctx.auditLoggingCtx.newObject = newSurvey;
|
||||
|
||||
return resultShareKey;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetResultShareUrlAction = z.object({
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
export const getResultShareUrlAction = authenticatedActionClient
|
||||
.schema(ZGetResultShareUrlAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
return {
|
||||
success: true,
|
||||
deletedResponsesCount: deletedResponsesCount,
|
||||
deletedDisplaysCount: deletedDisplaysCount,
|
||||
};
|
||||
}
|
||||
|
||||
return survey.resultShareKey;
|
||||
});
|
||||
|
||||
const ZDeleteResultShareUrlAction = z.object({
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
export const deleteResultShareUrlAction = authenticatedActionClient
|
||||
.schema(ZDeleteResultShareUrlAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = survey;
|
||||
|
||||
const newSurvey = await updateSurvey({ ...survey, resultShareKey: null });
|
||||
ctx.auditLoggingCtx.newObject = newSurvey;
|
||||
|
||||
return newSurvey;
|
||||
}
|
||||
)
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetEmailHtmlAction = z.object({
|
||||
surveyId: ZId,
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { ShareSurveyResults } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults";
|
||||
import { cleanup, render, screen } 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";
|
||||
|
||||
// Mock Button
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: vi.fn(({ children, onClick, asChild, ...props }: any) => {
|
||||
if (asChild) {
|
||||
// For 'asChild', Button renders its children, potentially passing props via Slot.
|
||||
// Mocking simply renders children inside a div that can receive Button's props.
|
||||
return <div {...props}>{children}</div>;
|
||||
}
|
||||
return (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Dialog
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: vi.fn(({ children, open, onOpenChange }) =>
|
||||
open ? (
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
DialogContent: vi.fn(({ children, ...props }) => (
|
||||
<div data-testid="dialog-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
|
||||
}));
|
||||
|
||||
// Mock useTranslate
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: vi.fn(() => ({
|
||||
t: (key: string) => key,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock Next Link
|
||||
vi.mock("next/link", () => ({
|
||||
default: vi.fn(({ children, href, target, rel, ...props }) => (
|
||||
<a href={href} target={target} rel={rel} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock react-hot-toast
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockSetOpen = vi.fn();
|
||||
const mockHandlePublish = vi.fn();
|
||||
const mockHandleUnpublish = vi.fn();
|
||||
const surveyUrl = "https://app.formbricks.com/s/some-survey-id";
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
setOpen: mockSetOpen,
|
||||
handlePublish: mockHandlePublish,
|
||||
handleUnpublish: mockHandleUnpublish,
|
||||
showPublishModal: false,
|
||||
surveyUrl: "",
|
||||
};
|
||||
|
||||
describe("ShareSurveyResults", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock navigator.clipboard
|
||||
Object.defineProperty(global.navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders publish warning when showPublishModal is false", async () => {
|
||||
render(<ShareSurveyResults {...defaultProps} />);
|
||||
expect(screen.getByText("environments.surveys.summary.publish_to_web_warning")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.publish_to_web_warning_description")
|
||||
).toBeInTheDocument();
|
||||
const publishButton = screen.getByText("environments.surveys.summary.publish_to_web");
|
||||
expect(publishButton).toBeInTheDocument();
|
||||
await userEvent.click(publishButton);
|
||||
expect(mockHandlePublish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("renders survey public info when showPublishModal is true and surveyUrl is provided", async () => {
|
||||
render(<ShareSurveyResults {...defaultProps} showPublishModal={true} surveyUrl={surveyUrl} />);
|
||||
expect(screen.getByText("environments.surveys.summary.survey_results_are_public")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(surveyUrl)).toBeInTheDocument();
|
||||
|
||||
const copyButton = screen.getByRole("button", { name: "Copy survey link to clipboard" });
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
await userEvent.click(copyButton);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(surveyUrl);
|
||||
expect(vi.mocked(toast.success)).toHaveBeenCalledWith("common.link_copied");
|
||||
|
||||
const unpublishButton = screen.getByText("environments.surveys.summary.unpublish_from_web");
|
||||
expect(unpublishButton).toBeInTheDocument();
|
||||
await userEvent.click(unpublishButton);
|
||||
expect(mockHandleUnpublish).toHaveBeenCalledTimes(1);
|
||||
|
||||
const viewSiteLink = screen.getByText("environments.surveys.summary.view_site");
|
||||
expect(viewSiteLink).toBeInTheDocument();
|
||||
const anchor = viewSiteLink.closest("a");
|
||||
expect(anchor).toHaveAttribute("href", surveyUrl);
|
||||
expect(anchor).toHaveAttribute("target", "_blank");
|
||||
expect(anchor).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
test("does not render content when modal is closed (open is false)", () => {
|
||||
render(<ShareSurveyResults {...defaultProps} open={false} />);
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("environments.surveys.summary.survey_results_are_public")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders publish warning if surveyUrl is empty even if showPublishModal is true", () => {
|
||||
render(<ShareSurveyResults {...defaultProps} showPublishModal={true} surveyUrl="" />);
|
||||
expect(screen.getByText("environments.surveys.summary.publish_to_web_warning")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("environments.surveys.summary.survey_results_are_public")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Dialog, DialogBody, DialogContent } from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
|
||||
import { Clipboard } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
interface ShareEmbedSurveyProps {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handlePublish: () => void;
|
||||
handleUnpublish: () => void;
|
||||
showPublishModal: boolean;
|
||||
surveyUrl: string;
|
||||
}
|
||||
export const ShareSurveyResults = ({
|
||||
open,
|
||||
setOpen,
|
||||
handlePublish,
|
||||
handleUnpublish,
|
||||
showPublishModal,
|
||||
surveyUrl,
|
||||
}: ShareEmbedSurveyProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogBody>
|
||||
{showPublishModal && surveyUrl ? (
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<CheckCircle2Icon className="text-primary h-20 w-20" />
|
||||
<div>
|
||||
<p className="text-primary text-lg font-medium">
|
||||
{t("environments.surveys.summary.survey_results_are_public")}
|
||||
</p>
|
||||
<p className="text-balanced mt-2 text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
|
||||
<span>{surveyUrl}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
title="Copy survey link to clipboard"
|
||||
aria-label="Copy survey link to clipboard"
|
||||
className="hover:cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrl);
|
||||
toast.success(t("common.link_copied"));
|
||||
}}>
|
||||
<Clipboard />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
className="text-center"
|
||||
onClick={() => handleUnpublish()}>
|
||||
{t("environments.surveys.summary.unpublish_from_web")}
|
||||
</Button>
|
||||
<Button className="text-center" asChild>
|
||||
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
|
||||
{t("environments.surveys.summary.view_site")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col rounded-2xl bg-white p-8">
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
|
||||
<div>
|
||||
<p className="text-lg font-medium text-slate-600">
|
||||
{t("environments.surveys.summary.publish_to_web_warning")}
|
||||
</p>
|
||||
<p className="text-balanced mt-2 text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.publish_to_web_warning_description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
|
||||
{t("environments.surveys.summary.publish_to_web")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -74,7 +74,6 @@ describe("SuccessMessage", () => {
|
||||
surveyClosedMessage: null,
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
variables: [],
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
|
||||
@@ -177,7 +177,6 @@ const mockSurvey = {
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
languages: [],
|
||||
resultShareKey: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
@@ -192,7 +191,7 @@ const mockSurvey = {
|
||||
variables: [],
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockSelectedFilter = { filter: [], onlyComplete: false };
|
||||
const mockSelectedFilter = { filter: [], responseStatus: "all" };
|
||||
const mockSetSelectedFilter = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
@@ -310,17 +309,13 @@ describe("SummaryList", () => {
|
||||
|
||||
test("renders EmptySpaceFiller when responseCount is 0 and summary is not empty (no responses match filter)", () => {
|
||||
const summaryWithItem = [createMockQuestionSummary("q1", TSurveyQuestionTypeEnum.OpenText)];
|
||||
render(
|
||||
<SummaryList {...defaultProps} summary={summaryWithItem} responseCount={0} totalResponseCount={10} />
|
||||
);
|
||||
render(<SummaryList {...defaultProps} summary={summaryWithItem} responseCount={0} />);
|
||||
expect(screen.getByText("Mocked EmptySpaceFiller")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders EmptySpaceFiller when responseCount is 0 and totalResponseCount is 0 (no responses at all)", () => {
|
||||
const summaryWithItem = [createMockQuestionSummary("q1", TSurveyQuestionTypeEnum.OpenText)];
|
||||
render(
|
||||
<SummaryList {...defaultProps} summary={summaryWithItem} responseCount={0} totalResponseCount={0} />
|
||||
);
|
||||
render(<SummaryList {...defaultProps} summary={summaryWithItem} responseCount={0} />);
|
||||
expect(screen.getByText("Mocked EmptySpaceFiller")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -398,7 +393,7 @@ describe("SummaryList", () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
onlyComplete: false,
|
||||
responseStatus: "all",
|
||||
});
|
||||
// Ensure vi.mocked(toast.success) refers to the spy from the named export
|
||||
expect(vi.mocked(toast).success).toHaveBeenCalledWith("Custom add message", { duration: 5000 });
|
||||
@@ -426,7 +421,7 @@ describe("SummaryList", () => {
|
||||
},
|
||||
};
|
||||
vi.mocked(useResponseFilter).mockReturnValue({
|
||||
selectedFilter: { filter: [existingFilter], onlyComplete: false },
|
||||
selectedFilter: { filter: [existingFilter], responseStatus: "all" },
|
||||
setSelectedFilter: mockSetSelectedFilter,
|
||||
resetFilter: vi.fn(),
|
||||
} as any);
|
||||
@@ -455,7 +450,7 @@ describe("SummaryList", () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
onlyComplete: false,
|
||||
responseStatus: "all",
|
||||
});
|
||||
expect(vi.mocked(toast.success)).toHaveBeenCalledWith(
|
||||
"environments.surveys.summary.filter_updated_successfully",
|
||||
|
||||
@@ -92,7 +92,7 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
|
||||
|
||||
setSelectedFilter({
|
||||
filter: [...filterObject.filter],
|
||||
onlyComplete: filterObject.onlyComplete,
|
||||
responseStatus: filterObject.responseStatus,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -44,43 +44,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
||||
getResponseCountBySurveySharingKeyAction: vi.fn().mockResolvedValue({ data: 42 }),
|
||||
getSummaryBySurveySharingKeyAction: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
completedPercentage: 80,
|
||||
completedResponses: 40,
|
||||
displayCount: 50,
|
||||
dropOffPercentage: 20,
|
||||
dropOffCount: 10,
|
||||
startsPercentage: 100,
|
||||
totalResponses: 50,
|
||||
ttcAverage: 120,
|
||||
},
|
||||
dropOff: [
|
||||
{
|
||||
questionId: "q1",
|
||||
headline: "Question 1",
|
||||
questionType: "openText",
|
||||
ttc: 20000,
|
||||
impressions: 50,
|
||||
dropOffCount: 5,
|
||||
dropOffPercentage: 10,
|
||||
},
|
||||
],
|
||||
summary: [
|
||||
{
|
||||
question: { id: "q1", headline: "Question 1", type: "openText", required: true },
|
||||
responseCount: 45,
|
||||
type: "openText",
|
||||
samples: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs",
|
||||
@@ -125,10 +88,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/
|
||||
CustomFilter: () => <div data-testid="custom-filter">Custom Filter</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton", () => ({
|
||||
ResultsShareButton: () => <div data-testid="results-share-button">Share Results</div>,
|
||||
}));
|
||||
|
||||
// Mock context
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
||||
useResponseFilter: () => ({
|
||||
@@ -172,7 +131,6 @@ describe("SummaryPage", () => {
|
||||
webAppUrl: "https://app.example.com",
|
||||
totalResponseCount: 50,
|
||||
locale,
|
||||
isReadOnly: false,
|
||||
};
|
||||
|
||||
test("renders loading state initially", () => {
|
||||
@@ -191,7 +149,6 @@ describe("SummaryPage", () => {
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("scroll-to-top")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("summary-list")).toBeInTheDocument();
|
||||
});
|
||||
@@ -214,15 +171,4 @@ describe("SummaryPage", () => {
|
||||
// Drop-offs should now be visible
|
||||
expect(screen.getByTestId("summary-drop-offs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("doesn't show share button in read-only mode", async () => {
|
||||
render(<SummaryPage {...defaultProps} isReadOnly={true} />);
|
||||
|
||||
// Wait for loading to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Is Loading: false")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,9 @@ import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]
|
||||
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
|
||||
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { getSummaryBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
@@ -36,9 +34,7 @@ interface SummaryPageProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
surveyId: string;
|
||||
publicDomain: string;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
initialSurveySummary?: TSurveySummary;
|
||||
}
|
||||
|
||||
@@ -46,15 +42,9 @@ export const SummaryPage = ({
|
||||
environment,
|
||||
survey,
|
||||
surveyId,
|
||||
publicDomain,
|
||||
locale,
|
||||
isReadOnly,
|
||||
initialSurveySummary,
|
||||
}: SummaryPageProps) => {
|
||||
const params = useParams();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
|
||||
@@ -87,17 +77,10 @@ export const SummaryPage = ({
|
||||
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
let updatedSurveySummary;
|
||||
|
||||
if (isSharingPage) {
|
||||
updatedSurveySummary = await getSummaryBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
} else {
|
||||
updatedSurveySummary = await getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
}
|
||||
updatedSurveySummary = await getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
|
||||
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
|
||||
setSurveySummary(surveySummary);
|
||||
@@ -109,7 +92,7 @@ export const SummaryPage = ({
|
||||
};
|
||||
|
||||
fetchSummary();
|
||||
}, [selectedFilter, dateRange, survey, isSharingPage, sharingKey, surveyId, initialSurveySummary]);
|
||||
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
return replaceHeadlineRecall(survey, "default");
|
||||
@@ -132,9 +115,6 @@ export const SummaryPage = ({
|
||||
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
|
||||
<div className="flex gap-1.5">
|
||||
<CustomFilter survey={surveyMemoized} />
|
||||
{!isReadOnly && !isSharingPage && (
|
||||
<ResultsShareButton survey={surveyMemoized} publicDomain={publicDomain} />
|
||||
)}
|
||||
</div>
|
||||
<ScrollToTop containerId="mainContent" />
|
||||
<SummaryList
|
||||
|
||||
@@ -33,6 +33,18 @@ vi.mock("@tolgee/react", () => ({
|
||||
if (key === "environments.surveys.edit.caution_edit_duplicate") {
|
||||
return "Duplicate & Edit";
|
||||
}
|
||||
if (key === "environments.surveys.summary.reset_survey") {
|
||||
return "Reset survey";
|
||||
}
|
||||
if (key === "environments.surveys.summary.delete_all_existing_responses_and_displays") {
|
||||
return "Delete all existing responses and displays";
|
||||
}
|
||||
if (key === "environments.surveys.summary.reset_survey_warning") {
|
||||
return "Resetting a survey removes all responses and metadata of this survey. This cannot be undone.";
|
||||
}
|
||||
if (key === "environments.surveys.summary.survey_reset_successfully") {
|
||||
return "Survey reset successfully! 5 responses and 3 displays were deleted.";
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
@@ -40,12 +52,14 @@ vi.mock("@tolgee/react", () => ({
|
||||
|
||||
// Mock Next.js hooks
|
||||
const mockPush = vi.fn();
|
||||
const mockRefresh = vi.fn();
|
||||
const mockPathname = "/environments/test-env-id/surveys/test-survey-id/summary";
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
refresh: mockRefresh,
|
||||
}),
|
||||
usePathname: () => mockPathname,
|
||||
useSearchParams: () => mockSearchParams,
|
||||
@@ -69,6 +83,10 @@ vi.mock("@/modules/survey/list/actions", () => ({
|
||||
copySurveyToOtherEnvironmentAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../actions", () => ({
|
||||
resetSurveyAction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the useSingleUseId hook
|
||||
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
|
||||
useSingleUseId: vi.fn(() => ({
|
||||
@@ -147,6 +165,34 @@ vi.mock("@/modules/ui/components/badge", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/confirmation-modal", () => ({
|
||||
ConfirmationModal: ({
|
||||
open,
|
||||
setOpen,
|
||||
title,
|
||||
text,
|
||||
buttonText,
|
||||
onConfirm,
|
||||
buttonVariant,
|
||||
buttonLoading,
|
||||
}: any) => (
|
||||
<div
|
||||
data-testid="confirmation-modal"
|
||||
data-open={open}
|
||||
data-loading={buttonLoading}
|
||||
data-variant={buttonVariant}>
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<div data-testid="modal-text">{text}</div>
|
||||
<button type="button" onClick={onConfirm} data-testid="confirm-button">
|
||||
{buttonText}
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)} data-testid="cancel-button">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, className }: any) => (
|
||||
<button type="button" data-testid="button" onClick={onClick} className={className}>
|
||||
@@ -178,9 +224,17 @@ vi.mock("@/modules/ui/components/iconbar", () => ({
|
||||
vi.mock("lucide-react", () => ({
|
||||
BellRing: () => <svg data-testid="bell-ring-icon" />,
|
||||
Eye: () => <svg data-testid="eye-icon" />,
|
||||
ListRestart: () => <svg data-testid="list-restart-icon" />,
|
||||
SquarePenIcon: () => <svg data-testid="square-pen-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/context/environment-context", () => ({
|
||||
useEnvironment: vi.fn(() => ({
|
||||
organizationId: "test-organization-id",
|
||||
project: { id: "test-project-id" },
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "test-env-id",
|
||||
@@ -230,7 +284,6 @@ const mockSurvey: TSurvey = {
|
||||
recaptcha: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
resultShareKey: null,
|
||||
};
|
||||
|
||||
const mockUser: TUser = {
|
||||
@@ -251,12 +304,8 @@ const mockUser: TUser = {
|
||||
isActive: true,
|
||||
notificationSettings: {
|
||||
alert: {
|
||||
weeklySummary: true,
|
||||
responseFinished: true,
|
||||
},
|
||||
weeklySummary: {
|
||||
test: true,
|
||||
},
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
};
|
||||
@@ -270,6 +319,7 @@ const defaultProps = {
|
||||
user: mockUser,
|
||||
publicDomain: "https://example.com",
|
||||
responseCount: 0,
|
||||
displayCount: 0,
|
||||
segments: mockSegments,
|
||||
isContactsEnabled: true,
|
||||
isFormbricksCloud: false,
|
||||
@@ -286,19 +336,19 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("renders share survey button", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByText("Share survey")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders success message component", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByTestId("success-message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders survey status dropdown when app setup is completed", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
@@ -310,7 +360,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("renders icon bar with correct actions", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("icon-bar-action-0")).toBeInTheDocument(); // Bell ring
|
||||
@@ -324,17 +374,9 @@ describe("SurveyAnalysisCTA", () => {
|
||||
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview");
|
||||
});
|
||||
|
||||
test("shows public results badge when resultShareKey exists", () => {
|
||||
const surveyWithShareKey = { ...mockSurvey, resultShareKey: "share-key" };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={surveyWithShareKey} />);
|
||||
|
||||
expect(screen.getByTestId("badge")).toBeInTheDocument();
|
||||
expect(screen.getByText("Results are public")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens share modal when share button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
|
||||
@@ -344,7 +386,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("opens share modal when share param is true", () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "start");
|
||||
@@ -352,7 +394,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("navigates to edit when edit button is clicked and no responses", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
@@ -363,14 +405,15 @@ describe("SurveyAnalysisCTA", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
// With responseCount > 0, the edit button should be at icon-bar-action-2 (after reset button)
|
||||
await user.click(screen.getByTestId("icon-bar-action-2"));
|
||||
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
|
||||
});
|
||||
|
||||
test("navigates to notifications when bell icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-0"));
|
||||
|
||||
@@ -391,7 +434,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("does not show icon bar actions when read-only", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isReadOnly={true} />);
|
||||
|
||||
const iconBar = screen.getByTestId("icon-bar");
|
||||
expect(iconBar).toBeInTheDocument();
|
||||
@@ -402,7 +445,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
test("handles modal close correctly", async () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
// Verify modal is open initially
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
@@ -429,13 +472,13 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("does not show status dropdown when app setup is not completed", () => {
|
||||
const environmentWithoutAppSetup = { ...mockEnvironment, appSetupCompleted: false };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} environment={environmentWithoutAppSetup} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} environment={environmentWithoutAppSetup} />);
|
||||
|
||||
expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly with all props", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
|
||||
expect(screen.getByText("Share survey")).toBeInTheDocument();
|
||||
@@ -454,7 +497,6 @@ describe("SurveyAnalysisCTA", () => {
|
||||
environmentId: "test-env-id",
|
||||
triggers: [],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
},
|
||||
});
|
||||
@@ -536,7 +578,6 @@ describe("SurveyAnalysisCTA", () => {
|
||||
environmentId: "test-env-id",
|
||||
triggers: [],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
},
|
||||
}),
|
||||
@@ -570,7 +611,6 @@ describe("SurveyAnalysisCTA", () => {
|
||||
environmentId: "test-env-id",
|
||||
triggers: [],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
},
|
||||
});
|
||||
@@ -579,8 +619,8 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
// Click edit button to open dialog (should be icon-bar-action-2 with responses)
|
||||
await user.click(screen.getByTestId("icon-bar-action-2"));
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Click primary button (duplicate & edit)
|
||||
@@ -647,7 +687,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("opens share modal with correct modal view when share button clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
|
||||
@@ -669,24 +709,28 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("does not render share modal when user is null", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} user={null as any} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} user={null as any} />);
|
||||
|
||||
expect(screen.queryByTestId("share-survey-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with different isFormbricksCloud values", () => {
|
||||
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={true} />);
|
||||
const { rerender } = render(
|
||||
<SurveyAnalysisCTA {...defaultProps} displayCount={0} isFormbricksCloud={true} />
|
||||
);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={false} />);
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isFormbricksCloud={false} />);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with different isContactsEnabled values", () => {
|
||||
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={true} />);
|
||||
const { rerender } = render(
|
||||
<SurveyAnalysisCTA {...defaultProps} displayCount={0} isContactsEnabled={true} />
|
||||
);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={false} />);
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isContactsEnabled={false} />);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -701,7 +745,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("handles modal state changes correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
// Open modal via share button
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
@@ -714,7 +758,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("opens share modal via share button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
|
||||
@@ -726,7 +770,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
test("closes share modal and updates modal state", async () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
// Modal should be open initially due to share param
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
@@ -738,19 +782,19 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("handles empty segments array", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} segments={[]} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} segments={[]} />);
|
||||
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles zero response count", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={0} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} responseCount={0} />);
|
||||
|
||||
expect(screen.queryByTestId("edit-public-survey-alert-dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows all icon actions for non-readonly app survey", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
// Should show bell (notifications) and edit actions
|
||||
expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts");
|
||||
@@ -766,4 +810,236 @@ describe("SurveyAnalysisCTA", () => {
|
||||
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview");
|
||||
expect(screen.getByTestId("icon-bar-action-2")).toHaveAttribute("title", "Edit");
|
||||
});
|
||||
|
||||
// Reset Survey Feature Tests
|
||||
test("shows reset survey button when responses exist", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows reset survey button when displays exist", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={3} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides reset survey button when no responses or displays exist", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={0} displayCount={0} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeUndefined();
|
||||
});
|
||||
|
||||
test("hides reset survey button for read-only users", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} responseCount={5} displayCount={3} />);
|
||||
|
||||
// For read-only users, there should be no icon bar actions
|
||||
expect(screen.queryAllByTestId(/icon-bar-action-/)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("opens reset confirmation modal when reset button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("modal-title")).toHaveTextContent("Delete all existing responses and displays");
|
||||
expect(screen.getByTestId("modal-text")).toHaveTextContent(
|
||||
"Resetting a survey removes all responses and metadata of this survey. This cannot be undone."
|
||||
);
|
||||
});
|
||||
|
||||
test("executes reset survey action when confirmed", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const toast = await import("react-hot-toast");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
expect(mockResetSurveyAction).toHaveBeenCalledWith({
|
||||
surveyId: "test-survey-id",
|
||||
organizationId: "test-organization-id",
|
||||
projectId: "test-project-id",
|
||||
});
|
||||
expect(toast.default.success).toHaveBeenCalledWith(
|
||||
"Survey reset successfully! 5 responses and 3 displays were deleted."
|
||||
);
|
||||
});
|
||||
|
||||
test("handles reset survey action error", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: undefined,
|
||||
serverError: "Reset failed",
|
||||
validationErrors: undefined,
|
||||
bindArgsValidationErrors: [],
|
||||
});
|
||||
|
||||
const toast = await import("react-hot-toast");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
expect(toast.default.error).toHaveBeenCalledWith("Error message");
|
||||
});
|
||||
|
||||
test("shows loading state during reset operation", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
|
||||
// Mock a delayed response
|
||||
mockResetSurveyAction.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
}),
|
||||
100
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
// Check loading state
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-loading", "true");
|
||||
});
|
||||
|
||||
test("closes reset modal after successful reset", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Confirm reset - wait for the action to complete
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
// Wait for the action to complete and the modal to close
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
});
|
||||
|
||||
test("cancels reset operation when cancel button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Cancel reset
|
||||
await user.click(screen.getByTestId("cancel-button"));
|
||||
|
||||
// Modal should be closed
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
test("shows destructive button variant for reset confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-variant", "destructive");
|
||||
});
|
||||
|
||||
test("refreshes page after successful reset", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
expect(mockRefresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
||||
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
@@ -7,11 +8,11 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
||||
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
||||
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
|
||||
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -19,6 +20,7 @@ import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { resetSurveyAction } from "../actions";
|
||||
|
||||
interface SurveyAnalysisCTAProps {
|
||||
survey: TSurvey;
|
||||
@@ -27,6 +29,7 @@ interface SurveyAnalysisCTAProps {
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
responseCount: number;
|
||||
displayCount: number;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -44,6 +47,7 @@ export const SurveyAnalysisCTA = ({
|
||||
user,
|
||||
publicDomain,
|
||||
responseCount,
|
||||
displayCount,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
@@ -57,8 +61,11 @@ export const SurveyAnalysisCTA = ({
|
||||
start: searchParams.get("share") === "true",
|
||||
share: false,
|
||||
});
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const { refreshSingleUseId } = useSingleUseId(survey);
|
||||
const { organizationId, project } = useEnvironment();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||
|
||||
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
|
||||
@@ -118,6 +125,29 @@ export const SurveyAnalysisCTA = ({
|
||||
|
||||
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
|
||||
|
||||
const handleResetSurvey = async () => {
|
||||
setIsResetting(true);
|
||||
const result = await resetSurveyAction({
|
||||
surveyId: survey.id,
|
||||
organizationId: organizationId,
|
||||
projectId: project.id,
|
||||
});
|
||||
if (result?.data) {
|
||||
toast.success(
|
||||
t("environments.surveys.summary.survey_reset_successfully", {
|
||||
responseCount: result.data.deletedResponsesCount,
|
||||
displayCount: result.data.deletedDisplaysCount,
|
||||
})
|
||||
);
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
setIsResetting(false);
|
||||
setIsResetModalOpen(false);
|
||||
};
|
||||
|
||||
const iconActions = [
|
||||
{
|
||||
icon: BellRing,
|
||||
@@ -134,6 +164,12 @@ export const SurveyAnalysisCTA = ({
|
||||
},
|
||||
isVisible: survey.type === "link",
|
||||
},
|
||||
{
|
||||
icon: ListRestart,
|
||||
tooltip: t("environments.surveys.summary.reset_survey"),
|
||||
onClick: () => setIsResetModalOpen(true),
|
||||
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
|
||||
},
|
||||
{
|
||||
icon: SquarePenIcon,
|
||||
tooltip: t("common.edit"),
|
||||
@@ -148,15 +184,6 @@ export const SurveyAnalysisCTA = ({
|
||||
|
||||
return (
|
||||
<div className="hidden justify-end gap-x-1.5 sm:flex">
|
||||
{survey.resultShareKey && (
|
||||
<Badge
|
||||
type="warning"
|
||||
size="normal"
|
||||
className="rounded-lg"
|
||||
text={t("environments.surveys.summary.results_are_public")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isReadOnly && (widgetSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
|
||||
<SurveyStatusDropdown environment={environment} survey={survey} />
|
||||
)}
|
||||
@@ -185,6 +212,7 @@ export const SurveyAnalysisCTA = ({
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
)}
|
||||
<SuccessMessage environment={environment} survey={survey} />
|
||||
@@ -202,6 +230,17 @@ export const SurveyAnalysisCTA = ({
|
||||
secondaryButtonText={t("common.edit")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
open={isResetModalOpen}
|
||||
setOpen={setIsResetModalOpen}
|
||||
title={t("environments.surveys.summary.delete_all_existing_responses_and_displays")}
|
||||
text={t("environments.surveys.summary.reset_survey_warning")}
|
||||
buttonText={t("environments.surveys.summary.reset_survey")}
|
||||
onConfirm={handleResetSurvey}
|
||||
buttonVariant="destructive"
|
||||
buttonLoading={isResetting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -227,7 +227,6 @@ const mockSurvey: TSurvey = {
|
||||
recaptcha: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
resultShareKey: null,
|
||||
};
|
||||
|
||||
const mockAppSurvey: TSurvey = {
|
||||
@@ -253,7 +252,6 @@ const mockUser: TUser = {
|
||||
isActive: true,
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,6 +34,7 @@ interface ShareSurveyModalProps {
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const ShareSurveyModal = ({
|
||||
@@ -46,6 +47,7 @@ export const ShareSurveyModal = ({
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
isReadOnly,
|
||||
}: ShareSurveyModalProps) => {
|
||||
const environmentId = survey.environmentId;
|
||||
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
||||
@@ -75,6 +77,7 @@ export const ShareSurveyModal = ({
|
||||
setSurveyUrl,
|
||||
locale: user.locale,
|
||||
surveyUrl,
|
||||
isReadOnly,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -192,6 +195,7 @@ export const ShareSurveyModal = ({
|
||||
tabs={linkTabs}
|
||||
handleViewChange={handleViewChange}
|
||||
handleEmbedViewWithTab={handleEmbedViewWithTab}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ interface AnonymousLinksTabProps {
|
||||
publicDomain: string;
|
||||
setSurveyUrl: (url: string) => void;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const AnonymousLinksTab = ({
|
||||
@@ -31,6 +32,7 @@ export const AnonymousLinksTab = ({
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: AnonymousLinksTabProps) => {
|
||||
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
|
||||
const router = useRouter();
|
||||
@@ -220,6 +222,7 @@ export const AnonymousLinksTab = ({
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
|
||||
<div className="w-full">
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
@@ -21,10 +22,12 @@ export const DisableLinkModal = ({ open, onOpenChange, type, onDisable }: Disabl
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent width="narrow" className="flex flex-col" hideCloseButton disableCloseOnOutsideClick>
|
||||
<DialogHeader className="text-sm font-medium text-slate-900">
|
||||
{type === "multi-use"
|
||||
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
|
||||
: t("common.are_you_sure")}
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-medium text-slate-900">
|
||||
{type === "multi-use"
|
||||
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
|
||||
: t("common.are_you_sure")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
|
||||
@@ -16,6 +16,7 @@ interface SuccessViewProps {
|
||||
tabs: { id: string; label: string; icon: React.ElementType }[];
|
||||
handleViewChange: (view: string) => void;
|
||||
handleEmbedViewWithTab: (tabId: string) => void;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const SuccessView: React.FC<SuccessViewProps> = ({
|
||||
@@ -27,6 +28,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
||||
tabs,
|
||||
handleViewChange,
|
||||
handleEmbedViewWithTab,
|
||||
isReadOnly,
|
||||
}) => {
|
||||
const { t } = useTranslate();
|
||||
const environmentId = survey.environmentId;
|
||||
@@ -43,6 +45,8 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={user.locale}
|
||||
enforceSurveyUrlWidth
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -81,7 +81,6 @@ const mockSurvey = {
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
resultShareKey: null,
|
||||
variables: [],
|
||||
segment: null,
|
||||
autoClose: null,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { deleteResponsesAndDisplaysForSurvey } from "./survey";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
response: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
display: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("Tests for deleteResponsesAndDisplaysForSurvey service", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("Deletes all responses and displays for a survey", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
// Mock $transaction to return the results directly
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 5 }, { count: 3 }]);
|
||||
|
||||
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("Handles case with no responses or displays to delete", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
// Mock $transaction to return zero counts
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 0 }, { count: 0 }]);
|
||||
|
||||
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
|
||||
|
||||
expect(result).toEqual({
|
||||
deletedResponsesCount: 0,
|
||||
deletedDisplaysCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("Throws a generic Error for other exceptions", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
const mockErrorMessage = "Mock error message";
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(new Error(mockErrorMessage));
|
||||
|
||||
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const deleteResponsesAndDisplaysForSurvey = async (
|
||||
surveyId: string
|
||||
): Promise<{ deletedResponsesCount: number; deletedDisplaysCount: number }> => {
|
||||
try {
|
||||
// Delete all responses for this survey
|
||||
|
||||
const [deletedResponsesCount, deletedDisplaysCount] = await prisma.$transaction([
|
||||
prisma.response.deleteMany({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
},
|
||||
}),
|
||||
prisma.display.deleteMany({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
deletedResponsesCount: deletedResponsesCount.count,
|
||||
deletedDisplaysCount: deletedDisplaysCount.count,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -93,7 +93,6 @@ const mockBaseSurvey: TSurvey = {
|
||||
environmentId: "env_123",
|
||||
singleUse: null,
|
||||
surveyClosedMessage: null,
|
||||
resultShareKey: null,
|
||||
pin: null,
|
||||
createdBy: "user_123",
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
|
||||
@@ -66,7 +66,6 @@ describe("Utils Tests", () => {
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
resultShareKey: null,
|
||||
displayOption: "displayOnce",
|
||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -111,7 +111,6 @@ vi.mock("next/navigation", () => ({
|
||||
useParams: () => ({
|
||||
environmentId: "test-environment-id",
|
||||
surveyId: "test-survey-id",
|
||||
sharingKey: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -145,7 +144,6 @@ const mockSurvey = {
|
||||
delay: 0,
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
resultShareKey: null,
|
||||
runOnDate: null,
|
||||
singleUse: null,
|
||||
surveyClosedMessage: null,
|
||||
@@ -249,8 +247,6 @@ describe("SurveyPage", () => {
|
||||
environment: mockEnvironment,
|
||||
survey: mockSurvey,
|
||||
surveyId: mockSurveyId,
|
||||
publicDomain: "http://localhost:3000",
|
||||
isReadOnly: false,
|
||||
locale: mockUser.locale ?? DEFAULT_LOCALE,
|
||||
initialSurveySummary: mockSurveySummary,
|
||||
})
|
||||
|
||||
@@ -58,6 +58,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
@@ -69,8 +70,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={params.surveyId}
|
||||
publicDomain={publicDomain}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={user.locale ?? DEFAULT_LOCALE}
|
||||
initialSurveySummary={initialSurveySummary}
|
||||
/>
|
||||
|
||||
@@ -102,7 +102,6 @@ const mockSurvey = {
|
||||
autoComplete: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
triggers: [],
|
||||
@@ -157,16 +156,6 @@ describe("CustomFilter", () => {
|
||||
expect(screen.getByText(`Select first date - ${format(mockDateToday, "dd LLL")}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render download button on sharing page", () => {
|
||||
vi.mocked(useParams).mockReturnValue({
|
||||
environmentId: "test-env",
|
||||
surveyId: "test-survey",
|
||||
sharingKey: "test-share-key",
|
||||
});
|
||||
render(<CustomFilter survey={mockSurvey} />);
|
||||
expect(screen.queryByText("common.download")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("useEffect logic for resetState and firstMountRef (as per current component code)", () => {
|
||||
// This test verifies the current behavior of the useEffects related to firstMountRef.
|
||||
// Based on the component's code, resetState() is not expected to be called by these effects,
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
subYears,
|
||||
} from "date-fns";
|
||||
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -125,8 +124,6 @@ const getDateRangeLabel = (from: Date, to: Date, t: TFnType) => {
|
||||
};
|
||||
|
||||
export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
const params = useParams();
|
||||
const isSharingPage = !!params.sharingKey;
|
||||
const { t } = useTranslate();
|
||||
const { selectedFilter, dateRange, setDateRange, resetState } = useResponseFilter();
|
||||
const [filterRange, setFilterRange] = useState(
|
||||
@@ -281,7 +278,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
||||
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
||||
}`
|
||||
: t(filterRange)}
|
||||
: filterRange}
|
||||
</span>
|
||||
{isFilterDropDownOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
@@ -296,28 +293,28 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
|
||||
setDateRange({ from: undefined, to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).ALL_TIME)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_7_DAYS)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_30_DAYS)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
|
||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_MONTH)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -327,14 +324,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_MONTH)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
|
||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_QUARTER)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -344,7 +341,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_QUARTER)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -354,14 +351,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfMonth(getTodayDate()),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_6_MONTHS)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
|
||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_YEAR)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -371,7 +368,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_YEAR)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -380,56 +377,52 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
}}>
|
||||
<p className="text-sm text-slate-700 hover:ring-0">
|
||||
{t(getFilterDropDownLabels(t).CUSTOM_RANGE)}
|
||||
{getFilterDropDownLabels(t).CUSTOM_RANGE}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!isSharingPage && (
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
|
||||
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex 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("common.download")}</span>
|
||||
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
<DownloadIcon className="block h-4 sm:hidden" />
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
|
||||
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex 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("common.download")}</span>
|
||||
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DownloadIcon className="block h-4 sm:hidden" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.ALL, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.ALL, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.FILTER, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">
|
||||
{t("environments.surveys.summary.filtered_responses_excel")}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.ALL, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.ALL, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.FILTER, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{isDatePickerOpen && (
|
||||
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("QuestionFilterComboBox", () => {
|
||||
|
||||
test("renders select placeholders", () => {
|
||||
render(<QuestionFilterComboBox {...defaultProps} />);
|
||||
expect(screen.getAllByText(/common.select\.../).length).toBe(2);
|
||||
expect(screen.getAllByText("common.select...").length).toBe(2);
|
||||
});
|
||||
|
||||
test("calls onChangeFilterValue when selecting filter", async () => {
|
||||
@@ -72,17 +72,21 @@ describe("QuestionFilterComboBox", () => {
|
||||
expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith(["Obj1"]);
|
||||
});
|
||||
|
||||
test("prevent combo-box opening when filterValue is Submitted", async () => {
|
||||
const props = { ...defaultProps, type: "NPS", filterValue: "Submitted" } as any;
|
||||
test("combobox is disabled when filterValue is 'Submitted' for NPS questions", async () => {
|
||||
const props = { ...defaultProps, type: "nps", filterValue: "Submitted" } as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
||||
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
|
||||
const comboBoxOpenerButton = screen.getAllByRole("button")[1];
|
||||
expect(comboBoxOpenerButton).toBeDisabled();
|
||||
await userEvent.click(comboBoxOpenerButton);
|
||||
expect(screen.queryByText("X")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("prevent combo-box opening when filterValue is Skipped", async () => {
|
||||
const props = { ...defaultProps, type: "Rating", filterValue: "Skipped" } as any;
|
||||
test("combobox is disabled when filterValue is 'Skipped' for rating questions", async () => {
|
||||
const props = { ...defaultProps, type: "rating", filterValue: "Skipped" } as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
||||
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
|
||||
const comboBoxOpenerButton = screen.getAllByRole("button")[1];
|
||||
expect(comboBoxOpenerButton).toBeDisabled();
|
||||
await userEvent.click(comboBoxOpenerButton);
|
||||
expect(screen.queryByText("X")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -197,7 +197,7 @@ export const QuestionFilterComboBox = ({
|
||||
</div>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
|
||||
@@ -188,7 +188,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
</button>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<div className="animate-in absolute top-0 z-50 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
||||
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
@@ -19,10 +18,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", (
|
||||
getSurveyFilterDataAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
||||
getSurveyFilterDataBySurveySharingKeyAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/surveys/surveys", () => ({
|
||||
generateQuestionAndFilterOptions: vi.fn(),
|
||||
}));
|
||||
@@ -35,6 +30,45 @@ vi.mock("@formkit/auto-animate/react", () => ({
|
||||
useAutoAnimate: () => [[vi.fn()]],
|
||||
}));
|
||||
|
||||
// Mock the Select components
|
||||
const mockOnValueChange = vi.fn();
|
||||
vi.mock("@/modules/ui/components/select", () => ({
|
||||
Select: ({ children, onValueChange, defaultValue }) => {
|
||||
// Store the onValueChange callback for testing
|
||||
mockOnValueChange.mockImplementation(onValueChange);
|
||||
return (
|
||||
<div data-testid="select-root" data-default-value={defaultValue}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
SelectTrigger: ({ children, className }) => (
|
||||
<div
|
||||
role="combobox"
|
||||
className={className}
|
||||
data-testid="select-trigger"
|
||||
tabIndex={0}
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox">
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SelectValue: () => <span>environments.surveys.filter.complete_and_partial_responses</span>,
|
||||
SelectContent: ({ children }) => <div data-testid="select-content">{children}</div>,
|
||||
SelectItem: ({ value, children, ...props }) => (
|
||||
<div
|
||||
data-testid={`select-item-${value}`}
|
||||
data-value={value}
|
||||
onClick={() => mockOnValueChange(value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && mockOnValueChange(value)}
|
||||
role="option"
|
||||
tabIndex={0}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionsComboBox", () => ({
|
||||
QuestionsComboBox: ({ onChangeValue }) => (
|
||||
<div data-testid="questions-combo-box">
|
||||
@@ -72,7 +106,7 @@ describe("ResponseFilter", () => {
|
||||
|
||||
const mockSelectedFilter = {
|
||||
filter: [],
|
||||
onlyComplete: false,
|
||||
responseStatus: "all",
|
||||
};
|
||||
|
||||
const mockSelectedOptions = {
|
||||
@@ -150,7 +184,7 @@ describe("ResponseFilter", () => {
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.show_all_responses_that_match")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.only_completed")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("select-trigger")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("fetches filter data when opened", async () => {
|
||||
@@ -165,7 +199,7 @@ describe("ResponseFilter", () => {
|
||||
test("handles adding new filter", async () => {
|
||||
// Start with an empty filter
|
||||
vi.mocked(useResponseFilter).mockReturnValue({
|
||||
selectedFilter: { filter: [], onlyComplete: false },
|
||||
selectedFilter: { filter: [], responseStatus: "all" },
|
||||
setSelectedFilter: mockSetSelectedFilter,
|
||||
selectedOptions: mockSelectedOptions,
|
||||
setSelectedOptions: mockSetSelectedOptions,
|
||||
@@ -183,14 +217,38 @@ describe("ResponseFilter", () => {
|
||||
expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles only complete checkbox toggle", async () => {
|
||||
test("handles response status filter change to complete", async () => {
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
await userEvent.click(screen.getByRole("checkbox"));
|
||||
|
||||
// Simulate selecting "complete" by calling the mock function
|
||||
mockOnValueChange("complete");
|
||||
|
||||
await userEvent.click(screen.getByText("common.apply_filters"));
|
||||
|
||||
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: true });
|
||||
expect(mockSetSelectedFilter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
responseStatus: "complete",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("handles response status filter change to partial", async () => {
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
|
||||
// Simulate selecting "partial" by calling the mock function
|
||||
mockOnValueChange("partial");
|
||||
|
||||
await userEvent.click(screen.getByText("common.apply_filters"));
|
||||
|
||||
expect(mockSetSelectedFilter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
responseStatus: "partial",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("handles selecting question and filter options", async () => {
|
||||
@@ -204,7 +262,7 @@ describe("ResponseFilter", () => {
|
||||
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
|
||||
},
|
||||
],
|
||||
onlyComplete: false,
|
||||
responseStatus: "all",
|
||||
},
|
||||
setSelectedFilter: setSelectedFilterMock,
|
||||
selectedOptions: mockSelectedOptions,
|
||||
@@ -233,31 +291,6 @@ describe("ResponseFilter", () => {
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
await userEvent.click(screen.getByText("common.clear_all"));
|
||||
|
||||
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: false });
|
||||
});
|
||||
|
||||
test("uses sharing key action when on sharing page", async () => {
|
||||
vi.mocked(useParams).mockReturnValue({
|
||||
environmentId: "env1",
|
||||
surveyId: "survey1",
|
||||
sharingKey: "share123",
|
||||
});
|
||||
vi.mocked(getSurveyFilterDataBySurveySharingKeyAction).mockResolvedValue({
|
||||
data: {
|
||||
attributes: [],
|
||||
meta: {},
|
||||
environmentTags: [],
|
||||
hiddenFields: [],
|
||||
} as any,
|
||||
});
|
||||
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
|
||||
expect(getSurveyFilterDataBySurveySharingKeyAction).toHaveBeenCalledWith({
|
||||
sharingKey: "share123",
|
||||
environmentId: "env1",
|
||||
});
|
||||
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], responseStatus: "all" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,20 +2,24 @@
|
||||
|
||||
import {
|
||||
SelectedFilterValue,
|
||||
TResponseStatus,
|
||||
useResponseFilter,
|
||||
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
|
||||
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
||||
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
@@ -33,10 +37,7 @@ interface ResponseFilterProps {
|
||||
|
||||
export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
const { t } = useTranslate();
|
||||
const params = useParams();
|
||||
const [parent] = useAutoAnimate();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const { selectedFilter, setSelectedFilter, selectedOptions, setSelectedOptions } = useResponseFilter();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
@@ -46,12 +47,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
// Fetch the initial data for the filter and load it into the state
|
||||
const handleInitialData = async () => {
|
||||
if (isOpen) {
|
||||
const surveyFilterData = isSharingPage
|
||||
? await getSurveyFilterDataBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
environmentId: survey.environmentId,
|
||||
})
|
||||
: await getSurveyFilterDataAction({ surveyId: survey.id });
|
||||
const surveyFilterData = await getSurveyFilterDataAction({ surveyId: survey.id });
|
||||
|
||||
if (!surveyFilterData?.data) return;
|
||||
|
||||
@@ -68,7 +64,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
};
|
||||
|
||||
handleInitialData();
|
||||
}, [isOpen, isSharingPage, setSelectedOptions, sharingKey, survey]);
|
||||
}, [isOpen, setSelectedOptions, survey]);
|
||||
|
||||
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
|
||||
if (filterValue.filter[index].questionType) {
|
||||
@@ -82,7 +78,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
)?.filterOptions[0],
|
||||
},
|
||||
};
|
||||
setFilterValue({ filter: [...filterValue.filter], onlyComplete: filterValue.onlyComplete });
|
||||
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
|
||||
} else {
|
||||
// Update the existing value at the specified index
|
||||
filterValue.filter[index].questionType = value;
|
||||
@@ -103,7 +99,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
// keep the filter if questionType is selected and filterComboBoxValue is selected
|
||||
return s.questionType.hasOwnProperty("label") && s.filterType.filterComboBoxValue?.length;
|
||||
}),
|
||||
onlyComplete: filterValue.onlyComplete,
|
||||
responseStatus: filterValue.responseStatus,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -130,8 +126,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
};
|
||||
|
||||
const handleClearAllFilters = () => {
|
||||
setFilterValue((filterValue) => ({ ...filterValue, filter: [] }));
|
||||
setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [] }));
|
||||
setFilterValue((filterValue) => ({ ...filterValue, filter: [], responseStatus: "all" }));
|
||||
setSelectedFilter((selectedFilters) => ({ ...selectedFilters, filter: [], responseStatus: "all" }));
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
@@ -168,8 +164,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
setFilterValue({ ...filterValue });
|
||||
};
|
||||
|
||||
const handleCheckOnlyComplete = (checked: boolean) => {
|
||||
setFilterValue({ ...filterValue, onlyComplete: checked });
|
||||
const handleResponseStatusChange = (responseStatus: TResponseStatus) => {
|
||||
setFilterValue({ ...filterValue, responseStatus });
|
||||
};
|
||||
|
||||
// remove the filter which has already been selected
|
||||
@@ -213,8 +209,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]">
|
||||
<div className="mb-8 flex flex-wrap items-start justify-between">
|
||||
className="w-[300px] border-slate-200 bg-slate-100 p-6 sm:w-[400px] md:w-[750px] lg:w-[1000px]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}>
|
||||
<div className="mb-8 flex flex-wrap items-start justify-between gap-2">
|
||||
<p className="text-slate800 hidden text-lg font-semibold sm:block">
|
||||
{t("environments.surveys.summary.show_all_responses_that_match")}
|
||||
</p>
|
||||
@@ -222,16 +219,24 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
{t("environments.surveys.summary.show_all_responses_where")}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="text-sm font-normal text-slate-600">
|
||||
{t("environments.surveys.summary.only_completed")}
|
||||
</label>
|
||||
<Checkbox
|
||||
className={clsx("rounded-md", filterValue.onlyComplete && "bg-black text-white")}
|
||||
checked={filterValue.onlyComplete}
|
||||
onCheckedChange={(checked) => {
|
||||
typeof checked === "boolean" && handleCheckOnlyComplete(checked);
|
||||
<Select
|
||||
onValueChange={(val) => {
|
||||
handleResponseStatusChange(val as TResponseStatus);
|
||||
}}
|
||||
/>
|
||||
defaultValue={filterValue.responseStatus}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="all">
|
||||
{t("environments.surveys.filter.complete_and_partial_responses")}
|
||||
</SelectItem>
|
||||
<SelectItem value="complete">
|
||||
{t("environments.surveys.filter.complete_responses")}
|
||||
</SelectItem>
|
||||
<SelectItem value="partial">{t("environments.surveys.filter.partial_responses")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { ResultsShareButton } from "./ResultsShareButton";
|
||||
|
||||
// Mock actions
|
||||
const mockDeleteResultShareUrlAction = vi.fn();
|
||||
const mockGenerateResultShareUrlAction = vi.fn();
|
||||
const mockGetResultShareUrlAction = vi.fn();
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions", () => ({
|
||||
deleteResultShareUrlAction: (...args) => mockDeleteResultShareUrlAction(...args),
|
||||
generateResultShareUrlAction: (...args) => mockGenerateResultShareUrlAction(...args),
|
||||
getResultShareUrlAction: (...args) => mockGetResultShareUrlAction(...args),
|
||||
}));
|
||||
|
||||
// Mock helper
|
||||
const mockGetFormattedErrorMessage = vi.fn((error) => error?.message || "An error occurred");
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: (error) => mockGetFormattedErrorMessage(error),
|
||||
}));
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }) => <div data-testid="dropdown-menu">{children}</div>,
|
||||
DropdownMenuContent: ({ children, align }) => (
|
||||
<div data-testid="dropdown-menu-content" data-align={align}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuItem: ({ children, onClick, icon }) => (
|
||||
<button data-testid="dropdown-menu-item" onClick={onClick}>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children }) => <div data-testid="dropdown-menu-trigger">{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock Tolgee
|
||||
const mockT = vi.fn((key) => key);
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({ t: mockT }),
|
||||
}));
|
||||
|
||||
// Mock icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
CopyIcon: () => <div data-testid="copy-icon" />,
|
||||
DownloadIcon: () => <div data-testid="download-icon" />,
|
||||
GlobeIcon: () => <div data-testid="globe-icon" />,
|
||||
LinkIcon: () => <div data-testid="link-icon" />,
|
||||
}));
|
||||
|
||||
// Mock toast
|
||||
const mockToastSuccess = vi.fn();
|
||||
const mockToastError = vi.fn();
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: (...args) => mockToastSuccess(...args),
|
||||
error: (...args) => mockToastError(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ShareSurveyResults component
|
||||
const mockShareSurveyResults = vi.fn();
|
||||
vi.mock("../(analysis)/summary/components/ShareSurveyResults", () => ({
|
||||
ShareSurveyResults: (props) => {
|
||||
mockShareSurveyResults(props);
|
||||
return props.open ? (
|
||||
<div data-testid="share-survey-results-modal">
|
||||
<span>ShareSurveyResults Modal</span>
|
||||
<button onClick={() => props.setOpen(false)}>Close Modal</button>
|
||||
<button data-testid="handle-publish-button" onClick={props.handlePublish}>
|
||||
Publish
|
||||
</button>
|
||||
<button data-testid="handle-unpublish-button" onClick={props.handleUnpublish}>
|
||||
Unpublish
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
},
|
||||
}));
|
||||
|
||||
const mockSurvey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
questions: [],
|
||||
hiddenFields: { enabled: false },
|
||||
displayOption: "displayOnce",
|
||||
recontactDays: 0,
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
triggers: [],
|
||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||
styling: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
variables: [],
|
||||
closeOnDate: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const webAppUrl = "https://app.formbricks.com";
|
||||
const originalLocation = window.location;
|
||||
|
||||
describe("ResultsShareButton", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock window.location.href
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { ...originalLocation, href: "https://app.formbricks.com/surveys/survey1" },
|
||||
});
|
||||
// Mock navigator.clipboard
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
test("renders initial state and fetches sharing key (no existing key)", async () => {
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
||||
|
||||
expect(screen.getByTestId("dropdown-menu-trigger")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
|
||||
expect(mockGetResultShareUrlAction).toHaveBeenCalledWith({ surveyId: mockSurvey.id });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("share-survey-results-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles copy private link to clipboard", async () => {
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
|
||||
const copyLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||
item.textContent?.includes("common.copy_link")
|
||||
);
|
||||
expect(copyLinkButton).toBeInTheDocument();
|
||||
await userEvent.click(copyLinkButton!);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(window.location.href);
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
});
|
||||
|
||||
test("handles copy public link to clipboard", async () => {
|
||||
const shareKey = "publicShareKey";
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
|
||||
render(
|
||||
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
|
||||
const copyPublicLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||
item.textContent?.includes("environments.surveys.summary.copy_link_to_public_results")
|
||||
);
|
||||
expect(copyPublicLinkButton).toBeInTheDocument();
|
||||
await userEvent.click(copyPublicLinkButton!);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`${webAppUrl}/share/${shareKey}`);
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
"environments.surveys.summary.link_to_public_results_copied"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles publish to web successfully", async () => {
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||
mockGenerateResultShareUrlAction.mockResolvedValue({ data: "newShareKey" });
|
||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
|
||||
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||
item.textContent?.includes("environments.surveys.summary.publish_to_web")
|
||||
);
|
||||
await userEvent.click(publishButton!);
|
||||
|
||||
expect(screen.getByTestId("share-survey-results-modal")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId("handle-publish-button"));
|
||||
|
||||
expect(mockGenerateResultShareUrlAction).toHaveBeenCalledWith({ surveyId: mockSurvey.id });
|
||||
await waitFor(() => {
|
||||
expect(mockShareSurveyResults).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
surveyUrl: `${webAppUrl}/share/newShareKey`,
|
||||
showPublishModal: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("handles unpublish from web successfully", async () => {
|
||||
const shareKey = "toUnpublishKey";
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
|
||||
mockDeleteResultShareUrlAction.mockResolvedValue({ data: { id: mockSurvey.id } });
|
||||
render(
|
||||
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
|
||||
const unpublishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||
item.textContent?.includes("environments.surveys.summary.unpublish_from_web")
|
||||
);
|
||||
await userEvent.click(unpublishButton!);
|
||||
|
||||
expect(screen.getByTestId("share-survey-results-modal")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId("handle-unpublish-button"));
|
||||
|
||||
expect(mockDeleteResultShareUrlAction).toHaveBeenCalledWith({ surveyId: mockSurvey.id });
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith("environments.surveys.results_unpublished_successfully");
|
||||
await waitFor(() => {
|
||||
expect(mockShareSurveyResults).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
showPublishModal: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("opens and closes ShareSurveyResults modal", async () => {
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
|
||||
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||
item.textContent?.includes("environments.surveys.summary.publish_to_web")
|
||||
);
|
||||
await userEvent.click(publishButton!);
|
||||
|
||||
expect(screen.getByTestId("share-survey-results-modal")).toBeInTheDocument();
|
||||
expect(mockShareSurveyResults).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
open: true,
|
||||
surveyUrl: "", // Initially empty as no key fetched yet for this flow
|
||||
showPublishModal: false, // Initially false
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Close Modal"));
|
||||
expect(screen.queryByTestId("share-survey-results-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
deleteResultShareUrlAction,
|
||||
generateResultShareUrlAction,
|
||||
getResultShareUrlAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CopyIcon, DownloadIcon, GlobeIcon, LinkIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { ShareSurveyResults } from "../(analysis)/summary/components/ShareSurveyResults";
|
||||
|
||||
interface ResultsShareButtonProps {
|
||||
survey: TSurvey;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
export const ResultsShareButton = ({ survey, publicDomain }: ResultsShareButtonProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [showResultsLinkModal, setShowResultsLinkModal] = useState(false);
|
||||
|
||||
const [showPublishModal, setShowPublishModal] = useState(false);
|
||||
const [surveyUrl, setSurveyUrl] = useState("");
|
||||
|
||||
const handlePublish = async () => {
|
||||
const resultShareKeyResponse = await generateResultShareUrlAction({ surveyId: survey.id });
|
||||
if (resultShareKeyResponse?.data) {
|
||||
setSurveyUrl(publicDomain + "/share/" + resultShareKeyResponse.data);
|
||||
setShowPublishModal(true);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(resultShareKeyResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpublish = () => {
|
||||
deleteResultShareUrlAction({ surveyId: survey.id }).then((deleteResultShareUrlResponse) => {
|
||||
if (deleteResultShareUrlResponse?.data) {
|
||||
toast.success(t("environments.surveys.results_unpublished_successfully"));
|
||||
setShowPublishModal(false);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteResultShareUrlResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSharingKey = async () => {
|
||||
const resultShareUrlResponse = await getResultShareUrlAction({ surveyId: survey.id });
|
||||
if (resultShareUrlResponse?.data) {
|
||||
setSurveyUrl(publicDomain + "/share/" + resultShareUrlResponse.data);
|
||||
setShowPublishModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSharingKey();
|
||||
}, [survey.id, publicDomain]);
|
||||
|
||||
const copyUrlToClipboard = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
const currentUrl = window.location.href;
|
||||
navigator.clipboard
|
||||
.writeText(currentUrl)
|
||||
.then(() => {
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("environments.surveys.failed_to_copy_link_to_results"));
|
||||
});
|
||||
} else {
|
||||
toast.error(t("environments.surveys.failed_to_copy_url"));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<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="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">
|
||||
{t("environments.surveys.summary.share_results")}
|
||||
</span>
|
||||
<LinkIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
<DownloadIcon className="block h-4 sm:hidden" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{survey.resultShareKey ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrl);
|
||||
toast.success(t("environments.surveys.summary.link_to_public_results_copied"));
|
||||
}}
|
||||
icon={<CopyIcon className="ml-1.5 inline h-4 w-4" />}>
|
||||
<p className="text-slate-700">
|
||||
{t("environments.surveys.summary.copy_link_to_public_results")}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
copyUrlToClipboard();
|
||||
}}
|
||||
icon={<CopyIcon className="ml-1.5 h-4 w-4" />}>
|
||||
<p className="flex items-center text-slate-700">{t("common.copy_link")}</p>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setShowResultsLinkModal(true);
|
||||
}}
|
||||
icon={<GlobeIcon className="ml-1.5 h-4 w-4" />}>
|
||||
<p className="flex items-center text-slate-700">
|
||||
{survey.resultShareKey
|
||||
? t("environments.surveys.summary.unpublish_from_web")
|
||||
: t("environments.surveys.summary.publish_to_web")}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{showResultsLinkModal && (
|
||||
<ShareSurveyResults
|
||||
open={showResultsLinkModal}
|
||||
setOpen={setShowResultsLinkModal}
|
||||
surveyUrl={surveyUrl}
|
||||
handlePublish={handlePublish}
|
||||
handleUnpublish={handleUnpublish}
|
||||
showPublishModal={showPublishModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -87,7 +87,6 @@ const baseSurvey: TSurvey = {
|
||||
isSingleUse: false,
|
||||
segment: null,
|
||||
surveyClosedMessage: null,
|
||||
resultShareKey: null,
|
||||
singleUse: null,
|
||||
verifyEmail: null,
|
||||
pin: null,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user