Compare commits

..

10 Commits

Author SHA1 Message Date
Johannes
0851e65a9a Merge branch 'main' of https://github.com/formbricks/formbricks into simplify-stripe 2025-10-28 09:11:23 +01:00
Johannes
7df0e8e9b9 Merge branch 'main' of https://github.com/formbricks/formbricks into simplify-stripe 2025-10-27 13:57:53 +01:00
Johannes
e3679d4a2d clean up locale files 2025-10-27 11:53:58 +01:00
Johannes
9bdbe50a83 add logs 2025-10-27 10:43:36 +01:00
Johannes
01e8333acd bring back monthly reset 2025-10-27 10:23:09 +01:00
Johannes
db2823504f fix code rabbit issue 2025-10-27 09:55:25 +01:00
Johannes
95bbe24109 remove not needed migration 2025-10-23 19:34:03 +02:00
Johannes
07a1b687f8 final tweaks 2025-10-23 19:33:02 +02:00
Johannes
e8636ceacb Merge branch 'main' of https://github.com/formbricks/formbricks into simplify-stripe 2025-10-22 17:32:38 +02:00
Johannes
8e30564103 refactor: simplify Stripe integration and rename enterprise to custom
BREAKING CHANGES:
- Remove SCALE plan from PROJECT_FEATURE_KEYS enum
- Rename PROJECT_FEATURE_KEYS.ENTERPRISE to PROJECT_FEATURE_KEYS.CUSTOM
- Update trial period from 30 to 15 days
- Remove invoice.finalized and subscription.created/updated webhook handlers

FEATURES:
- Hardcode billing limits instead of syncing from Stripe metadata
- Simplify checkout handler to always use hardcoded Startup plan limits
- Simplify subscription creation flow (single code path)
- Create database migration to rename existing enterprise plans to custom

REMOVED:
- subscription-created-or-updated.ts (125 lines of complex metadata parsing)
- invoice-finalized.ts (no longer needed)

FILES CHANGED:
- Remove SCALE plan from constants and billing limits
- Update type definitions to use custom instead of enterprise
- Simplify webhook handlers (4 → 2 handlers)
- Update all UI components to reference custom plan
- Create database migration for plan renaming
- Update all tests to reference custom instead of enterprise

IMPACT:
- Reduces Stripe integration complexity by ~80%
- Eliminates metadata sync failures
- Makes billing more predictable and maintainable
- Aligns code naming with UI (custom vs enterprise)
- Reduces trial period from 30 to 15 days

Refs: #simplify-stripe-integration
2025-10-20 18:55:22 +02:00
871 changed files with 28822 additions and 74698 deletions

View File

@@ -1,352 +0,0 @@
# Create New Question Element
Use this command to scaffold a new question element component in `packages/survey-ui/src/elements/`.
## Usage
When creating a new question type (e.g., `single-select`, `rating`, `nps`), follow these steps:
1. **Create the component file** `{question-type}.tsx` with this structure:
```typescript
import * as React from "react";
import { ElementHeader } from "../components/element-header";
import { useTextDirection } from "../hooks/use-text-direction";
import { cn } from "../lib/utils";
interface {QuestionType}Props {
/** Unique identifier for the element container */
elementId: string;
/** The main question or prompt text displayed as the headline */
headline: string;
/** Optional descriptive text displayed below the headline */
description?: string;
/** Unique identifier for the input/control group */
inputId: string;
/** Current value */
value?: {ValueType};
/** Callback function called when the value changes */
onChange: (value: {ValueType}) => void;
/** Whether the field is required (shows asterisk indicator) */
required?: boolean;
/** Error message to display */
errorMessage?: string;
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
dir?: "ltr" | "rtl" | "auto";
/** Whether the controls are disabled */
disabled?: boolean;
// Add question-specific props here
}
function {QuestionType}({
elementId,
headline,
description,
inputId,
value,
onChange,
required = false,
errorMessage,
dir = "auto",
disabled = false,
// ... question-specific props
}: {QuestionType}Props): React.JSX.Element {
// Ensure value is always the correct type (handle undefined/null)
const currentValue = value ?? {defaultValue};
// Detect text direction from content
const detectedDir = useTextDirection({
dir,
textContent: [headline, description ?? "", /* add other text content from question */],
});
return (
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
{/* Headline */}
<ElementHeader
headline={headline}
description={description}
required={required}
htmlFor={inputId}
/>
{/* Question-specific controls */}
{/* TODO: Add your question-specific UI here */}
{/* Error message */}
{errorMessage && (
<div className="text-destructive flex items-center gap-1 text-sm" dir={detectedDir}>
<span>{errorMessage}</span>
</div>
)}
</div>
);
}
export { {QuestionType} };
export type { {QuestionType}Props };
```
2. **Create the Storybook file** `{question-type}.stories.tsx`:
```typescript
import type { Decorator, Meta, StoryObj } from "@storybook/react";
import React from "react";
import { {QuestionType}, type {QuestionType}Props } from "./{question-type}";
// Styling options for the StylingPlayground story
interface StylingOptions {
// Question styling
questionHeadlineFontFamily: string;
questionHeadlineFontSize: string;
questionHeadlineFontWeight: string;
questionHeadlineColor: string;
questionDescriptionFontFamily: string;
questionDescriptionFontWeight: string;
questionDescriptionFontSize: string;
questionDescriptionColor: string;
// Add component-specific styling options here
}
type StoryProps = {QuestionType}Props & Partial<StylingOptions>;
const meta: Meta<StoryProps> = {
title: "UI-package/Elements/{QuestionType}",
component: {QuestionType},
parameters: {
layout: "centered",
docs: {
description: {
component: "A complete {question type} question element...",
},
},
},
tags: ["autodocs"],
argTypes: {
headline: {
control: "text",
description: "The main question text",
table: { category: "Content" },
},
description: {
control: "text",
description: "Optional description or subheader text",
table: { category: "Content" },
},
value: {
control: "object",
description: "Current value",
table: { category: "State" },
},
required: {
control: "boolean",
description: "Whether the field is required",
table: { category: "Validation" },
},
errorMessage: {
control: "text",
description: "Error message to display",
table: { category: "Validation" },
},
dir: {
control: { type: "select" },
options: ["ltr", "rtl", "auto"],
description: "Text direction for RTL support",
table: { category: "Layout" },
},
disabled: {
control: "boolean",
description: "Whether the controls are disabled",
table: { category: "State" },
},
onChange: {
action: "changed",
table: { category: "Events" },
},
// Add question-specific argTypes here
},
};
export default meta;
type Story = StoryObj<StoryProps>;
// Decorator to apply CSS variables from story args
const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
const args = context.args as StoryProps;
const {
questionHeadlineFontFamily,
questionHeadlineFontSize,
questionHeadlineFontWeight,
questionHeadlineColor,
questionDescriptionFontFamily,
questionDescriptionFontSize,
questionDescriptionFontWeight,
questionDescriptionColor,
// Extract component-specific styling options
} = args;
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
"--fb-question-headline-font-family": questionHeadlineFontFamily,
"--fb-question-headline-font-size": questionHeadlineFontSize,
"--fb-question-headline-font-weight": questionHeadlineFontWeight,
"--fb-question-headline-color": questionHeadlineColor,
"--fb-question-description-font-family": questionDescriptionFontFamily,
"--fb-question-description-font-size": questionDescriptionFontSize,
"--fb-question-description-font-weight": questionDescriptionFontWeight,
"--fb-question-description-color": questionDescriptionColor,
// Add component-specific CSS variables
};
return (
<div style={cssVarStyle} className="w-[600px]">
<Story />
</div>
);
};
export const StylingPlayground: Story = {
args: {
headline: "Example question?",
description: "Example description",
// Default styling values
questionHeadlineFontFamily: "system-ui, sans-serif",
questionHeadlineFontSize: "1.125rem",
questionHeadlineFontWeight: "600",
questionHeadlineColor: "#1e293b",
questionDescriptionFontFamily: "system-ui, sans-serif",
questionDescriptionFontSize: "0.875rem",
questionDescriptionFontWeight: "400",
questionDescriptionColor: "#64748b",
// Add component-specific default values
},
argTypes: {
// Question styling argTypes
questionHeadlineFontFamily: {
control: "text",
table: { category: "Question Styling" },
},
questionHeadlineFontSize: {
control: "text",
table: { category: "Question Styling" },
},
questionHeadlineFontWeight: {
control: "text",
table: { category: "Question Styling" },
},
questionHeadlineColor: {
control: "color",
table: { category: "Question Styling" },
},
questionDescriptionFontFamily: {
control: "text",
table: { category: "Question Styling" },
},
questionDescriptionFontSize: {
control: "text",
table: { category: "Question Styling" },
},
questionDescriptionFontWeight: {
control: "text",
table: { category: "Question Styling" },
},
questionDescriptionColor: {
control: "color",
table: { category: "Question Styling" },
},
// Add component-specific argTypes
},
decorators: [withCSSVariables],
};
export const Default: Story = {
args: {
headline: "Example question?",
// Add default props
},
};
export const WithDescription: Story = {
args: {
headline: "Example question?",
description: "Example description text",
},
};
export const Required: Story = {
args: {
headline: "Example question?",
required: true,
},
};
export const WithError: Story = {
args: {
headline: "Example question?",
errorMessage: "This field is required",
required: true,
},
};
export const Disabled: Story = {
args: {
headline: "Example question?",
disabled: true,
},
};
export const RTL: Story = {
args: {
headline: "مثال على السؤال؟",
description: "مثال على الوصف",
// Add RTL-specific props
},
};
```
3. **Add CSS variables** to `packages/survey-ui/src/styles/globals.css` if needed:
```css
/* Component-specific CSS variables */
--fb-{component}-{property}: {default-value};
```
4. **Export from** `packages/survey-ui/src/index.ts`:
```typescript
export { {QuestionType}, type {QuestionType}Props } from "./elements/{question-type}";
```
## Key Requirements
- ✅ Always use `ElementHeader` component for headline/description
- ✅ Always use `useTextDirection` hook for RTL support
- ✅ Always handle undefined/null values safely (e.g., `Array.isArray(value) ? value : []`)
- ✅ Always include error message display if applicable
- ✅ Always support disabled state if applicable
- ✅ Always add JSDoc comments to props interface
- ✅ Always create Storybook stories with styling playground
- ✅ Always export types from component file
- ✅ Always add to index.ts exports
## Examples
- `open-text.tsx` - Text input/textarea question (string value)
- `multi-select.tsx` - Multiple checkbox selection (string[] value)
## Checklist
When creating a new question element, verify:
- [ ] Component file created with proper structure
- [ ] Props interface with JSDoc comments for all props
- [ ] Uses `ElementHeader` component (don't duplicate header logic)
- [ ] Uses `useTextDirection` hook for RTL support
- [ ] Handles undefined/null values safely
- [ ] Storybook file created with styling playground
- [ ] Includes common stories: Default, WithDescription, Required, WithError, Disabled, RTL
- [ ] CSS variables added to `globals.css` if component needs custom styling
- [ ] Exported from `index.ts` with types
- [ ] TypeScript types properly exported
- [ ] Error message display included if applicable
- [ ] Disabled state supported if applicable

View File

@@ -179,14 +179,14 @@ For endpoints serving client SDKs, coordinate TTLs across layers:
```typescript
// Client SDK cache (expiresAt) - longest TTL for fewer requests
const CLIENT_TTL = 60; // 1 minute (seconds for client)
const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client)
// Server Redis cache - shorter TTL ensures fresh data for clients
const SERVER_TTL = 60 * 1000; // 1 minutes in milliseconds
const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds
// HTTP cache headers (seconds)
const BROWSER_TTL = 60; // 1 minute (max-age)
const CDN_TTL = 60; // 1 minute (s-maxage)
const BROWSER_TTL = 60 * 60; // 1 hour (max-age)
const CDN_TTL = 60 * 30; // 30 minutes (s-maxage)
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
```

View File

@@ -1,8 +1,13 @@
---
description: >
globs: schema.prisma
alwaysApply: false
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
and data patterns. It should be used **only when the agent explicitly requests database schema-level
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
globs: []
alwaysApply: agent-requested
---
# Formbricks Database Schema Reference
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.

View File

@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---

View File

@@ -0,0 +1,5 @@
---
description:
globs:
alwaysApply: false
---

View File

@@ -9,12 +9,8 @@
WEBAPP_URL=http://localhost:3000
# Required for next-auth. Should be the same as WEBAPP_URL
# If your pplication uses a custom base path, specify the route to the API endpoint in full, e.g. NEXTAUTH_URL=https://example.com/custom-route/api/auth
NEXTAUTH_URL=http://localhost:3000
# Can be used to deploy the application under a sub-path of a domain. This can only be set at build time
# BASE_PATH=
# Encryption keys
# Please set both for now, we will change this in the future
@@ -193,9 +189,8 @@ 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:
# Chatwoot
# CHATWOOT_BASE_URL=
# CHATWOOT_WEBSITE_TOKEN=
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=
# Enable Prometheus metrics
# PROMETHEUS_ENABLED=

View File

@@ -13,12 +13,13 @@ jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
id-token: write
actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
@@ -26,34 +27,16 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # latest
with:
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
workingDir: apps/storybook
zip: true

View File

@@ -3,9 +3,13 @@ name: E2E Tests
on:
workflow_call:
secrets:
PLAYWRIGHT_SERVICE_URL:
AZURE_CLIENT_ID:
required: false
PLAYWRIGHT_SERVICE_ACCESS_TOKEN:
AZURE_TENANT_ID:
required: false
AZURE_SUBSCRIPTION_ID:
required: false
PLAYWRIGHT_SERVICE_URL:
required: false
ENTERPRISE_LICENSE_KEY:
required: true
@@ -13,10 +17,12 @@ on:
workflow_dispatch:
env:
TELEMETRY_DISABLED: 1
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
permissions:
id-token: write
contents: read
actions: read
@@ -109,7 +115,7 @@ jobs:
- name: Start MinIO Server
run: |
set -euo pipefail
# Start MinIO server in background
docker run -d \
--name minio-server \
@@ -119,7 +125,7 @@ jobs:
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
echo "MinIO server started"
- name: Wait for MinIO and create S3 bucket
@@ -202,30 +208,32 @@ jobs:
- name: Install Playwright
run: pnpm exec playwright install --with-deps
- name: Determine Playwright execution mode
shell: bash
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
- name: Set Azure Secret Variables
run: |
set -euo pipefail
if [[ -n "${PLAYWRIGHT_SERVICE_URL}" && -n "${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}" ]]; then
echo "PW_MODE=service" >> "$GITHUB_ENV"
if [[ -n "${{ secrets.AZURE_CLIENT_ID }}" && -n "${{ secrets.AZURE_TENANT_ID }}" && -n "${{ secrets.AZURE_SUBSCRIPTION_ID }}" ]]; then
echo "AZURE_ENABLED=true" >> $GITHUB_ENV
else
echo "PW_MODE=local" >> "$GITHUB_ENV"
echo "AZURE_ENABLED=false" >> $GITHUB_ENV
fi
- name: Run E2E Tests (Playwright Service)
if: env.PW_MODE == 'service'
- name: Azure login
if: env.AZURE_ENABLED == 'true'
uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Run E2E Tests (Azure)
if: env.AZURE_ENABLED == 'true'
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
CI: true
run: pnpm test-e2e:azure
run: |
pnpm test-e2e:azure
- name: Run E2E Tests (Local)
if: env.PW_MODE == 'local'
if: env.AZURE_ENABLED == 'false'
env:
CI: true
run: |

View File

@@ -89,7 +89,7 @@ jobs:
- check-latest-release
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
docker-build-cloud:
name: Build & push Formbricks Cloud to ECR
@@ -101,7 +101,7 @@ jobs:
with:
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
needs:
- check-latest-release
- docker-build-community
@@ -154,4 +154,4 @@ jobs:
release_tag: ${{ github.event.release.tag_name }}
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}

