mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-29 18:00:26 -06:00
Compare commits
5 Commits
fix/sdk-ra
...
fix/openid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bfb7d8ad | ||
|
|
adab35d828 | ||
|
|
5ebf406552 | ||
|
|
f1deb4893e | ||
|
|
56e79ea467 |
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
6
apps/storybook/postcss.config.js
Normal file
6
apps/storybook/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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}"],
|
||||
};
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,10 +37,6 @@ ENV NODE_OPTIONS=${NODE_OPTIONS}
|
||||
# but needs explicit declaration for some build systems (like Depot)
|
||||
ARG TARGETARCH
|
||||
|
||||
# Base path for the application (optional)
|
||||
ARG BASE_PATH=""
|
||||
ENV BASE_PATH=${BASE_PATH}
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -77,8 +73,8 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
|
||||
#
|
||||
FROM base AS runner
|
||||
|
||||
RUN npm install --ignore-scripts -g corepack@latest && \
|
||||
corepack enable
|
||||
RUN npm install --ignore-scripts -g corepack@latest
|
||||
RUN corepack enable
|
||||
|
||||
RUN apk add --no-cache curl \
|
||||
&& apk add --no-cache supercronic \
|
||||
@@ -138,13 +134,12 @@ EXPOSE 3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
USER nextjs
|
||||
|
||||
# Prepare pnpm as the nextjs user to ensure it's available at runtime
|
||||
# Prepare volumes for uploads and SAML connections
|
||||
RUN corepack prepare pnpm@9.15.9 --activate && \
|
||||
mkdir -p /home/nextjs/apps/web/uploads/ && \
|
||||
mkdir -p /home/nextjs/apps/web/saml-connection
|
||||
|
||||
# Prepare volume for uploads
|
||||
RUN mkdir -p /home/nextjs/apps/web/uploads/
|
||||
VOLUME /home/nextjs/apps/web/uploads/
|
||||
|
||||
# Prepare volume for SAML preloaded connection
|
||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
||||
VOLUME /home/nextjs/apps/web/saml-connection
|
||||
|
||||
CMD ["/home/nextjs/start.sh"]
|
||||
@@ -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);
|
||||
|
||||
@@ -233,7 +231,6 @@ export const ProjectSettings = ({
|
||||
<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 } }}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@fo
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -48,8 +47,6 @@ 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
|
||||
@@ -65,7 +62,6 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
organizationTeams={organizationTeams}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
userProjectsCount={projects.length}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
interface ChatwootWidgetProps {
|
||||
chatwootBaseUrl: string;
|
||||
chatwootWebsiteToken?: string;
|
||||
userEmail?: string | null;
|
||||
userName?: string | null;
|
||||
userId?: string | null;
|
||||
}
|
||||
|
||||
const CHATWOOT_SCRIPT_ID = "chatwoot-script";
|
||||
|
||||
export const ChatwootWidget = ({
|
||||
userEmail,
|
||||
userName,
|
||||
userId,
|
||||
chatwootWebsiteToken,
|
||||
chatwootBaseUrl,
|
||||
}: ChatwootWidgetProps) => {
|
||||
const userSetRef = useRef(false);
|
||||
|
||||
const setUserInfo = useCallback(() => {
|
||||
const $chatwoot = (
|
||||
globalThis as unknown as {
|
||||
$chatwoot: {
|
||||
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
|
||||
};
|
||||
}
|
||||
).$chatwoot;
|
||||
if (userId && $chatwoot && !userSetRef.current) {
|
||||
$chatwoot.setUser(userId, {
|
||||
email: userEmail,
|
||||
name: userName,
|
||||
});
|
||||
userSetRef.current = true;
|
||||
}
|
||||
}, [userId, userEmail, userName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatwootWebsiteToken) return;
|
||||
|
||||
const existingScript = document.getElementById(CHATWOOT_SCRIPT_ID);
|
||||
if (existingScript) return;
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = `${chatwootBaseUrl}/packs/js/sdk.js`;
|
||||
script.id = CHATWOOT_SCRIPT_ID;
|
||||
script.async = true;
|
||||
|
||||
script.onload = () => {
|
||||
(
|
||||
globalThis as unknown as {
|
||||
chatwootSDK: { run: (options: { websiteToken: string; baseUrl: string }) => void };
|
||||
}
|
||||
).chatwootSDK?.run({
|
||||
websiteToken: chatwootWebsiteToken,
|
||||
baseUrl: chatwootBaseUrl,
|
||||
});
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
|
||||
const handleChatwootReady = () => setUserInfo();
|
||||
globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
|
||||
|
||||
// Check if Chatwoot is already ready
|
||||
if (
|
||||
(
|
||||
globalThis as unknown as {
|
||||
$chatwoot: {
|
||||
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
|
||||
};
|
||||
}
|
||||
).$chatwoot
|
||||
) {
|
||||
setUserInfo();
|
||||
}
|
||||
|
||||
return () => {
|
||||
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
|
||||
|
||||
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot;
|
||||
if ($chatwoot) {
|
||||
$chatwoot.reset();
|
||||
}
|
||||
|
||||
const scriptElement = document.getElementById(CHATWOOT_SCRIPT_ID);
|
||||
scriptElement?.remove();
|
||||
|
||||
userSetRef.current = false;
|
||||
};
|
||||
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]);
|
||||
|
||||
return null;
|
||||
};
|
||||
67
apps/web/app/intercom/IntercomClient.tsx
Normal file
67
apps/web/app/intercom/IntercomClient.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import Intercom from "@intercom/messenger-js-sdk";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
interface IntercomClientProps {
|
||||
isIntercomConfigured: boolean;
|
||||
intercomUserHash?: string;
|
||||
user?: TUser | null;
|
||||
intercomAppId?: string;
|
||||
}
|
||||
|
||||
export const IntercomClient = ({
|
||||
user,
|
||||
intercomUserHash,
|
||||
isIntercomConfigured,
|
||||
intercomAppId,
|
||||
}: IntercomClientProps) => {
|
||||
const initializeIntercom = useCallback(() => {
|
||||
let initParams = {};
|
||||
|
||||
if (user && intercomUserHash) {
|
||||
const { id, name, email, createdAt } = user;
|
||||
|
||||
initParams = {
|
||||
user_id: id,
|
||||
user_hash: intercomUserHash,
|
||||
name,
|
||||
email,
|
||||
created_at: createdAt ? Math.floor(createdAt.getTime() / 1000) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
Intercom({
|
||||
app_id: intercomAppId!,
|
||||
...initParams,
|
||||
});
|
||||
}, [user, intercomUserHash, intercomAppId]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (isIntercomConfigured) {
|
||||
if (!intercomAppId) {
|
||||
throw new Error("Intercom app ID is required");
|
||||
}
|
||||
|
||||
if (user && !intercomUserHash) {
|
||||
throw new Error("Intercom user hash is required");
|
||||
}
|
||||
|
||||
initializeIntercom();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Shutdown Intercom when component unmounts
|
||||
if (typeof window !== "undefined" && window.Intercom) {
|
||||
window.Intercom("shutdown");
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize Intercom:", error);
|
||||
}
|
||||
}, [isIntercomConfigured, initializeIntercom, intercomAppId, intercomUserHash, user]);
|
||||
|
||||
return null;
|
||||
};
|
||||
26
apps/web/app/intercom/IntercomClientWrapper.tsx
Normal file
26
apps/web/app/intercom/IntercomClientWrapper.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createHmac } from "crypto";
|
||||
import type { TUser } from "@formbricks/types/user";
|
||||
import { INTERCOM_APP_ID, INTERCOM_SECRET_KEY, IS_INTERCOM_CONFIGURED } from "@/lib/constants";
|
||||
import { IntercomClient } from "./IntercomClient";
|
||||
|
||||
interface IntercomClientWrapperProps {
|
||||
user?: TUser | null;
|
||||
}
|
||||
|
||||
export const IntercomClientWrapper = ({ user }: IntercomClientWrapperProps) => {
|
||||
let intercomUserHash: string | undefined;
|
||||
if (user) {
|
||||
const secretKey = INTERCOM_SECRET_KEY;
|
||||
if (secretKey) {
|
||||
intercomUserHash = createHmac("sha256", secretKey).update(user.id).digest("hex");
|
||||
}
|
||||
}
|
||||
return (
|
||||
<IntercomClient
|
||||
isIntercomConfigured={IS_INTERCOM_CONFIGURED}
|
||||
user={user}
|
||||
intercomAppId={INTERCOM_APP_ID}
|
||||
intercomUserHash={intercomUserHash}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1910,9 +1910,9 @@ checksums:
|
||||
s/want_to_respond: fbb26054f6af3b625cb569e19063302f
|
||||
setup/intro/get_started: 5c783951b0100a168bdd2161ff294833
|
||||
setup/intro/made_with_love_in_kiel: 1bbdd6e93bcdf7cbfbcac16db448a2e4
|
||||
setup/intro/paragraph_1: 41e6a1e7c9a4a1922c7064a89f6733fd
|
||||
setup/intro/paragraph_1: 360c902da0db044c6cc346ac18099902
|
||||
setup/intro/paragraph_2: 5b3cce4d8c75bab4d671e2af7fc7ee9f
|
||||
setup/intro/paragraph_3: 5bf4718d4c44ff27e55e0880331f293d
|
||||
setup/intro/paragraph_3: 0675e53f2f48e3a04db6e52698bdebae
|
||||
setup/intro/welcome_to_formbricks: 561427153e3effa108f54407dfc2126f
|
||||
setup/invite/add_another_member: 02947deaa4710893794f3cc6e160c2b4
|
||||
setup/invite/continue: 3cfba90b4600131e82fc4260c568d044
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { IS_PRODUCTION, PROMETHEUS_ENABLED, SENTRY_DSN } from "@/lib/constants";
|
||||
import { setupGlobalAgentProxy } from "@/lib/setupGlobalAgentProxy";
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
|
||||
export const register = async () => {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
// Initialize global-agent proxy support (opt-in via USE_GLOBAL_AGENT_PROXY=1)
|
||||
// Must run before any outbound HTTP requests to ensure proxy settings are applied
|
||||
setupGlobalAgentProxy();
|
||||
|
||||
if (PROMETHEUS_ENABLED) {
|
||||
await import("./instrumentation-node");
|
||||
}
|
||||
|
||||
@@ -215,9 +215,9 @@ export const BILLING_LIMITS = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const CHATWOOT_WEBSITE_TOKEN = env.CHATWOOT_WEBSITE_TOKEN;
|
||||
export const CHATWOOT_BASE_URL = env.CHATWOOT_BASE_URL || "https://app.chatwoot.com";
|
||||
export const IS_CHATWOOT_CONFIGURED = Boolean(env.CHATWOOT_WEBSITE_TOKEN);
|
||||
export const INTERCOM_SECRET_KEY = env.INTERCOM_SECRET_KEY;
|
||||
export const INTERCOM_APP_ID = env.INTERCOM_APP_ID;
|
||||
export const IS_INTERCOM_CONFIGURED = Boolean(env.INTERCOM_APP_ID && INTERCOM_SECRET_KEY);
|
||||
|
||||
export const TURNSTILE_SECRET_KEY = env.TURNSTILE_SECRET_KEY;
|
||||
export const TURNSTILE_SITE_KEY = env.TURNSTILE_SITE_KEY;
|
||||
|
||||
@@ -32,6 +32,9 @@ export const env = createEnv({
|
||||
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
|
||||
HTTP_PROXY: z.string().url().optional(),
|
||||
HTTPS_PROXY: z.string().url().optional(),
|
||||
GLOBAL_AGENT_NO_PROXY: z.string().optional(),
|
||||
NO_PROXY: z.string().optional(),
|
||||
USE_GLOBAL_AGENT_PROXY: z.enum(["1", "0"]).optional(),
|
||||
IMPRINT_URL: z
|
||||
.string()
|
||||
.url()
|
||||
@@ -39,12 +42,11 @@ export const env = createEnv({
|
||||
.or(z.string().refine((str) => str === "")),
|
||||
IMPRINT_ADDRESS: z.string().optional(),
|
||||
INVITE_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
CHATWOOT_WEBSITE_TOKEN: z.string().optional(),
|
||||
CHATWOOT_BASE_URL: z.string().url().optional(),
|
||||
INTERCOM_SECRET_KEY: z.string().optional(),
|
||||
INTERCOM_APP_ID: z.string().optional(),
|
||||
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
|
||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
|
||||
MAIL_FROM: z.string().email().optional(),
|
||||
NEXTAUTH_URL: z.string().url().optional(),
|
||||
NEXTAUTH_SECRET: z.string().optional(),
|
||||
MAIL_FROM_NAME: z.string().optional(),
|
||||
NOTION_OAUTH_CLIENT_ID: z.string().optional(),
|
||||
@@ -160,19 +162,21 @@ export const env = createEnv({
|
||||
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
|
||||
HTTP_PROXY: process.env.HTTP_PROXY,
|
||||
HTTPS_PROXY: process.env.HTTPS_PROXY,
|
||||
GLOBAL_AGENT_NO_PROXY: process.env.GLOBAL_AGENT_NO_PROXY,
|
||||
NO_PROXY: process.env.NO_PROXY,
|
||||
USE_GLOBAL_AGENT_PROXY: process.env.USE_GLOBAL_AGENT_PROXY,
|
||||
IMPRINT_URL: process.env.IMPRINT_URL,
|
||||
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
|
||||
INVITE_DISABLED: process.env.INVITE_DISABLED,
|
||||
CHATWOOT_WEBSITE_TOKEN: process.env.CHATWOOT_WEBSITE_TOKEN,
|
||||
CHATWOOT_BASE_URL: process.env.CHATWOOT_BASE_URL,
|
||||
INTERCOM_SECRET_KEY: process.env.INTERCOM_SECRET_KEY,
|
||||
IS_FORMBRICKS_CLOUD: process.env.IS_FORMBRICKS_CLOUD,
|
||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||
MAIL_FROM: process.env.MAIL_FROM,
|
||||
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
OPENTELEMETRY_LISTENER_URL: process.env.OPENTELEMETRY_LISTENER_URL,
|
||||
INTERCOM_APP_ID: process.env.INTERCOM_APP_ID,
|
||||
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
|
||||
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
|
||||
OIDC_CLIENT_ID: process.env.OIDC_CLIENT_ID,
|
||||
|
||||
235
apps/web/lib/setupGlobalAgentProxy.test.ts
Normal file
235
apps/web/lib/setupGlobalAgentProxy.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { env } from "./env";
|
||||
// Note: We don't mock global-agent because the code uses dynamic require("global" + "-agent")
|
||||
// which can't be easily mocked. The real module will run in tests, which is acceptable.
|
||||
|
||||
// Import after mocks are set up
|
||||
import { setupGlobalAgentProxy } from "./setupGlobalAgentProxy";
|
||||
|
||||
// Mock logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock env
|
||||
vi.mock("./env", () => ({
|
||||
env: {
|
||||
USE_GLOBAL_AGENT_PROXY: undefined,
|
||||
GLOBAL_AGENT_NO_PROXY: undefined,
|
||||
NO_PROXY: undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
describe("setupGlobalAgentProxy", () => {
|
||||
const originalWindow = globalThis.window;
|
||||
const originalProcess = globalThis.process;
|
||||
const originalGlobalAgentInitialized = globalThis.__FORMBRICKS_GLOBAL_AGENT_INITIALIZED;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset global state
|
||||
delete (globalThis as any).window;
|
||||
delete (globalThis as any).__FORMBRICKS_GLOBAL_AGENT_INITIALIZED;
|
||||
// Reset process to valid Node.js
|
||||
globalThis.process = {
|
||||
release: { name: "node" },
|
||||
versions: { node: "20.0.0" },
|
||||
env: {},
|
||||
} as any;
|
||||
// Reset env mocks
|
||||
vi.mocked(env).USE_GLOBAL_AGENT_PROXY = undefined;
|
||||
vi.mocked(env).GLOBAL_AGENT_NO_PROXY = undefined;
|
||||
vi.mocked(env).NO_PROXY = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original values
|
||||
if (originalWindow !== undefined) {
|
||||
globalThis.window = originalWindow;
|
||||
}
|
||||
if (originalProcess !== undefined) {
|
||||
globalThis.process = originalProcess;
|
||||
}
|
||||
if (originalGlobalAgentInitialized !== undefined) {
|
||||
globalThis.__FORMBRICKS_GLOBAL_AGENT_INITIALIZED = originalGlobalAgentInitialized;
|
||||
} else {
|
||||
delete (globalThis as any).__FORMBRICKS_GLOBAL_AGENT_INITIALIZED;
|
||||
}
|
||||
});
|
||||
|
||||
describe("browser environment", () => {
|
||||
test("should return early if window is defined", () => {
|
||||
globalThis.window = {} as any;
|
||||
|
||||
setupGlobalAgentProxy();
|
||||
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-Node environment", () => {
|
||||
test("should return early if process is undefined", () => {
|
||||
delete (globalThis as any).process;
|
||||
|
||||
setupGlobalAgentProxy();
|
||||
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return early if process.release.name is not 'node'", () => {
|
||||
globalThis.process = {
|
||||
release: { name: "deno" },
|
||||
versions: { node: "20.0.0" },
|
||||
env: {},
|
||||
} as any;
|
||||
|
||||
setupGlobalAgentProxy();
|
||||
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return early if process.versions.node is undefined", () => {
|
||||
globalThis.process = {
|
||||
release: { name: "node" },
|
||||
versions: {},
|
||||
env: {},
|
||||
} as any;
|
||||
|
||||
setupGlobalAgentProxy();
|
||||
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("idempotent initialization", () => {
|
||||
test("should return early if already initialized", () => {
|
||||
globalThis.__FORMBRICKS_GLOBAL_AGENT_INITIALIZED = true;
|
||||
vi.mocked(env).USE_GLOBAL_AGENT_PROXY = "1";
|
||||
|
||||
setupGlobalAgentProxy();
|
||||
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("opt-in flag", () => {
|
||||
test("should return early if USE_GLOBAL_AGENT_PROXY is not '1'", () => {
|
||||
vi.mocked(env).USE_GLOBAL_AGENT_PROXY = "0";
|
||||
|
||||
setupGlobalAgentProxy();
|
||||
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return early if USE_GLOBAL_AGENT_PROXY is undefined", () => {
|
||||
vi.mocked(env).USE_GLOBAL_AGENT_PROXY = undefined;
|
||||
|
||||
setupGlobalAgentProxy();
|
||||
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NO_PROXY resolution", () => {
|
||||
test("should use GLOBAL_AGENT_NO_PROXY when set", () => {
|
||||
vi.mocked(env).USE_GLOBAL_AGENT_PROXY = "1";
|
||||
vi.mocked(env).GLOBAL_AGENT_NO_PROXY = "keycloak.local,adfs.local";
|
||||
|
||||
setupGlobalAgentProxy();
|
||||
|
||||
expect(process.env.GLOBAL_AGENT_NO_PROXY).toBe("keycloak.local,adfs.local");
|
||||
// Verify bootstrap was attempted (either success or error logged)
|
||||
const infoCalled = vi
|
||||
.mocked(logger.info)
|
||||
.mock.calls.some(
|
||||
(call) => call[0] === "Enabled global-agent proxy support for outbound HTTP requests"
|
||||
);
|
||||
const errorCalled = vi.mocked(logger.error).mock.calls.length > 0;
|
||||
expect(infoCalled || errorCalled).toBe(true);
|
||||
});
|
||||
|
||||
test("should use NO_PROXY when GLOBAL_AGENT_NO_PROXY is not set", () => {
|
||||
vi.mocked(env).USE_GLOBAL_AGENT_PROXY = "1";
|
||||
vi.mocked(env).NO_PROXY = "auth.service.company.local";
|
||||
|
||||
setupGlobalAgentProxy();
|
||||
|
||||
expect(process.env.GLOBAL_AGENT_NO_PROXY).toBe("auth.service.company.local");
|
||||
// Verify bootstrap was attempted
|
||||
const infoCalled = vi
|
||||
.mocked(logger.info)
|
||||
.mock.calls.some(
|
||||
(call) => call[0] === "Enabled global-agent proxy support for outbound HTTP requests"
|
||||
);
|
||||
const errorCalled = vi.mocked(logger.error).mock.calls.length > 0;
|
||||
expect(infoCalled || errorCalled).toBe(true);
|
||||
});
|
||||
|
||||
test("should prefer GLOBAL_AGENT_NO_PROXY over NO_PROXY", () => {
|
||||
vi.mocked(env).USE_GLOBAL_AGENT_PROXY = "1";
|
||||
vi.mocked(env).GLOBAL_AGENT_NO_PROXY = "keycloak.local";
|
||||
vi.mocked(env).NO_PROXY = "auth.service.company.local";
|
||||
|
||||
setupGlobalAgentProxy();
|
||||
|
||||
expect(process.env.GLOBAL_AGENT_NO_PROXY).toBe("keycloak.local");
|
||||
// Verify bootstrap was attempted
|
||||
const infoCalled = vi.mocked(logger.info).mock.calls.length > 0;
|
||||
const errorCalled = vi.mocked(logger.error).mock.calls.length > 0;
|
||||
expect(infoCalled || errorCalled).toBe(true);
|
||||
});
|
||||
|
||||
test("should not set GLOBAL_AGENT_NO_PROXY when neither is set", () => {
|
||||
vi.mocked(env).USE_GLOBAL_AGENT_PROXY = "1";
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
const originalNoProxy = process.env.GLOBAL_AGENT_NO_PROXY;
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
delete process.env.GLOBAL_AGENT_NO_PROXY;
|
||||
|
||||
setupGlobalAgentProxy();
|
||||
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
expect(process.env.GLOBAL_AGENT_NO_PROXY).toBeUndefined();
|
||||
// Verify bootstrap was attempted
|
||||
const infoCalled = vi
|
||||
.mocked(logger.info)
|
||||
.mock.calls.some(
|
||||
(call) => call[0] === "Enabled global-agent proxy support for outbound HTTP requests"
|
||||
);
|
||||
const errorCalled = vi.mocked(logger.error).mock.calls.length > 0;
|
||||
expect(infoCalled || errorCalled).toBe(true);
|
||||
|
||||
// Restore
|
||||
if (originalNoProxy !== undefined) {
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
process.env.GLOBAL_AGENT_NO_PROXY = originalNoProxy;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("successful initialization", () => {
|
||||
test("should attempt to bootstrap global-agent when enabled", () => {
|
||||
vi.mocked(env).USE_GLOBAL_AGENT_PROXY = "1";
|
||||
|
||||
setupGlobalAgentProxy();
|
||||
|
||||
// Verify bootstrap was attempted (either success or error)
|
||||
const infoCalled = vi
|
||||
.mocked(logger.info)
|
||||
.mock.calls.some(
|
||||
(call) => call[0] === "Enabled global-agent proxy support for outbound HTTP requests"
|
||||
);
|
||||
const errorCalled = vi.mocked(logger.error).mock.calls.length > 0;
|
||||
expect(infoCalled || errorCalled).toBe(true);
|
||||
|
||||
// If successful, flag should be set
|
||||
if (infoCalled) {
|
||||
expect(globalThis.__FORMBRICKS_GLOBAL_AGENT_INITIALIZED).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
56
apps/web/lib/setupGlobalAgentProxy.ts
Normal file
56
apps/web/lib/setupGlobalAgentProxy.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import "server-only";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { env } from "./env";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __FORMBRICKS_GLOBAL_AGENT_INITIALIZED: boolean | undefined;
|
||||
}
|
||||
|
||||
export const setupGlobalAgentProxy = (): void => {
|
||||
// Only run in a Node.js runtime; skip edge/serverless where Node built-ins (net/tls) are missing
|
||||
if (globalThis.window !== undefined) {
|
||||
return;
|
||||
}
|
||||
// Hard guard: only run in real Node.js runtime (not edge/serverless)
|
||||
if (
|
||||
globalThis.process === undefined ||
|
||||
globalThis.process.release?.name !== "node" ||
|
||||
globalThis.process.versions?.node === undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (globalThis.__FORMBRICKS_GLOBAL_AGENT_INITIALIZED) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isEnabled = env.USE_GLOBAL_AGENT_PROXY === "1";
|
||||
|
||||
if (!isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve NO_PROXY value from validated env
|
||||
const noProxy = env.GLOBAL_AGENT_NO_PROXY ?? env.NO_PROXY;
|
||||
|
||||
// Set GLOBAL_AGENT_NO_PROXY in process.env for global-agent to read
|
||||
if (noProxy) {
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
process.env.GLOBAL_AGENT_NO_PROXY = noProxy;
|
||||
}
|
||||
|
||||
// Mark as initialized before attempting bootstrap to avoid repeated attempts
|
||||
globalThis.__FORMBRICKS_GLOBAL_AGENT_INITIALIZED = true;
|
||||
|
||||
try {
|
||||
// Dynamic require prevents bundling into edge/serverless builds
|
||||
// Using string concatenation to prevent webpack from statically analyzing the require
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires, turbo/no-undeclared-env-vars
|
||||
const { bootstrap } = require("global" + "-agent");
|
||||
bootstrap();
|
||||
logger.info("Enabled global-agent proxy support for outbound HTTP requests");
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to enable global-agent proxy support");
|
||||
}
|
||||
};
|
||||
@@ -2048,7 +2048,7 @@
|
||||
"made_with_love_in_kiel": "Gebaut mit 🤍 in Deutschland",
|
||||
"paragraph_1": "Formbricks ist eine Experience Management Suite, die auf der <b>am schnellsten wachsenden Open-Source-Umfrageplattform</b> weltweit basiert.",
|
||||
"paragraph_2": "Führe gezielte Umfragen auf Websites, in Apps oder überall online durch. Sammle wertvolle Insights, um unwiderstehliche Erlebnisse für Kunden, Nutzer und Mitarbeiter zu gestalten.",
|
||||
"paragraph_3": "Wir verpflichten uns zu höchstem Datenschutz. Hosten Sie selbst, um die <b>volle Kontrolle über Ihre Daten</b> zu behalten.",
|
||||
"paragraph_3": "Wir schreiben DATENSCHUTZ groß (ha!). Hoste Formbricks selbst, um <b>volle Kontrolle über deine Daten</b> zu behalten.",
|
||||
"welcome_to_formbricks": "Willkommen bei Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -2046,9 +2046,9 @@
|
||||
"intro": {
|
||||
"get_started": "Get started",
|
||||
"made_with_love_in_kiel": "Made with \uD83E\uDD0D in Germany",
|
||||
"paragraph_1": "Formbricks is an Experience Management Suite built on the <b>fastest growing open-source survey platform</b> worldwide.",
|
||||
"paragraph_1": "Formbricks is an Experience Management Suite built of the <b>fastest growing open source survey platform</b> worldwide.",
|
||||
"paragraph_2": "Run targeted surveys on websites, in apps or anywhere online. Gather valuable insights to <b>craft irresistible experiences</b> for customers, users and employees.",
|
||||
"paragraph_3": "We're committed to the highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
|
||||
"paragraph_3": "We're commited to highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
|
||||
"welcome_to_formbricks": "Welcome to Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -2046,9 +2046,9 @@
|
||||
"intro": {
|
||||
"get_started": "Comenzar",
|
||||
"made_with_love_in_kiel": "Hecho con 🤍 en Alemania",
|
||||
"paragraph_1": "Formbricks es una suite de gestión de experiencias construida sobre la <b>plataforma de encuestas de código abierto de más rápido crecimiento</b> a nivel mundial.",
|
||||
"paragraph_1": "Formbricks es una Suite de Gestión de Experiencia construida sobre la <b>plataforma de encuestas de código abierto de más rápido crecimiento</b> en todo el mundo.",
|
||||
"paragraph_2": "Realiza encuestas dirigidas en sitios web, en aplicaciones o en cualquier lugar online. Recopila información valiosa para <b>crear experiencias irresistibles</b> para clientes, usuarios y empleados.",
|
||||
"paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Aloja en tu propio servidor para mantener el <b>control total sobre tus datos</b>.",
|
||||
"paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Alójalo tú mismo para mantener <b>control total sobre tus datos</b>.",
|
||||
"welcome_to_formbricks": "¡Bienvenido a Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -2046,9 +2046,9 @@
|
||||
"intro": {
|
||||
"get_started": "Commencer",
|
||||
"made_with_love_in_kiel": "Fabriqué avec 🤍 en Allemagne",
|
||||
"paragraph_1": "Formbricks est une suite de gestion de l'expérience construite sur la <b>plateforme de sondage open-source à la croissance la plus rapide</b> au monde.",
|
||||
"paragraph_1": "Formbricks est une suite de gestion de l'expérience construite sur la <b>plateforme d'enquête open source à la croissance la plus rapide</b> au monde.",
|
||||
"paragraph_2": "Réalisez des enquêtes ciblées sur des sites web, dans des applications ou partout en ligne. Collectez des informations précieuses pour <b>créer des expériences irrésistibles</b> pour les clients, les utilisateurs et les employés.",
|
||||
"paragraph_3": "Nous nous engageons à respecter le plus haut degré de confidentialité des données. Auto-hébergez pour garder <b>le contrôle total de vos données</b>.",
|
||||
"paragraph_3": "Nous sommes engagés à garantir le plus haut niveau de confidentialité des données. Auto-hébergez pour garder <b>le contrôle total sur vos données</b>. Toujours.",
|
||||
"welcome_to_formbricks": "Bienvenue sur Formbricks !"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -2046,9 +2046,9 @@
|
||||
"intro": {
|
||||
"get_started": "始める",
|
||||
"made_with_love_in_kiel": "キールで愛を込めて作られました 🤍",
|
||||
"paragraph_1": "Formbricksは、世界で<b>最も急成長しているオープンソースのアンケートプラットフォーム</b>をベースに構築されたエクスペリエンス管理スイートです。",
|
||||
"paragraph_1": "Formbricksは、世界で<b>最も急速に成長しているオープンソースのフォームプラットフォーム</b>から構築されたエクスペリエンス管理スイートです。",
|
||||
"paragraph_2": "ウェブサイト、アプリ、またはオンラインのどこでもターゲットを絞ったフォームを実行できます。貴重な洞察を収集して、顧客、ユーザー、従業員向けの<b>魅力的な体験</b>を作り出します。",
|
||||
"paragraph_3": "私たちは最高レベルのデータプライバシーを重視しています。セルフホスティングにより、<b>データを完全に管理</b>できます。",
|
||||
"paragraph_3": "私たちは、最高のデータプライバシーを約束します。セルフホストして、<b>データを完全に制御</b>できます。",
|
||||
"welcome_to_formbricks": "Formbricksへようこそ!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -2046,9 +2046,9 @@
|
||||
"intro": {
|
||||
"get_started": "Ga aan de slag",
|
||||
"made_with_love_in_kiel": "Gemaakt met 🤍 in Duitsland",
|
||||
"paragraph_1": "Formbricks is een Experience Management Suite gebouwd op het <b>snelst groeiende open-source enquêteplatform</b> wereldwijd.",
|
||||
"paragraph_1": "Formbricks is een Experience Management Suite die is gebouwd op het <b>snelst groeiende open source enquêteplatform</b> wereldwijd.",
|
||||
"paragraph_2": "Voer gerichte enquêtes uit op websites, in apps of waar dan ook online. Verzamel waardevolle inzichten om <b>onweerstaanbare ervaringen te creëren</b> voor klanten, gebruikers en medewerkers.",
|
||||
"paragraph_3": "We zijn toegewijd aan de hoogste mate van gegevensprivacy. Self-host om <b>volledige controle over je gegevens</b> te behouden.",
|
||||
"paragraph_3": "We streven naar de hoogste mate van gegevensprivacy. Zelfhosting om <b>volledige controle over uw gegevens</b> te behouden.",
|
||||
"welcome_to_formbricks": "Welkom bij Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -2046,9 +2046,9 @@
|
||||
"intro": {
|
||||
"get_started": "Começar",
|
||||
"made_with_love_in_kiel": "Feito com 🤍 em Alemanha",
|
||||
"paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída sobre a <b>plataforma de pesquisa de código aberto de crescimento mais rápido</b> do mundo.",
|
||||
"paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída na <b>plataforma de pesquisa open source que mais cresce</b> no mundo.",
|
||||
"paragraph_2": "Faça pesquisas direcionadas em sites, apps ou em qualquer lugar online. Recolha insights valiosos para criar experiências irresistíveis para clientes, usuários e funcionários.",
|
||||
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Hospede você mesmo para manter <b>controle total sobre seus dados</b>.",
|
||||
"paragraph_3": "Estamos comprometidos com o mais alto nível de privacidade de dados. Hospede você mesmo para manter <b>controle total sobre seus dados</b>. Sempre",
|
||||
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -2046,9 +2046,9 @@
|
||||
"intro": {
|
||||
"get_started": "Começar",
|
||||
"made_with_love_in_kiel": "Feito com 🤍 na Alemanha",
|
||||
"paragraph_1": "Formbricks é uma Suite de Gestão de Experiência construída na <b>plataforma de inquéritos open-source de crescimento mais rápido</b> a nível mundial.",
|
||||
"paragraph_1": "Formbricks é uma Suite de Gestão de Experiência construída na <b>plataforma de inquéritos de código aberto de crescimento mais rápido</b> do mundo.",
|
||||
"paragraph_2": "Execute inquéritos direcionados em websites, em apps ou em qualquer lugar online. Recolha informações valiosas para <b>criar experiências irresistíveis</b> para clientes, utilizadores e funcionários.",
|
||||
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Faça self-host para manter <b>controlo total sobre os seus dados</b>.",
|
||||
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Auto-hospede para manter <b>controlo total sobre os seus dados</b>.",
|
||||
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -2046,9 +2046,9 @@
|
||||
"intro": {
|
||||
"get_started": "Începeți",
|
||||
"made_with_love_in_kiel": "Creat cu 🤍 în Germania",
|
||||
"paragraph_1": "Formbricks este o suită de management al experienței construită pe <b>cea mai rapidă platformă open-source de sondaje</b> din lume.",
|
||||
"paragraph_1": "Formbricks este o suită de management al experiențelor construită pe baza <b>platformei de sondaje open source care crește cel mai rapid</b> din lume.",
|
||||
"paragraph_2": "Rulați sondaje direcționate pe site-uri web, în aplicații sau oriunde online. Adunați informații valoroase pentru a <b>crea experiențe irezistibile</b> pentru clienți, utilizatori și angajați.",
|
||||
"paragraph_3": "Suntem dedicați celui mai înalt nivel de confidențialitate a datelor. Găzduiește local pentru a păstra <b>controlul deplin asupra datelor tale</b>.",
|
||||
"paragraph_3": "Suntem angajați la cel mai înalt grad de confidențialitate a datelor. Găzduirea proprie vă oferă <b>control deplin asupra datelor dumneavoastră</b>.",
|
||||
"welcome_to_formbricks": "Bine ai venit la Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -2046,9 +2046,9 @@
|
||||
"intro": {
|
||||
"get_started": "Kom igång",
|
||||
"made_with_love_in_kiel": "Gjort med 🤍 i Tyskland",
|
||||
"paragraph_1": "Formbricks är en Experience Management Suite byggd på den <b>snabbast växande open source-enkätplattformen</b> i världen.",
|
||||
"paragraph_1": "Formbricks är en Experience Management Suite byggd av den <b>snabbast växande öppenkällkods enkätplattformen</b> i världen.",
|
||||
"paragraph_2": "Kör riktade enkäter på webbplatser, i appar eller var som helst online. Samla värdefulla insikter för att <b>skapa oemotståndliga upplevelser</b> för kunder, användare och anställda.",
|
||||
"paragraph_3": "Vi är engagerade i högsta möjliga datasekretess. Självhosta för att behålla <b>full kontroll över dina data</b>.",
|
||||
"paragraph_3": "Vi är engagerade i högsta grad av dataintegritet. Självhosta för att behålla <b>full kontroll över dina data</b>.",
|
||||
"welcome_to_formbricks": "Välkommen till Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -2046,9 +2046,9 @@
|
||||
"intro": {
|
||||
"get_started": "开始使用",
|
||||
"made_with_love_in_kiel": "以 🤍 在 德国 制作",
|
||||
"paragraph_1": "Formbricks 是一款体验管理套件,基于全球<b>增长最快的开源调研平台</b>构建。",
|
||||
"paragraph_1": "Formbricks 是一个体验管理套件, 基于全球<b>增长最快的开源调查平台</b>构建。",
|
||||
"paragraph_2": "在网站、应用程序或任何在线平台上运行 定向 调查。收集 有价值 的见解,为客户、用户和员工<b>打造 无法抗拒 的体验</b>。",
|
||||
"paragraph_3": "我们致力于最高级别的数据隐私保护。自建部署,<b>全面掌控您的数据</b>。",
|
||||
"paragraph_3": "我们致力于最高级别的数据隐私。 自行托管以保持<b>对您的数据的完全控制</b>。",
|
||||
"welcome_to_formbricks": "欢迎来到 Formbricks !"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -2046,9 +2046,9 @@
|
||||
"intro": {
|
||||
"get_started": "開始使用",
|
||||
"made_with_love_in_kiel": "用 🤍 在德國製造",
|
||||
"paragraph_1": "Formbricks 是一套體驗管理工具,建構於全球<b>成長最快的開源問卷平台</b>之上。",
|
||||
"paragraph_1": "Formbricks 是一套體驗管理套件,建立於全球<b>成長最快的開源問卷平台</b>之上。",
|
||||
"paragraph_2": "在網站、應用程式或線上任何地方執行目標問卷。收集寶貴的洞察,為客戶、使用者和員工<b>打造無法抗拒的體驗</b>。",
|
||||
"paragraph_3": "我們致力於最高等級的資料隱私。自我託管,讓您<b>完全掌控您的資料</b>。",
|
||||
"paragraph_3": "我們致力於最高程度的資料隱私權。自行託管以<b>完全掌控您的資料</b>。",
|
||||
"welcome_to_formbricks": "歡迎使用 Formbricks!"
|
||||
},
|
||||
"invite": {
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const SignIn = ({ token, webAppUrl }) => {
|
||||
export const SignIn = ({ token }) => {
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
signIn("token", {
|
||||
token: token,
|
||||
callbackUrl: webAppUrl,
|
||||
callbackUrl: `/`,
|
||||
});
|
||||
}
|
||||
}, [token, webAppUrl]);
|
||||
}, [token]);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
|
||||
import { SignIn } from "@/modules/auth/verify/components/sign-in";
|
||||
@@ -10,7 +9,7 @@ export const VerifyPage = async ({ searchParams }) => {
|
||||
return token ? (
|
||||
<FormWrapper>
|
||||
<p className="text-center">{t("auth.verify.verifying")}</p>
|
||||
<SignIn token={token} webAppUrl={WEBAPP_URL} />
|
||||
<SignIn token={token} />
|
||||
</FormWrapper>
|
||||
) : (
|
||||
<p className="text-center">{t("auth.verify.no_token_provided")}</p>
|
||||
|
||||
@@ -15,7 +15,7 @@ export const renderEmailResponseValue = async (
|
||||
return (
|
||||
<Container>
|
||||
{overrideFileUploadResponse ? (
|
||||
<Text className="mt-0 text-sm break-words whitespace-pre-wrap italic">
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words text-sm italic">
|
||||
{t("emails.render_email_response_value_file_upload_response_link_not_included")}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -65,6 +65,6 @@ export const renderEmailResponseValue = async (
|
||||
);
|
||||
|
||||
default:
|
||||
return <Text className="mt-0 text-sm break-words whitespace-pre-wrap">{response}</Text>;
|
||||
return <Text className="mt-0 whitespace-pre-wrap break-words text-sm">{response}</Text>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ export async function ResponseFinishedEmail({
|
||||
)}
|
||||
{variable.name}
|
||||
</Text>
|
||||
<Text className="mt-0 font-medium break-words whitespace-pre-wrap">
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words font-medium">
|
||||
{variableResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
@@ -94,7 +94,7 @@ export async function ResponseFinishedEmail({
|
||||
<Text className="mb-2 flex items-center gap-2 text-sm">
|
||||
{hiddenFieldId} <EyeOffIcon />
|
||||
</Text>
|
||||
<Text className="mt-0 text-sm break-words whitespace-pre-wrap">
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words text-sm">
|
||||
{hiddenFieldResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import Link from "next/link";
|
||||
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { getActionClasses } from "@/lib/actionClass/service";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getActionClasses } from "@/lib/actionClass/service";
|
||||
import { getEnvironments } from "@/lib/environment/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
|
||||
@@ -38,7 +38,6 @@ interface ThemeStylingProps {
|
||||
isUnsplashConfigured: boolean;
|
||||
isReadOnly: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
export const ThemeStyling = ({
|
||||
@@ -48,7 +47,6 @@ export const ThemeStyling = ({
|
||||
isUnsplashConfigured,
|
||||
isReadOnly,
|
||||
isStorageConfigured = true,
|
||||
publicDomain,
|
||||
}: ThemeStylingProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -201,7 +199,6 @@ export const ThemeStyling = ({
|
||||
}}
|
||||
previewType={previewSurveyType}
|
||||
setPreviewType={setPreviewSurveyType}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { IS_STORAGE_CONFIGURED, SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { BrandingSettingsCard } from "@/modules/ee/whitelabel/remove-branding/components/branding-settings-card";
|
||||
@@ -28,7 +27,6 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
|
||||
}
|
||||
|
||||
const canRemoveBranding = await getRemoveBrandingPermission(organization.billing.plan);
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -51,7 +49,6 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
|
||||
isUnsplashConfigured={!!UNSPLASH_ACCESS_KEY}
|
||||
isReadOnly={isReadOnly}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
|
||||
@@ -284,7 +284,7 @@ export const BlockCard = ({
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 hover:cursor-move"
|
||||
className="opacity-0 hover:cursor-move group-hover:opacity-100"
|
||||
aria-label="Drag to reorder block">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -22,7 +22,6 @@ interface EditWelcomeCardProps {
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const EditWelcomeCard = ({
|
||||
@@ -35,7 +34,6 @@ export const EditWelcomeCard = ({
|
||||
setSelectedLanguageCode,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: EditWelcomeCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -67,7 +65,7 @@ export const EditWelcomeCard = ({
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
|
||||
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
|
||||
)}>
|
||||
<Hand className="h-4 w-4" />
|
||||
@@ -137,7 +135,6 @@ export const EditWelcomeCard = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
@@ -153,7 +150,6 @@ export const EditWelcomeCard = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -174,7 +170,6 @@ export const EditWelcomeCard = ({
|
||||
label={t("environments.surveys.edit.next_button_label")}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -808,7 +808,6 @@ export const ElementsView = ({
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -50,7 +50,6 @@ interface SurveyEditorProps {
|
||||
isStorageConfigured: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
isExternalUrlsAllowed: boolean;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
export const SurveyEditor = ({
|
||||
@@ -80,7 +79,6 @@ export const SurveyEditor = ({
|
||||
isStorageConfigured,
|
||||
quotas,
|
||||
isExternalUrlsAllowed,
|
||||
publicDomain,
|
||||
}: SurveyEditorProps) => {
|
||||
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("elements");
|
||||
const [activeElementId, setActiveElementId] = useState<string | null>(null);
|
||||
@@ -274,7 +272,6 @@ export const SurveyEditor = ({
|
||||
previewType={localSurvey.type === "app" ? "modal" : "fullwidth"}
|
||||
languageCode={selectedLanguageCode}
|
||||
isSpamProtectionAllowed={isSpamProtectionAllowed}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -400,7 +400,7 @@ export const SurveyMenuBar = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
|
||||
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
|
||||
{!isStorageConfigured && (
|
||||
<div>
|
||||
<Alert variant="warning" size="small">
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
SURVEY_BG_COLORS,
|
||||
UNSPLASH_ACCESS_KEY,
|
||||
} from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
@@ -106,7 +105,6 @@ export const SurveyEditorPage = async (props) => {
|
||||
}
|
||||
|
||||
const isCxMode = searchParams.mode === "cx";
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<SurveyEditor
|
||||
@@ -136,7 +134,6 @@ export const SurveyEditorPage = async (props) => {
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
|
||||
? `${t("emails.number_variable")}: ${variable.name}`
|
||||
: `${t("emails.text_variable")}: ${variable.name}`}
|
||||
</Text>
|
||||
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words text-sm text-slate-700">
|
||||
{variableResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
@@ -107,7 +107,7 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
|
||||
<Text className="mb-2 text-sm font-semibold text-slate-900">
|
||||
{t("emails.hidden_field")}: {hiddenFieldId}
|
||||
</Text>
|
||||
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
|
||||
<Text className="mt-0 whitespace-pre-wrap break-words text-sm text-slate-700">
|
||||
{hiddenFieldResponse}
|
||||
</Text>
|
||||
</Column>
|
||||
|
||||
@@ -155,7 +155,7 @@ export const FollowUpItem = ({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="absolute top-4 right-4 flex items-center">
|
||||
<div className="absolute right-4 top-4 flex items-center">
|
||||
<TooltipRenderer tooltipContent={t("common.delete")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { LinkSurveyWrapper } from "@/modules/survey/link/components/link-survey-wrapper";
|
||||
import { getPrefillValue } from "@/modules/survey/link/lib/prefill";
|
||||
import { getPrefillValue } from "@/modules/survey/link/lib/utils";
|
||||
import { SurveyInline } from "@/modules/ui/components/survey";
|
||||
|
||||
interface SurveyClientWrapperProps {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { transformElement } from "./transformers";
|
||||
import { validateElement } from "./validators";
|
||||
|
||||
/**
|
||||
* Extract prefilled values from URL search parameters
|
||||
*
|
||||
* Supports prefilling for all survey element types with the following features:
|
||||
* - Option ID or label matching for choice-based elements (single/multi-select, ranking, picture selection)
|
||||
* - Comma-separated values for multi-select and ranking
|
||||
* - Backward compatibility with label-based prefilling
|
||||
*
|
||||
* @param survey - The survey object containing blocks and elements
|
||||
* @param searchParams - URL search parameters (e.g., from useSearchParams() or new URLSearchParams())
|
||||
* @param languageId - Current language code for label matching
|
||||
* @returns Object with element IDs as keys and prefilled values, or undefined if no valid prefills
|
||||
*
|
||||
* @example
|
||||
* // Single select with option ID
|
||||
* ?questionId=option-abc123
|
||||
*
|
||||
* // Multi-select with labels (backward compatible)
|
||||
* ?questionId=Option1,Option2,Option3
|
||||
*
|
||||
* // Ranking with option IDs
|
||||
* ?rankingId=choice-3,choice-1,choice-2
|
||||
*
|
||||
* // NPS question
|
||||
* ?npsId=9
|
||||
*
|
||||
* // Multiple questions
|
||||
* ?q1=answer1&q2=10&q3=option-xyz
|
||||
*/
|
||||
export const getPrefillValue = (
|
||||
survey: TSurvey,
|
||||
searchParams: URLSearchParams,
|
||||
languageId: string
|
||||
): TResponseData | undefined => {
|
||||
const prefillData: TResponseData = {};
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
searchParams.forEach((value, key) => {
|
||||
try {
|
||||
// Skip reserved parameter names
|
||||
if (FORBIDDEN_IDS.includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find matching element
|
||||
const element = elements.find((el) => el.id === key);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the value for this element type (returns match data)
|
||||
const validationResult = validateElement(element, value, languageId);
|
||||
if (!validationResult.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Transform the value using pre-matched data from validation
|
||||
const transformedValue = transformElement(validationResult, value, languageId);
|
||||
prefillData[element.id] = transformedValue;
|
||||
} catch (error) {
|
||||
// Catch any errors to prevent one bad prefill from breaking all prefills
|
||||
console.error(`[Prefill] Error processing prefill for ${key}:`, error);
|
||||
}
|
||||
});
|
||||
return Object.keys(prefillData).length > 0 ? prefillData : undefined;
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { matchMultipleOptionsByIdOrLabel, matchOptionByIdOrLabel } from "./matchers";
|
||||
|
||||
describe("matchOptionByIdOrLabel", () => {
|
||||
const choices = [
|
||||
{ id: "choice-1", label: { en: "First", de: "Erste" } },
|
||||
{ id: "choice-2", label: { en: "Second", de: "Zweite" } },
|
||||
{ id: "other", label: { en: "Other", de: "Andere" } },
|
||||
];
|
||||
|
||||
test("matches by ID", () => {
|
||||
const result = matchOptionByIdOrLabel(choices, "choice-1", "en");
|
||||
expect(result).toEqual(choices[0]);
|
||||
});
|
||||
|
||||
test("matches by label in English", () => {
|
||||
const result = matchOptionByIdOrLabel(choices, "First", "en");
|
||||
expect(result).toEqual(choices[0]);
|
||||
});
|
||||
|
||||
test("matches by label in German", () => {
|
||||
const result = matchOptionByIdOrLabel(choices, "Zweite", "de");
|
||||
expect(result).toEqual(choices[1]);
|
||||
});
|
||||
|
||||
test("prefers ID match over label match", () => {
|
||||
const choicesWithConflict = [
|
||||
{ id: "First", label: { en: "Not First" } },
|
||||
{ id: "choice-2", label: { en: "First" } },
|
||||
];
|
||||
const result = matchOptionByIdOrLabel(choicesWithConflict, "First", "en");
|
||||
expect(result).toEqual(choicesWithConflict[0]); // Matches by ID, not label
|
||||
});
|
||||
|
||||
test("returns null for no match", () => {
|
||||
const result = matchOptionByIdOrLabel(choices, "NonExistent", "en");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
const result = matchOptionByIdOrLabel(choices, "", "en");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("handles special characters in labels", () => {
|
||||
const specialChoices = [{ id: "c1", label: { en: "Option (1)" } }];
|
||||
const result = matchOptionByIdOrLabel(specialChoices, "Option (1)", "en");
|
||||
expect(result).toEqual(specialChoices[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchMultipleOptionsByIdOrLabel", () => {
|
||||
const choices = [
|
||||
{ id: "choice-1", label: { en: "First" } },
|
||||
{ id: "choice-2", label: { en: "Second" } },
|
||||
{ id: "choice-3", label: { en: "Third" } },
|
||||
];
|
||||
|
||||
test("matches multiple values by ID", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["choice-1", "choice-3"], "en");
|
||||
expect(result).toEqual([choices[0], choices[2]]);
|
||||
});
|
||||
|
||||
test("matches multiple values by label", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["First", "Third"], "en");
|
||||
expect(result).toEqual([choices[0], choices[2]]);
|
||||
});
|
||||
|
||||
test("matches mixed IDs and labels", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["choice-1", "Second", "choice-3"], "en");
|
||||
expect(result).toEqual([choices[0], choices[1], choices[2]]);
|
||||
});
|
||||
|
||||
test("preserves order of values", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["Third", "First", "Second"], "en");
|
||||
expect(result).toEqual([choices[2], choices[0], choices[1]]);
|
||||
});
|
||||
|
||||
test("skips non-matching values", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["First", "NonExistent", "Third"], "en");
|
||||
expect(result).toEqual([choices[0], choices[2]]);
|
||||
});
|
||||
|
||||
test("returns empty array for all non-matching values", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, ["NonExistent1", "NonExistent2"], "en");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles empty values array", () => {
|
||||
const result = matchMultipleOptionsByIdOrLabel(choices, [], "en");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { TSurveyElementChoice } from "@formbricks/types/surveys/elements";
|
||||
|
||||
/**
|
||||
* Match a value against element choices by ID first, then by label
|
||||
* This enables both option ID-based and label-based prefilling
|
||||
*
|
||||
* @param choices - Array of choice objects with id and label
|
||||
* @param value - Value from URL parameter (either choice ID or label text)
|
||||
* @param languageCode - Current language code for label matching
|
||||
* @returns Matched choice or null if no match found
|
||||
*/
|
||||
export const matchOptionByIdOrLabel = (
|
||||
choices: TSurveyElementChoice[],
|
||||
value: string,
|
||||
languageCode: string
|
||||
): TSurveyElementChoice | null => {
|
||||
const matchById = choices.find((choice) => choice.id === value);
|
||||
if (matchById) return matchById;
|
||||
|
||||
const matchByLabel = choices.find((choice) => choice.label[languageCode] === value);
|
||||
if (matchByLabel) return matchByLabel;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Match multiple values against choices
|
||||
* Used for multi-select and ranking elements
|
||||
*
|
||||
* @param choices - Array of choice objects
|
||||
* @param values - Array of values from URL parameter
|
||||
* @param languageCode - Current language code
|
||||
* @returns Array of matched choices (preserves order)
|
||||
*/
|
||||
export const matchMultipleOptionsByIdOrLabel = (
|
||||
choices: TSurveyElementChoice[],
|
||||
values: string[],
|
||||
languageCode: string
|
||||
): TSurveyElementChoice[] =>
|
||||
values
|
||||
.map((value) => matchOptionByIdOrLabel(choices, value, languageCode))
|
||||
.filter((match) => match !== null);
|
||||
@@ -1,64 +0,0 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { parseCommaSeparated, parseNumber } from "./parsers";
|
||||
|
||||
describe("parseCommaSeparated", () => {
|
||||
test("parses simple comma-separated values", () => {
|
||||
expect(parseCommaSeparated("a,b,c")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("trims whitespace from values", () => {
|
||||
expect(parseCommaSeparated("a , b , c")).toEqual(["a", "b", "c"]);
|
||||
expect(parseCommaSeparated(" a, b, c ")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("filters out empty values", () => {
|
||||
expect(parseCommaSeparated("a,,b")).toEqual(["a", "b"]);
|
||||
expect(parseCommaSeparated("a,b,")).toEqual(["a", "b"]);
|
||||
expect(parseCommaSeparated(",a,b")).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(parseCommaSeparated("")).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles single value", () => {
|
||||
expect(parseCommaSeparated("single")).toEqual(["single"]);
|
||||
});
|
||||
|
||||
test("handles values with spaces", () => {
|
||||
expect(parseCommaSeparated("First Choice,Second Choice")).toEqual(["First Choice", "Second Choice"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseNumber", () => {
|
||||
test("parses valid integers", () => {
|
||||
expect(parseNumber("5")).toBe(5);
|
||||
expect(parseNumber("0")).toBe(0);
|
||||
expect(parseNumber("10")).toBe(10);
|
||||
});
|
||||
|
||||
test("parses valid floats", () => {
|
||||
expect(parseNumber("5.5")).toBe(5.5);
|
||||
expect(parseNumber("0.1")).toBe(0.1);
|
||||
});
|
||||
|
||||
test("parses negative numbers", () => {
|
||||
expect(parseNumber("-5")).toBe(-5);
|
||||
expect(parseNumber("-5.5")).toBe(-5.5);
|
||||
});
|
||||
|
||||
test("handles ampersand replacement", () => {
|
||||
expect(parseNumber("5&5")).toBe(null); // Invalid after replacement
|
||||
});
|
||||
|
||||
test("returns null for invalid strings", () => {
|
||||
expect(parseNumber("abc")).toBeNull();
|
||||
expect(parseNumber("")).toBeNull();
|
||||
expect(parseNumber("5a")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for NaN result", () => {
|
||||
expect(parseNumber("NaN")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Simple parsing helpers for URL parameter values
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse comma-separated values from URL parameter
|
||||
* Used for multi-select and ranking elements
|
||||
* Handles whitespace trimming and empty values
|
||||
*/
|
||||
export const parseCommaSeparated = (value: string): string[] => {
|
||||
return value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse number from URL parameter
|
||||
* Used for NPS and Rating elements
|
||||
* Returns null if parsing fails
|
||||
*/
|
||||
export const parseNumber = (value: string): number | null => {
|
||||
try {
|
||||
// Handle `&` being used instead of `;` in some cases
|
||||
const cleanedValue = value.replaceAll("&", ";");
|
||||
const num = Number(JSON.parse(cleanedValue));
|
||||
return Number.isNaN(num) ? null : num;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { parseNumber } from "./parsers";
|
||||
import {
|
||||
TValidationResult,
|
||||
isMultiChoiceResult,
|
||||
isPictureSelectionResult,
|
||||
isSingleChoiceResult,
|
||||
} from "./types";
|
||||
|
||||
export const transformOpenText = (answer: string): string => {
|
||||
return answer;
|
||||
};
|
||||
|
||||
export const transformMultipleChoiceSingle = (
|
||||
validationResult: TValidationResult,
|
||||
answer: string,
|
||||
language: string
|
||||
): string => {
|
||||
if (!isSingleChoiceResult(validationResult)) return answer;
|
||||
|
||||
const { matchedChoice } = validationResult;
|
||||
|
||||
// If we have a matched choice, return its label
|
||||
if (matchedChoice) {
|
||||
return matchedChoice.label[language] || answer;
|
||||
}
|
||||
|
||||
// If no matched choice (null), it's an "other" value - return original
|
||||
return answer;
|
||||
};
|
||||
|
||||
export const transformMultipleChoiceMulti = (validationResult: TValidationResult): string[] => {
|
||||
if (!isMultiChoiceResult(validationResult)) return [];
|
||||
|
||||
const { matched, others } = validationResult;
|
||||
|
||||
// Return matched choices + joined "other" values as single string
|
||||
if (others.length > 0) {
|
||||
return [...matched, others.join(",")];
|
||||
}
|
||||
|
||||
return matched;
|
||||
};
|
||||
|
||||
export const transformNPS = (answer: string): number => {
|
||||
const num = parseNumber(answer);
|
||||
return num ?? 0;
|
||||
};
|
||||
|
||||
export const transformRating = (answer: string): number => {
|
||||
const num = parseNumber(answer);
|
||||
return num ?? 0;
|
||||
};
|
||||
|
||||
export const transformConsent = (answer: string): string => {
|
||||
if (answer === "dismissed") return "";
|
||||
return answer;
|
||||
};
|
||||
|
||||
export const transformPictureSelection = (validationResult: TValidationResult): string[] => {
|
||||
if (!isPictureSelectionResult(validationResult)) return [];
|
||||
|
||||
return validationResult.selectedIds;
|
||||
};
|
||||
|
||||
/**
|
||||
* Main transformation dispatcher
|
||||
* Routes to appropriate transformer based on element type
|
||||
* Uses pre-matched data from validation result to avoid duplicate matching
|
||||
*/
|
||||
export const transformElement = (
|
||||
validationResult: TValidationResult,
|
||||
answer: string,
|
||||
language: string
|
||||
): string | number | string[] => {
|
||||
if (!validationResult.isValid) return "";
|
||||
|
||||
try {
|
||||
switch (validationResult.type) {
|
||||
case TSurveyElementTypeEnum.OpenText:
|
||||
return transformOpenText(answer);
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
return transformMultipleChoiceSingle(validationResult, answer, language);
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
return transformConsent(answer);
|
||||
case TSurveyElementTypeEnum.Rating:
|
||||
return transformRating(answer);
|
||||
case TSurveyElementTypeEnum.NPS:
|
||||
return transformNPS(answer);
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
return transformPictureSelection(validationResult);
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
||||
return transformMultipleChoiceMulti(validationResult);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import { TSurveyElementChoice, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
|
||||
type TInvalidResult = {
|
||||
isValid: false;
|
||||
};
|
||||
|
||||
// Base valid result for simple types (no match data needed)
|
||||
type TSimpleValidResult = {
|
||||
isValid: true;
|
||||
};
|
||||
|
||||
// Single choice match result (MultipleChoiceSingle)
|
||||
type TSingleChoiceValidResult = {
|
||||
isValid: true;
|
||||
matchedChoice: TSurveyElementChoice | null; // null means "other" value
|
||||
};
|
||||
|
||||
// Multi choice match result (MultipleChoiceMulti)
|
||||
type TMultiChoiceValidResult = {
|
||||
isValid: true;
|
||||
matched: string[]; // matched labels
|
||||
others: string[]; // other text values
|
||||
};
|
||||
|
||||
// Picture selection result (indices are already validated)
|
||||
type TPictureSelectionValidResult = {
|
||||
isValid: true;
|
||||
selectedIds: string[];
|
||||
};
|
||||
|
||||
// Discriminated union for all validation results
|
||||
export type TValidationResult =
|
||||
| (TInvalidResult & { type?: TSurveyElementTypeEnum })
|
||||
| (TSimpleValidResult & {
|
||||
type:
|
||||
| TSurveyElementTypeEnum.OpenText
|
||||
| TSurveyElementTypeEnum.NPS
|
||||
| TSurveyElementTypeEnum.Rating
|
||||
| TSurveyElementTypeEnum.Consent;
|
||||
})
|
||||
| (TSingleChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceSingle })
|
||||
| (TMultiChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceMulti })
|
||||
| (TPictureSelectionValidResult & { type: TSurveyElementTypeEnum.PictureSelection });
|
||||
|
||||
// Type guards for narrowing validation results
|
||||
export const isValidResult = (result: TValidationResult): result is TValidationResult & { isValid: true } =>
|
||||
result.isValid;
|
||||
|
||||
export const isSingleChoiceResult = (
|
||||
result: TValidationResult
|
||||
): result is TSingleChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceSingle } =>
|
||||
result.isValid && result.type === TSurveyElementTypeEnum.MultipleChoiceSingle;
|
||||
|
||||
export const isMultiChoiceResult = (
|
||||
result: TValidationResult
|
||||
): result is TMultiChoiceValidResult & { type: TSurveyElementTypeEnum.MultipleChoiceMulti } =>
|
||||
result.isValid && result.type === TSurveyElementTypeEnum.MultipleChoiceMulti;
|
||||
|
||||
export const isPictureSelectionResult = (
|
||||
result: TValidationResult
|
||||
): result is TPictureSelectionValidResult & { type: TSurveyElementTypeEnum.PictureSelection } =>
|
||||
result.isValid && result.type === TSurveyElementTypeEnum.PictureSelection;
|
||||
@@ -1,228 +0,0 @@
|
||||
import {
|
||||
TSurveyConsentElement,
|
||||
TSurveyElement,
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyPictureSelectionElement,
|
||||
TSurveyRatingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { matchOptionByIdOrLabel } from "./matchers";
|
||||
import { parseCommaSeparated, parseNumber } from "./parsers";
|
||||
import { TValidationResult } from "./types";
|
||||
|
||||
const invalid = (type?: TSurveyElementTypeEnum): TValidationResult => ({ isValid: false, type });
|
||||
|
||||
export const validateOpenText = (): TValidationResult => {
|
||||
return { isValid: true, type: TSurveyElementTypeEnum.OpenText };
|
||||
};
|
||||
|
||||
export const validateMultipleChoiceSingle = (
|
||||
element: TSurveyMultipleChoiceElement,
|
||||
answer: string,
|
||||
language: string
|
||||
): TValidationResult => {
|
||||
if (element.type !== TSurveyElementTypeEnum.MultipleChoiceSingle) {
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
|
||||
}
|
||||
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
|
||||
}
|
||||
|
||||
const hasOther = element.choices.at(-1)?.id === "other";
|
||||
|
||||
// Try matching by ID or label (new: supports both)
|
||||
const matchedChoice = matchOptionByIdOrLabel(element.choices, answer, language);
|
||||
if (matchedChoice) {
|
||||
return {
|
||||
isValid: true,
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
matchedChoice,
|
||||
};
|
||||
}
|
||||
|
||||
// If no match and has "other" option, accept any non-empty text as "other" value
|
||||
if (hasOther) {
|
||||
const trimmedAnswer = answer.trim();
|
||||
if (trimmedAnswer !== "") {
|
||||
return {
|
||||
isValid: true,
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
matchedChoice: null, // null indicates "other" value
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceSingle);
|
||||
};
|
||||
|
||||
export const validateMultipleChoiceMulti = (
|
||||
element: TSurveyMultipleChoiceElement,
|
||||
answer: string,
|
||||
language: string
|
||||
): TValidationResult => {
|
||||
if (element.type !== TSurveyElementTypeEnum.MultipleChoiceMulti) {
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
|
||||
}
|
||||
|
||||
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
|
||||
}
|
||||
|
||||
const hasOther = element.choices.at(-1)?.id === "other";
|
||||
const lastChoiceLabel = hasOther ? element.choices.at(-1)?.label?.[language] : undefined;
|
||||
|
||||
const answerChoices = parseCommaSeparated(answer);
|
||||
|
||||
if (answerChoices.length === 0) {
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
|
||||
}
|
||||
|
||||
// Process all answers and collect results
|
||||
const matched: string[] = [];
|
||||
const others: string[] = [];
|
||||
let freeTextOtherCount = 0;
|
||||
|
||||
for (const ans of answerChoices) {
|
||||
const matchedChoice = matchOptionByIdOrLabel(element.choices, ans, language);
|
||||
|
||||
if (matchedChoice) {
|
||||
const label = matchedChoice.label[language];
|
||||
if (label) {
|
||||
matched.push(label);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's the "Other" label itself
|
||||
if (ans === lastChoiceLabel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// It's a free-text "other" value
|
||||
if (hasOther) {
|
||||
freeTextOtherCount++;
|
||||
if (freeTextOtherCount > 1) {
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti); // Only one free-text "other" value allowed
|
||||
}
|
||||
others.push(ans);
|
||||
} else {
|
||||
// No "other" option and doesn't match any choice
|
||||
return invalid(TSurveyElementTypeEnum.MultipleChoiceMulti);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
matched,
|
||||
others,
|
||||
};
|
||||
};
|
||||
|
||||
export const validateNPS = (answer: string): TValidationResult => {
|
||||
const answerNumber = parseNumber(answer);
|
||||
if (answerNumber === null || answerNumber < 0 || answerNumber > 10) {
|
||||
return invalid(TSurveyElementTypeEnum.NPS);
|
||||
}
|
||||
return { isValid: true, type: TSurveyElementTypeEnum.NPS };
|
||||
};
|
||||
|
||||
export const validateConsent = (element: TSurveyConsentElement, answer: string): TValidationResult => {
|
||||
if (element.type !== TSurveyElementTypeEnum.Consent) {
|
||||
return invalid(TSurveyElementTypeEnum.Consent);
|
||||
}
|
||||
if (element.required && answer === "dismissed") {
|
||||
return invalid(TSurveyElementTypeEnum.Consent);
|
||||
}
|
||||
if (answer !== "accepted" && answer !== "dismissed") {
|
||||
return invalid(TSurveyElementTypeEnum.Consent);
|
||||
}
|
||||
return { isValid: true, type: TSurveyElementTypeEnum.Consent };
|
||||
};
|
||||
|
||||
export const validateRating = (element: TSurveyRatingElement, answer: string): TValidationResult => {
|
||||
if (element.type !== TSurveyElementTypeEnum.Rating) {
|
||||
return invalid(TSurveyElementTypeEnum.Rating);
|
||||
}
|
||||
const answerNumber = parseNumber(answer);
|
||||
if (answerNumber === null || answerNumber < 1 || answerNumber > (element.range ?? 5)) {
|
||||
return invalid(TSurveyElementTypeEnum.Rating);
|
||||
}
|
||||
return { isValid: true, type: TSurveyElementTypeEnum.Rating };
|
||||
};
|
||||
|
||||
export const validatePictureSelection = (
|
||||
element: TSurveyPictureSelectionElement,
|
||||
answer: string
|
||||
): TValidationResult => {
|
||||
if (element.type !== TSurveyElementTypeEnum.PictureSelection) {
|
||||
return invalid(TSurveyElementTypeEnum.PictureSelection);
|
||||
}
|
||||
if (!element.choices || !Array.isArray(element.choices) || element.choices.length === 0) {
|
||||
return invalid(TSurveyElementTypeEnum.PictureSelection);
|
||||
}
|
||||
|
||||
const answerChoices = parseCommaSeparated(answer);
|
||||
const selectedIds: string[] = [];
|
||||
|
||||
// Validate all indices and collect selected IDs
|
||||
for (const ans of answerChoices) {
|
||||
const num = parseNumber(ans);
|
||||
if (num === null || num < 1 || num > element.choices.length) {
|
||||
return invalid(TSurveyElementTypeEnum.PictureSelection);
|
||||
}
|
||||
const index = num - 1;
|
||||
const choice = element.choices[index];
|
||||
if (choice?.id) {
|
||||
selectedIds.push(choice.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply allowMulti constraint
|
||||
const finalIds = element.allowMulti ? selectedIds : selectedIds.slice(0, 1);
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
type: TSurveyElementTypeEnum.PictureSelection,
|
||||
selectedIds: finalIds,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Main validation dispatcher
|
||||
* Routes to appropriate validator based on element type
|
||||
* Returns validation result with match data for transformers
|
||||
*/
|
||||
export const validateElement = (
|
||||
element: TSurveyElement,
|
||||
answer: string,
|
||||
language: string
|
||||
): TValidationResult => {
|
||||
// Empty required fields are invalid
|
||||
if (element.required && (!answer || answer === "")) {
|
||||
return invalid(element.type);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (element.type) {
|
||||
case TSurveyElementTypeEnum.OpenText:
|
||||
return validateOpenText();
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle:
|
||||
return validateMultipleChoiceSingle(element, answer, language);
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti:
|
||||
return validateMultipleChoiceMulti(element, answer, language);
|
||||
case TSurveyElementTypeEnum.NPS:
|
||||
return validateNPS(answer);
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
return validateConsent(element, answer);
|
||||
case TSurveyElementTypeEnum.Rating:
|
||||
return validateRating(element, answer);
|
||||
case TSurveyElementTypeEnum.PictureSelection:
|
||||
return validatePictureSelection(element, answer);
|
||||
default:
|
||||
return invalid();
|
||||
}
|
||||
} catch {
|
||||
return invalid(element.type);
|
||||
}
|
||||
};
|
||||
@@ -3,9 +3,9 @@ import { describe, expect, test } from "vitest";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
|
||||
import { getPrefillValue } from "./index";
|
||||
import { getPrefillValue } from "./utils";
|
||||
|
||||
describe("prefill integration tests", () => {
|
||||
describe("survey link utils", () => {
|
||||
const mockSurvey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
@@ -76,7 +76,15 @@ describe("prefill integration tests", () => {
|
||||
lowerLabel: { default: "Not likely" },
|
||||
upperLabel: { default: "Very likely" },
|
||||
},
|
||||
|
||||
{
|
||||
id: "q7",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "CTA Question" },
|
||||
required: false,
|
||||
buttonLabel: { default: "Click me" },
|
||||
buttonExternal: false,
|
||||
buttonUrl: "",
|
||||
},
|
||||
{
|
||||
id: "q8",
|
||||
type: TSurveyElementTypeEnum.Consent,
|
||||
@@ -154,21 +162,13 @@ describe("prefill integration tests", () => {
|
||||
expect(result).toEqual({ q1: "Open text answer" });
|
||||
});
|
||||
|
||||
test("validates MultipleChoiceSingle questions with label", () => {
|
||||
test("validates MultipleChoiceSingle questions", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q2", "Option 1");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
expect(result).toEqual({ q2: "Option 1" });
|
||||
});
|
||||
|
||||
test("validates MultipleChoiceSingle questions with option ID", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q2", "c2");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
// Option ID is converted to label
|
||||
expect(result).toEqual({ q2: "Option 2" });
|
||||
});
|
||||
|
||||
test("invalidates MultipleChoiceSingle with non-existent option", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q2", "Non-existent option");
|
||||
@@ -183,29 +183,13 @@ describe("prefill integration tests", () => {
|
||||
expect(result).toEqual({ q3: "Custom answer" });
|
||||
});
|
||||
|
||||
test("handles MultipleChoiceMulti questions with labels", () => {
|
||||
test("handles MultipleChoiceMulti questions", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q4", "Option 4,Option 5");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
|
||||
});
|
||||
|
||||
test("handles MultipleChoiceMulti questions with option IDs", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q4", "c4,c5");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
// Option IDs are converted to labels
|
||||
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
|
||||
});
|
||||
|
||||
test("handles MultipleChoiceMulti with mixed IDs and labels", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q4", "c4,Option 5");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
// Mixed: ID converted to label + label stays as-is
|
||||
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
|
||||
});
|
||||
|
||||
test("handles MultipleChoiceMulti with Other", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q5", "Option 6,Custom answer");
|
||||
@@ -227,6 +211,20 @@ describe("prefill integration tests", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles CTA questions with clicked value", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q7", "clicked");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
expect(result).toEqual({ q7: "clicked" });
|
||||
});
|
||||
|
||||
test("handles CTA questions with dismissed value", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q7", "dismissed");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
expect(result).toEqual({ q7: "" });
|
||||
});
|
||||
|
||||
test("validates Consent questions", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q8", "accepted");
|
||||
@@ -295,18 +293,4 @@ describe("prefill integration tests", () => {
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles whitespace in comma-separated values", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q4", "Option 4 , Option 5");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
|
||||
});
|
||||
|
||||
test("ignores trailing commas in multi-select", () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("q4", "Option 4,Option 5,");
|
||||
const result = getPrefillValue(mockSurvey, searchParams, "default");
|
||||
expect(result).toEqual({ q4: ["Option 4", "Option 5"] });
|
||||
});
|
||||
});
|
||||
@@ -1,2 +1,230 @@
|
||||
// Prefilling logic has been moved to @/modules/survey/link/lib/prefill
|
||||
// This file is kept for any future utility functions
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurveyCTAElement,
|
||||
TSurveyConsentElement,
|
||||
TSurveyElement,
|
||||
TSurveyElementTypeEnum,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyRatingElement,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
|
||||
export const getPrefillValue = (
|
||||
survey: TSurvey,
|
||||
searchParams: URLSearchParams,
|
||||
languageId: string
|
||||
): TResponseData | undefined => {
|
||||
const prefillAnswer: TResponseData = {};
|
||||
|
||||
const questions = getElementsFromBlocks(survey.blocks);
|
||||
const questionIdxMap = questions.reduce(
|
||||
(acc, question, idx) => {
|
||||
acc[question.id] = idx;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
searchParams.forEach((value, key) => {
|
||||
if (FORBIDDEN_IDS.includes(key)) return;
|
||||
const questionId = key;
|
||||
const questionIdx = questionIdxMap[questionId];
|
||||
const question = questions[questionIdx];
|
||||
const answer = value;
|
||||
if (question) {
|
||||
if (checkValidity(question, answer, languageId)) {
|
||||
prefillAnswer[questionId] = transformAnswer(question, answer, languageId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(prefillAnswer).length > 0 ? prefillAnswer : undefined;
|
||||
};
|
||||
|
||||
const validateOpenText = (): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateMultipleChoiceSingle = (
|
||||
question: TSurveyMultipleChoiceElement,
|
||||
answer: string,
|
||||
language: string
|
||||
): boolean => {
|
||||
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceSingle) return false;
|
||||
const choices = question.choices;
|
||||
const hasOther = choices[choices.length - 1].id === "other";
|
||||
|
||||
if (!hasOther) {
|
||||
return choices.some((choice) => choice.label[language] === answer);
|
||||
}
|
||||
|
||||
const matchesAnyChoice = choices.some((choice) => choice.label[language] === answer);
|
||||
|
||||
if (matchesAnyChoice) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const trimmedAnswer = answer.trim();
|
||||
return trimmedAnswer !== "";
|
||||
};
|
||||
|
||||
const validateMultipleChoiceMulti = (question: TSurveyElement, answer: string, language: string): boolean => {
|
||||
if (question.type !== TSurveyElementTypeEnum.MultipleChoiceMulti) return false;
|
||||
const choices = (
|
||||
question as TSurveyElement & { choices: Array<{ id: string; label: Record<string, string> }> }
|
||||
).choices;
|
||||
const hasOther = choices[choices.length - 1].id === "other";
|
||||
const lastChoiceLabel = hasOther ? choices[choices.length - 1].label[language] : undefined;
|
||||
|
||||
const answerChoices = answer
|
||||
.split(",")
|
||||
.map((ans) => ans.trim())
|
||||
.filter((ans) => ans !== "");
|
||||
|
||||
if (answerChoices.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasOther) {
|
||||
return answerChoices.every((ans: string) => choices.some((choice) => choice.label[language] === ans));
|
||||
}
|
||||
|
||||
let freeTextOtherCount = 0;
|
||||
for (const ans of answerChoices) {
|
||||
const matchesChoice = choices.some((choice) => choice.label[language] === ans);
|
||||
|
||||
if (matchesChoice) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ans === lastChoiceLabel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
freeTextOtherCount++;
|
||||
if (freeTextOtherCount > 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateNPS = (answer: string): boolean => {
|
||||
try {
|
||||
const cleanedAnswer = answer.replace(/&/g, ";");
|
||||
const answerNumber = Number(JSON.parse(cleanedAnswer));
|
||||
return !isNaN(answerNumber) && answerNumber >= 0 && answerNumber <= 10;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const validateCTA = (question: TSurveyCTAElement, answer: string): boolean => {
|
||||
if (question.required && answer === "dismissed") return false;
|
||||
return answer === "clicked" || answer === "dismissed";
|
||||
};
|
||||
|
||||
const validateConsent = (question: TSurveyConsentElement, answer: string): boolean => {
|
||||
if (question.required && answer === "dismissed") return false;
|
||||
return answer === "accepted" || answer === "dismissed";
|
||||
};
|
||||
|
||||
const validateRating = (question: TSurveyRatingElement, answer: string): boolean => {
|
||||
if (question.type !== TSurveyElementTypeEnum.Rating) return false;
|
||||
const ratingQuestion = question;
|
||||
try {
|
||||
const cleanedAnswer = answer.replace(/&/g, ";");
|
||||
const answerNumber = Number(JSON.parse(cleanedAnswer));
|
||||
return answerNumber >= 1 && answerNumber <= (ratingQuestion.range ?? 5);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const validatePictureSelection = (answer: string): boolean => {
|
||||
const answerChoices = answer.split(",");
|
||||
return answerChoices.every((ans: string) => !isNaN(Number(ans)));
|
||||
};
|
||||
|
||||
const checkValidity = (question: TSurveyElement, answer: string, language: string): boolean => {
|
||||
if (question.required && (!answer || answer === "")) return false;
|
||||
|
||||
const validators: Partial<
|
||||
Record<TSurveyElementTypeEnum, (q: TSurveyElement, a: string, l: string) => boolean>
|
||||
> = {
|
||||
[TSurveyElementTypeEnum.OpenText]: () => validateOpenText(),
|
||||
[TSurveyElementTypeEnum.MultipleChoiceSingle]: (q, a, l) =>
|
||||
validateMultipleChoiceSingle(q as TSurveyMultipleChoiceElement, a, l),
|
||||
[TSurveyElementTypeEnum.MultipleChoiceMulti]: (q, a, l) => validateMultipleChoiceMulti(q, a, l),
|
||||
[TSurveyElementTypeEnum.NPS]: (_, a) => validateNPS(a),
|
||||
[TSurveyElementTypeEnum.CTA]: (q, a) => validateCTA(q as TSurveyCTAElement, a),
|
||||
[TSurveyElementTypeEnum.Consent]: (q, a) => validateConsent(q as TSurveyConsentElement, a),
|
||||
[TSurveyElementTypeEnum.Rating]: (q, a) => validateRating(q as TSurveyRatingElement, a),
|
||||
[TSurveyElementTypeEnum.PictureSelection]: (_, a) => validatePictureSelection(a),
|
||||
};
|
||||
|
||||
const validator = validators[question.type];
|
||||
if (!validator) return false;
|
||||
|
||||
try {
|
||||
return validator(question, answer, language);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const transformAnswer = (
|
||||
question: TSurveyElement,
|
||||
answer: string,
|
||||
language: string
|
||||
): string | number | string[] => {
|
||||
switch (question.type) {
|
||||
case TSurveyElementTypeEnum.OpenText:
|
||||
case TSurveyElementTypeEnum.MultipleChoiceSingle: {
|
||||
return answer;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Consent:
|
||||
case TSurveyElementTypeEnum.CTA: {
|
||||
if (answer === "dismissed") return "";
|
||||
return answer;
|
||||
}
|
||||
|
||||
case TSurveyElementTypeEnum.Rating:
|
||||
case TSurveyElementTypeEnum.NPS: {
|
||||
const cleanedAnswer = answer.replace(/&/g, ";");
|
||||
return Number(JSON.parse(cleanedAnswer));
|
||||
}
|
||||
|
||||
case TSurveyElementTypeEnum.PictureSelection: {
|
||||
const answerChoicesIdx = answer.split(",");
|
||||
const answerArr: string[] = [];
|
||||
|
||||
answerChoicesIdx.forEach((ansIdx) => {
|
||||
const choice = question.choices[Number(ansIdx) - 1];
|
||||
if (choice) answerArr.push(choice.id);
|
||||
});
|
||||
|
||||
if (question.allowMulti) return answerArr;
|
||||
return answerArr.slice(0, 1);
|
||||
}
|
||||
|
||||
case TSurveyElementTypeEnum.MultipleChoiceMulti: {
|
||||
let ansArr = answer.split(",");
|
||||
const hasOthers = question.choices[question.choices.length - 1].id === "other";
|
||||
if (!hasOthers) return ansArr;
|
||||
|
||||
// answer can be "a,b,c,d" and options can be a,c,others so we are filtering out the options that are not in the options list and sending these non-existing values as a single string(representing others) like "a", "c", "b,d"
|
||||
const options = question.choices.map((o) => o.label[language]);
|
||||
const others = ansArr.filter((a: string) => !options.includes(a));
|
||||
if (others.length > 0) ansArr = ansArr.filter((a: string) => options.includes(a));
|
||||
if (others.length > 0) ansArr.push(others.join(","));
|
||||
return ansArr;
|
||||
}
|
||||
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,7 +70,6 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
|
||||
environment={environment}
|
||||
project={projectWithRequiredProps}
|
||||
isTemplatePage={false}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ type TemplateContainerWithPreviewProps = {
|
||||
environment: Pick<Environment, "id" | "appSetupCompleted">;
|
||||
userId: string;
|
||||
isTemplatePage?: boolean;
|
||||
publicDomain: string;
|
||||
};
|
||||
|
||||
export const TemplateContainerWithPreview = ({
|
||||
@@ -24,7 +23,6 @@ export const TemplateContainerWithPreview = ({
|
||||
environment,
|
||||
userId,
|
||||
isTemplatePage = true,
|
||||
publicDomain,
|
||||
}: TemplateContainerWithPreviewProps) => {
|
||||
const { t } = useTranslation();
|
||||
const initialTemplate = customSurveyTemplate(t);
|
||||
@@ -74,7 +72,6 @@ export const TemplateContainerWithPreview = ({
|
||||
environment={environment}
|
||||
languageCode={"default"}
|
||||
isSpamProtectionAllowed={false} // setting it to false as this is a template
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
|
||||
@@ -28,14 +27,7 @@ export const SurveyTemplatesPage = async (props: SurveyTemplateProps) => {
|
||||
return redirect(`/environments/${environment.id}/surveys`);
|
||||
}
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<TemplateContainerWithPreview
|
||||
userId={session.user.id}
|
||||
environment={environment}
|
||||
project={project}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
<TemplateContainerWithPreview userId={session.user.id} environment={environment} project={project} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,7 +25,6 @@ interface PreviewSurveyProps {
|
||||
environment: Pick<Environment, "id" | "appSetupCompleted">;
|
||||
languageCode: string;
|
||||
isSpamProtectionAllowed: boolean;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
let surveyNameTemp: string;
|
||||
@@ -39,7 +38,6 @@ export const PreviewSurvey = ({
|
||||
environment,
|
||||
languageCode,
|
||||
isSpamProtectionAllowed,
|
||||
publicDomain,
|
||||
}: PreviewSurveyProps) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(true);
|
||||
const [isFullScreenPreview, setIsFullScreenPreview] = useState(false);
|
||||
@@ -246,7 +244,6 @@ export const PreviewSurvey = ({
|
||||
borderRadius={styling?.roundness ?? 8}
|
||||
background={styling?.cardBackgroundColor?.light}>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={survey}
|
||||
isBrandingEnabled={project.inAppSurveyBranding}
|
||||
@@ -276,7 +273,6 @@ export const PreviewSurvey = ({
|
||||
</div>
|
||||
<div className="z-10 w-full rounded-lg border border-transparent">
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={{ ...survey, type: "link" }}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
@@ -349,7 +345,6 @@ export const PreviewSurvey = ({
|
||||
borderRadius={styling.roundness ?? 8}
|
||||
background={styling.cardBackgroundColor?.light}>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={survey}
|
||||
isBrandingEnabled={project.inAppSurveyBranding}
|
||||
@@ -383,7 +378,6 @@ export const PreviewSurvey = ({
|
||||
</div>
|
||||
<div className="z-0 w-full max-w-4xl rounded-lg border-transparent">
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={{ ...survey, type: "link" }}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
|
||||
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 focus:outline-none hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
|
||||
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-1 hover:enabled:border-slate-400 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-slate-400",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
@@ -52,7 +52,7 @@ const SelectLabel: React.ComponentType<SelectPrimitive.SelectLabelProps> = React
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pr-2 pl-8 text-sm font-semibold text-slate-900 dark:text-slate-200", className)}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold text-slate-900 dark:text-slate-200", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -65,7 +65,7 @@ const SelectItem: React.ComponentType<SelectPrimitive.SelectItemProps> = React.f
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer items-center rounded-md py-1.5 pr-2 pl-2 text-sm font-medium outline-none select-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-2 pr-2 text-sm font-medium outline-none focus:bg-slate-100 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { SurveyContainerProps } from "@formbricks/types/formbricks-surveys";
|
||||
import { executeRecaptcha, loadRecaptchaScript } from "@/modules/ui/components/survey/recaptcha";
|
||||
@@ -39,8 +37,7 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
|
||||
// Set loading flag immediately to prevent concurrent loads
|
||||
isLoadingScript = true;
|
||||
try {
|
||||
const scriptUrl = props.appUrl ? `${props.appUrl}/js/surveys.umd.cjs` : "/js/surveys.umd.cjs";
|
||||
const response = await fetch(scriptUrl);
|
||||
const response = await fetch("/js/surveys.umd.cjs");
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load the surveys package");
|
||||
|
||||
@@ -16,7 +16,6 @@ interface ThemeStylingPreviewSurveyProps {
|
||||
project: Project;
|
||||
previewType: TSurveyType;
|
||||
setPreviewType: (type: TSurveyType) => void;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
const previewParentContainerVariant: Variants = {
|
||||
@@ -51,7 +50,6 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
project,
|
||||
previewType,
|
||||
setPreviewType,
|
||||
publicDomain,
|
||||
}: ThemeStylingPreviewSurveyProps) => {
|
||||
const [isFullScreenPreview] = useState(false);
|
||||
const [previewPosition] = useState("relative");
|
||||
@@ -168,7 +166,6 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
borderRadius={project.styling.roundness ?? 8}>
|
||||
<Fragment key={surveyKey}>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={{ ...survey, type: "app" }}
|
||||
isBrandingEnabled={project.inAppSurveyBranding}
|
||||
@@ -195,7 +192,6 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
key={surveyKey}
|
||||
className={`${project.logo?.url && !project.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md rounded-lg p-4`}>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={{ ...survey, type: "link" }}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
|
||||
@@ -16,11 +16,10 @@ const getHostname = (url) => {
|
||||
|
||||
const nextConfig = {
|
||||
assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
|
||||
basePath: process.env.BASE_PATH || undefined,
|
||||
output: "standalone",
|
||||
poweredByHeader: false,
|
||||
productionBrowserSourceMaps: true,
|
||||
serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
|
||||
serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty", "global-agent"],
|
||||
outputFileTracingIncludes: {
|
||||
"/api/auth/**/*": ["../../node_modules/jose/**/*"],
|
||||
},
|
||||
@@ -62,6 +61,10 @@ const nextConfig = {
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api-iam.eu.intercom.io",
|
||||
},
|
||||
],
|
||||
},
|
||||
async redirects() {
|
||||
@@ -110,6 +113,9 @@ const nextConfig = {
|
||||
config.resolve.fallback = {
|
||||
http: false, // Prevents Next.js from trying to bundle 'http'
|
||||
https: false,
|
||||
net: false, // Prevents Next.js from trying to bundle 'net' (used by global-agent)
|
||||
tls: false, // Prevents Next.js from trying to bundle 'tls' (used by global-agent)
|
||||
domain: false, // Prevents Next.js from trying to bundle 'domain' (used by global-agent dependencies)
|
||||
};
|
||||
return config;
|
||||
},
|
||||
@@ -165,7 +171,7 @@ const nextConfig = {
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' blob: data: http://localhost:9000 https:; font-src 'self' data: https:; connect-src 'self' http://localhost:9000 https: wss:; frame-src 'self' https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
|
||||
value: `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https://*.intercom.io https://*.intercomcdn.com https:; style-src 'self' 'unsafe-inline' https://*.intercomcdn.com https:; img-src 'self' blob: data: http://localhost:9000 https://*.intercom.io https://*.intercomcdn.com https:; font-src 'self' data: https://*.intercomcdn.com https:; connect-src 'self' http://localhost:9000 https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https:; frame-src 'self' https://*.intercom.io https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`,
|
||||
},
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
@@ -404,7 +410,7 @@ const nextConfig = {
|
||||
];
|
||||
},
|
||||
env: {
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL, // TODO: Remove this once we have a proper solution for the base path
|
||||
NEXTAUTH_URL: process.env.WEBAPP_URL,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -442,7 +448,4 @@ const sentryOptions = {
|
||||
// Runtime Sentry reporting still depends on DSN being set via environment variables
|
||||
const exportConfig = process.env.SENTRY_AUTH_TOKEN ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
|
||||
|
||||
console.log("BASE PATH", nextConfig.basePath);
|
||||
|
||||
|
||||
export default exportConfig;
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@formbricks/surveys": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@hookform/resolvers": "5.0.1",
|
||||
"@intercom/messenger-js-sdk": "0.0.14",
|
||||
"@json2csv/node": "7.0.6",
|
||||
"@lexical/code": "0.36.2",
|
||||
"@lexical/link": "0.36.2",
|
||||
@@ -72,8 +73,8 @@
|
||||
"@radix-ui/react-tooltip": "1.2.6",
|
||||
"@react-email/components": "0.0.38",
|
||||
"@sentry/nextjs": "10.5.0",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@ungap/structured-clone": "1.3.0",
|
||||
@@ -89,6 +90,7 @@
|
||||
"file-loader": "6.2.0",
|
||||
"framer-motion": "12.10.0",
|
||||
"googleapis": "148.0.0",
|
||||
"global-agent": "3.0.0",
|
||||
"heic-convert": "2.1.0",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"i18next": "25.5.2",
|
||||
@@ -116,7 +118,6 @@
|
||||
"react-day-picker": "9.6.7",
|
||||
"react-hook-form": "7.56.2",
|
||||
"react-hot-toast": "2.5.2",
|
||||
"react-calendar": "5.1.0",
|
||||
"react-i18next": "15.7.3",
|
||||
"react-turnstile": "1.1.4",
|
||||
"react-use": "17.6.0",
|
||||
|
||||
@@ -115,12 +115,12 @@ test.describe("JS Package Test", async () => {
|
||||
await page.locator("#questionCard-2").getByRole("button", { name: "Next" }).click();
|
||||
await page
|
||||
.locator("#questionCard-3")
|
||||
.getByRole("textbox")
|
||||
.getByLabel("textarea")
|
||||
.fill("People who believe that PMF is necessary");
|
||||
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
|
||||
await page.locator("#questionCard-4").getByRole("textbox").fill("Much higher response rates!");
|
||||
await page.locator("#questionCard-4").getByLabel("textarea").fill("Much higher response rates!");
|
||||
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
|
||||
await page.locator("#questionCard-5").getByRole("textbox").fill("Make this end to end test pass!");
|
||||
await page.locator("#questionCard-5").getByLabel("textarea").fill("Make this end to end test pass!");
|
||||
await page.locator("#questionCard-5").getByRole("button", { name: "Finish" }).click();
|
||||
|
||||
await page.getByTestId("loading-spinner").waitFor({ state: "hidden" });
|
||||
|
||||
@@ -113,12 +113,10 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
await expect(
|
||||
page.locator("#questionCard-3").getByText(surveys.createAndSubmit.ratingQuestion.highLabel)
|
||||
).toBeVisible();
|
||||
// Rating component uses fieldset with labels, not a group with name "Choices"
|
||||
expect(await page.locator("#questionCard-3").locator("fieldset label").count()).toBe(5);
|
||||
expect(await page.getByRole("group", { name: "Choices" }).locator("label").count()).toBe(5);
|
||||
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-3").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
// Click on the label instead of the radio to avoid SVG intercepting pointer events
|
||||
await page.locator("#questionCard-3").locator('label:has(input[value="3"])').click();
|
||||
await page.getByRole("radio", { name: "Rate 3 out of" }).check();
|
||||
await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// NPS Question
|
||||
@@ -167,7 +165,9 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
await expect(page.getByText(surveys.createAndSubmit.fileUploadQuestion.question)).toBeVisible();
|
||||
await expect(page.locator("#questionCard-8").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-8").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Upload files by clicking or" })).toBeVisible();
|
||||
await expect(
|
||||
page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("button").nth(0)
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator("input[type=file]").setInputFiles({
|
||||
name: "file.doc",
|
||||
@@ -191,22 +191,22 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
page.getByRole("rowheader", { name: surveys.createAndSubmit.matrix.rows[2] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[0], exact: true })
|
||||
page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[0] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[1], exact: true })
|
||||
page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[1] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[2], exact: true })
|
||||
page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[2] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[3], exact: true })
|
||||
page.getByRole("columnheader", { name: surveys.createAndSubmit.matrix.columns[3] })
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByRole("radio", { name: "Roses-0" }).click();
|
||||
await page.getByRole("radio", { name: "Trees-0" }).click();
|
||||
await page.getByRole("radio", { name: "Ocean-0" }).click();
|
||||
await page.getByRole("cell", { name: "Roses – 0" }).locator("div").click();
|
||||
await page.getByRole("cell", { name: "Trees – 0" }).locator("div").click();
|
||||
await page.getByRole("cell", { name: "Ocean – 0" }).locator("div").click();
|
||||
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Address Question
|
||||
@@ -858,8 +858,7 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-4").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
// Click on the label instead of the radio to avoid SVG intercepting pointer events
|
||||
await page.locator("#questionCard-4").locator('label:has(input[value="4"])').click();
|
||||
await page.getByRole("radio", { name: "Rate 4 out of" }).check();
|
||||
await page.locator("#questionCard-4").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// NPS Question
|
||||
@@ -896,22 +895,22 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
page.getByRole("rowheader", { name: surveys.createWithLogicAndSubmit.matrix.rows[2] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[0], exact: true })
|
||||
page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[0] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[1], exact: true })
|
||||
page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[1] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[2], exact: true })
|
||||
page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[2] })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("cell", { name: surveys.createWithLogicAndSubmit.matrix.columns[3], exact: true })
|
||||
page.getByRole("columnheader", { name: surveys.createWithLogicAndSubmit.matrix.columns[3] })
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-7").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByRole("radio", { name: "Roses-0" }).click();
|
||||
await page.getByRole("radio", { name: "Trees-0" }).click();
|
||||
await page.getByRole("radio", { name: "Ocean-0" }).click();
|
||||
await page.getByRole("cell", { name: "Roses – 0" }).locator("div").click();
|
||||
await page.getByRole("cell", { name: "Trees – 0" }).locator("div").click();
|
||||
await page.getByRole("cell", { name: "Ocean – 0" }).locator("div").click();
|
||||
await page.locator("#questionCard-7").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// CTA Question
|
||||
@@ -940,9 +939,9 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
).toBeVisible();
|
||||
await expect(page.locator("#questionCard-10").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-10").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Upload files by clicking or" })).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("button").nth(0)
|
||||
).toBeVisible();
|
||||
await page.locator("input[type=file]").setInputFiles({
|
||||
name: "file.doc",
|
||||
mimeType: "application/msword",
|
||||
@@ -953,10 +952,11 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
|
||||
// Date Question
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.date.question)).toBeVisible();
|
||||
// Click the "Today" button in the date picker - matches format like "Today, Tuesday, December 16th,"
|
||||
await page.getByRole("button", { name: /^Today,/ }).click();
|
||||
await page.getByRole("button", { name: "Scroll to bottom" }).click();
|
||||
await page.locator("#questionCard-11").getByRole("button", { name: "Next", exact: true }).click();
|
||||
await page.getByText("Select a date").click();
|
||||
const date = new Date().getDate();
|
||||
const month = new Date().toLocaleString("default", { month: "long" });
|
||||
await page.getByRole("button", { name: `${month} ${date},` }).click();
|
||||
await page.locator("#questionCard-11").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Cal Question
|
||||
await expect(page.getByText(surveys.createWithLogicAndSubmit.cal.question)).toBeVisible();
|
||||
|
||||
@@ -41,3 +41,12 @@ OIDC_SIGNING_ALGORITHM=HS256
|
||||
- Restart your Formbricks instance.
|
||||
|
||||
- You're all set! Users can now sign up & log in using their OIDC credentials.
|
||||
|
||||
## Use behind a proxy
|
||||
|
||||
If outbound traffic must pass through a corporate proxy and your IdP should bypass it:
|
||||
|
||||
- Set `USE_GLOBAL_AGENT_PROXY=1` to enable proxy handling for all Auth.js HTTP requests.
|
||||
- Set `HTTP_PROXY` / `HTTPS_PROXY` to your proxy endpoints.
|
||||
- Set `NO_PROXY` (or `GLOBAL_AGENT_NO_PROXY`) to include your IdP host (for example, `auth.service.company.local,keycloak`).
|
||||
- Restart the app to apply the settings.
|
||||
|
||||
@@ -91,6 +91,18 @@ Adds 'I love Formbricks' as the answer to the open text question:
|
||||
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?openText_question_id=I%20love%20Formbricks
|
||||
```
|
||||
|
||||
### CTA Question
|
||||
|
||||
Accepts only 'dismissed' as answer option. Due to the risk of domain abuse, this value cannot be set to 'clicked' via prefilling:
|
||||
|
||||
```txt CTA Question
|
||||
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?cta_question_id=dismissed
|
||||
```
|
||||
|
||||
<Note>
|
||||
Due to the risk of domain abuse, this value cannot be set to 'clicked' via prefilling.
|
||||
</Note>
|
||||
|
||||
### Consent Question
|
||||
|
||||
Adds 'accepted' as the answer to the Consent question. Alternatively, you can set it to 'dismissed' to skip the question.
|
||||
@@ -103,53 +115,11 @@ https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?consent_question_id=accep
|
||||
|
||||
Adds index of the selected image(s) as the answer to the Picture Selection question. The index starts from 1
|
||||
|
||||
```txt Picture Selection Question
|
||||
```txt Picture Selection Question.
|
||||
https://app.formbricks.com/s/clin3yxja52k8l80hpwmx4bjy?pictureSelection_question_id=1%2C2%2C3
|
||||
```
|
||||
|
||||
|
||||
## Using Option IDs for Choice-Based Questions
|
||||
|
||||
All choice-based question types (Single Select, Multi Select, Picture Selection) now support prefilling with **option IDs** in addition to option labels. This is the recommended approach as it's more reliable and doesn't break when you update option text.
|
||||
|
||||
### Benefits of Using Option IDs
|
||||
|
||||
- **Stable:** Option IDs don't change when you edit the option label text
|
||||
- **Language-independent:** Works across all survey languages without modification
|
||||
- **Reliable:** No issues with special characters or URL encoding of complex text
|
||||
|
||||
### How to Find Option IDs
|
||||
|
||||
1. Open your survey in the Survey Editor
|
||||
2. Click on the question you want to prefill
|
||||
3. Open the **Advanced Settings** at the bottom of the question card
|
||||
4. Each option shows its unique ID that you can copy
|
||||
|
||||
### Examples with Option IDs
|
||||
|
||||
**Single Select:**
|
||||
|
||||
```sh Single Select with Option ID
|
||||
https://app.formbricks.com/s/surveyId?questionId=option-abc123
|
||||
```
|
||||
|
||||
**Multi Select:**
|
||||
|
||||
```sh Multi Select with Option IDs
|
||||
https://app.formbricks.com/s/surveyId?questionId=option-1,option-2,option-3
|
||||
```
|
||||
|
||||
**Mixed IDs and Labels:**
|
||||
|
||||
You can even mix option IDs and labels in the same URL (though using IDs consistently is recommended):
|
||||
|
||||
```sh Mixed Approach
|
||||
https://app.formbricks.com/s/surveyId?questionId=option-abc,Some%20Label,option-xyz
|
||||
```
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
All existing URLs using option labels continue to work. The system tries to match by option ID first, then falls back to matching by label text if no ID match is found.
|
||||
<Note>All other question types, you currently cannot prefill via the URL.</Note>
|
||||
|
||||
## Validation
|
||||
|
||||
@@ -157,12 +127,13 @@ Make sure that the answer in the URL matches the expected type for the questions
|
||||
|
||||
The URL validation works as follows:
|
||||
|
||||
- **Rating or NPS questions:** The response is parsed as a number and verified to be within the valid range.
|
||||
- **Consent type questions:** Valid values are "accepted" (consent given) and "dismissed" (consent not given).
|
||||
- **Picture Selection questions:** The response is parsed as comma-separated numbers (1-based indices) and verified against available choices.
|
||||
- **Single/Multi Select questions:** Values can be either option IDs or exact label text. The system tries to match by option ID first, then falls back to label matching.
|
||||
- **Open Text questions:** Any string value is accepted.
|
||||
- For Rating or NPS questions, the response is parsed as a number and verified if it's accepted by the schema.
|
||||
- For CTA type questions, the valid values are "clicked" (main CTA) and "dismissed" (skip CTA).
|
||||
- For Consent type questions, the valid values are "accepted" (consent given) and "dismissed" (consent not given).
|
||||
- For Picture Selection type questions, the response is parsed as an array of numbers and verified if it's accepted by the schema.
|
||||
- All other question types are strings.
|
||||
|
||||
<Note>
|
||||
If an answer is invalid or doesn't match any option, the prefilling will be ignored and the question is presented as if not prefilled.
|
||||
If an answer is invalid, the prefilling will be ignored and the question is
|
||||
presented as if not prefilled.
|
||||
</Note>
|
||||
|
||||
10
package.json
10
package.json
@@ -42,8 +42,8 @@
|
||||
"i18n:validate": "pnpm scan-translations"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1"
|
||||
"react": "19.1.2",
|
||||
"react-dom": "19.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@azure/identity": "4.13.0",
|
||||
@@ -80,6 +80,9 @@
|
||||
"showDetails": true
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.12": "patches/next-auth@4.24.12.patch"
|
||||
},
|
||||
"overrides": {
|
||||
"axios": ">=1.12.2",
|
||||
"node-forge": ">=1.3.2",
|
||||
@@ -88,9 +91,6 @@
|
||||
},
|
||||
"comments": {
|
||||
"overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: axios (CVE-2025-58754) - awaiting @boxyhq/saml-jackson update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | tar-fs (Dependabot #205) - awaiting upstream dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"next-auth@4.24.12": "patches/next-auth@4.24.12.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,27 +42,22 @@ const setup = async (setupConfig: TConfigInput): Promise<void> => {
|
||||
|
||||
const setUserId = async (userId: string): Promise<void> => {
|
||||
await queue.add(User.setUserId, CommandType.UserAction, true, userId);
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
const setEmail = async (email: string): Promise<void> => {
|
||||
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { email });
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
const setAttribute = async (key: string, value: string): Promise<void> => {
|
||||
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { [key]: value });
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
const setAttributes = async (attributes: Record<string, string>): Promise<void> => {
|
||||
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, attributes);
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
const setLanguage = async (language: string): Promise<void> => {
|
||||
await queue.add(Attribute.setAttributes, CommandType.UserAction, true, { language });
|
||||
await queue.wait();
|
||||
};
|
||||
|
||||
const logout = async (): Promise<void> => {
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { type NetworkError, type Result, err, okVoid } from "@/types/error";
|
||||
import { type NetworkError, type Result, okVoid } from "@/types/error";
|
||||
|
||||
export const setAttributes = async (
|
||||
attributes: Record<string, string>
|
||||
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
|
||||
): Promise<Result<void, NetworkError>> => {
|
||||
const logger = Logger.getInstance();
|
||||
const updateQueue = UpdateQueue.getInstance();
|
||||
updateQueue.updateAttributes(attributes);
|
||||
try {
|
||||
await updateQueue.processUpdates();
|
||||
return okVoid();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to process attribute updates: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
);
|
||||
return err({
|
||||
code: "network_error",
|
||||
message: "Failed to sync attributes",
|
||||
responseMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
void updateQueue.processUpdates();
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
@@ -17,16 +17,6 @@ vi.mock("@/lib/user/update-queue", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the Logger
|
||||
vi.mock("@/lib/common/logger", () => ({
|
||||
Logger: {
|
||||
getInstance: vi.fn(() => ({
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("User Attributes", () => {
|
||||
const mockUpdateQueue = {
|
||||
updateAttributes: vi.fn(),
|
||||
@@ -42,8 +32,6 @@ describe("User Attributes", () => {
|
||||
|
||||
describe("setAttributes", () => {
|
||||
test("successfully updates attributes and triggers processing", async () => {
|
||||
mockUpdateQueue.processUpdates.mockResolvedValue(undefined);
|
||||
|
||||
const result = await setAttributes(mockAttributes);
|
||||
|
||||
// Verify UpdateQueue methods were called correctly
|
||||
@@ -55,8 +43,6 @@ describe("User Attributes", () => {
|
||||
});
|
||||
|
||||
test("processes multiple attribute updates", async () => {
|
||||
mockUpdateQueue.processUpdates.mockResolvedValue(undefined);
|
||||
|
||||
const firstAttributes = { name: mockAttributes.name };
|
||||
const secondAttributes = { email: mockAttributes.email };
|
||||
|
||||
@@ -69,35 +55,22 @@ describe("User Attributes", () => {
|
||||
expect(mockUpdateQueue.processUpdates).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("waits for processUpdates to complete", async () => {
|
||||
test("processes updates asynchronously", async () => {
|
||||
const attributes = { name: mockAttributes.name };
|
||||
let processUpdatesResolved = false;
|
||||
|
||||
// Mock processUpdates to be async and set a flag when resolved
|
||||
// Mock processUpdates to be async
|
||||
mockUpdateQueue.processUpdates.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
processUpdatesResolved = true;
|
||||
resolve(undefined);
|
||||
}, 100);
|
||||
setTimeout(resolve, 100);
|
||||
})
|
||||
);
|
||||
|
||||
const resultPromise = setAttributes(attributes);
|
||||
const result = await setAttributes(attributes);
|
||||
|
||||
// Verify processUpdates was called
|
||||
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
|
||||
|
||||
// Verify the function hasn't resolved yet
|
||||
expect(processUpdatesResolved).toBe(false);
|
||||
|
||||
// Wait for setAttributes to complete
|
||||
const result = await resultPromise;
|
||||
|
||||
// Verify it completed after processUpdates
|
||||
expect(processUpdatesResolved).toBe(true);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
|
||||
// The function returns before processUpdates completes due to void operator
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ describe("user.ts", () => {
|
||||
|
||||
const mockUpdateQueue = {
|
||||
updateUserId: vi.fn(),
|
||||
processUpdates: vi.fn().mockResolvedValue(undefined),
|
||||
processUpdates: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
|
||||
@@ -113,42 +113,6 @@ describe("user.ts", () => {
|
||||
expect(mockUpdateQueue.updateUserId).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns error if processUpdates fails", async () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
user: {
|
||||
data: {
|
||||
userId: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const mockUpdateQueue = {
|
||||
updateUserId: vi.fn(),
|
||||
processUpdates: vi.fn().mockRejectedValue(new Error("Network error")),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
|
||||
getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger);
|
||||
getInstanceUpdateQueueMock.mockReturnValue(mockUpdateQueue as unknown as UpdateQueue);
|
||||
const result = await setUserId(mockUserId);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("network_error");
|
||||
expect(result.error.status).toBe(500);
|
||||
}
|
||||
expect(mockUpdateQueue.updateUserId).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
|
||||
expect(mockLogger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tearDown } from "@/lib/common/setup";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { type ApiErrorResponse, type Result, err, okVoid } from "@/types/error";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
|
||||
export const setUserId = async (userId: string): Promise<Result<void, ApiErrorResponse>> => {
|
||||
const appConfig = Config.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
@@ -26,20 +27,8 @@ export const setUserId = async (userId: string): Promise<Result<void, ApiErrorRe
|
||||
}
|
||||
|
||||
updateQueue.updateUserId(userId);
|
||||
try {
|
||||
await updateQueue.processUpdates();
|
||||
return okVoid();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to process userId update: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
);
|
||||
return err({
|
||||
code: "network_error",
|
||||
message: "Failed to sync userId",
|
||||
responseMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
void updateQueue.processUpdates();
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
export const logout = (): Result<void> => {
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/react.js"],
|
||||
ignorePatterns: ["**/*.stories.tsx", "**/*.stories.ts", "story-helpers.tsx", "**/*.test.ts"],
|
||||
};
|
||||
|
||||
8
packages/survey-ui/.gitignore
vendored
8
packages/survey-ui/.gitignore
vendored
@@ -1,8 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.turbo
|
||||
coverage
|
||||
*.log
|
||||
src/**/*.d.ts
|
||||
src/**/*.d.ts.map
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# @formbricks/survey-ui
|
||||
|
||||
Reusable UI components package for Formbricks applications.
|
||||
|
||||
## Installation
|
||||
|
||||
This package is part of the Formbricks monorepo and is available as a workspace dependency.
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { Button } from "@formbricks/survey-ui";
|
||||
|
||||
function MyComponent() {
|
||||
return (
|
||||
<Button variant="default" size="default">
|
||||
Click me
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build the package
|
||||
pnpm build
|
||||
|
||||
# Watch mode for development
|
||||
pnpm dev
|
||||
|
||||
|
||||
# Lint
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
```text
|
||||
src/
|
||||
├── components/ # React components
|
||||
├── lib/ # Utility functions
|
||||
└── index.ts # Main entry point
|
||||
```
|
||||
|
||||
## Adding New Components
|
||||
|
||||
### Using shadcn CLI (Recommended)
|
||||
|
||||
This package is configured to work with shadcn/ui CLI. You can add components using:
|
||||
|
||||
```bash
|
||||
cd packages/survey-ui
|
||||
pnpm ui:add <component-name>
|
||||
```
|
||||
|
||||
**Important**: After adding a component, reorganize it into a folder structure:
|
||||
|
||||
For example:
|
||||
```bash
|
||||
pnpm ui:add button
|
||||
pnpm ui:organize button
|
||||
```
|
||||
|
||||
Then export the component from `src/components/index.ts`.
|
||||
|
||||
### Manual Component Creation
|
||||
|
||||
1. Create a new component directory under `src/components/<component-name>/`
|
||||
2. Create `index.tsx` inside that directory
|
||||
3. Export the component from `src/components/index.ts`
|
||||
4. The component will be available from the main package export
|
||||
|
||||
## Component Structure
|
||||
|
||||
Components follow this folder structure:
|
||||
|
||||
```text
|
||||
src/components/
|
||||
├── button.tsx
|
||||
├── button.stories.tsx
|
||||
```
|
||||
|
||||
## Theming
|
||||
|
||||
This package uses CSS variables for theming. The theme can be customized by modifying `src/styles/globals.css`.
|
||||
|
||||
Both light and dark modes are supported out of the box.
|
||||
|
||||
## CSS Scoping
|
||||
|
||||
By default, this package builds CSS scoped to `#fbjs` for use in the surveys package. This ensures proper specificity and prevents conflicts with preflight CSS.
|
||||
|
||||
To build unscoped CSS (e.g., for standalone usage or Storybook), set the `SURVEY_UI_UNSCOPED` environment variable:
|
||||
|
||||
```bash
|
||||
SURVEY_UI_UNSCOPED=true pnpm build
|
||||
```
|
||||
|
||||
**Note:** Storybook imports the source CSS directly and compiles it with its own Tailwind config, so it's not affected by this scoping setting.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"hooks": "@/hooks",
|
||||
"lib": "@/lib",
|
||||
"ui": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
},
|
||||
"rsc": false,
|
||||
"style": "new-york",
|
||||
"tailwind": {
|
||||
"baseColor": "slate",
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/styles/globals.css",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"tsx": true
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
{
|
||||
"name": "@formbricks/survey-ui",
|
||||
"license": "MIT",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Reusable UI components for Formbricks applications",
|
||||
"homepage": "https://formbricks.com",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/formbricks/formbricks"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"source": "src/index.ts",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./styles": "./dist/survey-ui.css"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite build --watch --mode dev",
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode dev",
|
||||
"go": "vite build --watch --mode dev",
|
||||
"lint": "eslint src --fix --ext .ts,.js,.tsx,.jsx",
|
||||
"preview": "vite preview",
|
||||
"clean": "rimraf .turbo node_modules dist coverage",
|
||||
"ui:add": "npx shadcn@latest add",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "0.8.2",
|
||||
"@radix-ui/react-checkbox": "1.3.1",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.14",
|
||||
"@radix-ui/react-popover": "1.1.13",
|
||||
"@radix-ui/react-progress": "1.1.8",
|
||||
"@radix-ui/react-radio-group": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"date-fns": "4.1.0",
|
||||
"isomorphic-dompurify": "2.33.0",
|
||||
"lucide-react": "0.507.0",
|
||||
"react-day-picker": "9.6.7",
|
||||
"tailwind-merge": "3.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@storybook/react": "8.5.4",
|
||||
"@storybook/react-vite": "8.5.4",
|
||||
"@tailwindcss/postcss": "4.0.0",
|
||||
"@tailwindcss/vite": "4.1.17",
|
||||
"@types/react": "19.2.1",
|
||||
"@types/react-dom": "19.2.1",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"rimraf": "6.0.1",
|
||||
"tailwindcss": "4.1.1",
|
||||
"vite": "6.4.1",
|
||||
"vite-plugin-dts": "4.5.3",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"@vitest/coverage-v8": "3.1.3",
|
||||
"vitest": "3.1.3"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
pickArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { Consent, type ConsentProps } from "./consent";
|
||||
|
||||
type StoryProps = ConsentProps & Partial<BaseStylingOptions> & Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/Consent",
|
||||
component: Consent,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A consent element that displays a checkbox for users to accept terms, conditions, or agreements.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
checkboxLabel: {
|
||||
control: "text",
|
||||
description: "Label text for the consent checkbox",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "boolean",
|
||||
description: "Whether consent is checked",
|
||||
table: { category: "State" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(Consent),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
elementId: "consent-1",
|
||||
inputId: "consent-input-1",
|
||||
headline: "Terms and Conditions",
|
||||
description: "Please read and accept the terms",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
onChange: () => {},
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...pickArgTypes(inputStylingArgTypes, [
|
||||
"inputBgColor",
|
||||
"inputBorderColor",
|
||||
"inputColor",
|
||||
"inputFontSize",
|
||||
"inputFontWeight",
|
||||
"inputWidth",
|
||||
"inputBorderRadius",
|
||||
"inputPaddingX",
|
||||
"inputPaddingY",
|
||||
]),
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "consent-1",
|
||||
inputId: "consent-input-1",
|
||||
headline: "Terms and Conditions",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "consent-2",
|
||||
inputId: "consent-input-2",
|
||||
headline: "Terms and Conditions",
|
||||
description: "Please read and accept the terms to continue",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithConsent: Story = {
|
||||
args: {
|
||||
elementId: "consent-3",
|
||||
inputId: "consent-input-3",
|
||||
headline: "Terms and Conditions",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
value: true,
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "consent-4",
|
||||
inputId: "consent-input-4",
|
||||
headline: "Terms and Conditions",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
required: true,
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "consent-5",
|
||||
inputId: "consent-input-5",
|
||||
headline: "Terms and Conditions",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
required: true,
|
||||
errorMessage: "You must accept the terms to continue",
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "consent-6",
|
||||
inputId: "consent-input-6",
|
||||
headline: "Terms and Conditions",
|
||||
checkboxLabel: "I agree to the terms and conditions",
|
||||
value: true,
|
||||
disabled: true,
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "consent-rtl",
|
||||
inputId: "consent-input-rtl",
|
||||
headline: "الشروط والأحكام",
|
||||
description: "يرجى قراءة الشروط والموافقة عليها",
|
||||
checkboxLabel: "أوافق على الشروط والأحكام",
|
||||
dir: "rtl",
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithConsent: Story = {
|
||||
args: {
|
||||
elementId: "consent-rtl-checked",
|
||||
inputId: "consent-input-rtl-checked",
|
||||
headline: "الشروط والأحكام",
|
||||
checkboxLabel: "أوافق على الشروط والأحكام",
|
||||
value: true,
|
||||
dir: "rtl",
|
||||
onChange: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<Consent
|
||||
elementId="consent-1"
|
||||
inputId="consent-input-1"
|
||||
headline="Terms and Conditions"
|
||||
description="Please read and accept the terms"
|
||||
checkboxLabel="I agree to the terms and conditions"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<Consent
|
||||
elementId="consent-2"
|
||||
inputId="consent-input-2"
|
||||
headline="Privacy Policy"
|
||||
description="Please review our privacy policy"
|
||||
checkboxLabel="I agree to the privacy policy"
|
||||
value
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Checkbox } from "@/components/general/checkbox";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Props for the Consent element component
|
||||
*/
|
||||
export interface ConsentProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the consent checkbox */
|
||||
inputId: string;
|
||||
/** Label text for the consent checkbox */
|
||||
checkboxLabel: string;
|
||||
/** Whether consent is checked */
|
||||
value?: boolean;
|
||||
/** Callback function called when consent changes */
|
||||
onChange: (checked: boolean) => 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 checkbox is disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function Consent({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
checkboxLabel,
|
||||
value = false,
|
||||
onChange,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
}: Readonly<ConsentProps>): React.JSX.Element {
|
||||
const handleCheckboxChange = (checked: boolean): void => {
|
||||
if (disabled) return;
|
||||
onChange(checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Consent Checkbox */}
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<label
|
||||
htmlFor={`${inputId}-checkbox`}
|
||||
className={cn(
|
||||
"bg-input-bg border-input-border text-input-text w-input px-input-x py-input-y rounded-input flex cursor-pointer items-center gap-3 border p-4 transition-colors",
|
||||
"focus-within:border-ring focus-within:ring-ring/50 font-fontWeight focus-within:shadow-sm",
|
||||
errorMessage && "border-destructive",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
dir={dir}>
|
||||
<Checkbox
|
||||
id={`${inputId}-checkbox`}
|
||||
checked={value}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
aria-required={required}
|
||||
/>
|
||||
{/* need to use style here because tailwind is not able to use css variables for font size and weight */}
|
||||
<span
|
||||
className="font-input-weight text-input-text flex-1"
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}
|
||||
dir={dir}>
|
||||
{checkboxLabel}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Consent };
|
||||
@@ -1,186 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type ButtonStylingOptions,
|
||||
buttonStylingArgTypes,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
elementStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { CTA, type CTAProps } from "./cta";
|
||||
|
||||
type StoryProps = CTAProps & Partial<BaseStylingOptions & ButtonStylingOptions> & Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/CTA",
|
||||
component: CTA,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A Call-to-Action (CTA) element that displays a button. Can optionally open an external URL when clicked.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
buttonLabel: {
|
||||
control: "text",
|
||||
description: "Label text for the CTA button",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
buttonUrl: {
|
||||
control: "text",
|
||||
description: "URL to open when button is clicked (if external)",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
buttonExternal: {
|
||||
control: "boolean",
|
||||
description: "Whether the button opens an external URL",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
buttonVariant: {
|
||||
control: "select",
|
||||
options: ["default", "destructive", "outline", "secondary", "ghost", "link", "custom"],
|
||||
description: "Variant for the button. Must be 'custom' for button styling controls to work.",
|
||||
table: { category: "Button Styling (Only applicable when buttonVariant is 'custom')" },
|
||||
},
|
||||
onClick: {
|
||||
action: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
table: { category: "Events" },
|
||||
},
|
||||
...elementStylingArgTypes,
|
||||
...buttonStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "cta-1",
|
||||
inputId: "cta-input-1",
|
||||
headline: "Ready to get started?",
|
||||
buttonLabel: "Get Started",
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "cta-2",
|
||||
inputId: "cta-input-2",
|
||||
headline: "Ready to get started?",
|
||||
description: "Click the button below to begin your journey",
|
||||
buttonLabel: "Get Started",
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ExternalButton: Story = {
|
||||
args: {
|
||||
elementId: "cta-3",
|
||||
inputId: "cta-input-3",
|
||||
headline: "Learn more about us",
|
||||
description: "Visit our website to learn more",
|
||||
buttonLabel: "Visit Website",
|
||||
buttonUrl: "https://example.com",
|
||||
buttonExternal: true,
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "cta-4",
|
||||
inputId: "cta-input-4",
|
||||
headline: "Ready to get started?",
|
||||
buttonLabel: "Get Started",
|
||||
required: true,
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "cta-5",
|
||||
inputId: "cta-input-5",
|
||||
headline: "Ready to get started?",
|
||||
buttonLabel: "Get Started",
|
||||
required: true,
|
||||
errorMessage: "Please click the button to continue",
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "cta-6",
|
||||
inputId: "cta-input-6",
|
||||
headline: "Ready to get started?",
|
||||
buttonLabel: "Get Started",
|
||||
disabled: true,
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "cta-rtl",
|
||||
inputId: "cta-input-rtl",
|
||||
headline: "هل أنت مستعد للبدء؟",
|
||||
description: "انقر على الزر أدناه للبدء",
|
||||
buttonLabel: "ابدأ الآن",
|
||||
dir: "rtl",
|
||||
onClick: () => {
|
||||
alert("clicked");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<CTA
|
||||
elementId="cta-1"
|
||||
inputId="cta-input-1"
|
||||
headline="Ready to get started?"
|
||||
description="Click the button below to begin"
|
||||
buttonLabel="Get Started"
|
||||
onClick={() => {
|
||||
alert("clicked");
|
||||
}}
|
||||
/>
|
||||
<CTA
|
||||
elementId="cta-2"
|
||||
inputId="cta-input-2"
|
||||
headline="Learn more about us"
|
||||
description="Visit our website"
|
||||
buttonLabel="Visit Website"
|
||||
buttonUrl="https://example.com"
|
||||
buttonExternal
|
||||
onClick={() => {
|
||||
alert("clicked");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
import { SquareArrowOutUpRightIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/general/button";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
|
||||
/**
|
||||
* Props for the CTA (Call to Action) element component
|
||||
*/
|
||||
export interface CTAProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the CTA button */
|
||||
inputId: string;
|
||||
/** Label text for the CTA button */
|
||||
buttonLabel: string;
|
||||
/** URL to open when button is clicked (if external button) */
|
||||
buttonUrl?: string;
|
||||
/** Whether the button opens an external URL */
|
||||
buttonExternal?: boolean;
|
||||
/** Callback function called when button is clicked */
|
||||
onClick: () => 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 button is disabled */
|
||||
disabled?: boolean;
|
||||
/** Variant for the button */
|
||||
buttonVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "custom";
|
||||
}
|
||||
|
||||
function CTA({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
buttonLabel,
|
||||
buttonUrl,
|
||||
buttonExternal = false,
|
||||
onClick,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
buttonVariant = "default",
|
||||
}: Readonly<CTAProps>): React.JSX.Element {
|
||||
const handleButtonClick = (): void => {
|
||||
if (disabled) return;
|
||||
onClick();
|
||||
|
||||
if (buttonExternal && buttonUrl) {
|
||||
window.open(buttonUrl, "_blank")?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="relative space-y-2">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<div className="flex w-full justify-start">
|
||||
<Button
|
||||
id={inputId}
|
||||
type="button"
|
||||
onClick={handleButtonClick}
|
||||
disabled={disabled}
|
||||
className="flex items-center gap-2"
|
||||
variant={buttonVariant}>
|
||||
{buttonLabel}
|
||||
{buttonExternal ? <SquareArrowOutUpRightIcon className="size-4" /> : null}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CTA };
|
||||
@@ -1,315 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type InputLayoutStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { DateElement, type DateElementProps } from "./date";
|
||||
|
||||
type StoryProps = DateElementProps &
|
||||
Partial<BaseStylingOptions & Pick<InputLayoutStylingOptions, "inputBorderRadius">> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/Date",
|
||||
component: DateElement,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete date element that combines headline, description, and a date input. Supports date range constraints, validation, and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
value: {
|
||||
control: "text",
|
||||
description: "Current date value in ISO format (YYYY-MM-DD)",
|
||||
table: { category: "State" },
|
||||
},
|
||||
minDate: {
|
||||
control: "text",
|
||||
description: "Minimum date allowed (ISO format: YYYY-MM-DD)",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
maxDate: {
|
||||
control: "text",
|
||||
description: "Maximum date allowed (ISO format: YYYY-MM-DD)",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
locale: {
|
||||
control: { type: "select" },
|
||||
options: [
|
||||
"en",
|
||||
"de",
|
||||
"fr",
|
||||
"es",
|
||||
"ja",
|
||||
"pt",
|
||||
"pt-BR",
|
||||
"ro",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
"nl",
|
||||
"ar",
|
||||
"it",
|
||||
"ru",
|
||||
"uz",
|
||||
"hi",
|
||||
],
|
||||
description: "Locale code for date formatting (survey language codes: 'en', 'de', 'ar', etc.)",
|
||||
table: { category: "Localization" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(DateElement),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
description: "Please select a date",
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...surveyStylingArgTypes,
|
||||
inputBgColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBorderColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBorderRadius: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
headline: "When would you like to schedule the appointment?",
|
||||
description: "Please select a date for your appointment",
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
description: "Please select your date of birth",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
value: "1990-01-15",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDateRange: Story = {
|
||||
args: {
|
||||
headline: "Select a date for your event",
|
||||
description: "Please choose a date between today and next year",
|
||||
minDate: new Date().toISOString().split("T")[0],
|
||||
maxDate: new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString().split("T")[0],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
description: "Please select your date of birth",
|
||||
errorMessage: "Please select a valid date",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
headline: "This date field is disabled",
|
||||
description: "You cannot change the date",
|
||||
value: "2024-01-15",
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const PastDatesOnly: Story = {
|
||||
args: {
|
||||
headline: "When did you start your current job?",
|
||||
description: "Select a date in the past",
|
||||
maxDate: new Date().toISOString().split("T")[0],
|
||||
},
|
||||
};
|
||||
|
||||
export const FutureDatesOnly: Story = {
|
||||
args: {
|
||||
headline: "When would you like to schedule the meeting?",
|
||||
description: "Select a date in the future",
|
||||
minDate: new Date().toISOString().split("T")[0],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
headline: "ما هو تاريخ ميلادك؟",
|
||||
description: "يرجى اختيار تاريخ",
|
||||
dir: "rtl",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithValue: Story = {
|
||||
args: {
|
||||
headline: "ما هو تاريخ ميلادك؟",
|
||||
description: "يرجى اختيار تاريخ",
|
||||
value: "1990-01-15",
|
||||
dir: "rtl",
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<DateElement
|
||||
elementId="date-1"
|
||||
inputId="date-1-input"
|
||||
headline="What is your date of birth?"
|
||||
description="Please select your date of birth"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<DateElement
|
||||
elementId="date-2"
|
||||
inputId="date-2-input"
|
||||
headline="When would you like to schedule the appointment?"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLocale: Story = {
|
||||
args: {
|
||||
headline: "What is your date of birth?",
|
||||
description: "Date picker with locale-specific formatting",
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
export const LocaleExamples: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">English (en)</h3>
|
||||
<DateElement
|
||||
elementId="date-en"
|
||||
inputId="date-en-input"
|
||||
headline="What is your date of birth?"
|
||||
locale="en"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">German (de)</h3>
|
||||
<DateElement
|
||||
elementId="date-de"
|
||||
inputId="date-de-input"
|
||||
headline="Was ist Ihr Geburtsdatum?"
|
||||
locale="de"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">French (fr)</h3>
|
||||
<DateElement
|
||||
elementId="date-fr"
|
||||
inputId="date-fr-input"
|
||||
headline="Quelle est votre date de naissance ?"
|
||||
locale="fr"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Spanish (es)</h3>
|
||||
<DateElement
|
||||
elementId="date-es"
|
||||
inputId="date-es-input"
|
||||
headline="¿Cuál es su fecha de nacimiento?"
|
||||
locale="es"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Japanese (ja)</h3>
|
||||
<DateElement
|
||||
elementId="date-ja"
|
||||
inputId="date-ja-input"
|
||||
headline="生年月日を教えてください"
|
||||
locale="ja"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Arabic (ar)</h3>
|
||||
<DateElement
|
||||
elementId="date-ar"
|
||||
inputId="date-ar-input"
|
||||
headline="ما هو تاريخ ميلادك؟"
|
||||
locale="ar"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Russian (ru)</h3>
|
||||
<DateElement
|
||||
elementId="date-ru"
|
||||
inputId="date-ru-input"
|
||||
headline="Какова ваша дата рождения?"
|
||||
locale="ru"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 text-sm font-semibold">Chinese Simplified (zh-Hans)</h3>
|
||||
<DateElement
|
||||
elementId="date-zh"
|
||||
inputId="date-zh-input"
|
||||
headline="您的出生日期是什么?"
|
||||
locale="zh-Hans"
|
||||
value="2024-12-25"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -1,148 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Calendar } from "@/components/general/calendar";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { getDateFnsLocale } from "@/lib/locale";
|
||||
|
||||
interface DateElementProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the date input */
|
||||
inputId: string;
|
||||
/** Current date value in ISO format (YYYY-MM-DD) */
|
||||
value?: string;
|
||||
/** Callback function called when the date value changes */
|
||||
onChange: (value: string) => void;
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Minimum date allowed (ISO format: YYYY-MM-DD) */
|
||||
minDate?: string;
|
||||
/** Maximum date allowed (ISO format: YYYY-MM-DD) */
|
||||
maxDate?: string;
|
||||
/** 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 date input is disabled */
|
||||
disabled?: boolean;
|
||||
/** Locale code for date formatting (e.g., "en-US", "de-DE", "fr-FR"). Defaults to browser locale or "en-US" */
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
function DateElement({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
value,
|
||||
onChange,
|
||||
required = false,
|
||||
minDate,
|
||||
maxDate,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
locale = "en-US",
|
||||
}: Readonly<DateElementProps>): React.JSX.Element {
|
||||
// Initialize date from value string, parsing as local time to avoid timezone issues
|
||||
const [date, setDate] = React.useState<Date | undefined>(() => {
|
||||
if (!value) return undefined;
|
||||
// Parse YYYY-MM-DD format as local date (not UTC)
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
});
|
||||
|
||||
// Sync date state when value prop changes
|
||||
React.useEffect(() => {
|
||||
if (value) {
|
||||
// Parse YYYY-MM-DD format as local date (not UTC)
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
const newDate = new Date(year, month - 1, day);
|
||||
setDate((prevDate) => {
|
||||
// Only update if the date actually changed to avoid unnecessary re-renders
|
||||
if (!prevDate || newDate.getTime() !== prevDate.getTime()) {
|
||||
return newDate;
|
||||
}
|
||||
return prevDate;
|
||||
});
|
||||
} else {
|
||||
setDate(undefined);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Convert Date to ISO string (YYYY-MM-DD) when date changes
|
||||
const handleDateSelect = (selectedDate: Date | undefined): void => {
|
||||
setDate(selectedDate);
|
||||
if (selectedDate) {
|
||||
// Convert to ISO format (YYYY-MM-DD) using local time to avoid timezone issues
|
||||
const year = String(selectedDate.getFullYear());
|
||||
const month = String(selectedDate.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(selectedDate.getDate()).padStart(2, "0");
|
||||
const isoString = `${year}-${month}-${day}`;
|
||||
onChange(isoString);
|
||||
} else {
|
||||
onChange("");
|
||||
}
|
||||
};
|
||||
|
||||
// Convert minDate/maxDate strings to Date objects
|
||||
const minDateObj = minDate ? new Date(minDate) : undefined;
|
||||
const maxDateObj = maxDate ? new Date(maxDate) : undefined;
|
||||
|
||||
// Create disabled function for date restrictions
|
||||
const isDateDisabled = React.useCallback(
|
||||
(dateToCheck: Date): boolean => {
|
||||
if (disabled) return true;
|
||||
if (minDateObj) {
|
||||
const minAtMidnight = new Date(minDateObj.getFullYear(), minDateObj.getMonth(), minDateObj.getDate());
|
||||
const checkAtMidnight = new Date(
|
||||
dateToCheck.getFullYear(),
|
||||
dateToCheck.getMonth(),
|
||||
dateToCheck.getDate()
|
||||
);
|
||||
if (checkAtMidnight < minAtMidnight) return true;
|
||||
}
|
||||
if (maxDateObj) {
|
||||
const maxAtMidnight = new Date(maxDateObj.getFullYear(), maxDateObj.getMonth(), maxDateObj.getDate());
|
||||
const checkAtMidnight = new Date(
|
||||
dateToCheck.getFullYear(),
|
||||
dateToCheck.getMonth(),
|
||||
dateToCheck.getDate()
|
||||
);
|
||||
if (checkAtMidnight > maxAtMidnight) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[disabled, minDateObj, maxDateObj]
|
||||
);
|
||||
|
||||
// Get locale for date formatting
|
||||
const dateLocale = React.useMemo(() => {
|
||||
return locale ? getDateFnsLocale(locale) : undefined;
|
||||
}, [locale]);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Calendar - Always visible */}
|
||||
<div className="w-full">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
captionLayout="dropdown"
|
||||
disabled={isDateDisabled}
|
||||
onSelect={handleDateSelect}
|
||||
locale={dateLocale}
|
||||
className="rounded-input border-input-border bg-input-bg text-input-text shadow-input mx-auto w-full max-w-[25rem] border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DateElement };
|
||||
export type { DateElementProps };
|
||||
@@ -1,244 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type InputLayoutStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
pickArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { FileUpload, type FileUploadProps, type UploadedFile } from "./file-upload";
|
||||
|
||||
type StoryProps = FileUploadProps &
|
||||
Partial<BaseStylingOptions & InputLayoutStylingOptions> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/FileUpload",
|
||||
component: FileUpload,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete file upload element that combines headline, description, and a file upload area with drag-and-drop support. Supports file type restrictions, size limits, multiple files, validation, and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Array of uploaded files",
|
||||
table: { category: "State" },
|
||||
},
|
||||
allowMultiple: {
|
||||
control: "boolean",
|
||||
description: "Whether multiple files are allowed",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
maxSizeInMB: {
|
||||
control: "number",
|
||||
description: "Maximum file size in MB",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
allowedFileExtensions: {
|
||||
control: "object",
|
||||
description: "Allowed file extensions (e.g., ['.pdf', '.jpg'])",
|
||||
table: { category: "Validation" },
|
||||
},
|
||||
...elementStylingArgTypes,
|
||||
...pickArgTypes(inputStylingArgTypes, [
|
||||
"inputBgColor",
|
||||
"inputBorderColor",
|
||||
"inputColor",
|
||||
"inputFontSize",
|
||||
"inputFontWeight",
|
||||
"inputWidth",
|
||||
"inputHeight",
|
||||
"inputBorderRadius",
|
||||
"inputPaddingX",
|
||||
"inputPaddingY",
|
||||
]),
|
||||
},
|
||||
render: function Render(args: StoryProps) {
|
||||
const [value, setValue] = useState(args.value);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(args.value);
|
||||
}, [args.value]);
|
||||
|
||||
return (
|
||||
<FileUpload
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
args.onChange?.(v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
headline: "Upload your file",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
headline: "Upload your resume",
|
||||
description: "Please upload your resume in PDF format",
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleFile: Story = {
|
||||
args: {
|
||||
headline: "Upload a single file",
|
||||
description: "Select one file to upload",
|
||||
allowMultiple: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleFiles: Story = {
|
||||
args: {
|
||||
headline: "Upload multiple files",
|
||||
description: "You can upload multiple files at once",
|
||||
allowMultiple: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithFileTypeRestrictions: Story = {
|
||||
args: {
|
||||
headline: "Upload an image",
|
||||
description: "Please upload an image file",
|
||||
allowedFileExtensions: [".jpg", ".jpeg", ".png", ".gif", ".webp"],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSizeLimit: Story = {
|
||||
args: {
|
||||
headline: "Upload a document",
|
||||
description: "Maximum file size: 5MB",
|
||||
maxSizeInMB: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRestrictions: Story = {
|
||||
args: {
|
||||
headline: "Upload a PDF document",
|
||||
description: "PDF files only, maximum 10MB",
|
||||
allowedFileExtensions: [".pdf"],
|
||||
maxSizeInMB: 10,
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
headline: "Upload required file",
|
||||
description: "Please upload a file",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithUploadedFiles: Story = {
|
||||
args: {
|
||||
headline: "Upload your files",
|
||||
description: "Files you've uploaded",
|
||||
allowMultiple: true,
|
||||
value: [
|
||||
{
|
||||
name: "document.pdf",
|
||||
url: "data:application/pdf;base64,...",
|
||||
size: 1024 * 500, // 500 KB
|
||||
},
|
||||
{
|
||||
name: "image.jpg",
|
||||
url: "data:image/jpeg;base64,...",
|
||||
size: 1024 * 1024 * 2, // 2 MB
|
||||
},
|
||||
] as UploadedFile[],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
headline: "Upload your file",
|
||||
description: "Please upload a file",
|
||||
errorMessage: "Please upload at least one file",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
headline: "This upload is disabled",
|
||||
description: "You cannot upload files",
|
||||
value: [
|
||||
{
|
||||
name: "existing-file.pdf",
|
||||
url: "data:application/pdf;base64,...",
|
||||
size: 1024 * 300,
|
||||
},
|
||||
] as UploadedFile[],
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
headline: "قم بتحميل ملفك",
|
||||
description: "يرجى اختيار ملف للتحميل",
|
||||
dir: "rtl",
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithFiles: Story = {
|
||||
args: {
|
||||
headline: "قم بتحميل ملفاتك",
|
||||
description: "الملفات التي قمت بتحميلها",
|
||||
allowMultiple: true,
|
||||
value: [
|
||||
{
|
||||
name: "ملف.pdf",
|
||||
url: "data:application/pdf;base64,...",
|
||||
size: 1024 * 500,
|
||||
},
|
||||
] as UploadedFile[],
|
||||
dir: "rtl",
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<FileUpload
|
||||
elementId="file-1"
|
||||
inputId="file-1-input"
|
||||
headline="Upload your resume"
|
||||
description="PDF format only"
|
||||
allowedFileExtensions={[".pdf"]}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<FileUpload
|
||||
elementId="file-2"
|
||||
inputId="file-2-input"
|
||||
headline="Upload multiple images"
|
||||
description="You can upload multiple images"
|
||||
allowMultiple
|
||||
allowedFileExtensions={[".jpg", ".png", ".gif"]}
|
||||
maxSizeInMB={5}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -1,336 +0,0 @@
|
||||
import { Upload, UploadIcon, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Input } from "@/components/general/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Uploaded file information
|
||||
*/
|
||||
export interface UploadedFile {
|
||||
/** File name */
|
||||
name: string;
|
||||
/** File URL or data URL */
|
||||
url: string;
|
||||
/** File size in bytes */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
interface FileUploadProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the file input */
|
||||
inputId: string;
|
||||
/** Currently uploaded files */
|
||||
value?: UploadedFile[];
|
||||
/** Callback function called when files change */
|
||||
onChange: (files: UploadedFile[]) => void;
|
||||
/** Callback function called when files are selected (before validation) */
|
||||
onFileSelect?: (files: FileList) => void;
|
||||
/** Whether multiple files are allowed */
|
||||
allowMultiple?: boolean;
|
||||
/** Allowed file extensions (e.g., ['.pdf', '.jpg', '.png']) */
|
||||
allowedFileExtensions?: string[];
|
||||
/** Whether the field is required (shows asterisk indicator) */
|
||||
required?: boolean;
|
||||
/** Error message to display */
|
||||
errorMessage?: string;
|
||||
/** Whether the component is in uploading state */
|
||||
isUploading?: boolean;
|
||||
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
/** Whether the file input is disabled */
|
||||
disabled?: boolean;
|
||||
/** Image URL to display above the headline */
|
||||
imageUrl?: string;
|
||||
/** Video URL to display above the headline */
|
||||
videoUrl?: string;
|
||||
/** Alt text for the image */
|
||||
imageAltText?: string;
|
||||
/** Placeholder text for the file upload */
|
||||
placeholderText?: string;
|
||||
}
|
||||
|
||||
interface UploadedFileItemProps {
|
||||
file: UploadedFile;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
onDelete: (index: number, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function UploadedFileItem({
|
||||
file,
|
||||
index,
|
||||
disabled,
|
||||
onDelete,
|
||||
}: Readonly<UploadedFileItemProps>): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-input-border bg-accent-selected text-input-text rounded-input relative m-1 rounded-md border"
|
||||
)}>
|
||||
<div className="absolute top-0 right-0 m-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
onDelete(index, e);
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex h-5 w-5 cursor-pointer items-center justify-center rounded-md",
|
||||
"bg-background hover:bg-accent",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
aria-label={`Delete ${file.name}`}>
|
||||
<X className="text-foreground h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center p-2">
|
||||
<UploadIcon />
|
||||
<p
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}
|
||||
className="mt-1 w-full overflow-hidden px-2 text-center overflow-ellipsis whitespace-nowrap text-[var(--foreground)]"
|
||||
title={file.name}>
|
||||
{file.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UploadedFilesListProps {
|
||||
files: UploadedFile[];
|
||||
disabled: boolean;
|
||||
onDelete: (index: number, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function UploadedFilesList({
|
||||
files,
|
||||
disabled,
|
||||
onDelete,
|
||||
}: Readonly<UploadedFilesListProps>): React.JSX.Element | null {
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2 p-2">
|
||||
{files.map((file, index) => (
|
||||
<UploadedFileItem
|
||||
key={`${file.name}-${file.url}`}
|
||||
file={file}
|
||||
index={index}
|
||||
disabled={disabled}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface UploadAreaProps {
|
||||
inputId: string;
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
placeholderText: string;
|
||||
allowMultiple: boolean;
|
||||
acceptAttribute?: string;
|
||||
required: boolean;
|
||||
disabled: boolean;
|
||||
dir: "ltr" | "rtl" | "auto";
|
||||
onFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onDragOver: (e: React.DragEvent<HTMLLabelElement>) => void;
|
||||
onDrop: (e: React.DragEvent<HTMLLabelElement>) => void;
|
||||
showUploader: boolean;
|
||||
}
|
||||
|
||||
function UploadArea({
|
||||
inputId,
|
||||
fileInputRef,
|
||||
placeholderText,
|
||||
allowMultiple,
|
||||
acceptAttribute,
|
||||
required,
|
||||
disabled,
|
||||
dir,
|
||||
onFileChange,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
showUploader,
|
||||
}: Readonly<UploadAreaProps>): React.JSX.Element | null {
|
||||
if (!showUploader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
className={cn("block w-full", disabled && "cursor-not-allowed")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex w-full flex-col items-center justify-center py-6",
|
||||
"hover:cursor-pointer",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
aria-label="Upload files by clicking or dragging them here">
|
||||
<Upload className="text-input-text h-6" aria-hidden="true" />
|
||||
<span
|
||||
className="text-input-text font-input-weight m-2"
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}
|
||||
id={`${inputId}-label`}>
|
||||
{placeholderText}
|
||||
</span>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id={inputId}
|
||||
className="hidden"
|
||||
multiple={allowMultiple}
|
||||
accept={acceptAttribute}
|
||||
onChange={onFileChange}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
dir={dir}
|
||||
aria-label="File upload"
|
||||
aria-describedby={`${inputId}-label`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function FileUpload({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
value = [],
|
||||
onChange,
|
||||
onFileSelect,
|
||||
allowMultiple = false,
|
||||
allowedFileExtensions,
|
||||
required = false,
|
||||
errorMessage,
|
||||
isUploading = false,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
imageAltText,
|
||||
placeholderText = "Click or drag to upload files",
|
||||
}: Readonly<FileUploadProps>): React.JSX.Element {
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Ensure value is always an array
|
||||
const uploadedFiles = Array.isArray(value) ? value : [];
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
if (!e.target.files || disabled) return;
|
||||
if (onFileSelect) {
|
||||
onFileSelect(e.target.files);
|
||||
}
|
||||
// Reset input to allow selecting the same file again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onFileSelect && e.dataTransfer.files.length > 0) {
|
||||
onFileSelect(e.dataTransfer.files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = (index: number, e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
const updatedFiles = [...uploadedFiles];
|
||||
updatedFiles.splice(index, 1);
|
||||
onChange(updatedFiles);
|
||||
};
|
||||
|
||||
// Build accept attribute from allowed extensions
|
||||
const acceptAttribute = allowedFileExtensions
|
||||
?.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
|
||||
.join(",");
|
||||
|
||||
// Show uploader if uploading, or if multiple files allowed, or if no files uploaded yet
|
||||
const showUploader = isUploading || allowMultiple || uploadedFiles.length === 0;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
imageUrl={imageUrl}
|
||||
videoUrl={videoUrl}
|
||||
imageAltText={imageAltText}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"w-input px-input-x py-input-y rounded-input relative flex flex-col items-center justify-center border-2 border-dashed transition-colors",
|
||||
errorMessage ? "border-destructive" : "border-input-border bg-accent",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}>
|
||||
<UploadedFilesList files={uploadedFiles} disabled={disabled} onDelete={handleDeleteFile} />
|
||||
|
||||
<div className="w-full">
|
||||
{isUploading ? (
|
||||
<div className="flex animate-pulse items-center justify-center rounded-lg py-4">
|
||||
<p
|
||||
className="text-muted-foreground font-medium"
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}>
|
||||
Uploading...
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<UploadArea
|
||||
inputId={inputId}
|
||||
fileInputRef={fileInputRef}
|
||||
placeholderText={placeholderText}
|
||||
allowMultiple={allowMultiple}
|
||||
acceptAttribute={acceptAttribute}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
dir={dir}
|
||||
onFileChange={handleFileChange}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
showUploader={showUploader}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { FileUpload };
|
||||
export type { FileUploadProps };
|
||||
@@ -1,362 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type InputLayoutStylingOptions,
|
||||
type LabelStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
} from "../../lib/story-helpers";
|
||||
import { FormField, type FormFieldConfig, type FormFieldProps } from "./form-field";
|
||||
|
||||
type StoryProps = FormFieldProps &
|
||||
Partial<BaseStylingOptions & LabelStylingOptions & InputLayoutStylingOptions & { inputShadow: string }> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/FormField",
|
||||
component: FormField,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A flexible form field element that can display multiple input fields with different configurations. Replaces Contact Info and Address elements.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
fields: {
|
||||
control: "object",
|
||||
description: "Array of form field configurations",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Current values as a record mapping field IDs to their values",
|
||||
table: { category: "State" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(FormField),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
// Decorator to apply CSS variables from story args
|
||||
|
||||
// Contact Info fields preset
|
||||
const contactInfoFields: FormFieldConfig[] = [
|
||||
{ id: "firstName", label: "First Name", placeholder: "First Name", required: true, show: true },
|
||||
{ id: "lastName", label: "Last Name", placeholder: "Last Name", required: true, show: true },
|
||||
{ id: "email", label: "Email", placeholder: "Email", type: "email", required: true, show: true },
|
||||
{ id: "phone", label: "Phone", placeholder: "Phone", type: "tel", required: true, show: true },
|
||||
{ id: "company", label: "Company", placeholder: "Company", required: true, show: true },
|
||||
];
|
||||
|
||||
// Address fields preset
|
||||
const addressFields: FormFieldConfig[] = [
|
||||
{ id: "addressLine1", label: "Address Line 1", placeholder: "Address Line 1", required: true, show: true },
|
||||
{ id: "addressLine2", label: "Address Line 2", placeholder: "Address Line 2", required: true, show: true },
|
||||
{ id: "city", label: "City", placeholder: "City", required: true, show: true },
|
||||
{ id: "state", label: "State", placeholder: "State", required: true, show: true },
|
||||
{ id: "zip", label: "Zip", placeholder: "Zip", required: true, show: true },
|
||||
{ id: "country", label: "Country", placeholder: "Country", required: true, show: true },
|
||||
];
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
elementId: "form-field-1",
|
||||
headline: "Please provide your contact information",
|
||||
description: "We'll use this to contact you",
|
||||
fields: contactInfoFields,
|
||||
},
|
||||
argTypes: {
|
||||
elementHeadlineFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementHeadlineColor: {
|
||||
control: "color",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
elementDescriptionColor: {
|
||||
control: "color",
|
||||
table: { category: "Element Styling" },
|
||||
},
|
||||
labelFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
labelFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
labelFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
labelColor: {
|
||||
control: "color",
|
||||
table: { category: "Label Styling" },
|
||||
},
|
||||
inputWidth: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputHeight: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBgColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBorderColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputBorderRadius: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputFontFamily: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputFontSize: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputFontWeight: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputColor: {
|
||||
control: "color",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputPaddingX: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputPaddingY: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
inputShadow: {
|
||||
control: "text",
|
||||
table: { category: "Input Styling" },
|
||||
},
|
||||
brandColor: {
|
||||
control: "color",
|
||||
table: { category: "Survey Styling" },
|
||||
},
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "form-field-1",
|
||||
headline: "Please provide your contact information",
|
||||
fields: contactInfoFields,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "form-field-2",
|
||||
headline: "Please provide your contact information",
|
||||
description: "We'll use this to contact you about your inquiry",
|
||||
fields: contactInfoFields,
|
||||
},
|
||||
};
|
||||
|
||||
export const ContactInfo: Story = {
|
||||
args: {
|
||||
elementId: "form-field-contact",
|
||||
headline: "Contact Information",
|
||||
description: "Please provide your contact details",
|
||||
fields: contactInfoFields,
|
||||
},
|
||||
};
|
||||
|
||||
export const Address: Story = {
|
||||
args: {
|
||||
elementId: "form-field-address",
|
||||
headline: "Shipping Address",
|
||||
description: "Please provide your shipping address",
|
||||
fields: addressFields,
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "form-field-3",
|
||||
headline: "Please provide your contact information",
|
||||
fields: contactInfoFields,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValues: Story = {
|
||||
args: {
|
||||
elementId: "form-field-4",
|
||||
headline: "Please provide your contact information",
|
||||
fields: contactInfoFields,
|
||||
value: {
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
email: "john.doe@example.com",
|
||||
phone: "+1234567890",
|
||||
company: "Acme Inc.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "form-field-5",
|
||||
headline: "Please provide your contact information",
|
||||
fields: contactInfoFields,
|
||||
required: true,
|
||||
errorMessage: "Please fill in all required fields",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "form-field-6",
|
||||
headline: "Please provide your contact information",
|
||||
fields: contactInfoFields,
|
||||
disabled: true,
|
||||
value: {
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
email: "john.doe@example.com",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const PartialFields: Story = {
|
||||
args: {
|
||||
elementId: "form-field-7",
|
||||
headline: "Basic Information",
|
||||
fields: [
|
||||
{ id: "firstName", label: "First Name", placeholder: "First Name", required: true, show: true },
|
||||
{ id: "lastName", label: "Last Name", placeholder: "Last Name", required: true, show: true },
|
||||
{ id: "email", label: "Email", placeholder: "Email", type: "email", required: false, show: true },
|
||||
{
|
||||
id: "phone",
|
||||
label: "Phone",
|
||||
placeholder: "Phone (optional)",
|
||||
type: "tel",
|
||||
required: false,
|
||||
show: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const OptionalFields: Story = {
|
||||
args: {
|
||||
elementId: "form-field-8",
|
||||
headline: "Optional Information",
|
||||
fields: [
|
||||
{ id: "firstName", label: "First Name", placeholder: "First Name", required: false, show: true },
|
||||
{ id: "lastName", label: "Last Name", placeholder: "Last Name", required: false, show: true },
|
||||
{ id: "email", label: "Email", placeholder: "Email", type: "email", required: false, show: true },
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "form-field-rtl",
|
||||
headline: "يرجى تقديم معلومات الاتصال الخاصة بك",
|
||||
description: "سنستخدم هذا للاتصال بك",
|
||||
dir: "rtl",
|
||||
fields: [
|
||||
{ id: "firstName", label: "الاسم الأول", placeholder: "الاسم الأول", required: true, show: true },
|
||||
{ id: "lastName", label: "اسم العائلة", placeholder: "اسم العائلة", required: true, show: true },
|
||||
{
|
||||
id: "email",
|
||||
label: "البريد الإلكتروني",
|
||||
placeholder: "البريد الإلكتروني",
|
||||
type: "email",
|
||||
required: true,
|
||||
show: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithValues: Story = {
|
||||
args: {
|
||||
elementId: "form-field-rtl-values",
|
||||
dir: "rtl",
|
||||
headline: "يرجى تقديم معلومات الاتصال الخاصة بك",
|
||||
fields: [
|
||||
{ id: "firstName", label: "الاسم الأول", placeholder: "الاسم الأول", required: true, show: true },
|
||||
{ id: "lastName", label: "اسم العائلة", placeholder: "اسم العائلة", required: true, show: true },
|
||||
{
|
||||
id: "email",
|
||||
label: "البريد الإلكتروني",
|
||||
placeholder: "البريد الإلكتروني",
|
||||
type: "email",
|
||||
required: true,
|
||||
show: true,
|
||||
},
|
||||
],
|
||||
value: {
|
||||
firstName: "أحمد",
|
||||
lastName: "محمد",
|
||||
email: "ahmed@example.com",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<FormField
|
||||
elementId="form-field-1"
|
||||
headline="Contact Information"
|
||||
description="Please provide your contact details"
|
||||
fields={contactInfoFields}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<FormField
|
||||
elementId="form-field-2"
|
||||
headline="Shipping Address"
|
||||
description="Where should we ship your order?"
|
||||
fields={addressFields}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -1,140 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Input } from "@/components/general/input";
|
||||
import { Label } from "@/components/general/label";
|
||||
|
||||
/**
|
||||
* Form field configuration
|
||||
*/
|
||||
export interface FormFieldConfig {
|
||||
/** Unique identifier for the field */
|
||||
id: string;
|
||||
/** Label text for the field */
|
||||
label: string;
|
||||
/** Placeholder text for the input */
|
||||
placeholder?: string;
|
||||
/** Input type (text, email, tel, number, url, etc.) */
|
||||
type?: "text" | "email" | "tel" | "number" | "url";
|
||||
/** Whether this field is required */
|
||||
required?: boolean;
|
||||
/** Whether this field should be shown */
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
interface FormFieldProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Array of form field configurations */
|
||||
fields: FormFieldConfig[];
|
||||
/** Current values as a record mapping field IDs to their values */
|
||||
value?: Record<string, string>;
|
||||
/** Callback function called when any field value changes */
|
||||
onChange: (value: Record<string, string>) => void;
|
||||
/** Whether the entire form 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;
|
||||
}
|
||||
|
||||
function FormField({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
fields,
|
||||
value = {},
|
||||
onChange,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
}: Readonly<FormFieldProps>): React.JSX.Element {
|
||||
// Ensure value is always an object
|
||||
const currentValues = React.useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- value can be undefined
|
||||
return value ?? {};
|
||||
}, [value]);
|
||||
|
||||
// Determine if a field is required
|
||||
const isFieldRequired = (field: FormFieldConfig): boolean => {
|
||||
if (field.required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If all fields are optional and the form is required, then fields should be required
|
||||
const visibleFields = fields.filter((f) => f.show !== false);
|
||||
const allOptional = visibleFields.every((f) => !f.required);
|
||||
if (allOptional && required) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Handle field value change
|
||||
const handleFieldChange = (fieldId: string, fieldValue: string): void => {
|
||||
onChange({
|
||||
...currentValues,
|
||||
[fieldId]: fieldValue,
|
||||
});
|
||||
};
|
||||
|
||||
// Get visible fields
|
||||
const visibleFields = fields.filter((field) => field.show !== false);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} />
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="relative space-y-3">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
{visibleFields.map((field) => {
|
||||
const fieldRequired = isFieldRequired(field);
|
||||
const fieldValue = currentValues[field.id] ?? "";
|
||||
const fieldInputId = `${elementId}-${field.id}`;
|
||||
|
||||
// Determine input type
|
||||
let inputType: "text" | "email" | "tel" | "number" | "url" = field.type ?? "text";
|
||||
if (field.id === "email" && !field.type) {
|
||||
inputType = "email";
|
||||
} else if (field.id === "phone" && !field.type) {
|
||||
inputType = "tel";
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field.id} className="space-y-2">
|
||||
<Label htmlFor={fieldInputId} variant="default">
|
||||
{fieldRequired ? `${field.label}*` : field.label}
|
||||
</Label>
|
||||
<Input
|
||||
id={fieldInputId}
|
||||
type={inputType}
|
||||
value={fieldValue}
|
||||
onChange={(e) => {
|
||||
handleFieldChange(field.id, e.target.value);
|
||||
}}
|
||||
required={fieldRequired}
|
||||
disabled={disabled}
|
||||
dir={dir}
|
||||
aria-invalid={Boolean(errorMessage) || undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { FormField };
|
||||
export type { FormFieldProps };
|
||||
@@ -1,307 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type InputLayoutStylingOptions,
|
||||
type LabelStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
createStatefulRender,
|
||||
elementStylingArgTypes,
|
||||
inputStylingArgTypes,
|
||||
labelStylingArgTypes,
|
||||
pickArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { Matrix, type MatrixOption, type MatrixProps } from "./matrix";
|
||||
|
||||
type StoryProps = MatrixProps &
|
||||
Partial<BaseStylingOptions & LabelStylingOptions & InputLayoutStylingOptions> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/Matrix",
|
||||
component: Matrix,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete matrix element that combines headline, description, and a table with rows and columns. Each row can have one selected column value. Supports validation and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
rows: {
|
||||
control: "object",
|
||||
description: "Array of row options (left side)",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
columns: {
|
||||
control: "object",
|
||||
description: "Array of column options (top header)",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Record mapping row ID to column ID",
|
||||
table: { category: "State" },
|
||||
},
|
||||
},
|
||||
render: createStatefulRender(Matrix),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
// Decorator to apply CSS variables from story args
|
||||
|
||||
const defaultRows: MatrixOption[] = [
|
||||
{ id: "row-1", label: "Row 1" },
|
||||
{ id: "row-2", label: "Row 2" },
|
||||
{ id: "row-3", label: "Row 3" },
|
||||
];
|
||||
|
||||
const defaultColumns: MatrixOption[] = [
|
||||
{ id: "col-1", label: "Column 1" },
|
||||
{ id: "col-2", label: "Column 2" },
|
||||
{ id: "col-3", label: "Column 3" },
|
||||
{ id: "col-4", label: "Column 4" },
|
||||
];
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
headline: "Rate each item",
|
||||
description: "Select a value for each row",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...labelStylingArgTypes,
|
||||
...pickArgTypes(inputStylingArgTypes, ["inputBgColor", "inputBorderColor"]),
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
elementId: "matrix-default",
|
||||
inputId: "matrix-default-input",
|
||||
headline: "Rate each item",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
elementId: "matrix-with-description",
|
||||
inputId: "matrix-with-description-input",
|
||||
headline: "How satisfied are you with each feature?",
|
||||
description: "Please rate each feature on a scale from 1 to 5",
|
||||
rows: [
|
||||
{ id: "feature-1", label: "Feature 1" },
|
||||
{ id: "feature-2", label: "Feature 2" },
|
||||
{ id: "feature-3", label: "Feature 3" },
|
||||
],
|
||||
columns: [
|
||||
{ id: "1", label: "1" },
|
||||
{ id: "2", label: "2" },
|
||||
{ id: "3", label: "3" },
|
||||
{ id: "4", label: "4" },
|
||||
{ id: "5", label: "5" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
elementId: "matrix-required",
|
||||
inputId: "matrix-required-input",
|
||||
headline: "Rate each item",
|
||||
description: "Please select a value for each row",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelections: Story = {
|
||||
args: {
|
||||
elementId: "matrix-selections",
|
||||
inputId: "matrix-selections-input",
|
||||
headline: "Rate each item",
|
||||
description: "Select a value for each row",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
value: {
|
||||
"row-1": "col-2",
|
||||
"row-2": "col-3",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
elementId: "matrix-error",
|
||||
inputId: "matrix-error-input",
|
||||
headline: "Rate each item",
|
||||
description: "Please select a value for each row",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
errorMessage: "Please complete all rows",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
elementId: "matrix-disabled",
|
||||
inputId: "matrix-disabled-input",
|
||||
headline: "This element is disabled",
|
||||
description: "You cannot change the selection",
|
||||
rows: defaultRows,
|
||||
columns: defaultColumns,
|
||||
value: {
|
||||
"row-1": "col-2",
|
||||
"row-2": "col-3",
|
||||
},
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const RatingScale: Story = {
|
||||
args: {
|
||||
elementId: "matrix-rating-scale",
|
||||
inputId: "matrix-rating-scale-input",
|
||||
headline: "Rate your experience",
|
||||
description: "How would you rate each aspect?",
|
||||
rows: [
|
||||
{ id: "quality", label: "Quality" },
|
||||
{ id: "service", label: "Service" },
|
||||
{ id: "value", label: "Value for Money" },
|
||||
{ id: "support", label: "Customer Support" },
|
||||
],
|
||||
columns: [
|
||||
{ id: "poor", label: "Poor" },
|
||||
{ id: "fair", label: "Fair" },
|
||||
{ id: "good", label: "Good" },
|
||||
{ id: "very-good", label: "Very Good" },
|
||||
{ id: "excellent", label: "Excellent" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const NumericScale: Story = {
|
||||
args: {
|
||||
elementId: "matrix-numeric-scale",
|
||||
inputId: "matrix-numeric-scale-input",
|
||||
headline: "Rate from 0 to 10",
|
||||
description: "Select a number for each item",
|
||||
rows: [
|
||||
{ id: "item-1", label: "Item 1" },
|
||||
{ id: "item-2", label: "Item 2" },
|
||||
{ id: "item-3", label: "Item 3" },
|
||||
],
|
||||
columns: [
|
||||
{ id: "0", label: "0" },
|
||||
{ id: "1", label: "1" },
|
||||
{ id: "2", label: "2" },
|
||||
{ id: "3", label: "3" },
|
||||
{ id: "4", label: "4" },
|
||||
{ id: "5", label: "5" },
|
||||
{ id: "6", label: "6" },
|
||||
{ id: "7", label: "7" },
|
||||
{ id: "8", label: "8" },
|
||||
{ id: "9", label: "9" },
|
||||
{ id: "10", label: "10" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
elementId: "matrix-rtl",
|
||||
inputId: "matrix-rtl-input",
|
||||
headline: "قيم كل عنصر",
|
||||
description: "اختر قيمة لكل صف",
|
||||
dir: "rtl",
|
||||
rows: [
|
||||
{ id: "row-1", label: "الصف الأول" },
|
||||
{ id: "row-2", label: "الصف الثاني" },
|
||||
{ id: "row-3", label: "الصف الثالث" },
|
||||
],
|
||||
columns: [
|
||||
{ id: "col-1", label: "عمود 1" },
|
||||
{ id: "col-2", label: "عمود 2" },
|
||||
{ id: "col-3", label: "عمود 3" },
|
||||
{ id: "col-4", label: "عمود 4" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithSelections: Story = {
|
||||
args: {
|
||||
elementId: "matrix-rtl-selections",
|
||||
inputId: "matrix-rtl-selections-input",
|
||||
dir: "rtl",
|
||||
headline: "قيم كل عنصر",
|
||||
description: "يرجى اختيار قيمة لكل صف",
|
||||
rows: [
|
||||
{ id: "quality", label: "الجودة" },
|
||||
{ id: "service", label: "الخدمة" },
|
||||
{ id: "value", label: "القيمة" },
|
||||
],
|
||||
columns: [
|
||||
{ id: "poor", label: "ضعيف" },
|
||||
{ id: "fair", label: "مقبول" },
|
||||
{ id: "good", label: "جيد" },
|
||||
{ id: "very-good", label: "جيد جداً" },
|
||||
{ id: "excellent", label: "ممتاز" },
|
||||
],
|
||||
value: {
|
||||
quality: "good",
|
||||
service: "very-good",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<Matrix
|
||||
elementId="matrix-1"
|
||||
inputId="matrix-1-input"
|
||||
headline="Rate each item"
|
||||
description="Select a value for each row"
|
||||
rows={defaultRows}
|
||||
columns={defaultColumns}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<Matrix
|
||||
elementId="matrix-2"
|
||||
inputId="matrix-2-input"
|
||||
headline="How satisfied are you?"
|
||||
rows={[
|
||||
{ id: "feature-1", label: "Feature 1" },
|
||||
{ id: "feature-2", label: "Feature 2" },
|
||||
]}
|
||||
columns={[
|
||||
{ id: "1", label: "1" },
|
||||
{ id: "2", label: "2" },
|
||||
{ id: "3", label: "3" },
|
||||
{ id: "4", label: "4" },
|
||||
{ id: "5", label: "5" },
|
||||
]}
|
||||
value={{
|
||||
"feature-1": "4",
|
||||
"feature-2": "5",
|
||||
}}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -1,166 +0,0 @@
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import * as React from "react";
|
||||
import { ElementError } from "@/components/general/element-error";
|
||||
import { ElementHeader } from "@/components/general/element-header";
|
||||
import { Label } from "@/components/general/label";
|
||||
import { RadioGroupItem } from "@/components/general/radio-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Option for matrix element rows and columns
|
||||
*/
|
||||
export interface MatrixOption {
|
||||
/** Unique identifier for the option */
|
||||
id: string;
|
||||
/** Display label for the option */
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MatrixProps {
|
||||
/** Unique identifier for the element container */
|
||||
elementId: string;
|
||||
/** The main element or prompt text displayed as the headline */
|
||||
headline: string;
|
||||
/** Optional descriptive text displayed below the headline */
|
||||
description?: string;
|
||||
/** Unique identifier for the matrix group */
|
||||
inputId: string;
|
||||
/** Array of row options (left side) */
|
||||
rows: MatrixOption[];
|
||||
/** Array of column options (top header) */
|
||||
columns: MatrixOption[];
|
||||
/** Currently selected values: Record mapping row ID to column ID */
|
||||
value?: Record<string, string>;
|
||||
/** Callback function called when selection changes */
|
||||
onChange: (value: Record<string, string>) => 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 options are disabled */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function Matrix({
|
||||
elementId,
|
||||
headline,
|
||||
description,
|
||||
inputId,
|
||||
rows,
|
||||
columns,
|
||||
value = {},
|
||||
onChange,
|
||||
required = false,
|
||||
errorMessage,
|
||||
dir = "auto",
|
||||
disabled = false,
|
||||
}: Readonly<MatrixProps>): React.JSX.Element {
|
||||
// Ensure value is always an object (value already has default of {})
|
||||
const selectedValues = value;
|
||||
|
||||
// Check which rows have errors (no selection when required)
|
||||
const hasError = Boolean(errorMessage);
|
||||
const rowsWithErrors = hasError && required ? rows.filter((row) => !selectedValues[row.id]) : [];
|
||||
|
||||
const handleRowChange = (rowId: string, columnId: string): void => {
|
||||
// Toggle: if same column is selected, deselect it
|
||||
if (selectedValues[rowId] === columnId) {
|
||||
// Create new object without the rowId property
|
||||
const { [rowId]: _, ...rest } = selectedValues;
|
||||
onChange(rest);
|
||||
} else {
|
||||
onChange({ ...selectedValues, [rowId]: columnId });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
||||
|
||||
{/* Matrix Table */}
|
||||
<div className="relative">
|
||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||
|
||||
{/* Table container with overflow for mobile */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
{/* Column headers */}
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-2 text-left" />
|
||||
{columns.map((column) => (
|
||||
<th key={column.id} className="p-2 text-center font-normal">
|
||||
<Label className="justify-center">{column.label}</Label>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
{/* Rows */}
|
||||
<tbody>
|
||||
{rows.map((row, index) => {
|
||||
const rowGroupId = `${inputId}-row-${row.id}`;
|
||||
const selectedColumnId = selectedValues[row.id];
|
||||
const rowHasError = rowsWithErrors.includes(row);
|
||||
const baseBgColor = index % 2 === 0 ? "bg-input-bg" : "bg-transparent";
|
||||
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
key={row.id}
|
||||
asChild
|
||||
value={selectedColumnId}
|
||||
onValueChange={(newColumnId) => {
|
||||
handleRowChange(row.id, newColumnId);
|
||||
}}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
aria-invalid={Boolean(errorMessage)}>
|
||||
<tr className={cn("relative", baseBgColor, rowHasError ? "bg-destructive-muted" : "")}>
|
||||
{/* Row label */}
|
||||
<th scope="row" className={cn("p-2 align-middle", !rowHasError && "rounded-l-input")}>
|
||||
<div className="flex flex-col gap-0 leading-none">
|
||||
<Label>{row.label}</Label>
|
||||
{rowHasError ? (
|
||||
<span className="text-destructive text-xs font-normal">Select one option</span>
|
||||
) : null}
|
||||
</div>
|
||||
</th>
|
||||
{/* Column options for this row */}
|
||||
{columns.map((column, colIndex) => {
|
||||
const cellId = `${rowGroupId}-${column.id}`;
|
||||
const isLastColumn = colIndex === columns.length - 1;
|
||||
|
||||
return (
|
||||
<td
|
||||
key={column.id}
|
||||
className={cn(
|
||||
"p-2 text-center align-middle",
|
||||
isLastColumn && !rowHasError && "rounded-r-input"
|
||||
)}>
|
||||
<Label htmlFor={cellId} className="flex cursor-pointer justify-center">
|
||||
<RadioGroupItem
|
||||
value={column.id}
|
||||
id={cellId}
|
||||
disabled={disabled}
|
||||
aria-label={`${row.label}-${column.label}`}
|
||||
/>
|
||||
</Label>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</RadioGroupPrimitive.Root>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { Matrix };
|
||||
export type { MatrixProps };
|
||||
@@ -1,353 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
type BaseStylingOptions,
|
||||
type CheckboxInputStylingOptions,
|
||||
type LabelStylingOptions,
|
||||
type OptionStylingOptions,
|
||||
commonArgTypes,
|
||||
createCSSVariablesDecorator,
|
||||
elementStylingArgTypes,
|
||||
labelStylingArgTypes,
|
||||
optionStylingArgTypes,
|
||||
surveyStylingArgTypes,
|
||||
} from "../../lib/story-helpers";
|
||||
import { MultiSelect, type MultiSelectOption, type MultiSelectProps } from "./multi-select";
|
||||
|
||||
type StoryProps = MultiSelectProps &
|
||||
Partial<BaseStylingOptions & LabelStylingOptions & OptionStylingOptions & CheckboxInputStylingOptions> &
|
||||
Record<string, unknown>;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI-package/Elements/MultiSelect",
|
||||
component: MultiSelect,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"A complete multi-select element that combines headline, description, and checkbox options. Supports multiple selections, validation, and RTL text direction.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ["autodocs"],
|
||||
argTypes: {
|
||||
...commonArgTypes,
|
||||
options: {
|
||||
control: "object",
|
||||
description: "Array of options to choose from",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
value: {
|
||||
control: "object",
|
||||
description: "Array of selected option IDs",
|
||||
table: { category: "State" },
|
||||
},
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["list", "dropdown"],
|
||||
description: "Display variant: 'list' shows checkboxes, 'dropdown' shows a dropdown menu",
|
||||
table: { category: "Layout" },
|
||||
},
|
||||
placeholder: {
|
||||
control: "text",
|
||||
description: "Placeholder text for dropdown button when no options are selected",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
},
|
||||
render: function Render(args: StoryProps) {
|
||||
const [value, setValue] = useState(args.value);
|
||||
const [otherValue, setOtherValue] = useState(args.otherValue);
|
||||
const handleOtherValueChange = (v: string) => {
|
||||
setOtherValue(v);
|
||||
args.onOtherValueChange?.(v);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setValue(args.value);
|
||||
}, [args.value]);
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
args.onChange?.(v);
|
||||
}}
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={handleOtherValueChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<StoryProps>;
|
||||
|
||||
const defaultOptions: MultiSelectOption[] = [
|
||||
{ id: "option-1", label: "Option 1" },
|
||||
{ id: "option-2", label: "Option 2" },
|
||||
{ id: "option-3", label: "Option 3" },
|
||||
{ id: "option-4", label: "Option 4" },
|
||||
];
|
||||
|
||||
export const StylingPlayground: Story = {
|
||||
args: {
|
||||
headline: "Which features do you use?",
|
||||
description: "Select all that apply",
|
||||
options: defaultOptions,
|
||||
},
|
||||
argTypes: {
|
||||
...elementStylingArgTypes,
|
||||
...labelStylingArgTypes,
|
||||
...optionStylingArgTypes,
|
||||
...surveyStylingArgTypes,
|
||||
},
|
||||
decorators: [createCSSVariablesDecorator<StoryProps>()],
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
headline: "Which features do you use?",
|
||||
options: defaultOptions,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
headline: "What programming languages do you know?",
|
||||
description: "Select all programming languages you're familiar with",
|
||||
options: [
|
||||
{ id: "js", label: "JavaScript" },
|
||||
{ id: "ts", label: "TypeScript" },
|
||||
{ id: "python", label: "Python" },
|
||||
{ id: "java", label: "Java" },
|
||||
{ id: "go", label: "Go" },
|
||||
{ id: "rust", label: "Rust" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
headline: "Select your interests",
|
||||
description: "Please select at least one option",
|
||||
options: [
|
||||
{ id: "tech", label: "Technology" },
|
||||
{ id: "design", label: "Design" },
|
||||
{ id: "marketing", label: "Marketing" },
|
||||
{ id: "sales", label: "Sales" },
|
||||
],
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelections: Story = {
|
||||
args: {
|
||||
headline: "Which features do you use?",
|
||||
description: "Select all that apply",
|
||||
options: defaultOptions,
|
||||
value: ["option-1", "option-3"],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
headline: "Select your preferences",
|
||||
description: "Please select at least one option",
|
||||
options: [
|
||||
{ id: "email", label: "Email notifications" },
|
||||
{ id: "sms", label: "SMS notifications" },
|
||||
{ id: "push", label: "Push notifications" },
|
||||
],
|
||||
errorMessage: "Please select at least one option",
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
headline: "This element is disabled",
|
||||
description: "You cannot change the selection",
|
||||
options: defaultOptions,
|
||||
value: ["option-2"],
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ManyOptions: Story = {
|
||||
args: {
|
||||
headline: "Select all that apply",
|
||||
description: "Choose as many as you like",
|
||||
options: [
|
||||
{ id: "1", label: "Option 1" },
|
||||
{ id: "2", label: "Option 2" },
|
||||
{ id: "3", label: "Option 3" },
|
||||
{ id: "4", label: "Option 4" },
|
||||
{ id: "5", label: "Option 5" },
|
||||
{ id: "6", label: "Option 6" },
|
||||
{ id: "7", label: "Option 7" },
|
||||
{ id: "8", label: "Option 8" },
|
||||
{ id: "9", label: "Option 9" },
|
||||
{ id: "10", label: "Option 10" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTL: Story = {
|
||||
args: {
|
||||
headline: "ما هي الميزات التي تستخدمها؟",
|
||||
dir: "rtl",
|
||||
description: "اختر كل ما ينطبق",
|
||||
options: [
|
||||
{ id: "opt-1", label: "الخيار الأول" },
|
||||
{ id: "opt-2", label: "الخيار الثاني" },
|
||||
{ id: "opt-3", label: "الخيار الثالث" },
|
||||
{ id: "opt-4", label: "الخيار الرابع" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const RTLWithSelections: Story = {
|
||||
args: {
|
||||
headline: "ما هي اهتماماتك؟",
|
||||
dir: "rtl",
|
||||
description: "يرجى اختيار جميع الخيارات المناسبة",
|
||||
options: [
|
||||
{ id: "tech", label: "التكنولوجيا" },
|
||||
{ id: "design", label: "التصميم" },
|
||||
{ id: "marketing", label: "التسويق" },
|
||||
{ id: "sales", label: "المبيعات" },
|
||||
],
|
||||
value: ["tech", "design"],
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleElements: Story = {
|
||||
render: () => (
|
||||
<div className="w-[600px] space-y-8">
|
||||
<MultiSelect
|
||||
elementId="features"
|
||||
inputId="features-input"
|
||||
headline="Which features do you use?"
|
||||
description="Select all that apply"
|
||||
options={defaultOptions}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<MultiSelect
|
||||
elementId="languages"
|
||||
inputId="languages-input"
|
||||
headline="What programming languages do you know?"
|
||||
options={[
|
||||
{ id: "js", label: "JavaScript" },
|
||||
{ id: "ts", label: "TypeScript" },
|
||||
{ id: "python", label: "Python" },
|
||||
]}
|
||||
value={["js", "ts"]}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Dropdown: Story = {
|
||||
args: {
|
||||
headline: "Which features do you use?",
|
||||
description: "Select all that apply",
|
||||
options: defaultOptions,
|
||||
variant: "dropdown",
|
||||
placeholder: "Select options...",
|
||||
},
|
||||
};
|
||||
|
||||
export const DropdownWithSelections: Story = {
|
||||
args: {
|
||||
headline: "Which features do you use?",
|
||||
description: "Select all that apply",
|
||||
options: defaultOptions,
|
||||
value: ["option-1", "option-3"],
|
||||
variant: "dropdown",
|
||||
placeholder: "Select options...",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOtherOption: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string[]>([]);
|
||||
const [otherValue, setOtherValue] = React.useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<MultiSelect
|
||||
elementId="multi-select-other"
|
||||
inputId="multi-select-other-input"
|
||||
headline="Which features do you use?"
|
||||
description="Select all that apply"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOtherOptionSelected: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string[]>(["option-1", "other"]);
|
||||
const [otherValue, setOtherValue] = React.useState<string>("Custom feature");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<MultiSelect
|
||||
elementId="multi-select-other-selected"
|
||||
inputId="multi-select-other-selected-input"
|
||||
headline="Which features do you use?"
|
||||
description="Select all that apply"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const DropdownWithOtherOption: Story = {
|
||||
render: () => {
|
||||
const [value, setValue] = React.useState<string[]>([]);
|
||||
const [otherValue, setOtherValue] = React.useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="w-[600px]">
|
||||
<MultiSelect
|
||||
elementId="multi-select-dropdown-other"
|
||||
inputId="multi-select-dropdown-other-input"
|
||||
headline="Which features do you use?"
|
||||
description="Select all that apply"
|
||||
options={defaultOptions}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
variant="dropdown"
|
||||
placeholder="Select options..."
|
||||
otherOptionId="other"
|
||||
otherOptionLabel="Other"
|
||||
otherOptionPlaceholder="Please specify"
|
||||
otherValue={otherValue}
|
||||
onOtherValueChange={setOtherValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user