Compare commits

..

3 Commits

Author SHA1 Message Date
Johannes
85285d1fe1 make work with blocks 2025-12-16 23:03:38 +01:00
Johannes
1ae98226ad Merge branch 'main' of https://github.com/formbricks/formbricks into feature/response-generation 2025-12-16 11:21:35 +01:00
Johannes
d25dc8f85d add generate response functionality 2025-11-13 09:24:44 +01:00
523 changed files with 11328 additions and 35197 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

@@ -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

@@ -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

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

@@ -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

@@ -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

@@ -15,7 +15,7 @@ const OnboardingLayout = async (props) => {
const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session?.user) {
if (!session || !session.user) {
return redirect(`/auth/login`);
}

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);
}
};
@@ -107,6 +105,7 @@ export const ProjectSettings = ({
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
teamIds: [],
},
resolver: zodResolver(ZProjectUpdateInput),
});
const projectName = form.watch("name");
@@ -130,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>
@@ -154,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>
@@ -185,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
@@ -193,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>
@@ -226,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 +0,0 @@
export { AttributesPage as default } from "@/modules/ee/contacts/attributes/page";

View File

@@ -57,7 +57,7 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached");
throw new OperationNotAllowedError("Organization project limit reached");
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {

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

@@ -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

@@ -2,7 +2,7 @@
import * as Sentry from "@sentry/nextjs";
import {
Building2Icon,
BuildingIcon,
ChevronDownIcon,
ChevronRightIcon,
Loader2,
@@ -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"),
@@ -172,7 +166,7 @@ export const OrganizationBreadcrumb = ({
id="organizationDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<Building2Icon className="h-3 w-3" strokeWidth={1.5} />
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{organizationName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isOrganizationDropdownOpen ? (
@@ -186,7 +180,7 @@ export const OrganizationBreadcrumb = ({
{showOrganizationDropdown && (
<>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" />
<BuildingIcon className="mr-2 inline h-4 w-4" />
{t("common.choose_organization")}
</div>
{isLoadingOrganizations && (

View File

@@ -1,7 +1,7 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
@@ -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,42 +101,42 @@ 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`,
},
];
if (!currentProject) {
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
const errorMessage = `Project not found for project id: ${currentProjectId}`;
logger.error(errorMessage);
Sentry.captureException(new Error(errorMessage));
return;
@@ -145,7 +145,7 @@ export const ProjectBreadcrumb = ({
const handleProjectChange = (projectId: string) => {
if (projectId === currentProjectId) return;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
router.push(`/projects/${projectId}/`);
});
};
@@ -159,7 +159,7 @@ export const ProjectBreadcrumb = ({
const handleProjectSettingsNavigation = (settingId: string) => {
startTransition(() => {
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
router.push(`/environments/${currentEnvironmentId}/project/${settingId}`);
});
};
@@ -198,21 +198,21 @@ export const ProjectBreadcrumb = ({
id="projectDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{projectName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
) : (
{isProjectDropdownOpen ? (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
) : (
isEnvironmentBreadcrumbVisible && <ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="mt-2">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")}
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{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

@@ -17,9 +17,9 @@ import {
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";
@@ -58,12 +58,12 @@ const ElementCheckbox = ({
onChange: (value: string[]) => void;
};
}) => {
const addElement = () => {
field.onChange([...(field.value || []), element.id]);
};
const removeElement = () => {
field.onChange(field.value?.filter((value) => value !== element.id) || []);
const handleCheckedChange = (checked: boolean) => {
if (checked) {
field.onChange([...(field.value || []), element.id]);
} else {
field.onChange(field.value?.filter((value) => value !== element.id) || []);
}
};
return (
@@ -75,7 +75,7 @@ const ElementCheckbox = ({
value={element.id}
className="bg-white"
checked={field.value?.includes(element.id)}
onCheckedChange={(checked) => (checked ? addElement() : removeElement())}
onCheckedChange={handleCheckedChange}
/>
<span className="ml-2">
{getTextContent(recallToHeadline(element.headline, selectedSurvey, false, "default")["default"])}

View File

@@ -5,8 +5,8 @@ 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";

View File

@@ -8,8 +8,8 @@ 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";

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

View File

@@ -12,13 +12,13 @@ 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 { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -266,7 +266,7 @@ 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) => (
<div key={question.id} className="my-1 flex items-center space-x-2">

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";

View File

@@ -9,7 +9,7 @@ import {
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";

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

@@ -1,8 +1,8 @@
"use client";
import { createId } from "@paralleldrive/cuid2";
import { PlusIcon, TrashIcon } from "lucide-react";
import Image from "next/image";
import { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -15,15 +15,17 @@ import {
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 { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
import {
MappingRow,
TMapping,
createEmptyMapping,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
ERRORS,
TYPE_MAPPING,
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
import NotionLogo from "@/images/notion.png";
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 { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -37,6 +39,59 @@ 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[];
@@ -60,7 +115,21 @@ export const AddIntegrationModal = ({
const { handleSubmit } = useForm();
const [selectedDatabase, setSelectedDatabase] = useState<TIntegrationNotionDatabase | null>();
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [mapping, setMapping] = useState<TMapping[]>([createEmptyMapping()]);
const [mapping, setMapping] = useState<
{
column: { id: string; name: string; type: string };
element: { id: string; name: string; type: string };
error?: {
type: string;
msg: React.ReactNode | string;
} | null;
}[]
>([
{
column: { id: "", name: "", type: "" },
element: { id: "", name: "", type: "" },
},
]);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [isLinkingDatabase, setIsLinkingDatabase] = useState(false);
const integrationData = {
@@ -165,7 +234,7 @@ export const AddIntegrationModal = ({
return survey.id === selectedIntegration.surveyId;
})!
);
setMapping(selectedIntegration.mapping.map((m) => ({ ...m, id: createId() })));
setMapping(selectedIntegration.mapping);
return;
}
resetForm();
@@ -251,11 +320,154 @@ export const AddIntegrationModal = ({
setSelectedDatabase(null);
setSelectedSurvey(null);
};
const getFilteredElementItems = (selectedIdx: number) => {
const selectedElementIds = new Set(
mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.element.id)
const getFilteredElementItems = (selectedIdx) => {
const selectedElementIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.element.id);
return elementItems.filter((el) => !selectedElementIds.includes(el.id));
};
const createCopy = (item) => structuredClone(item);
const MappingRow = ({ idx }: { idx: number }) => {
const filteredElementItems = getFilteredElementItems(idx);
const addRow = () => {
setMapping((prev) => [
...prev,
{
column: { id: "", name: "", type: "" },
element: { id: "", name: "", type: "" },
},
]);
};
const deleteRow = () => {
setMapping((prev) => {
return prev.filter((_, i) => i !== idx);
});
};
const getFilteredDbItems = () => {
const colMapping = mapping.map((m) => m.column.id);
return dbItems.filter((item) => !colMapping.includes(item.id));
};
return (
<div className="w-full">
<MappingErrorMessage
key={idx}
error={mapping[idx]?.error}
col={mapping[idx].column}
elem={mapping[idx].element}
t={t}
/>
<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}
setSelectedItem={(item) => {
setMapping((prev) => {
const copy = createCopy(prev);
const col = copy[idx].column;
if (col.id) {
if (UNSUPPORTED_TYPES_BY_NOTION.includes(col.type)) {
copy[idx] = {
...copy[idx],
error: {
type: ERRORS.UNSUPPORTED_TYPE,
},
element: item,
};
return copy;
}
const isValidColType = TYPE_MAPPING[item.type].includes(col.type);
if (!isValidColType) {
copy[idx] = {
...copy[idx],
error: {
type: ERRORS.MAPPING,
},
element: item,
};
return copy;
}
}
copy[idx] = {
...copy[idx],
element: item,
error: null,
};
return copy;
});
}}
disabled={elementItems.length === 0}
/>
</div>
<div className="h-px w-4 border-t border-t-slate-300" />
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
items={getFilteredDbItems()}
selectedItem={mapping?.[idx]?.column}
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);
if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) {
copy[idx] = {
...copy[idx],
error: {
type: ERRORS.UNSUPPORTED_TYPE,
},
column: item,
};
return copy;
}
if (!isValidElemType) {
copy[idx] = {
...copy[idx],
error: {
type: ERRORS.MAPPING,
},
column: item,
};
return copy;
}
}
copy[idx] = {
...copy[idx],
column: item,
error: null,
};
return copy;
});
}}
disabled={dbItems.length === 0}
/>
</div>
</div>
<div className="flex space-x-2">
{mapping.length > 1 && (
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
<TrashIcon />
</Button>
)}
<Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
<PlusIcon />
</Button>
</div>
</div>
</div>
);
return elementItems.filter((el) => !selectedElementIds.has(el.id));
};
return (
@@ -327,17 +539,8 @@ export const AddIntegrationModal = ({
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
</Label>
<div className="mt-1 space-y-2 overflow-y-auto">
{mapping.map((m, idx) => (
<MappingRow
key={m.id}
idx={idx}
mapping={mapping}
setMapping={setMapping}
filteredElementItems={getFilteredElementItems(idx)}
dbItems={dbItems}
elementItems={elementItems}
t={t}
/>
{mapping.map((_, idx) => (
<MappingRow idx={idx} key={idx} />
))}
</div>
</div>

View File

@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
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";

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";

View File

@@ -0,0 +1,43 @@
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
export const TYPE_MAPPING = {
[TSurveyQuestionTypeEnum.CTA]: ["checkbox"],
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ["multi_select"],
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: ["select", "status"],
[TSurveyQuestionTypeEnum.OpenText]: [
"created_by",
"created_time",
"email",
"last_edited_by",
"last_edited_time",
"number",
"phone_number",
"rich_text",
"title",
"url",
],
[TSurveyQuestionTypeEnum.NPS]: ["number"],
[TSurveyQuestionTypeEnum.Consent]: ["checkbox"],
[TSurveyQuestionTypeEnum.Rating]: ["number"],
[TSurveyQuestionTypeEnum.PictureSelection]: ["url"],
[TSurveyQuestionTypeEnum.FileUpload]: ["url"],
[TSurveyQuestionTypeEnum.Date]: ["date"],
[TSurveyQuestionTypeEnum.Address]: ["rich_text"],
[TSurveyQuestionTypeEnum.Matrix]: ["rich_text"],
[TSurveyQuestionTypeEnum.Cal]: ["checkbox"],
[TSurveyQuestionTypeEnum.ContactInfo]: ["rich_text"],
[TSurveyQuestionTypeEnum.Ranking]: ["rich_text"],
};
export const UNSUPPORTED_TYPES_BY_NOTION = [
"rollup",
"created_by",
"created_time",
"last_edited_by",
"last_edited_time",
];
export const ERRORS = {
MAPPING: "Mapping Error",
UNSUPPORTED_TYPE: "Unsupported type by Notion",
};

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

@@ -15,7 +15,7 @@ import {
} from "@formbricks/types/integration/slack";
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 { 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";

View File

@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
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";

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";

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

@@ -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,72 +0,0 @@
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import { FaviconCustomizationSettings } from "@/modules/ee/whitelabel/favicon-customization/components/favicon-customization-settings";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getSurveysWithSlugsByOrganizationId } from "@/modules/survey/lib/slug";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
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, isOwner, isManager } = await getEnvironmentAuth(
params.environmentId
);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
const isOwnerOrManager = isManager || isOwner;
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
<OrganizationSettingsNavbar
environmentId={params.environmentId}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={currentUserMembership?.role}
activeId="domain"
/>
</PageHeader>
{!IS_STORAGE_CONFIGURED && (
<div className="max-w-4xl">
<Alert variant="warning">
<AlertDescription>{t("common.storage_not_configured")}</AlertDescription>
</Alert>
</div>
)}
<FaviconCustomizationSettings
organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission}
environmentId={params.environmentId}
isReadOnly={!isOwnerOrManager}
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
<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) {

View File

@@ -27,7 +27,7 @@ export const EmptyAppSurveys = ({ environment }: TEmptyAppSurveysProps) => {
{t("environments.surveys.summary.connect_your_website_or_app_with_formbricks_to_get_started")}
</p>
<Link className="mt-2" href={`/environments/${environment.id}/workspace/app-connection`}>
<Link className="mt-2" href={`/environments/${environment.id}/project/app-connection`}>
<Button size="sm" className="flex w-[120px] justify-center">
{t("common.connect")}
</Button>

View File

@@ -3,8 +3,13 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { TResponseInput } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { createResponseWithQuotaEvaluation } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
@@ -17,6 +22,29 @@ import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customizat
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
const loremIpsumSentences = [
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
"Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.",
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum.",
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit.",
"Nisi ut aliquip ex ea commodo consequat.",
"Pellentesque habitant morbi tristique senectus et netus et malesuada fames.",
"Vestibulum tortor quam, feugiat vitae, ultricies eget, tempor sit amet, ante.",
"Donec eu libero sit amet quam egestas semper.",
"Aenean ultricies mi vitae est. Mauris placerat eleifend leo.",
];
function generateLoremIpsum(): string {
const sentenceCount = Math.floor(Math.random() * 3) + 1;
const selectedSentences: string[] = [];
for (let i = 0; i < sentenceCount; i++) {
const randomIndex = Math.floor(Math.random() * loremIpsumSentences.length);
selectedSentences.push(loremIpsumSentences[randomIndex]);
}
return selectedSentences.join(" ");
}
const ZSendEmbedSurveyPreviewEmailAction = z.object({
surveyId: ZId,
});
@@ -260,3 +288,169 @@ export const updateSingleUseLinksAction = authenticatedActionClient
return updatedSurvey;
});
const ZGenerateTestResponsesAction = z.object({
surveyId: ZId,
environmentId: ZId,
});
export const generateTestResponsesAction = authenticatedActionClient
.schema(ZGenerateTestResponsesAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
if (survey.environmentId !== parsedInput.environmentId) {
throw new OperationNotAllowedError("Survey does not belong to the specified environment");
}
const supportedElementTypes = [
TSurveyElementTypeEnum.OpenText,
TSurveyElementTypeEnum.NPS,
TSurveyElementTypeEnum.Rating,
TSurveyElementTypeEnum.MultipleChoiceSingle,
TSurveyElementTypeEnum.MultipleChoiceMulti,
TSurveyElementTypeEnum.PictureSelection,
TSurveyElementTypeEnum.Ranking,
TSurveyElementTypeEnum.Matrix,
];
// Extract elements from blocks
const elements = getElementsFromBlocks(survey.blocks);
const supportedElements = elements.filter((element) => supportedElementTypes.includes(element.type));
if (supportedElements.length === 0) {
throw new OperationNotAllowedError(
"Survey does not contain any supported question types (OpenText, NPS, Rating, Multiple Choice, Picture Selection, Ranking, or Matrix)"
);
}
const responsesToCreate = 5;
const createdResponses: string[] = [];
for (let i = 0; i < responsesToCreate; i++) {
const responseData: Record<string, string | number | string[] | Record<string, string>> = {};
for (const element of supportedElements) {
if (element.type === TSurveyElementTypeEnum.OpenText) {
responseData[element.id] = generateLoremIpsum();
} else if (element.type === TSurveyElementTypeEnum.NPS) {
responseData[element.id] = Math.floor(Math.random() * 11);
} else if (element.type === TSurveyElementTypeEnum.Rating) {
const range = "range" in element && typeof element.range === "number" ? element.range : 5;
responseData[element.id] = Math.floor(Math.random() * range) + 1;
} else if (element.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
// Single choice: pick one random option, store the label
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const randomIndex = Math.floor(Math.random() * element.choices.length);
const selectedChoice = element.choices[randomIndex];
// For "other" option, generate custom text; otherwise use the choice label
responseData[element.id] =
selectedChoice.id === "other"
? generateLoremIpsum()
: getLocalizedValue(selectedChoice.label, "default");
}
} else if (element.type === TSurveyElementTypeEnum.MultipleChoiceMulti) {
// Multi choice: pick 1-3 random options, store the labels
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const numSelections = Math.min(Math.floor(Math.random() * 3) + 1, element.choices.length);
const shuffled = [...element.choices].sort(() => Math.random() - 0.5);
responseData[element.id] = shuffled.slice(0, numSelections).map((choice) => {
// For "other" option, generate custom text; otherwise use the choice label
return choice.id === "other"
? generateLoremIpsum()
: getLocalizedValue(choice.label, "default");
});
}
} else if (element.type === TSurveyElementTypeEnum.PictureSelection) {
// Picture selection: single or multi based on allowMulti
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const allowMulti = "allowMulti" in element ? element.allowMulti : false;
if (allowMulti) {
const numSelections = Math.min(Math.floor(Math.random() * 3) + 1, element.choices.length);
const shuffled = [...element.choices].sort(() => Math.random() - 0.5);
responseData[element.id] = shuffled.slice(0, numSelections).map((choice) => choice.id);
} else {
const randomIndex = Math.floor(Math.random() * element.choices.length);
responseData[element.id] = element.choices[randomIndex].id;
}
}
} else if (element.type === TSurveyElementTypeEnum.Ranking) {
// Ranking: all options in random order, store the labels
if ("choices" in element && Array.isArray(element.choices) && element.choices.length > 0) {
const shuffled = [...element.choices].sort(() => Math.random() - 0.5);
responseData[element.id] = shuffled.map((choice) => {
// For "other" option, generate custom text; otherwise use the choice label
return choice.id === "other"
? generateLoremIpsum()
: getLocalizedValue(choice.label, "default");
});
}
} else if (element.type === TSurveyElementTypeEnum.Matrix) {
// Matrix: for each row, pick a random column
if (
"rows" in element &&
"columns" in element &&
Array.isArray(element.rows) &&
Array.isArray(element.columns) &&
element.rows.length > 0 &&
element.columns.length > 0
) {
const matrixData: Record<string, string> = {};
for (const row of element.rows) {
const randomColumnIndex = Math.floor(Math.random() * element.columns.length);
matrixData[row.id] = element.columns[randomColumnIndex].id;
}
responseData[element.id] = matrixData;
}
}
}
const responseInput: TResponseInput = {
environmentId: parsedInput.environmentId,
surveyId: parsedInput.surveyId,
finished: true,
data: responseData,
meta: {
source: "test",
userAgent: {
browser: "Test Generator",
device: "desktop",
os: "Test OS",
},
},
};
try {
const response = await createResponseWithQuotaEvaluation(responseInput);
createdResponses.push(response.id);
} catch (error) {
throw new UnknownError(
`Failed to create response: ${error instanceof Error ? error.message : "Unknown error"}`
);
}
}
return {
success: true,
createdCount: createdResponses.length,
};
});

View File

@@ -1,6 +1,6 @@
"use client";
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
import { BellRing, Eye, ListRestart, Sparkles, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
@@ -20,7 +20,7 @@ import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/action
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { IconBar } from "@/modules/ui/components/iconbar";
import { resetSurveyAction } from "../actions";
import { generateTestResponsesAction, resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
@@ -63,6 +63,7 @@ export const SurveyAnalysisCTA = ({
});
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [isGeneratingResponses, setIsGeneratingResponses] = useState(false);
const { organizationId, project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
@@ -147,6 +148,23 @@ export const SurveyAnalysisCTA = ({
setIsResetModalOpen(false);
};
const handleGenerateTestResponses = async () => {
if (isGeneratingResponses) return;
setIsGeneratingResponses(true);
const result = await generateTestResponsesAction({
surveyId: survey.id,
environmentId: environment.id,
});
if (result?.data?.success) {
toast.success(`Successfully generated ${result.data.createdCount} test responses`);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
setIsGeneratingResponses(false);
};
const iconActions = [
{
icon: BellRing,
@@ -163,6 +181,12 @@ export const SurveyAnalysisCTA = ({
},
isVisible: survey.type === "link",
},
{
icon: Sparkles,
tooltip: isGeneratingResponses ? "Generating responses..." : "Generate test responses",
onClick: handleGenerateTestResponses,
isVisible: !isReadOnly,
},
{
icon: ListRestart,
tooltip: t("environments.surveys.summary.reset_survey"),

View File

@@ -3,7 +3,7 @@
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import {
Code2Icon,
Link2Icon,
LinkIcon,
MailIcon,
QrCodeIcon,
Settings,
@@ -22,7 +22,6 @@ import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/survey
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
import { PrettyUrlTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/pretty-url-tab";
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
@@ -81,13 +80,13 @@ export const ShareSurveyModal = ({
componentType: React.ComponentType<unknown>;
componentProps: unknown;
disabled?: boolean;
}[] = useMemo(() => {
const tabs = [
}[] = useMemo(
() => [
{
id: ShareViaType.ANON_LINKS,
type: LinkTabsType.SHARE_VIA,
label: t("environments.surveys.share.anonymous_links.nav_title"),
icon: Link2Icon,
icon: LinkIcon,
title: t("environments.surveys.share.anonymous_links.nav_title"),
description: t("environments.surveys.share.anonymous_links.description"),
componentType: AnonymousLinksTab,
@@ -181,33 +180,22 @@ export const ShareSurveyModal = ({
componentType: LinkSettingsTab,
componentProps: { isReadOnly, locale: user.locale, isStorageConfigured },
},
{
id: ShareSettingsType.PRETTY_URL,
type: LinkTabsType.SHARE_SETTING,
label: t("environments.surveys.share.pretty_url.title"),
icon: Link2Icon,
title: t("environments.surveys.share.pretty_url.title"),
description: t("environments.surveys.share.pretty_url.description"),
componentType: PrettyUrlTab,
componentProps: { publicDomain, isReadOnly },
},
];
return isFormbricksCloud ? tabs.filter((tab) => tab.id !== ShareSettingsType.PRETTY_URL) : tabs;
}, [
t,
survey,
publicDomain,
user.locale,
surveyUrl,
isReadOnly,
environmentId,
segments,
isContactsEnabled,
isFormbricksCloud,
email,
isStorageConfigured,
]);
],
[
t,
survey,
publicDomain,
user.locale,
surveyUrl,
isReadOnly,
environmentId,
segments,
isContactsEnabled,
isFormbricksCloud,
email,
isStorageConfigured,
]
);
const getDefaultActiveId = useCallback(() => {
if (survey.type !== "link") {

View File

@@ -163,7 +163,7 @@ export const AppTab = () => {
</AlertDescription>
{!environment.appSetupCompleted && (
<AlertButton asChild>
<Link href={`/environments/${environment.id}/workspace/app-connection`}>
<Link href={`/environments/${environment.id}/project/app-connection`}>
{t("common.connect_formbricks")}
</Link>
</AlertButton>

View File

@@ -1,31 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { Input } from "@/modules/ui/components/input";
interface PrettyUrlInputProps {
value: string;
onChange: (value: string) => void;
publicDomain: string;
disabled?: boolean;
}
export const PrettyUrlInput = ({ value, onChange, publicDomain, disabled = false }: PrettyUrlInputProps) => {
const { t } = useTranslation();
return (
<div className="flex items-center overflow-hidden rounded-md border border-slate-300 bg-white">
<span className="flex-shrink-0 border-r border-slate-300 bg-slate-50 px-3 py-2 text-sm text-slate-600">
{publicDomain}/p/
</span>
<Input
type="text"
value={value}
onChange={(e) => onChange(e.target.value.toLowerCase().replaceAll(/[^a-z0-9-]/g, ""))}
placeholder={t("environments.surveys.share.pretty_url.slug_placeholder")}
disabled={disabled}
className="border-0 bg-white focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
);
};

View File

@@ -1,197 +0,0 @@
"use client";
import { Copy, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { removeSurveySlugAction, updateSurveySlugAction } from "@/modules/survey/slug/actions";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { PrettyUrlInput } from "./pretty-url-input";
interface PrettyUrlTabProps {
publicDomain: string;
isReadOnly?: boolean;
}
interface PrettyUrlFormData {
slug: string;
}
export const PrettyUrlTab = ({ publicDomain, isReadOnly = false }: PrettyUrlTabProps) => {
const { t } = useTranslation();
const router = useRouter();
const { survey } = useSurvey();
const [isEditing, setIsEditing] = useState(!survey.slug);
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// Initialize form with current values - memoize to prevent re-initialization
const initialFormData = useMemo(() => {
return {
slug: survey.slug || "",
};
}, [survey.slug]);
const form = useForm<PrettyUrlFormData>({
defaultValues: initialFormData,
});
const { handleSubmit, reset } = form;
// Sync isEditing state and form with survey.slug changes
useEffect(() => {
setIsEditing(!survey.slug);
reset({ slug: survey.slug || "" });
}, [survey.slug, reset]);
const onSubmit = async (data: PrettyUrlFormData) => {
if (!data.slug.trim()) {
toast.error(t("environments.surveys.share.pretty_url.slug_required"));
return;
}
setIsSubmitting(true);
try {
const result = await updateSurveySlugAction({
surveyId: survey.id,
slug: data.slug,
});
const errorMessage = getFormattedErrorMessage(result);
if (errorMessage) {
toast.error(errorMessage);
} else {
toast.success(t("environments.surveys.share.pretty_url.save_success"));
router.refresh();
setIsEditing(false);
}
} catch (err) {
const message = err instanceof Error ? err.message : t("common.something_went_wrong_please_try_again");
toast.error(message);
} finally {
setIsSubmitting(false);
}
};
const handleEdit = () => {
setIsEditing(true);
};
const handleCancel = () => {
reset({ slug: survey.slug || "" });
setIsEditing(false);
};
const handleRemove = async () => {
setIsSubmitting(true);
try {
const result = await removeSurveySlugAction({ surveyId: survey.id });
const errorMessage = getFormattedErrorMessage(result);
if (errorMessage) {
toast.error(errorMessage);
} else {
setShowRemoveDialog(false);
reset({ slug: "" });
router.refresh();
setIsEditing(true);
toast.success(t("environments.surveys.share.pretty_url.remove_success"));
}
} catch (err) {
const message = err instanceof Error ? err.message : t("common.something_went_wrong_please_try_again");
toast.error(message);
} finally {
setIsSubmitting(false);
}
};
const handleCopyUrl = () => {
if (!survey.slug) return;
const prettyUrl = `${publicDomain}/p/${survey.slug}`;
navigator.clipboard.writeText(prettyUrl);
toast.success(t("common.copied_to_clipboard"));
};
return (
<div className="px-1">
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>{t("environments.surveys.share.pretty_url.slug_label")}</FormLabel>
<FormControl>
<PrettyUrlInput
value={field.value}
onChange={field.onChange}
publicDomain={publicDomain}
disabled={isReadOnly || !isEditing}
/>
</FormControl>
<FormDescription>{t("environments.surveys.share.pretty_url.slug_help")}</FormDescription>
</FormItem>
)}
/>
<div className="flex gap-2">
{isEditing ? (
<>
<Button type="submit" disabled={isReadOnly || isSubmitting}>
{t("common.save")}
</Button>
{survey.slug && (
<Button type="button" variant="secondary" onClick={handleCancel} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
)}
</>
) : (
<Button type="button" variant="secondary" onClick={handleEdit} disabled={isReadOnly}>
{t("common.edit")}
</Button>
)}
{survey.slug && !isEditing && (
<>
<Button type="button" variant="default" onClick={handleCopyUrl} disabled={isReadOnly}>
<Copy className="mr-2 h-4 w-4" />
{t("common.copy")} URL
</Button>
<Button
type="button"
variant="destructive"
onClick={() => setShowRemoveDialog(true)}
disabled={isReadOnly}>
<Trash2 className="mr-2 h-4 w-4" />
{t("common.remove")}
</Button>
</>
)}
</div>
</form>
</FormProvider>
<DeleteDialog
open={showRemoveDialog}
setOpen={setShowRemoveDialog}
deleteWhat={t("environments.surveys.share.pretty_url.title")}
onDelete={handleRemove}
text={t("environments.surveys.share.pretty_url.remove_description")}></DeleteDialog>
</div>
);
};

View File

@@ -66,7 +66,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.use_personal_links")}
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
</button>
<Link
href={`/environments/${environmentId}/settings/notifications`}
@@ -75,7 +75,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
{t("environments.surveys.summary.configure_alerts")}
</Link>
<Link
href={`/environments/${environmentId}/workspace/integrations`}
href={`/environments/${environmentId}/project/integrations`}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.setup_integrations")}

View File

@@ -13,7 +13,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
}
const project = await getProjectByEnvironmentId(survey.environmentId);
if (!project) {
throw new Error("Workspace not found");
throw new Error("Project not found");
}
const styling = getStyling(project, survey);

View File

@@ -12,7 +12,6 @@ export enum ShareViaType {
export enum ShareSettingsType {
LINK_SETTINGS = "link-settings",
PRETTY_URL = "pretty-url",
}
export enum LinkTabsType {

View File

@@ -1,228 +0,0 @@
"use client";
import { createId } from "@paralleldrive/cuid2";
import { PlusIcon, TrashIcon } from "lucide-react";
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
ERRORS,
TYPE_MAPPING,
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/constants";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getElementTypes } from "@/modules/survey/lib/elements";
import { Button } from "@/modules/ui/components/button";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
const filterByIdx = (targetIdx: number) => (_: unknown, i: number) => i !== targetIdx;
export type TColumnOrElement = { id: string; name: string; type: string };
export type TMappingError = { type: string; msg?: React.ReactNode | string } | null;
export type TMapping = {
id: string;
column: TColumnOrElement;
element: TColumnOrElement;
error?: TMappingError;
};
export const createEmptyMapping = (): TMapping => ({
id: createId(),
column: { id: "", name: "", type: "" },
element: { id: "", name: "", type: "" },
});
const MappingErrorMessage = ({
error,
col,
elem,
t,
}: {
error: TMappingError | undefined;
col: TColumnOrElement;
elem: TColumnOrElement;
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 MappingRowProps {
idx: number;
mapping: TMapping[];
setMapping: React.Dispatch<React.SetStateAction<TMapping[]>>;
filteredElementItems: TColumnOrElement[];
dbItems: TColumnOrElement[];
elementItems: TColumnOrElement[];
t: ReturnType<typeof useTranslation>["t"];
}
export const MappingRow = ({
idx,
mapping,
setMapping,
filteredElementItems,
dbItems,
elementItems,
t,
}: MappingRowProps) => {
const createCopy = (items: TMapping[]) => structuredClone(items);
const addRow = () => {
setMapping((prev) => [...prev, createEmptyMapping()]);
};
const deleteRow = () => {
setMapping((prev) => prev.filter(filterByIdx(idx)));
};
const getFilteredDbItems = () => {
const colMapping = new Set(mapping.map((m) => m.column.id));
return dbItems.filter((item) => !colMapping.has(item.id));
};
const handleElementSelect = (item: TColumnOrElement) => {
setMapping((prev) => {
const copy = createCopy(prev);
const col = copy[idx].column;
if (col.id) {
if (UNSUPPORTED_TYPES_BY_NOTION.includes(col.type)) {
copy[idx] = {
...copy[idx],
error: { type: ERRORS.UNSUPPORTED_TYPE },
element: item,
};
return copy;
}
const isValidColType = TYPE_MAPPING[item.type].includes(col.type);
if (!isValidColType) {
copy[idx] = {
...copy[idx],
error: { type: ERRORS.MAPPING },
element: item,
};
return copy;
}
}
copy[idx] = { ...copy[idx], element: item, error: null };
return copy;
});
};
const handleColumnSelect = (item: TColumnOrElement) => {
setMapping((prev) => {
const copy = createCopy(prev);
const elem = copy[idx].element;
if (elem.id) {
if (UNSUPPORTED_TYPES_BY_NOTION.includes(item.type)) {
copy[idx] = {
...copy[idx],
error: { type: ERRORS.UNSUPPORTED_TYPE },
column: item,
};
return copy;
}
const isValidElemType = TYPE_MAPPING[elem.type].includes(item.type);
if (!isValidElemType) {
copy[idx] = {
...copy[idx],
error: { type: ERRORS.MAPPING },
column: item,
};
return copy;
}
}
copy[idx] = { ...copy[idx], column: item, error: null };
return copy;
});
};
return (
<div className="w-full">
<MappingErrorMessage
error={mapping[idx]?.error}
col={mapping[idx].column}
elem={mapping[idx].element}
t={t}
/>
<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}
setSelectedItem={handleElementSelect}
disabled={elementItems.length === 0}
/>
</div>
<div className="h-px w-4 border-t border-t-slate-300" />
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
items={getFilteredDbItems()}
selectedItem={mapping?.[idx]?.column}
setSelectedItem={handleColumnSelect}
disabled={dbItems.length === 0}
/>
</div>
</div>
<div className="flex space-x-2">
{mapping.length > 1 && (
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow} type="button">
<TrashIcon />
</Button>
)}
<Button variant="secondary" size="icon" className="size-10" onClick={addRow} type="button">
<PlusIcon />
</Button>
</div>
</div>
</div>
);
};

View File

@@ -1,43 +0,0 @@
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
export const TYPE_MAPPING = {
[TSurveyElementTypeEnum.CTA]: ["checkbox"],
[TSurveyElementTypeEnum.MultipleChoiceMulti]: ["multi_select"],
[TSurveyElementTypeEnum.MultipleChoiceSingle]: ["select", "status"],
[TSurveyElementTypeEnum.OpenText]: [
"created_by",
"created_time",
"email",
"last_edited_by",
"last_edited_time",
"number",
"phone_number",
"rich_text",
"title",
"url",
],
[TSurveyElementTypeEnum.NPS]: ["number"],
[TSurveyElementTypeEnum.Consent]: ["checkbox"],
[TSurveyElementTypeEnum.Rating]: ["number"],
[TSurveyElementTypeEnum.PictureSelection]: ["url"],
[TSurveyElementTypeEnum.FileUpload]: ["url"],
[TSurveyElementTypeEnum.Date]: ["date"],
[TSurveyElementTypeEnum.Address]: ["rich_text"],
[TSurveyElementTypeEnum.Matrix]: ["rich_text"],
[TSurveyElementTypeEnum.Cal]: ["checkbox"],
[TSurveyElementTypeEnum.ContactInfo]: ["rich_text"],
[TSurveyElementTypeEnum.Ranking]: ["rich_text"],
};
export const UNSUPPORTED_TYPES_BY_NOTION = [
"rollup",
"created_by",
"created_time",
"last_edited_by",
"last_edited_time",
];
export const ERRORS = {
MAPPING: "Mapping Error",
UNSUPPORTED_TYPE: "Unsupported type by Notion",
};

View File

@@ -1,6 +1,5 @@
import { getServerSession } from "next-auth";
import { ChatwootWidget } from "@/app/chatwoot/ChatwootWidget";
import { CHATWOOT_BASE_URL, CHATWOOT_WEBSITE_TOKEN, IS_CHATWOOT_CONFIGURED } from "@/lib/constants";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
@@ -19,15 +18,7 @@ const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
{IS_CHATWOOT_CONFIGURED && (
<ChatwootWidget
userEmail={user?.email}
userName={user?.name}
userId={user?.id}
chatwootWebsiteToken={CHATWOOT_WEBSITE_TOKEN}
chatwootBaseUrl={CHATWOOT_BASE_URL}
/>
)}
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}
</>

View File

@@ -1,9 +1,11 @@
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
const AppLayout = async ({ children }) => {
return (
<>
<NoMobileOverlay />
<IntercomClientWrapper />
{children}
</>
);

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