View File

@@ -203,14 +203,6 @@ Here are a few options:
</a>
## Thanks
Formbricks is supported by the following companies who provide us with their tools for free as part of their open-source support:
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" width="153" height="30" alt="Chromatic" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sentry.io/"><img src="https://github.com/user-attachments/assets/d743ffd4-b575-4802-a29a-10136be9227e" width="150" height="30" alt="Sentry" /></a>
<a id="contact-us"></a>
## 📆 Contact us

View File

@@ -1,11 +1,8 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { createRequire } from "module";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
const require = createRequire(import.meta.url);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* This function is used to resolve the absolute path of a package.
@@ -16,7 +13,7 @@ function getAbsolutePath(value: string): any {
}
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
@@ -28,25 +25,5 @@ const config: StorybookConfig = {
name: getAbsolutePath("@storybook/react-vite"),
options: {},
},
async viteFinal(config) {
const surveyUiPath = resolve(__dirname, "../../../packages/survey-ui/src");
const rootPath = resolve(__dirname, "../../../");
// Configure server to allow files from outside the storybook directory
config.server = config.server || {};
config.server.fs = {
...config.server.fs,
allow: [...(config.server.fs?.allow || []), rootPath],
};
// Configure simple alias resolution
config.resolve = config.resolve || {};
config.resolve.alias = {
...config.resolve.alias,
"@": surveyUiPath,
};
return config;
},
};
export default config;

View File

@@ -1,6 +1,19 @@
import type { Preview } from "@storybook/react-vite";
import React from "react";
import "../../../packages/survey-ui/src/styles/globals.css";
import { I18nProvider } from "../../web/lingodotdev/client";
import "../../web/modules/ui/globals.css";
// Create a Storybook-specific Lingodot Dev decorator
const withLingodotDev = (Story: any) => {
return React.createElement(
I18nProvider,
{
language: "en-US",
defaultLanguage: "en-US",
} as any,
React.createElement(Story)
);
};
const preview: Preview = {
parameters: {
@@ -9,23 +22,9 @@ const preview: Preview = {
color: /(background|color)$/i,
date: /Date$/i,
},
expanded: true,
},
backgrounds: {
default: "light",
},
},
decorators: [
(Story) =>
React.createElement(
"div",
{
id: "fbjs",
className: "w-full h-full min-h-screen p-4 bg-background font-sans antialiased text-foreground",
},
React.createElement(Story)
),
],
decorators: [withLingodotDev],
};
export default preview;

View File

@@ -11,24 +11,22 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"@formbricks/survey-ui": "workspace:*",
"eslint-plugin-react-refresh": "0.4.24"
"eslint-plugin-react-refresh": "0.4.20"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.1.3",
"@storybook/addon-a11y": "10.0.8",
"@storybook/addon-links": "10.0.8",
"@storybook/addon-onboarding": "10.0.8",
"@storybook/react-vite": "10.0.8",
"@typescript-eslint/eslint-plugin": "8.48.0",
"@tailwindcss/vite": "4.1.17",
"@typescript-eslint/parser": "8.48.0",
"@vitejs/plugin-react": "5.1.1",
"esbuild": "0.27.0",
"eslint-plugin-storybook": "10.0.8",
"@chromatic-com/storybook": "^4.0.1",
"@storybook/addon-a11y": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/react-vite": "9.0.15",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.4",
"eslint-plugin-storybook": "9.0.15",
"prop-types": "15.8.1",
"storybook": "10.0.8",
"vite": "7.2.4",
"@storybook/addon-docs": "10.0.8"
"storybook": "9.0.15",
"vite": "6.4.1",
"@storybook/addon-docs": "9.0.15"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -1,15 +1,7 @@
/** @type {import('tailwindcss').Config} */
import surveyUi from "../../packages/survey-ui/tailwind.config";
import base from "../web/tailwind.config";
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
"../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
...surveyUi.theme?.extend,
},
},
...base,
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"],
};

View File

@@ -1,17 +1,16 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vite";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [react()],
define: {
"process.env": {},
},
resolve: {
alias: {
"@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"),
"@": path.resolve(__dirname, "../web"),
},
},
});

View File

@@ -37,10 +37,6 @@ ENV NODE_OPTIONS=${NODE_OPTIONS}
# but needs explicit declaration for some build systems (like Depot)
ARG TARGETARCH
# Base path for the application (optional)
ARG BASE_PATH=""
ENV BASE_PATH=${BASE_PATH}
# Set the working directory
WORKDIR /app
@@ -77,8 +73,8 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
#
FROM base AS runner
RUN npm install --ignore-scripts -g corepack@latest && \
corepack enable
RUN npm install --ignore-scripts -g corepack@latest
RUN corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
@@ -128,7 +124,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g prisma@6
RUN npm install -g prisma
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
@@ -138,13 +134,12 @@ EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
USER nextjs
# Prepare pnpm as the nextjs user to ensure it's available at runtime
# Prepare volumes for uploads and SAML connections
RUN corepack prepare pnpm@9.15.9 --activate && \
mkdir -p /home/nextjs/apps/web/uploads/ && \
mkdir -p /home/nextjs/apps/web/saml-connection
# Prepare volume for uploads
RUN mkdir -p /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/uploads/
# Prepare volume for SAML preloaded connection
RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"]

View File

@@ -25,7 +25,7 @@ const Page = async (props: ConnectPageProps) => {
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new Error(t("common.project_not_found"));
}
const channel = project.config.channel || null;
@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel}
/>
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>

View File

@@ -32,22 +32,14 @@ const mockProject: TProject = {
};
const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey",
blocks: [
questions: [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "q1",
type: "openText" as const,
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
charLimit: 1000,
},
],
id: "q1",
inputType: "text",
type: "email" as any,
headline: { default: "$[projectName] Question" },
required: false,
charLimit: { enabled: true, min: 400, max: 1000 },
},
],
endings: [
@@ -74,9 +66,9 @@ describe("replacePresetPlaceholders", () => {
expect(result.name).toBe("Test Project Survey");
});
test("replaces projectName placeholder in element headline", () => {
test("replaces projectName placeholder in question headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
expect(result.questions[0].headline.default).toBe("Test Project Question");
});
test("returns a new object without mutating the original template", () => {

View File

@@ -1,16 +1,13 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TXMTemplate } from "@formbricks/types/templates";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
import { replaceQuestionPresetPlaceholders } from "@/lib/utils/templates";
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject) => {
const survey = structuredClone(template);
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
}));
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
survey.name = survey.name.replace("$[projectName]", project.name);
survey.questions = survey.questions.map((question) => {
return replaceQuestionPresetPlaceholders(question, project);
});
return { ...template, ...survey };
};

View File

@@ -20,7 +20,7 @@ describe("xm-templates", () => {
expect(result).toEqual({
name: "",
endings: expect.any(Array),
blocks: [],
questions: [],
styling: {
overwriteThemeStyling: true,
},

View File

@@ -3,21 +3,19 @@ import { TFunction } from "i18next";
import { logger } from "@formbricks/logger";
import { TXMTemplate } from "@formbricks/types/templates";
import {
buildBlock,
buildCTAElement,
buildNPSElement,
buildOpenTextElement,
buildRatingElement,
createBlockJumpLogic,
} from "@/app/lib/survey-block-builder";
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
buildCTAQuestion,
buildNPSQuestion,
buildOpenTextQuestion,
buildRatingQuestion,
getDefaultEndingCard,
} from "@/app/lib/survey-builder";
export const getXMSurveyDefault = (t: TFunction): TXMTemplate => {
try {
return {
name: "",
endings: [getDefaultEndingCard([], t)],
blocks: [],
questions: [],
styling: {
overwriteThemeStyling: true,
},
@@ -32,40 +30,25 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
return {
...getXMSurveyDefault(t),
name: t("templates.nps_survey_name"),
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildNPSElement({
headline: t("templates.nps_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
upperLabel: t("templates.nps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
}),
],
questions: [
buildNPSQuestion({
headline: t("templates.nps_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
upperLabel: t("templates.nps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
t,
}),
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
headline: t("templates.nps_survey_question_2_headline"),
required: false,
inputType: "text",
}),
],
buildOpenTextQuestion({
headline: t("templates.nps_survey_question_2_headline"),
required: false,
inputType: "text",
t,
}),
buildBlock({
name: "Block 3",
elements: [
buildOpenTextElement({
headline: t("templates.nps_survey_question_3_headline"),
required: false,
inputType: "text",
}),
],
buildOpenTextQuestion({
headline: t("templates.nps_survey_question_3_headline"),
required: false,
inputType: "text",
t,
}),
],
@@ -73,27 +56,15 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
};
const starRatingSurvey = (t: TFunction): TXMTemplate => {
const reusableElementIds = [createId(), createId(), createId()];
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
const reusableQuestionIds = [createId(), createId(), createId()];
const defaultSurvey = getXMSurveyDefault(t);
return {
...defaultSurvey,
name: t("templates.star_rating_survey_name"),
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
id: reusableElementIds[0],
range: 5,
scale: "number",
headline: t("templates.star_rating_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
}),
],
questions: [
buildRatingQuestion({
id: reusableQuestionIds[0],
logic: [
{
id: createId(),
@@ -104,8 +75,8 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
{
id: createId(),
leftOperand: {
value: reusableElementIds[0],
type: "element",
value: reusableQuestionIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
rightOperand: {
@@ -118,44 +89,64 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
actions: [
{
id: createId(),
objective: "jumpToBlock",
target: block3Id,
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
},
],
},
],
range: 5,
scale: "number",
headline: t("templates.star_rating_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
t,
}),
buildBlock({
name: "Block 2",
elements: [
buildCTAElement({
id: reusableElementIds[1],
subheader: t("templates.star_rating_survey_question_2_html"),
headline: t("templates.star_rating_survey_question_2_headline"),
required: false,
buttonUrl: "https://formbricks.com/github",
buttonExternal: true,
ctaButtonLabel: t("templates.star_rating_survey_question_2_button_label"),
}),
buildCTAQuestion({
id: reusableQuestionIds[1],
subheader: t("templates.star_rating_survey_question_2_html"),
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isClicked",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: defaultSurvey.endings[0].id,
},
],
},
],
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
headline: t("templates.star_rating_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
buttonExternal: true,
t,
}),
buildBlock({
id: block3Id,
name: "Block 3",
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.star_rating_survey_question_3_headline"),
required: true,
subheader: t("templates.star_rating_survey_question_3_subheader"),
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
inputType: "text",
}),
],
buildOpenTextQuestion({
id: reusableQuestionIds[2],
headline: t("templates.star_rating_survey_question_3_headline"),
required: true,
subheader: t("templates.star_rating_survey_question_3_subheader"),
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
inputType: "text",
t,
}),
],
@@ -163,27 +154,15 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
};
const csatSurvey = (t: TFunction): TXMTemplate => {
const reusableElementIds = [createId(), createId(), createId()];
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
const reusableQuestionIds = [createId(), createId(), createId()];
const defaultSurvey = getXMSurveyDefault(t);
return {
...defaultSurvey,
name: t("templates.csat_survey_name"),
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
id: reusableElementIds[0],
range: 5,
scale: "smiley",
headline: t("templates.csat_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
upperLabel: t("templates.csat_survey_question_1_upper_label"),
}),
],
questions: [
buildRatingQuestion({
id: reusableQuestionIds[0],
logic: [
{
id: createId(),
@@ -194,8 +173,8 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
{
id: createId(),
leftOperand: {
value: reusableElementIds[0],
type: "element",
value: reusableQuestionIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
rightOperand: {
@@ -208,40 +187,60 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
actions: [
{
id: createId(),
objective: "jumpToBlock",
target: block3Id,
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
},
],
},
],
range: 5,
scale: "smiley",
headline: t("templates.csat_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
upperLabel: t("templates.csat_survey_question_1_upper_label"),
t,
}),
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
id: reusableElementIds[1],
headline: t("templates.csat_survey_question_2_headline"),
required: false,
placeholder: t("templates.csat_survey_question_2_placeholder"),
inputType: "text",
}),
buildOpenTextQuestion({
id: reusableQuestionIds[1],
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isSubmitted",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: defaultSurvey.endings[0].id,
},
],
},
],
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isSubmitted")],
headline: t("templates.csat_survey_question_2_headline"),
required: false,
placeholder: t("templates.csat_survey_question_2_placeholder"),
inputType: "text",
t,
}),
buildBlock({
id: block3Id,
name: "Block 3",
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.csat_survey_question_3_headline"),
required: false,
placeholder: t("templates.csat_survey_question_3_placeholder"),
inputType: "text",
}),
],
buildOpenTextQuestion({
id: reusableQuestionIds[2],
headline: t("templates.csat_survey_question_3_headline"),
required: false,
placeholder: t("templates.csat_survey_question_3_placeholder"),
inputType: "text",
t,
}),
],
@@ -252,31 +251,21 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
return {
...getXMSurveyDefault(t),
name: t("templates.cess_survey_name"),
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
range: 5,
scale: "number",
headline: t("templates.cess_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
upperLabel: t("templates.cess_survey_question_1_upper_label"),
}),
],
questions: [
buildRatingQuestion({
range: 5,
scale: "number",
headline: t("templates.cess_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
upperLabel: t("templates.cess_survey_question_1_upper_label"),
t,
}),
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
headline: t("templates.cess_survey_question_2_headline"),
required: true,
placeholder: t("templates.cess_survey_question_2_placeholder"),
inputType: "text",
}),
],
buildOpenTextQuestion({
headline: t("templates.cess_survey_question_2_headline"),
required: true,
placeholder: t("templates.cess_survey_question_2_placeholder"),
inputType: "text",
t,
}),
],
@@ -284,27 +273,15 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
};
const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
const reusableElementIds = [createId(), createId(), createId()];
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
const reusableQuestionIds = [createId(), createId(), createId()];
const defaultSurvey = getXMSurveyDefault(t);
return {
...defaultSurvey,
name: t("templates.smileys_survey_name"),
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildRatingElement({
id: reusableElementIds[0],
range: 5,
scale: "smiley",
headline: t("templates.smileys_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
}),
],
questions: [
buildRatingQuestion({
id: reusableQuestionIds[0],
logic: [
{
id: createId(),
@@ -315,8 +292,8 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
{
id: createId(),
leftOperand: {
value: reusableElementIds[0],
type: "element",
value: reusableQuestionIds[0],
type: "question",
},
operator: "isLessThanOrEqual",
rightOperand: {
@@ -329,44 +306,64 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
actions: [
{
id: createId(),
objective: "jumpToBlock",
target: block3Id,
objective: "jumpToQuestion",
target: reusableQuestionIds[2],
},
],
},
],
range: 5,
scale: "smiley",
headline: t("templates.smileys_survey_question_1_headline"),
required: true,
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
t,
}),
buildBlock({
name: "Block 2",
elements: [
buildCTAElement({
id: reusableElementIds[1],
subheader: t("templates.smileys_survey_question_2_html"),
headline: t("templates.smileys_survey_question_2_headline"),
required: false,
buttonUrl: "https://formbricks.com/github",
buttonExternal: true,
ctaButtonLabel: t("templates.smileys_survey_question_2_button_label"),
}),
buildCTAQuestion({
id: reusableQuestionIds[1],
subheader: t("templates.smileys_survey_question_2_html"),
logic: [
{
id: createId(),
conditions: {
id: createId(),
connector: "and",
conditions: [
{
id: createId(),
leftOperand: {
value: reusableQuestionIds[1],
type: "question",
},
operator: "isClicked",
},
],
},
actions: [
{
id: createId(),
objective: "jumpToQuestion",
target: defaultSurvey.endings[0].id,
},
],
},
],
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
headline: t("templates.smileys_survey_question_2_headline"),
required: true,
buttonUrl: "https://formbricks.com/github",
buttonLabel: t("templates.smileys_survey_question_2_button_label"),
buttonExternal: true,
t,
}),
buildBlock({
id: block3Id,
name: "Block 3",
elements: [
buildOpenTextElement({
id: reusableElementIds[2],
headline: t("templates.smileys_survey_question_3_headline"),
required: true,
subheader: t("templates.smileys_survey_question_3_subheader"),
placeholder: t("templates.smileys_survey_question_3_placeholder"),
inputType: "text",
}),
],
buildOpenTextQuestion({
id: reusableQuestionIds[2],
headline: t("templates.smileys_survey_question_3_headline"),
required: true,
subheader: t("templates.smileys_survey_question_3_subheader"),
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
placeholder: t("templates.smileys_survey_question_3_placeholder"),
inputType: "text",
t,
}),
],
@@ -377,40 +374,25 @@ const enpsSurvey = (t: TFunction): TXMTemplate => {
return {
...getXMSurveyDefault(t),
name: t("templates.enps_survey_name"),
blocks: [
buildBlock({
name: "Block 1",
elements: [
buildNPSElement({
headline: t("templates.enps_survey_question_1_headline"),
required: false,
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
upperLabel: t("templates.enps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
}),
],
questions: [
buildNPSQuestion({
headline: t("templates.enps_survey_question_1_headline"),
required: false,
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
upperLabel: t("templates.enps_survey_question_1_upper_label"),
isColorCodingEnabled: true,
t,
}),
buildBlock({
name: "Block 2",
elements: [
buildOpenTextElement({
headline: t("templates.enps_survey_question_2_headline"),
required: false,
inputType: "text",
}),
],
buildOpenTextQuestion({
headline: t("templates.enps_survey_question_2_headline"),
required: false,
inputType: "text",
t,
}),
buildBlock({
name: "Block 3",
elements: [
buildOpenTextElement({
headline: t("templates.enps_survey_question_3_headline"),
required: false,
inputType: "text",
}),
],
buildOpenTextQuestion({
headline: t("templates.enps_survey_question_3_headline"),
required: false,
inputType: "text",
t,
}),
],

View File

@@ -38,7 +38,7 @@ const Page = async (props: XMTemplatePageProps) => {
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new Error(t("common.project_not_found"));
}
const projects = await getUserProjects(session.user.id, organizationId);
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -50,8 +50,8 @@ const Page = async (props) => {
</div>
<div className="flex h-full flex-col items-center justify-center space-y-12">
<Header
title={t("organizations.landing.no_workspaces_warning_title")}
subtitle={t("organizations.landing.no_workspaces_warning_subtitle")}
title={t("organizations.landing.no_projects_warning_title")}
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
/>
</div>
</div>

View File

@@ -1,6 +1,8 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors";
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
@@ -38,6 +40,14 @@ const ProjectOnboardingLayout = async (props) => {
return (
<div className="flex-1 bg-slate-50">
<PosthogIdentify
session={session}
user={user}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<ToasterClient />
{children}
</div>

View File

@@ -26,16 +26,16 @@ const Page = async (props: ChannelPageProps) => {
const t = await getTranslate();
const channelOptions = [
{
title: t("organizations.workspaces.new.channel.link_and_email_surveys"),
description: t("organizations.workspaces.new.channel.link_and_email_surveys_description"),
title: t("organizations.projects.new.channel.link_and_email_surveys"),
description: t("organizations.projects.new.channel.link_and_email_surveys_description"),
icon: SendIcon,
href: `/organizations/${params.organizationId}/workspaces/new/settings?channel=link`,
href: `/organizations/${params.organizationId}/projects/new/settings?channel=link`,
},
{
title: t("organizations.workspaces.new.channel.in_product_surveys"),
description: t("organizations.workspaces.new.channel.in_product_surveys_description"),
title: t("organizations.projects.new.channel.in_product_surveys"),
description: t("organizations.projects.new.channel.in_product_surveys_description"),
icon: PictureInPicture2Icon,
href: `/organizations/${params.organizationId}/workspaces/new/settings?channel=app`,
href: `/organizations/${params.organizationId}/projects/new/settings?channel=app`,
},
];
@@ -44,13 +44,13 @@ const Page = async (props: ChannelPageProps) => {
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
title={t("organizations.workspaces.new.channel.channel_select_title")}
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
title={t("organizations.projects.new.channel.channel_select_title")}
subtitle={t("organizations.projects.new.channel.channel_select_subtitle")}
/>
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -26,16 +26,16 @@ const Page = async (props: ModePageProps) => {
const t = await getTranslate();
const channelOptions = [
{
title: t("organizations.workspaces.new.mode.formbricks_surveys"),
description: t("organizations.workspaces.new.mode.formbricks_surveys_description"),
title: t("organizations.projects.new.mode.formbricks_surveys"),
description: t("organizations.projects.new.mode.formbricks_surveys_description"),
icon: ListTodoIcon,
href: `/organizations/${params.organizationId}/workspaces/new/channel`,
href: `/organizations/${params.organizationId}/projects/new/channel`,
},
{
title: t("organizations.workspaces.new.mode.formbricks_cx"),
description: t("organizations.workspaces.new.mode.formbricks_cx_description"),
title: t("organizations.projects.new.mode.formbricks_cx"),
description: t("organizations.projects.new.mode.formbricks_cx_description"),
icon: HeartIcon,
href: `/organizations/${params.organizationId}/workspaces/new/settings?mode=cx`,
href: `/organizations/${params.organizationId}/projects/new/settings?mode=cx`,
},
];
@@ -43,11 +43,11 @@ const Page = async (props: ModePageProps) => {
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
<Header title={t("organizations.projects.new.mode.what_are_you_here_for")} />
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -44,7 +44,6 @@ interface ProjectSettingsProps {
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
userProjectsCount: number;
publicDomain: string;
}
export const ProjectSettings = ({
@@ -56,7 +55,6 @@ export const ProjectSettings = ({
organizationTeams,
isAccessControlAllowed = false,
userProjectsCount,
publicDomain,
}: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
@@ -79,7 +77,7 @@ export const ProjectSettings = ({
(environment) => environment.type === "production"
);
if (productionEnvironment) {
if (globalThis.window !== undefined) {
if (typeof window !== "undefined") {
// Rmove filters when creating a new project
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
@@ -96,7 +94,7 @@ export const ProjectSettings = ({
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("organizations.workspaces.new.settings.workspace_creation_failed"));
toast.error(t("organizations.projects.new.settings.project_creation_failed"));
console.error(error);
}
};
@@ -131,9 +129,9 @@ export const ProjectSettings = ({
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<div>
<FormLabel>{t("organizations.workspaces.new.settings.brand_color")}</FormLabel>
<FormLabel>{t("organizations.projects.new.settings.brand_color")}</FormLabel>
<FormDescription>
{t("organizations.workspaces.new.settings.brand_color_description")}
{t("organizations.projects.new.settings.brand_color_description")}
</FormDescription>
</div>
<FormControl>
@@ -155,9 +153,9 @@ export const ProjectSettings = ({
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<div>
<FormLabel>{t("organizations.workspaces.new.settings.workspace_name")}</FormLabel>
<FormLabel>{t("organizations.projects.new.settings.project_name")}</FormLabel>
<FormDescription>
{t("organizations.workspaces.new.settings.workspace_name_description")}
{t("organizations.projects.new.settings.project_name_description")}
</FormDescription>
</div>
<FormControl>
@@ -186,7 +184,7 @@ export const ProjectSettings = ({
<div>
<FormLabel>{t("common.teams")}</FormLabel>
<FormDescription>
{t("organizations.workspaces.new.settings.team_description")}
{t("organizations.projects.new.settings.team_description")}
</FormDescription>
</div>
<Button
@@ -194,7 +192,7 @@ export const ProjectSettings = ({
size="sm"
type="button"
onClick={() => setCreateTeamModalOpen(true)}>
{t("organizations.workspaces.new.settings.create_new_team")}
{t("organizations.projects.new.settings.create_new_team")}
</Button>
</div>
<FormControl>
@@ -227,13 +225,12 @@ export const ProjectSettings = ({
alt="Logo"
width={256}
height={56}
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
)}
<p className="text-sm text-slate-400">{t("common.preview")}</p>
<div className="z-0 h-3/4 w-3/4">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)}
styling={{ brandColor: { light: brandColor } }}

View File

@@ -3,9 +3,8 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
@@ -48,13 +47,11 @@ const Page = async (props: ProjectSettingsPageProps) => {
throw new Error(t("common.organization_teams_not_found"));
}
const publicDomain = getPublicDomain();
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
title={t("organizations.projects.new.settings.project_settings_title")}
subtitle={t("organizations.projects.new.settings.project_settings_subtitle")}
/>
<ProjectSettings
organizationId={params.organizationId}
@@ -65,11 +62,10 @@ const Page = async (props: ProjectSettingsPageProps) => {
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
publicDomain={publicDomain}
/>
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -1,13 +1,14 @@
import { redirect } from "next/navigation";
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
const SurveyEditorEnvironmentLayout = async (props) => {
const params = await props.params;
const { children } = props;
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
if (!session) {
return redirect(`/auth/login`);
@@ -24,9 +25,15 @@ const SurveyEditorEnvironmentLayout = async (props) => {
}
return (
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={session}
user={user}
organization={organization}>
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
</EnvironmentIdBaseLayout>
);
};

View File

@@ -1,7 +1,6 @@
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
@@ -16,7 +15,6 @@ interface EnvironmentLayoutProps {
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
const t = await getTranslate();
const publicDomain = getPublicDomain();
// Destructure all data from props (NO database queries)
const {
@@ -43,7 +41,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
// Validate that project permission exists for members
if (isMember && !projectPermission) {
throw new Error(t("common.workspace_permission_not_found"));
throw new Error(t("common.project_permission_not_found"));
}
return (
@@ -74,7 +72,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role}
publicDomain={publicDomain}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar

View File

@@ -46,7 +46,6 @@ interface NavigationProps {
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
publicDomain: string;
}
export const MainNavigation = ({
@@ -57,7 +56,6 @@ export const MainNavigation = ({
membershipRole,
isFormbricksCloud,
isDevelopment,
publicDomain,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -113,7 +111,7 @@ export const MainNavigation = ({
},
{
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
href: `/environments/${environment.id}/project/general`,
icon: Cog,
isActive: pathname?.includes("/project"),
},
@@ -164,7 +162,7 @@ export const MainNavigation = ({
<aside
className={cn(
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
isCollapsed ? "w-sidebar-expanded" : "w-sidebar-collapsed"
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
)}>
<div>
{/* Logo and Toggle */}
@@ -185,7 +183,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -288,16 +286,15 @@ export const MainNavigation = ({
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: loginUrl,
redirectUrl: "/auth/login",
organizationId: organization.id,
redirect: false,
callbackUrl: loginUrl,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}

View File

@@ -0,0 +1,61 @@
"use client";
import type { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
interface PosthogIdentifyProps {
session: Session;
user: TUser;
environmentId?: string;
organizationId?: string;
organizationName?: string;
organizationBilling?: TOrganizationBilling;
isPosthogEnabled: boolean;
}
export const PosthogIdentify = ({
session,
user,
environmentId,
organizationId,
organizationName,
organizationBilling,
isPosthogEnabled,
}: PosthogIdentifyProps) => {
const posthog = usePostHog();
useEffect(() => {
if (isPosthogEnabled && session.user && posthog) {
posthog.identify(session.user.id, {
name: user.name,
email: user.email,
});
if (environmentId) {
posthog.group("environment", environmentId, { name: environmentId });
}
if (organizationId) {
posthog.group("organization", organizationId, {
name: organizationName,
plan: organizationBilling?.plan,
responseLimit: organizationBilling?.limits.monthly.responses,
miuLimit: organizationBilling?.limits.monthly.miu,
});
}
}
}, [
posthog,
session.user,
environmentId,
organizationId,
organizationName,
organizationBilling,
user.name,
user.email,
isPosthogEnabled,
]);
return null;
};

View File

@@ -2,14 +2,14 @@
import React, { createContext, useCallback, useContext, useState } from "react";
import {
ElementOption,
ElementOptions,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ElementsComboBox";
import { ElementFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
QuestionOption,
QuestionOptions,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
import { getTodayDate } from "@/app/lib/surveys/surveys";
export interface FilterValue {
elementType: Partial<ElementOption>;
questionType: Partial<QuestionOption>;
filterType: {
filterValue: string | undefined;
filterComboBoxValue: string | string[] | undefined;
@@ -24,8 +24,8 @@ export interface SelectedFilterValue {
}
interface SelectedFilterOptions {
elementOptions: ElementOptions[];
elementFilterOptions: ElementFilterOptions[];
questionOptions: QuestionOptions[];
questionFilterOptions: QuestionFilterOptions[];
}
export interface DateRange {
@@ -53,8 +53,8 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
});
// state holds all the options of the responses fetched
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
elementFilterOptions: [],
elementOptions: [],
questionFilterOptions: [],
questionOptions: [],
});
const [dateRange, setDateRange] = useState<DateRange>({

View File

@@ -17,13 +17,13 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
const stati = {
notImplemented: {
icon: AlertTriangleIcon,
title: t("environments.workspace.app-connection.formbricks_sdk_not_connected"),
subtitle: t("environments.workspace.app-connection.formbricks_sdk_not_connected_description"),
title: t("environments.project.app-connection.formbricks_sdk_not_connected"),
subtitle: t("environments.project.app-connection.formbricks_sdk_not_connected_description"),
},
running: {
icon: CheckIcon,
title: t("environments.workspace.app-connection.receiving_data"),
subtitle: t("environments.workspace.app-connection.formbricks_sdk_connected"),
title: t("environments.project.app-connection.receiving_data"),
subtitle: t("environments.project.app-connection.formbricks_sdk_connected"),
},
};
@@ -53,11 +53,11 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon />
</div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
{status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon />
{t("environments.workspace.app-connection.recheck")}
{t("environments.project.app-connection.recheck")}
</Button>
)}
</div>

View File

@@ -135,7 +135,7 @@ export const OrganizationBreadcrumb = ({
},
{
id: "teams",
label: t("common.members_and_teams"),
label: t("common.teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`,
},
{
@@ -144,12 +144,6 @@ export const OrganizationBreadcrumb = ({
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
hidden: !isOwnerOrManager,
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${currentEnvironmentId}/settings/domain`,
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),

View File

@@ -36,12 +36,12 @@ interface ProjectBreadcrumbProps {
}
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
// Match /project/{settingId} or /project/{settingId}/... but exclude settings paths
if (pathname.includes("/settings/")) {
return false;
}
// Check if path matches /workspace/{settingId} (with optional trailing path)
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
// Check if path matches /project/{settingId} (with optional trailing path)
const pattern = new RegExp(`/project/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
@@ -90,7 +90,7 @@ export const ProjectBreadcrumb = ({
const error = new Error(errorMessage);
logger.error(error, "Failed to load projects");
Sentry.captureException(error);
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
setLoadError(errorMessage || t("common.failed_to_load_projects"));
}
setIsLoadingProjects(false);
});
@@ -101,37 +101,37 @@ export const ProjectBreadcrumb = ({
{
id: "general",
label: t("common.general"),
href: `/environments/${currentEnvironmentId}/workspace/general`,
href: `/environments/${currentEnvironmentId}/project/general`,
},
{
id: "look",
label: t("common.look_and_feel"),
href: `/environments/${currentEnvironmentId}/workspace/look`,
href: `/environments/${currentEnvironmentId}/project/look`,
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
href: `/environments/${currentEnvironmentId}/workspace/app-connection`,
href: `/environments/${currentEnvironmentId}/project/app-connection`,
},
{
id: "integrations",
label: t("common.integrations"),
href: `/environments/${currentEnvironmentId}/workspace/integrations`,
href: `/environments/${currentEnvironmentId}/project/integrations`,
},
{
id: "teams",
label: t("common.team_access"),
href: `/environments/${currentEnvironmentId}/workspace/teams`,
href: `/environments/${currentEnvironmentId}/project/teams`,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `/environments/${currentEnvironmentId}/workspace/languages`,
href: `/environments/${currentEnvironmentId}/project/languages`,
},
{
id: "tags",
label: t("common.tags"),
href: `/environments/${currentEnvironmentId}/workspace/tags`,
href: `/environments/${currentEnvironmentId}/project/tags`,
},
];
@@ -159,7 +159,7 @@ export const ProjectBreadcrumb = ({
const handleProjectSettingsNavigation = (settingId: string) => {
startTransition(() => {
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
router.push(`/environments/${currentEnvironmentId}/project/${settingId}`);
});
};
@@ -212,7 +212,7 @@ export const ProjectBreadcrumb = ({
<DropdownMenuContent align="start" className="mt-2">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")}
{t("common.choose_project")}
</div>
{isLoadingProjects && (
<div className="flex items-center justify-center py-2">
@@ -251,7 +251,7 @@ export const ProjectBreadcrumb = ({
<DropdownMenuCheckboxItem
onClick={handleAddProject}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<span>{t("common.add_new_project")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
@@ -261,7 +261,7 @@ export const ProjectBreadcrumb = ({
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.workspace_configuration")}
{t("common.project_configuration")}
</div>
{projectSettings.map((setting) => (
<DropdownMenuCheckboxItem

View File

@@ -4,6 +4,7 @@ import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/comp
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
const EnvLayout = async (props: {
@@ -23,7 +24,11 @@ const EnvLayout = async (props: {
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
return (
<>
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={layoutData.session}
user={layoutData.user}
organization={layoutData.organization}>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper
environment={layoutData.environment}
@@ -31,7 +36,7 @@ const EnvLayout = async (props: {
organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper>
</>
</EnvironmentIdBaseLayout>
);
};

View File

@@ -3,7 +3,7 @@
import { TFunction } from "i18next";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { Control, Controller, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -14,15 +14,14 @@ import {
TIntegrationAirtableInput,
TIntegrationAirtableTables,
} from "@formbricks/types/integration/airtable";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
import AirtableLogo from "@/images/airtableLogo.svg";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
@@ -46,45 +45,6 @@ import {
} from "@/modules/ui/components/select";
import { IntegrationModalInputs } from "../lib/types";
const ElementCheckbox = ({
element,
selectedSurvey,
field,
}: {
element: TSurveyElement;
selectedSurvey: TSurvey;
field: {
value: string[] | undefined;
onChange: (value: string[]) => void;
};
}) => {
const handleCheckedChange = (checked: boolean) => {
if (checked) {
field.onChange([...(field.value || []), element.id]);
} else {
field.onChange(field.value?.filter((value) => value !== element.id) || []);
}
};
return (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={element.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={element.id}
value={element.id}
className="bg-white"
checked={field.value?.includes(element.id)}
onCheckedChange={handleCheckedChange}
/>
<span className="ml-2">
{getTextContent(recallToHeadline(element.headline, selectedSurvey, false, "default")["default"])}
</span>
</label>
</div>
);
};
type EditModeProps =
| { isEditMode: false; defaultData?: never }
| { isEditMode: true; defaultData: IntegrationModalInputs & { index: number } };
@@ -108,10 +68,9 @@ const NoBaseFoundError = () => {
);
};
const renderElementSelection = ({
const renderQuestionSelection = ({
t,
selectedSurvey,
elements,
control,
includeVariables,
setIncludeVariables,
@@ -124,7 +83,6 @@ const renderElementSelection = ({
}: {
t: TFunction;
selectedSurvey: TSurvey;
elements: TSurveyElement[];
control: Control<IntegrationModalInputs>;
includeVariables: boolean;
setIncludeVariables: (value: boolean) => void;
@@ -141,13 +99,31 @@ const renderElementSelection = ({
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{elements.map((element) => (
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<Controller
key={element.id}
key={question.id}
control={control}
name={"elements"}
name={"questions"}
render={({ field }) => (
<ElementCheckbox element={element} selectedSurvey={selectedSurvey} field={field} />
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">
{getTextContent(getLocalizedValue(question.headline, "default"))}
</span>
</label>
</div>
)}
/>
))}
@@ -218,11 +194,6 @@ export const AddIntegrationModal = ({
};
const selectedSurvey = surveys.find((item) => item.id === survey);
const elements = useMemo(
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
[selectedSurvey]
);
const submitHandler = async (data: IntegrationModalInputs) => {
try {
if (!data.base || data.base === "") {
@@ -237,7 +208,7 @@ export const AddIntegrationModal = ({
throw new Error(t("environments.integrations.please_select_a_survey_error"));
}
if (data.elements.length === 0) {
if (data.questions.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
}
@@ -245,9 +216,9 @@ export const AddIntegrationModal = ({
const integrationData: TIntegrationAirtableConfigData = {
surveyId: selectedSurvey.id,
surveyName: selectedSurvey.name,
elementIds: data.elements,
elements:
data.elements.length === elements.length
questionIds: data.questions,
questions:
data.questions.length === selectedSurvey.questions.length
? t("common.all_questions")
: t("common.selected_questions"),
createdAt: new Date(),
@@ -395,7 +366,7 @@ export const AddIntegrationModal = ({
required
onValueChange={(val) => {
field.onChange(val);
setValue("elements", []);
setValue("questions", []);
}}
defaultValue={defaultData?.survey}>
<SelectTrigger>
@@ -421,10 +392,9 @@ export const AddIntegrationModal = ({
{survey &&
selectedSurvey &&
renderElementSelection({
renderQuestionSelection({
t,
selectedSurvey,
elements: elements,
control,
includeVariables,
setIncludeVariables,

View File

@@ -1,12 +1,13 @@
"use client";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
import airtableLogo from "@/images/airtableLogo.svg";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
@@ -15,6 +16,7 @@ interface AirtableWrapperProps {
airtableArray: TIntegrationItem[];
airtableIntegration?: TIntegrationAirtable;
surveys: TSurvey[];
environment: TEnvironment;
isEnabled: boolean;
webAppUrl: string;
locale: TUserLocale;
@@ -25,6 +27,7 @@ export const AirtableWrapper = ({
airtableArray,
airtableIntegration,
surveys,
environment,
isEnabled,
webAppUrl,
locale,
@@ -45,6 +48,7 @@ export const AirtableWrapper = ({
<ManageIntegration
airtableArray={airtableArray}
environmentId={environmentId}
environment={environment}
airtableIntegration={airtableIntegration}
setIsConnected={setIsConnected}
surveys={surveys}

View File

@@ -4,21 +4,23 @@ import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AddIntegrationModal";
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 { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps {
airtableIntegration: TIntegrationAirtable;
environment: TEnvironment;
environmentId: string;
setIsConnected: (data: boolean) => void;
surveys: TSurvey[];
@@ -27,7 +29,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslation();
const tableHeaders = [
@@ -108,7 +110,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
onClick={() => {
setDefaultValues({
base: data.baseId,
elements: data.elementIds,
questions: data.questionIds,
survey: data.surveyId,
table: data.tableId,
includeVariables: !!data.includeVariables,
@@ -121,7 +123,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.tableName}</div>
<div className="col-span-2 text-center">{data.elements}</div>
<div className="col-span-2 text-center">{data.questions}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), props.locale)}
</div>
@@ -130,7 +132,12 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
</div>
) : (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.airtable.no_integrations_yet")} />
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.airtable.no_integrations_yet")}
/>
</div>
)}

View File

@@ -2,7 +2,7 @@ export type IntegrationModalInputs = {
base: string;
table: string;
survey: string;
elements: string[];
questions: string[];
includeVariables: boolean;
includeHiddenFields: boolean;
includeMetadata: boolean;

View File

@@ -1,8 +1,8 @@
import { redirect } from "next/navigation";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
@@ -42,7 +42,7 @@ const Page = async (props) => {
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} />
<div className="h-[75vh] w-full">
<AirtableWrapper
@@ -51,6 +51,7 @@ const Page = async (props) => {
airtableArray={airtableArray}
environmentId={environment.id}
surveys={surveys}
environment={environment}
webAppUrl={WEBAPP_URL}
locale={locale}
/>

View File

@@ -1,7 +1,7 @@
"use client";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,17 +12,17 @@ import {
} from "@formbricks/types/integration/google-sheet";
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions";
import {
constructGoogleSheetsUrl,
extractSpreadsheetIdFromUrl,
isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
} from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
@@ -62,12 +62,12 @@ export const AddIntegrationModal = ({
spreadsheetName: "",
surveyId: "",
surveyName: "",
elementIds: [""],
elements: "",
questionIds: [""],
questions: "",
createdAt: new Date(),
};
const { handleSubmit } = useForm();
const [selectedElements, setSelectedElements] = useState<string[]>([]);
const [selectedQuestions, setSelectedQuestions] = useState<string[]>([]);
const [isLinkingSheet, setIsLinkingSheet] = useState(false);
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [spreadsheetUrl, setSpreadsheetUrl] = useState("");
@@ -86,17 +86,12 @@ export const AddIntegrationModal = ({
},
};
const surveyElements = useMemo(
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
[selectedSurvey]
);
useEffect(() => {
if (selectedSurvey && !selectedIntegration) {
const elementIds = surveyElements.map((element) => element.id);
setSelectedElements(elementIds);
const questionIds = selectedSurvey.questions.map((question) => question.id);
setSelectedQuestions(questionIds);
}
}, [surveyElements, selectedIntegration, selectedSurvey]);
}, [selectedIntegration, selectedSurvey]);
useEffect(() => {
if (selectedIntegration) {
@@ -106,7 +101,7 @@ export const AddIntegrationModal = ({
return survey.id === selectedIntegration.surveyId;
})!
);
setSelectedElements(selectedIntegration.elementIds);
setSelectedQuestions(selectedIntegration.questionIds);
setIncludeVariables(!!selectedIntegration.includeVariables);
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
setIncludeMetadata(!!selectedIntegration.includeMetadata);
@@ -126,7 +121,7 @@ export const AddIntegrationModal = ({
if (!selectedSurvey) {
throw new Error(t("environments.integrations.please_select_a_survey_error"));
}
if (selectedElements.length === 0) {
if (selectedQuestions.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
}
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
@@ -148,9 +143,9 @@ export const AddIntegrationModal = ({
integrationData.spreadsheetName = spreadsheetName;
integrationData.surveyId = selectedSurvey.id;
integrationData.surveyName = selectedSurvey.name;
integrationData.elementIds = selectedElements;
integrationData.elements =
selectedElements.length === surveyElements.length
integrationData.questionIds = selectedQuestions;
integrationData.questions =
selectedQuestions.length === selectedSurvey?.questions.length
? t("common.all_questions")
: t("common.selected_questions");
integrationData.createdAt = new Date();
@@ -181,7 +176,7 @@ export const AddIntegrationModal = ({
};
const handleCheckboxChange = (questionId: TSurveyQuestionId) => {
setSelectedElements((prevValues) =>
setSelectedQuestions((prevValues) =>
prevValues.includes(questionId)
? prevValues.filter((value) => value !== questionId)
: [...prevValues, questionId]
@@ -266,9 +261,9 @@ export const AddIntegrationModal = ({
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{surveyElements.map((question) => (
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
@@ -276,17 +271,13 @@ export const AddIntegrationModal = ({
id={question.id}
value={question.id}
className="bg-white"
checked={selectedElements.includes(question.id)}
checked={selectedQuestions.includes(question.id)}
onCheckedChange={() => {
handleCheckboxChange(question.id);
}}
/>
<span className="ml-2 w-[30rem] truncate">
{getTextContent(
recallToHeadline(question.headline, selectedSurvey, false, "default")[
"default"
]
)}
{getTextContent(getLocalizedValue(question.headline, "default"))}
</span>
</label>
</div>

View File

@@ -8,8 +8,8 @@ import {
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/google";
import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { AddIntegrationModal } from "./AddIntegrationModal";
@@ -60,6 +60,7 @@ export const GoogleSheetWrapper = ({
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
googleSheetIntegration={googleSheetIntegration}
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}

View File

@@ -4,19 +4,21 @@ import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData,
} from "@formbricks/types/integration/google-sheet";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/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 { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
interface ManageIntegrationProps {
environment: TEnvironment;
googleSheetIntegration: TIntegrationGoogleSheets;
setOpenAddIntegrationModal: (v: boolean) => void;
setIsConnected: (v: boolean) => void;
@@ -25,6 +27,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = ({
environment,
googleSheetIntegration,
setOpenAddIntegrationModal,
setIsConnected,
@@ -87,7 +90,12 @@ export const ManageIntegration = ({
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.google_sheets.no_integrations_yet")} />
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.google_sheets.no_integrations_yet")}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
@@ -110,7 +118,7 @@ export const ManageIntegration = ({
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.spreadsheetName}</div>
<div className="col-span-2 text-center">{data.elements}</div>
<div className="col-span-2 text-center">{data.questions}</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
</button>
);

View File

@@ -10,7 +10,7 @@ const Loading = () => {
<div className="mt-6 p-6">
<GoBackButton />
<div className="mb-6 text-right">
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
{t("environments.integrations.google_sheets.link_new_sheet")}
</Button>
</div>
@@ -51,7 +51,7 @@ const Loading = () => {
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="text-center"></div>

View File

@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
@@ -40,7 +40,7 @@ const Page = async (props) => {
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
<PageHeader pageTitle={t("environments.integrations.google_sheets.google_sheets_integration")} />
<div className="h-[75vh] w-full">
<GoogleSheetWrapper

View File

@@ -12,20 +12,18 @@ import {
TIntegrationNotionConfigData,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import {
ERRORS,
TYPE_MAPPING,
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/constants";
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
import NotionLogo from "@/images/notion.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { getElementTypes } from "@/modules/survey/lib/elements";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -39,59 +37,6 @@ import {
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
const MappingErrorMessage = ({
error,
col,
elem,
t,
}: {
error: { type: string; msg?: React.ReactNode | string } | null | undefined;
col: { id: string; name: string; type: string };
elem: { id: string; name: string; type: string };
t: ReturnType<typeof useTranslation>["t"];
}) => {
const showErrorMsg = useMemo(() => {
switch (error?.type) {
case ERRORS.UNSUPPORTED_TYPE:
return (
<>
-{" "}
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
col_name: col.name,
type: col.type,
})}
</>
);
case ERRORS.MAPPING:
const element = getElementTypes(t).find((et) => et.id === elem.type);
if (!element) return null;
return (
<>
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
que_name: elem.name,
question_label: element.label,
col_name: col.name,
col_type: col.type,
mapped_type: TYPE_MAPPING[element.id].join(" ,"),
})}
</>
);
default:
return null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error, col, elem, t]);
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>
{showErrorMsg}
</div>
);
};
interface AddIntegrationModalProps {
environmentId: string;
surveys: TSurvey[];
@@ -118,7 +63,7 @@ export const AddIntegrationModal = ({
const [mapping, setMapping] = useState<
{
column: { id: string; name: string; type: string };
element: { id: string; name: string; type: string };
question: { id: string; name: string; type: string };
error?: {
type: string;
msg: React.ReactNode | string;
@@ -127,7 +72,7 @@ export const AddIntegrationModal = ({
>([
{
column: { id: "", name: "", type: "" },
element: { id: "", name: "", type: "" },
question: { id: "", name: "", type: "" },
},
]);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
@@ -140,17 +85,12 @@ export const AddIntegrationModal = ({
mapping: [
{
column: { id: "", name: "", type: "" },
element: { id: "", name: "", type: "" },
question: { id: "", name: "", type: "" },
},
],
createdAt: new Date(),
};
const elements = useMemo(
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
[selectedSurvey]
);
const notionIntegrationData: TIntegrationInput = {
type: "notion",
config: {
@@ -178,12 +118,12 @@ export const AddIntegrationModal = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDatabase?.id]);
const elementItems = useMemo(() => {
const mappedElements = selectedSurvey
? elements.map((el) => ({
id: el.id,
name: getTextContent(recallToHeadline(el.headline, selectedSurvey, false, "default")["default"]),
type: el.type,
const questionItems = useMemo(() => {
const questions = selectedSurvey
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
id: q.id,
name: getLocalizedValue(q.headline, "default"),
type: q.type,
}))
: [];
@@ -191,31 +131,31 @@ export const AddIntegrationModal = ({
selectedSurvey?.variables.map((variable) => ({
id: variable.id,
name: variable.name,
type: TSurveyElementTypeEnum.OpenText,
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const hiddenFields =
selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId,
name: `${t("common.hidden_field")} : ${fId}`,
type: TSurveyElementTypeEnum.OpenText,
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const Metadata = [
{
id: "metadata",
name: t("common.metadata"),
type: TSurveyElementTypeEnum.OpenText,
type: TSurveyQuestionTypeEnum.OpenText,
},
];
const createdAt = [
{
id: "createdAt",
name: t("common.created_at"),
type: TSurveyElementTypeEnum.Date,
type: TSurveyQuestionTypeEnum.Date,
},
];
return [...mappedElements, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
return [...questions, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSurvey?.id]);
@@ -249,7 +189,7 @@ export const AddIntegrationModal = ({
throw new Error(t("environments.integrations.please_select_a_survey_error"));
}
if (mapping.length === 1 && (!mapping[0].element.id || !mapping[0].column.id)) {
if (mapping.length === 1 && (!mapping[0].question.id || !mapping[0].column.id)) {
throw new Error(t("environments.integrations.notion.please_select_at_least_one_mapping"));
}
@@ -258,8 +198,8 @@ export const AddIntegrationModal = ({
}
if (
mapping.filter((m) => m.column.id && !m.element.id).length >= 1 ||
mapping.filter((m) => m.element.id && !m.column.id).length >= 1
mapping.filter((m) => m.column.id && !m.question.id).length >= 1 ||
mapping.filter((m) => m.question.id && !m.column.id).length >= 1
) {
throw new Error(
t("environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
@@ -320,23 +260,23 @@ export const AddIntegrationModal = ({
setSelectedDatabase(null);
setSelectedSurvey(null);
};
const getFilteredElementItems = (selectedIdx) => {
const selectedElementIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.element.id);
const getFilteredQuestionItems = (selectedIdx) => {
const selectedQuestionIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.question.id);
return elementItems.filter((el) => !selectedElementIds.includes(el.id));
return questionItems.filter((q) => !selectedQuestionIds.includes(q.id));
};
const createCopy = (item) => structuredClone(item);
const MappingRow = ({ idx }: { idx: number }) => {
const filteredElementItems = getFilteredElementItems(idx);
const filteredQuestionItems = getFilteredQuestionItems(idx);
const addRow = () => {
setMapping((prev) => [
...prev,
{
column: { id: "", name: "", type: "" },
element: { id: "", name: "", type: "" },
question: { id: "", name: "", type: "" },
},
]);
};
@@ -347,6 +287,49 @@ export const AddIntegrationModal = ({
});
};
const ErrorMsg = ({ error, col, ques }) => {
const showErrorMsg = useMemo(() => {
switch (error?.type) {
case ERRORS.UNSUPPORTED_TYPE:
return (
<>
-{" "}
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
col_name: col.name,
type: col.type,
})}
</>
);
case ERRORS.MAPPING:
const question = getQuestionTypes(t).find((qt) => qt.id === ques.type);
if (!question) return null;
return (
<>
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
que_name: ques.name,
question_label: question.label,
col_name: col.name,
col_type: col.type,
mapped_type: TYPE_MAPPING[question.id].join(" ,"),
})}
</>
);
default:
return null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error]);
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>
{showErrorMsg}
</div>
);
};
const getFilteredDbItems = () => {
const colMapping = mapping.map((m) => m.column.id);
return dbItems.filter((item) => !colMapping.includes(item.id));
@@ -354,20 +337,19 @@ export const AddIntegrationModal = ({
return (
<div className="w-full">
<MappingErrorMessage
<ErrorMsg
key={idx}
error={mapping[idx]?.error}
col={mapping[idx].column}
elem={mapping[idx].element}
t={t}
ques={mapping[idx].question}
/>
<div className="flex w-full items-center space-x-2">
<div className="flex w-full items-center">
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.notion.select_a_survey_question")}
items={filteredElementItems}
selectedItem={mapping?.[idx]?.element}
items={filteredQuestionItems}
selectedItem={mapping?.[idx]?.question}
setSelectedItem={(item) => {
setMapping((prev) => {
const copy = createCopy(prev);
@@ -379,7 +361,7 @@ export const AddIntegrationModal = ({
error: {
type: ERRORS.UNSUPPORTED_TYPE,
},
element: item,
question: item,
};
return copy;
}
@@ -391,7 +373,7 @@ export const AddIntegrationModal = ({
error: {
type: ERRORS.MAPPING,
},
element: item,
question: item,
};
return copy;
}
@@ -399,13 +381,13 @@ export const AddIntegrationModal = ({
copy[idx] = {
...copy[idx],
element: item,
question: item,
error: null,
};
return copy;
});
}}
disabled={elementItems.length === 0}
disabled={questionItems.length === 0}
/>
</div>
<div className="h-px w-4 border-t border-t-slate-300" />
@@ -417,9 +399,9 @@ export const AddIntegrationModal = ({
setSelectedItem={(item) => {
setMapping((prev) => {
const copy = createCopy(prev);
const elem = copy[idx].element;
if (elem.id) {
const isValidElemType = TYPE_MAPPING[elem.type].includes(item.type);
const ques = copy[idx].question;
if (ques.id) {
const isValidQuesType = TYPE_MAPPING[ques.type].includes(item.type);
if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) {
copy[idx] = {
@@ -432,7 +414,7 @@ export const AddIntegrationModal = ({
return copy;
}
if (!isValidElemType) {
if (!isValidQuesType) {
copy[idx] = {
...copy[idx],
error: {

View File

@@ -4,17 +4,19 @@ import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/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 { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ManageIntegrationProps {
environment: TEnvironment;
notionIntegration: TIntegrationNotion;
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
@@ -26,6 +28,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = ({
environment,
notionIntegration,
setOpenAddIntegrationModal,
setIsConnected,
@@ -98,7 +101,12 @@ export const ManageIntegration = ({
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.notion.no_databases_found")} />
<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">

View File

@@ -9,8 +9,8 @@ import {
} from "@formbricks/types/integration/notion";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/AddIntegrationModal";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/ManageIntegration";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/AddIntegrationModal";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/ManageIntegration";
import notionLogo from "@/images/notion.png";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { authorize } from "../lib/notion";
@@ -64,6 +64,7 @@ export const NotionWrapper = ({
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
notionIntegration={notionIntegration}
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}

View File

@@ -10,7 +10,7 @@ const Loading = () => {
<div className="mt-6 p-6">
<GoBackButton />
<div className="mb-6 text-right">
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
{t("environments.integrations.notion.link_database")}
</Button>
</div>
@@ -48,7 +48,7 @@ const Loading = () => {
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="text-center"></div>

View File

@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/NotionWrapper";
import {
NOTION_AUTH_URL,
NOTION_OAUTH_CLIENT_ID,

View File

@@ -2,7 +2,7 @@ import { TFunction } from "i18next";
import Image from "next/image";
import { redirect } from "next/navigation";
import { TIntegrationType } from "@formbricks/types/integration";
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/webhook";
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/webhook";
import ActivePiecesLogo from "@/images/activepieces.webp";
import AirtableLogo from "@/images/airtableLogo.svg";
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
@@ -79,7 +79,7 @@ const Page = async (props) => {
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/workspace/integrations/webhooks`,
connectHref: `/environments/${params.environmentId}/project/integrations/webhooks`,
connectText: t("environments.integrations.manage_webhooks"),
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
@@ -93,7 +93,7 @@ const Page = async (props) => {
disabled: false,
},
{
connectHref: `/environments/${params.environmentId}/workspace/integrations/google-sheets`,
connectHref: `/environments/${params.environmentId}/project/integrations/google-sheets`,
connectText: `${isGoogleSheetsIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
@@ -107,7 +107,7 @@ const Page = async (props) => {
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/workspace/integrations/airtable`,
connectHref: `/environments/${params.environmentId}/project/integrations/airtable`,
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
@@ -121,7 +121,7 @@ const Page = async (props) => {
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/workspace/integrations/slack`,
connectHref: `/environments/${params.environmentId}/project/integrations/slack`,
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
@@ -163,7 +163,7 @@ const Page = async (props) => {
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/workspace/integrations/notion`,
connectHref: `/environments/${params.environmentId}/project/integrations/notion`,
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/notion",
@@ -196,7 +196,7 @@ const Page = async (props) => {
docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
docsText: t("common.docs"),
docsNewTab: true,
connectHref: `/environments/${params.environmentId}/workspace/app-connection`,
connectHref: `/environments/${params.environmentId}/project/app-connection`,
connectText: t("common.connect"),
connectNewTab: false,
label: "Javascript SDK",
@@ -209,7 +209,7 @@ const Page = async (props) => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.workspace_configuration")}>
<PageHeader pageTitle={t("common.project_configuration")}>
<ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" />
</PageHeader>
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">

View File

@@ -13,12 +13,12 @@ import {
TIntegrationSlackConfigData,
TIntegrationSlackInput,
} from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import SlackLogo from "@/images/slacklogo.png";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
@@ -55,7 +55,7 @@ export const AddChannelMappingModal = ({
}: AddChannelMappingModalProps) => {
const { handleSubmit } = useForm();
const { t } = useTranslation();
const [selectedElements, setSelectedElements] = useState<string[]>([]);
const [selectedQuestions, setSelectedQuestions] = useState<string[]>([]);
const [isLinkingChannel, setIsLinkingChannel] = useState(false);
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [selectedChannel, setSelectedChannel] = useState<TIntegrationItem | null>(null);
@@ -73,19 +73,14 @@ export const AddChannelMappingModal = ({
},
};
const surveyElements = useMemo(
() => (selectedSurvey ? getElementsFromBlocks(selectedSurvey.blocks) : []),
[selectedSurvey]
);
useEffect(() => {
if (selectedSurvey) {
const elementIds = surveyElements.map((element) => element.id);
const questionIds = selectedSurvey.questions.map((question) => question.id);
if (!selectedIntegration) {
setSelectedElements(elementIds);
setSelectedQuestions(questionIds);
}
}
}, [surveyElements, selectedIntegration, selectedSurvey]);
}, [selectedIntegration, selectedSurvey]);
useEffect(() => {
if (selectedIntegration) {
@@ -98,7 +93,7 @@ export const AddChannelMappingModal = ({
return survey.id === selectedIntegration.surveyId;
})!
);
setSelectedElements(selectedIntegration.elementIds);
setSelectedQuestions(selectedIntegration.questionIds);
setIncludeVariables(!!selectedIntegration.includeVariables);
setIncludeHiddenFields(!!selectedIntegration.includeHiddenFields);
setIncludeMetadata(!!selectedIntegration.includeMetadata);
@@ -117,7 +112,7 @@ export const AddChannelMappingModal = ({
throw new Error(t("environments.integrations.please_select_a_survey_error"));
}
if (selectedElements.length === 0) {
if (selectedQuestions.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
}
setIsLinkingChannel(true);
@@ -126,9 +121,9 @@ export const AddChannelMappingModal = ({
channelName: selectedChannel.name,
surveyId: selectedSurvey.id,
surveyName: selectedSurvey.name,
elementIds: selectedElements,
elements:
selectedElements.length === surveyElements.length
questionIds: selectedQuestions,
questions:
selectedQuestions.length === selectedSurvey?.questions.length
? t("common.all_questions")
: t("common.selected_questions"),
createdAt: new Date(),
@@ -159,11 +154,11 @@ export const AddChannelMappingModal = ({
}
};
const handleCheckboxChange = (elementId: string) => {
setSelectedElements((prevValues) =>
prevValues.includes(elementId)
? prevValues.filter((value) => value !== elementId)
: [...prevValues, elementId]
const handleCheckboxChange = (questionId: TSurveyQuestionId) => {
setSelectedQuestions((prevValues) =>
prevValues.includes(questionId)
? prevValues.filter((value) => value !== questionId)
: [...prevValues, questionId]
);
};
@@ -274,25 +269,21 @@ export const AddChannelMappingModal = ({
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{surveyElements.map((element) => (
<div key={element.id} className="my-1 flex items-center space-x-2">
<label htmlFor={element.id} className="flex cursor-pointer items-center">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions?.map((question) => (
<div key={question.id} className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={element.id}
value={element.id}
id={question.id}
value={question.id}
className="bg-white"
checked={selectedElements.includes(element.id)}
checked={selectedQuestions.includes(question.id)}
onCheckedChange={() => {
handleCheckboxChange(element.id);
handleCheckboxChange(question.id);
}}
/>
<span className="ml-2">
{getTextContent(
recallToHeadline(element.headline, selectedSurvey, false, "default")[
"default"
]
)}
{getTextContent(getLocalizedValue(question.headline, "default"))}
</span>
</label>
</div>

View File

@@ -4,16 +4,18 @@ import { Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/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 { EmptyState } from "@/modules/ui/components/empty-state";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
interface ManageIntegrationProps {
environment: TEnvironment;
slackIntegration: TIntegrationSlack;
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
@@ -27,6 +29,7 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = ({
environment,
slackIntegration,
setOpenAddIntegrationModal,
setIsConnected,
@@ -103,7 +106,12 @@ export const ManageIntegration = ({
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.slack.connect_your_first_slack_channel")} />
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.slack.connect_your_first_slack_channel")}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
@@ -126,7 +134,7 @@ export const ManageIntegration = ({
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.channelName}</div>
<div className="col-span-2 text-center">{data.elements}</div>
<div className="col-span-2 text-center">{data.questions}</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
</button>
);

View File

@@ -6,10 +6,10 @@ import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getSlackChannelsAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/actions";
import { AddChannelMappingModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/AddChannelMappingModal";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/lib/slack";
import { getSlackChannelsAction } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/actions";
import { AddChannelMappingModal } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/AddChannelMappingModal";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/lib/slack";
import slackLogo from "@/images/slacklogo.png";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
@@ -78,6 +78,7 @@ export const SlackWrapper = ({
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
slackIntegration={slackIntegration}
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}

View File

@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/SlackWrapper";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
@@ -32,7 +32,7 @@ const Page = async (props) => {
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
<PageHeader pageTitle={t("environments.integrations.slack.slack_integration")} />
<div className="h-[75vh] w-full">
<SlackWrapper

View File

@@ -21,7 +21,7 @@ const AccountSettingsLayout = async (props) => {
}
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new Error(t("common.project_not_found"));
}
if (!session) {

View File

@@ -16,7 +16,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
<p className="text-sm">
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
<a
href={`/environments/${environmentId}/workspace/integrations`}
href={`/environments/${environmentId}/project/integrations`}
className="ml-1 cursor-pointer text-sm underline">
{t("environments.settings.notifications.use_the_integration")}
</a>

View File

@@ -215,7 +215,7 @@ export const EditProfileDetailsForm = ({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-slate-50 text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => (

View File

@@ -36,7 +36,7 @@ export const OrganizationSettingsNavbar = ({
},
{
id: "teams",
label: t("common.members_and_teams"),
label: t("common.teams"),
href: `/environments/${environmentId}/settings/teams`,
current: pathname?.includes("/teams"),
},
@@ -47,13 +47,6 @@ export const OrganizationSettingsNavbar = ({
current: pathname?.includes("/api-keys"),
hidden: !isOwner,
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${environmentId}/settings/domain`,
current: pathname?.includes("/domain"),
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),

View File

@@ -1,102 +0,0 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TSurveyStatus } from "@formbricks/types/surveys/types";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
interface SurveyWithSlug {
id: string;
name: string;
slug: string | null;
status: TSurveyStatus;
environment: {
id: string;
type: "production" | "development";
project: {
id: string;
name: string;
};
};
createdAt: Date;
}
interface PrettyUrlsTableProps {
surveys: SurveyWithSlug[];
}
export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
const { t } = useTranslation();
const getEnvironmentBadgeColor = (type: string) => {
return type === "production" ? "bg-green-100 text-green-800" : "bg-blue-100 text-blue-800";
};
const tableHeaders = [
{
label: t("environments.settings.domain.survey_name"),
key: "name",
},
{
label: t("environments.settings.domain.workspace"),
key: "project",
},
{
label: t("environments.settings.domain.pretty_url"),
key: "slug",
},
{
label: t("common.environment"),
key: "environment",
},
];
return (
<div className="overflow-hidden rounded-lg">
<Table>
<TableHeader>
<TableRow className="bg-slate-100">
{tableHeaders.map((header) => (
<TableHead key={header.key} className="font-medium text-slate-500">
{header.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="[&_tr:last-child]:border-b">
{surveys.length === 0 && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={4} className="text-center text-slate-500">
{t("environments.settings.domain.no_pretty_urls")}
</TableCell>
</TableRow>
)}
{surveys.map((survey) => (
<TableRow key={survey.id} className="border-slate-200 hover:bg-transparent">
<TableCell className="font-medium">
<Link
href={`/environments/${survey.environment.id}/surveys/${survey.id}/summary`}
className="text-slate-900 hover:text-slate-700 hover:underline">
{survey.name}
</Link>
</TableCell>
<TableCell>{survey.environment.project.name}</TableCell>
<TableCell>
<IdBadge id={survey.slug ?? ""} />
</TableCell>
<TableCell>
<span
className={`rounded px-2 py-1 text-xs font-medium ${getEnvironmentBadgeColor(survey.environment.type)}`}>
{survey.environment.type === "production"
? t("common.production")
: t("common.development")}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};

View File

@@ -1,53 +0,0 @@
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getSurveysWithSlugsByOrganizationId } from "@/modules/survey/lib/slug";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsCard } from "../../components/SettingsCard";
import { OrganizationSettingsNavbar } from "../components/OrganizationSettingsNavbar";
import { PrettyUrlsTable } from "./components/pretty-urls-table";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const result = await getSurveysWithSlugsByOrganizationId(organization.id);
if (!result.ok) {
throw new Error(t("common.something_went_wrong"));
}
const surveys = result.data;
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
<OrganizationSettingsNavbar
environmentId={params.environmentId}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={currentUserMembership?.role}
activeId="domain"
/>
</PageHeader>
<SettingsCard
title={t("environments.settings.domain.title")}
description={t("environments.settings.domain.description")}>
<PrettyUrlsTable surveys={surveys} />
</SettingsCard>
</PageContentWrapper>
);
};
export default Page;

View File

@@ -39,7 +39,7 @@ const Page = async (props) => {
onRequest: false,
},
{
title: t("environments.workspace.languages.multi_language_surveys"),
title: t("environments.project.languages.multi_language_surveys"),
comingSoon: false,
onRequest: false,
},
@@ -118,7 +118,7 @@ const Page = async (props) => {
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
<svg
viewBox="0 0 1024 1024"
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
aria-hidden="true">
<circle
cx={512}

View File

@@ -21,7 +21,7 @@ const Layout = async (props) => {
}
if (!project) {
throw new Error(t("common.workspace_not_found"));
throw new Error(t("common.project_not_found"));
}
if (!session) {

Some files were not shown because too many files have changed in this diff Show More