mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-04 10:30:00 -06:00
Compare commits
202 Commits
simplify-s
...
dashboards
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36955ddbb8 | ||
|
|
ef56e97c95 | ||
|
|
ea3b4b9413 | ||
|
|
131a04b77c | ||
|
|
ca7e2c64de | ||
|
|
e4bd9a839a | ||
|
|
8f7d225d6a | ||
|
|
094b6dedba | ||
|
|
36f0be07c4 | ||
|
|
e079055a43 | ||
|
|
9ae9a3a9fc | ||
|
|
b4606c0113 | ||
|
|
6be654ab60 | ||
|
|
95c2e24416 | ||
|
|
5b86dd3a8f | ||
|
|
0da083a214 | ||
|
|
379a86cf46 | ||
|
|
bed78716f0 | ||
|
|
6167c3d9e6 | ||
|
|
1db1271e7f | ||
|
|
9ec1964106 | ||
|
|
d5a70796dd | ||
|
|
246351b3e6 | ||
|
|
22ea7302bb | ||
|
|
8d47ab9709 | ||
|
|
8f6d27c1ef | ||
|
|
a37815b831 | ||
|
|
2b526a87ca | ||
|
|
047750967c | ||
|
|
a54356c3b0 | ||
|
|
38ea5ed6ae | ||
|
|
6e19de32f7 | ||
|
|
957a4432f4 | ||
|
|
22a5d4bb7d | ||
|
|
226dff0344 | ||
|
|
d474a94a21 | ||
|
|
c1a4cc308b | ||
|
|
210da98b69 | ||
|
|
2fc183d384 | ||
|
|
78fb111610 | ||
|
|
11c0cb4b61 | ||
|
|
95831f7c7f | ||
|
|
a31e7bfaa5 | ||
|
|
6e35fc1769 | ||
|
|
48cded1646 | ||
|
|
db752cee15 | ||
|
|
b33aae0a73 | ||
|
|
72126ad736 | ||
|
|
4a2eeac90b | ||
|
|
46be3e7d70 | ||
|
|
6d140532a7 | ||
|
|
8c4a7f1518 | ||
|
|
63fe32a786 | ||
|
|
84c465f974 | ||
|
|
6a33498737 | ||
|
|
5130c747d4 | ||
|
|
f5583d2652 | ||
|
|
e0d75914a4 | ||
|
|
f02ca1cfe1 | ||
|
|
4ade83f189 | ||
|
|
f1fc9fea2c | ||
|
|
25266e4566 | ||
|
|
b960cfd2a1 | ||
|
|
9e1d1c1dc2 | ||
|
|
8c63a9f7af | ||
|
|
fff0a7f052 | ||
|
|
0ecc8aabff | ||
|
|
01cc0ab64d | ||
|
|
1d125bdac2 | ||
|
|
ca67c4d5a8 | ||
|
|
d167d591ce | ||
|
|
acc3b0179a | ||
|
|
3434b5cf08 | ||
|
|
a618f2df95 | ||
|
|
5b334f6623 | ||
|
|
fa2b63d6a1 | ||
|
|
9f0fe69b6b | ||
|
|
98cb2de02b | ||
|
|
f00d0b7e20 | ||
|
|
65abd4ee07 | ||
|
|
939f135bf4 | ||
|
|
729a16854a | ||
|
|
a2d3e37d69 | ||
|
|
adf12f551d | ||
|
|
3f2bddc358 | ||
|
|
ae6d1ac133 | ||
|
|
7c4569cd50 | ||
|
|
7354122447 | ||
|
|
d54dca2b27 | ||
|
|
acd5cff534 | ||
|
|
834929e766 | ||
|
|
09f40ad816 | ||
|
|
689b6491b3 | ||
|
|
b70b2eef95 | ||
|
|
392a95834b | ||
|
|
66d9cc8eac | ||
|
|
befdc078f1 | ||
|
|
13b983b3b2 | ||
|
|
1e285ebe4e | ||
|
|
a7c4971952 | ||
|
|
c8689d91d5 | ||
|
|
73a2ff7421 | ||
|
|
0c28e89b41 | ||
|
|
a736436e29 | ||
|
|
7dbb0300d3 | ||
|
|
e71f3f412c | ||
|
|
07ed926225 | ||
|
|
15dc83a4eb | ||
|
|
3ce07edf43 | ||
|
|
0f34d9cc5f | ||
|
|
e9f800f017 | ||
|
|
ba2070b638 | ||
|
|
75cdb25d27 | ||
|
|
6bc7db852c | ||
|
|
ffb4eac1a4 | ||
|
|
56da3b5725 | ||
|
|
c189af5482 | ||
|
|
5dbf42fd6a | ||
|
|
42525a86a8 | ||
|
|
b96f0e67c5 | ||
|
|
2d7b99ba26 | ||
|
|
666a79044f | ||
|
|
c3d97c2932 | ||
|
|
cc5d630a05 | ||
|
|
be38d76ccf | ||
|
|
a8eea306e5 | ||
|
|
4fd53ac115 | ||
|
|
eb92392ed1 | ||
|
|
7412b32526 | ||
|
|
193346a70d | ||
|
|
a1d4754b04 | ||
|
|
f4b918a4b6 | ||
|
|
fb9a0b197a | ||
|
|
95b6c16dd1 | ||
|
|
cfdf09650f | ||
|
|
4c94fc25ae | ||
|
|
ccf501d925 | ||
|
|
04dfbe0777 | ||
|
|
cbf255ab0d | ||
|
|
942366956c | ||
|
|
a6ee796cef | ||
|
|
a535529bd3 | ||
|
|
018cef61a6 | ||
|
|
c53e4f54cb | ||
|
|
e2fd71abfd | ||
|
|
f888aa8a19 | ||
|
|
2698817adb | ||
|
|
2c18912f2f | ||
|
|
f57497d8b3 | ||
|
|
aab6798b29 | ||
|
|
f07092595f | ||
|
|
c03c7ec1ed | ||
|
|
628de8e6ae | ||
|
|
be4b54a827 | ||
|
|
e03df83e88 | ||
|
|
ed26427302 | ||
|
|
554809742b | ||
|
|
28adfb905c | ||
|
|
05c455ed62 | ||
|
|
f7687bc0ea | ||
|
|
af34391309 | ||
|
|
70978fbbdf | ||
|
|
f6683d1165 | ||
|
|
13be7a8970 | ||
|
|
0472d5e8f0 | ||
|
|
00a61f7abe | ||
|
|
6999abba3b | ||
|
|
9ae66f44ae | ||
|
|
7933d0077a | ||
|
|
cc8289fa33 | ||
|
|
c458051839 | ||
|
|
718a199d5b | ||
|
|
5ab9fdf1e3 | ||
|
|
5741209aa9 | ||
|
|
35d0d8ed54 | ||
|
|
5bce5c0a3b | ||
|
|
c61212964c | ||
|
|
b8d41a6e9b | ||
|
|
eedd5200a4 | ||
|
|
71a85c7126 | ||
|
|
341e2639e1 | ||
|
|
056470e6f0 | ||
|
|
e965ad4b97 | ||
|
|
12e703c02b | ||
|
|
07065f2675 | ||
|
|
7ca45cefeb | ||
|
|
4df28878db | ||
|
|
b355d05b25 | ||
|
|
e757e9aec9 | ||
|
|
cf4119baf6 | ||
|
|
6be2ae3071 | ||
|
|
600b793641 | ||
|
|
cde03b6997 | ||
|
|
00371bfb01 | ||
|
|
6be6782531 | ||
|
|
3ae4f8aa68 | ||
|
|
3d3c69a92b | ||
|
|
b1b94eaa66 | ||
|
|
67cc96449d | ||
|
|
bf41a53b86 | ||
|
|
26292ecf39 | ||
|
|
056e572a31 |
352
.cursor/commands/create-question.md
Normal file
352
.cursor/commands/create-question.md
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Build & Deployment Best Practices
|
|
||||||
|
|
||||||
## Build Process
|
|
||||||
|
|
||||||
### Running Builds
|
|
||||||
- Use `pnpm build` from project root for full build
|
|
||||||
- Monitor for React hooks warnings and fix them immediately
|
|
||||||
- Ensure all TypeScript errors are resolved before deployment
|
|
||||||
|
|
||||||
### Common Build Issues & Fixes
|
|
||||||
|
|
||||||
#### React Hooks Warnings
|
|
||||||
- Capture ref values in variables within useEffect cleanup
|
|
||||||
- Avoid accessing `.current` directly in cleanup functions
|
|
||||||
- Pattern for fixing ref cleanup warnings:
|
|
||||||
```typescript
|
|
||||||
useEffect(() => {
|
|
||||||
const currentRef = myRef.current;
|
|
||||||
return () => {
|
|
||||||
if (currentRef) {
|
|
||||||
currentRef.cleanup();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Test Failures During Build
|
|
||||||
- Ensure all test mocks include required constants like `SESSION_MAX_AGE`
|
|
||||||
- Mock Next.js navigation hooks properly: `useParams`, `useRouter`, `useSearchParams`
|
|
||||||
- Remove unused imports and constants from test files
|
|
||||||
- Use literal values instead of imported constants when the constant isn't actually needed
|
|
||||||
|
|
||||||
### Test Execution
|
|
||||||
- Run `pnpm test` to execute all tests
|
|
||||||
- Use `pnpm test -- --run filename.test.tsx` for specific test files
|
|
||||||
- Fix test failures before merging code
|
|
||||||
- Ensure 100% test coverage for new components
|
|
||||||
|
|
||||||
### Performance Monitoring
|
|
||||||
- Monitor build times and optimize if necessary
|
|
||||||
- Watch for memory usage during builds
|
|
||||||
- Use proper caching strategies for faster rebuilds
|
|
||||||
|
|
||||||
### Deployment Checklist
|
|
||||||
1. All tests passing
|
|
||||||
2. Build completes without warnings
|
|
||||||
3. TypeScript compilation successful
|
|
||||||
4. No linter errors
|
|
||||||
5. Database migrations applied (if any)
|
|
||||||
6. Environment variables configured
|
|
||||||
|
|
||||||
### EKS Deployment Considerations
|
|
||||||
- Ensure latest code is deployed to all pods
|
|
||||||
- Monitor AWS RDS Performance Insights for database issues
|
|
||||||
- Verify environment-specific configurations
|
|
||||||
- Check pod health and resource usage
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
---
|
|
||||||
description: Caching rules for performance improvements
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Cache Optimization Patterns for Formbricks
|
|
||||||
|
|
||||||
## Cache Strategy Overview
|
|
||||||
|
|
||||||
Formbricks uses a **hybrid caching approach** optimized for enterprise scale:
|
|
||||||
|
|
||||||
- **Redis** for persistent cross-request caching
|
|
||||||
- **React `cache()`** for request-level deduplication
|
|
||||||
- **NO Next.js `unstable_cache()`** - avoid for reliability
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
### Core Cache Infrastructure
|
|
||||||
- [packages/cache/src/service.ts](mdc:packages/cache/src/service.ts) - Redis cache service
|
|
||||||
- [packages/cache/src/client.ts](mdc:packages/cache/src/client.ts) - Cache client initialization and singleton management
|
|
||||||
- [apps/web/lib/cache/index.ts](mdc:apps/web/lib/cache/index.ts) - Cache service proxy for web app
|
|
||||||
- [packages/cache/src/index.ts](mdc:packages/cache/src/index.ts) - Cache package exports and utilities
|
|
||||||
|
|
||||||
### Environment State Caching (Critical Endpoint)
|
|
||||||
- [apps/web/app/api/v1/client/[environmentId]/environment/route.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/route.ts) - Main endpoint serving hundreds of thousands of SDK clients
|
|
||||||
- [apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts) - Optimized data layer with caching
|
|
||||||
|
|
||||||
## Enterprise-Grade Cache Key Patterns
|
|
||||||
|
|
||||||
**Always use** the `createCacheKey` utilities from the cache package:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ Correct patterns
|
|
||||||
createCacheKey.environment.state(environmentId) // "fb:env:abc123:state"
|
|
||||||
createCacheKey.organization.billing(organizationId) // "fb:org:xyz789:billing"
|
|
||||||
createCacheKey.license.status(organizationId) // "fb:license:org123:status"
|
|
||||||
createCacheKey.user.permissions(userId, orgId) // "fb:user:456:org:123:permissions"
|
|
||||||
|
|
||||||
// ❌ Never use flat keys - collision-prone
|
|
||||||
"environment_abc123"
|
|
||||||
"user_data_456"
|
|
||||||
```
|
|
||||||
|
|
||||||
## When to Use Each Cache Type
|
|
||||||
|
|
||||||
### Use React `cache()` for Request Deduplication
|
|
||||||
```typescript
|
|
||||||
// ✅ Prevents multiple calls within same request
|
|
||||||
export const getEnterpriseLicense = reactCache(async () => {
|
|
||||||
// Complex license validation logic
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Use `cache.withCache()` for Simple Database Queries
|
|
||||||
```typescript
|
|
||||||
// ✅ Simple caching with automatic fallback (TTL in milliseconds)
|
|
||||||
export const getActionClasses = (environmentId: string) => {
|
|
||||||
return cache.withCache(() => fetchActionClassesFromDB(environmentId),
|
|
||||||
createCacheKey.environment.actionClasses(environmentId),
|
|
||||||
60 * 30 * 1000 // 30 minutes in milliseconds
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Use Explicit Redis Cache for Complex Business Logic
|
|
||||||
```typescript
|
|
||||||
// ✅ Full control for high-stakes endpoints
|
|
||||||
export const getEnvironmentState = async (environmentId: string) => {
|
|
||||||
const cached = await environmentStateCache.getEnvironmentState(environmentId);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const fresh = await buildComplexState(environmentId);
|
|
||||||
await environmentStateCache.setEnvironmentState(environmentId, fresh);
|
|
||||||
return fresh;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Caching Decision Framework
|
|
||||||
|
|
||||||
### When TO Add Caching
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ Expensive operations that benefit from caching
|
|
||||||
- Database queries (>10ms typical)
|
|
||||||
- External API calls (>50ms typical)
|
|
||||||
- Complex computations (>5ms)
|
|
||||||
- File system operations
|
|
||||||
- Heavy data transformations
|
|
||||||
|
|
||||||
// Example: Database query with complex joins (TTL in milliseconds)
|
|
||||||
export const getEnvironmentWithDetails = withCache(
|
|
||||||
async (environmentId: string) => {
|
|
||||||
return prisma.environment.findUnique({
|
|
||||||
where: { id: environmentId },
|
|
||||||
include: { /* complex joins */ }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ key: createCacheKey.environment.details(environmentId), ttl: 60 * 30 * 1000 } // 30 minutes
|
|
||||||
)();
|
|
||||||
```
|
|
||||||
|
|
||||||
### When NOT to Add Caching
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ Don't cache these operations - minimal overhead
|
|
||||||
- Simple property access (<0.1ms)
|
|
||||||
- Basic transformations (<1ms)
|
|
||||||
- Functions that just call already-cached functions
|
|
||||||
- Pure computation without I/O
|
|
||||||
|
|
||||||
// ❌ Bad example: Redundant caching
|
|
||||||
const getCachedLicenseFeatures = withCache(
|
|
||||||
async () => {
|
|
||||||
const license = await getEnterpriseLicense(); // Already cached!
|
|
||||||
return license.active ? license.features : null; // Just property access
|
|
||||||
},
|
|
||||||
{ key: "license-features", ttl: 1800 * 1000 } // 30 minutes in milliseconds
|
|
||||||
);
|
|
||||||
|
|
||||||
// ✅ Good example: Simple and efficient
|
|
||||||
const getLicenseFeatures = async () => {
|
|
||||||
const license = await getEnterpriseLicense(); // Already cached
|
|
||||||
return license.active ? license.features : null; // 0.1ms overhead
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Computational Overhead Analysis
|
|
||||||
|
|
||||||
Before adding caching, analyze the overhead:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ High overhead - CACHE IT
|
|
||||||
- Database queries: ~10-100ms
|
|
||||||
- External APIs: ~50-500ms
|
|
||||||
- File I/O: ~5-50ms
|
|
||||||
- Complex algorithms: >5ms
|
|
||||||
|
|
||||||
// ❌ Low overhead - DON'T CACHE
|
|
||||||
- Property access: ~0.001ms
|
|
||||||
- Simple lookups: ~0.1ms
|
|
||||||
- Basic validation: ~1ms
|
|
||||||
- Type checks: ~0.01ms
|
|
||||||
|
|
||||||
// Example decision tree:
|
|
||||||
const expensiveOperation = async () => {
|
|
||||||
return prisma.query(); // 50ms - CACHE IT
|
|
||||||
};
|
|
||||||
|
|
||||||
const cheapOperation = (data: any) => {
|
|
||||||
return data.property; // 0.001ms - DON'T CACHE
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Avoid Cache Wrapper Anti-Pattern
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ Don't create wrapper functions just for caching
|
|
||||||
const getCachedUserPermissions = withCache(
|
|
||||||
async (userId: string) => getUserPermissions(userId),
|
|
||||||
{ key: createCacheKey.user.permissions(userId), ttl: 3600 * 1000 } // 1 hour in milliseconds
|
|
||||||
);
|
|
||||||
|
|
||||||
// ✅ Add caching directly to the original function
|
|
||||||
export const getUserPermissions = withCache(
|
|
||||||
async (userId: string) => {
|
|
||||||
return prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
include: { permissions: true }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ key: createCacheKey.user.permissions(userId), ttl: 3600 * 1000 } // 1 hour in milliseconds
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## TTL Coordination Strategy
|
|
||||||
|
|
||||||
### Multi-Layer Cache Coordination
|
|
||||||
For endpoints serving client SDKs, coordinate TTLs across layers:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Client SDK cache (expiresAt) - longest TTL for fewer requests
|
|
||||||
const CLIENT_TTL = 60 * 60; // 1 hour (seconds for client)
|
|
||||||
|
|
||||||
// Server Redis cache - shorter TTL ensures fresh data for clients
|
|
||||||
const SERVER_TTL = 60 * 30 * 1000; // 30 minutes in milliseconds
|
|
||||||
|
|
||||||
// HTTP cache headers (seconds)
|
|
||||||
const BROWSER_TTL = 60 * 60; // 1 hour (max-age)
|
|
||||||
const CDN_TTL = 60 * 30; // 30 minutes (s-maxage)
|
|
||||||
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Standard TTL Guidelines (in milliseconds for cache-manager + Keyv)
|
|
||||||
```typescript
|
|
||||||
// Configuration data - rarely changes
|
|
||||||
const CONFIG_TTL = 60 * 60 * 24 * 1000; // 24 hours
|
|
||||||
|
|
||||||
// User data - moderate frequency
|
|
||||||
const USER_TTL = 60 * 60 * 2 * 1000; // 2 hours
|
|
||||||
|
|
||||||
// Survey data - changes moderately
|
|
||||||
const SURVEY_TTL = 60 * 15 * 1000; // 15 minutes
|
|
||||||
|
|
||||||
// Billing data - expensive to compute
|
|
||||||
const BILLING_TTL = 60 * 30 * 1000; // 30 minutes
|
|
||||||
|
|
||||||
// Action classes - infrequent changes
|
|
||||||
const ACTION_CLASS_TTL = 60 * 30 * 1000; // 30 minutes
|
|
||||||
```
|
|
||||||
|
|
||||||
## High-Frequency Endpoint Optimization
|
|
||||||
|
|
||||||
### Performance Patterns for High-Volume Endpoints
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ Optimized high-frequency endpoint pattern
|
|
||||||
export const GET = async (request: NextRequest, props: { params: Promise<{ id: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Simple validation (avoid Zod for high-frequency)
|
|
||||||
if (!params.id || typeof params.id !== 'string') {
|
|
||||||
return responses.badRequestResponse("ID is required", undefined, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single optimized query with caching
|
|
||||||
const data = await getOptimizedData(params.id);
|
|
||||||
|
|
||||||
return responses.successResponse(
|
|
||||||
{
|
|
||||||
data,
|
|
||||||
expiresAt: new Date(Date.now() + CLIENT_TTL * 1000), // SDK cache duration
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
// Simplified error handling for performance
|
|
||||||
if (err instanceof ResourceNotFoundError) {
|
|
||||||
return responses.notFoundResponse(err.resourceType, err.resourceId);
|
|
||||||
}
|
|
||||||
logger.error({ error: err, url: request.url }, "Error in high-frequency endpoint");
|
|
||||||
return responses.internalServerErrorResponse(err.message, true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Avoid These Performance Anti-Patterns
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ Avoid for high-frequency endpoints
|
|
||||||
const inputValidation = ZodSchema.safeParse(input); // Too slow
|
|
||||||
const startTime = Date.now(); logger.debug(...); // Logging overhead
|
|
||||||
const { data, revalidateEnvironment } = await get(); // Complex return types
|
|
||||||
```
|
|
||||||
|
|
||||||
### CORS Optimization
|
|
||||||
```typescript
|
|
||||||
// ✅ Balanced CORS caching (not too aggressive)
|
|
||||||
export const OPTIONS = async (): Promise<Response> => {
|
|
||||||
return responses.successResponse(
|
|
||||||
{},
|
|
||||||
true,
|
|
||||||
"public, s-maxage=3600, max-age=3600" // 1 hour balanced approach
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Redis Cache Migration from Next.js
|
|
||||||
|
|
||||||
### Avoid Legacy Next.js Patterns
|
|
||||||
```typescript
|
|
||||||
// ❌ Old Next.js unstable_cache pattern (avoid)
|
|
||||||
const getCachedData = unstable_cache(
|
|
||||||
async (id) => fetchData(id),
|
|
||||||
['cache-key'],
|
|
||||||
{ tags: ['environment'], revalidate: 900 }
|
|
||||||
);
|
|
||||||
|
|
||||||
// ❌ Don't use revalidateEnvironment flags with Redis
|
|
||||||
return { data, revalidateEnvironment: true }; // This gets cached incorrectly!
|
|
||||||
|
|
||||||
// ✅ New Redis pattern with withCache (TTL in milliseconds)
|
|
||||||
export const getCachedData = (id: string) =>
|
|
||||||
withCache(
|
|
||||||
() => fetchData(id),
|
|
||||||
{
|
|
||||||
key: createCacheKey.environment.data(id),
|
|
||||||
ttl: 60 * 15 * 1000, // 15 minutes in milliseconds
|
|
||||||
}
|
|
||||||
)();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Remove Revalidation Logic
|
|
||||||
When migrating from Next.js `unstable_cache`:
|
|
||||||
- Remove `revalidateEnvironment` or similar flags
|
|
||||||
- Remove tag-based invalidation logic
|
|
||||||
- Use TTL-based expiration instead
|
|
||||||
- Handle one-time updates (like `appSetupCompleted`) directly in cache
|
|
||||||
|
|
||||||
## Data Layer Optimization
|
|
||||||
|
|
||||||
### Single Query Pattern
|
|
||||||
```typescript
|
|
||||||
// ✅ Optimize with single database query
|
|
||||||
export const getOptimizedEnvironmentData = async (environmentId: string) => {
|
|
||||||
return prisma.environment.findUniqueOrThrow({
|
|
||||||
where: { id: environmentId },
|
|
||||||
include: {
|
|
||||||
project: {
|
|
||||||
select: { id: true, recontactDays: true, /* ... */ }
|
|
||||||
},
|
|
||||||
organization: {
|
|
||||||
select: { id: true, billing: true }
|
|
||||||
},
|
|
||||||
surveys: {
|
|
||||||
where: { status: "inProgress" },
|
|
||||||
select: { id: true, name: true, /* ... */ }
|
|
||||||
},
|
|
||||||
actionClasses: {
|
|
||||||
select: { id: true, name: true, /* ... */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ❌ Avoid multiple separate queries
|
|
||||||
const environment = await getEnvironment(id);
|
|
||||||
const organization = await getOrganization(environment.organizationId);
|
|
||||||
const surveys = await getSurveys(id);
|
|
||||||
const actionClasses = await getActionClasses(id);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Invalidation Best Practices
|
|
||||||
|
|
||||||
**Always use explicit key-based invalidation:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ Clear and debuggable
|
|
||||||
await invalidateCache(createCacheKey.environment.state(environmentId));
|
|
||||||
await invalidateCache([
|
|
||||||
createCacheKey.environment.surveys(environmentId),
|
|
||||||
createCacheKey.environment.actionClasses(environmentId)
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ❌ Avoid complex tag systems
|
|
||||||
await invalidateByTags(["environment", "survey"]); // Don't do this
|
|
||||||
```
|
|
||||||
|
|
||||||
## Critical Performance Targets
|
|
||||||
|
|
||||||
### High-Frequency Endpoint Goals
|
|
||||||
- **Cache hit ratio**: >85%
|
|
||||||
- **Response time P95**: <200ms
|
|
||||||
- **Database load reduction**: >60%
|
|
||||||
- **HTTP cache duration**: 1hr browser, 30min Cloudflare
|
|
||||||
- **SDK refresh interval**: 1 hour with 30min server cache
|
|
||||||
|
|
||||||
### Performance Monitoring
|
|
||||||
- Use **existing elastic cache analytics** for metrics
|
|
||||||
- Log cache errors and warnings (not debug info)
|
|
||||||
- Track database query reduction
|
|
||||||
- Monitor response times for cached endpoints
|
|
||||||
- **Avoid performance logging** in high-frequency endpoints
|
|
||||||
|
|
||||||
## Error Handling Pattern
|
|
||||||
|
|
||||||
Always provide fallback to fresh data on cache errors:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
try {
|
|
||||||
const cached = await cache.get(key);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const fresh = await fetchFresh();
|
|
||||||
await cache.set(key, fresh, ttl); // ttl in milliseconds
|
|
||||||
return fresh;
|
|
||||||
} catch (error) {
|
|
||||||
// ✅ Always fallback to fresh data
|
|
||||||
logger.warn("Cache error, fetching fresh", { key, error });
|
|
||||||
return fetchFresh();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Pitfalls to Avoid
|
|
||||||
|
|
||||||
1. **Never use Next.js `unstable_cache()`** - unreliable in production
|
|
||||||
2. **Don't use revalidation flags with Redis** - they get cached incorrectly
|
|
||||||
3. **Avoid Zod validation** for simple parameters in high-frequency endpoints
|
|
||||||
4. **Don't add performance logging** to high-frequency endpoints
|
|
||||||
5. **Coordinate TTLs** between client and server caches
|
|
||||||
6. **Don't over-engineer** with complex tag systems
|
|
||||||
7. **Avoid caching rapidly changing data** (real-time metrics)
|
|
||||||
8. **Always validate cache keys** to prevent collisions
|
|
||||||
9. **Don't add redundant caching layers** - analyze computational overhead first
|
|
||||||
10. **Avoid cache wrapper functions** - add caching directly to expensive operations
|
|
||||||
11. **Don't cache property access or simple transformations** - overhead is negligible
|
|
||||||
12. **Analyze the full call chain** before adding caching to avoid double-caching
|
|
||||||
13. **Remember TTL is in milliseconds** for cache-manager + Keyv stack (not seconds)
|
|
||||||
|
|
||||||
## Monitoring Strategy
|
|
||||||
|
|
||||||
- Use **existing elastic cache analytics** for metrics
|
|
||||||
- Log cache errors and warnings
|
|
||||||
- Track database query reduction
|
|
||||||
- Monitor response times for cached endpoints
|
|
||||||
- **Don't add custom metrics** that duplicate existing monitoring
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
### TTL Units
|
|
||||||
- **cache-manager + Keyv**: TTL in **milliseconds**
|
|
||||||
- **Direct Redis commands**: TTL in **seconds** (EXPIRE, SETEX) or **milliseconds** (PEXPIRE, PSETEX)
|
|
||||||
- **HTTP cache headers**: TTL in **seconds** (max-age, s-maxage)
|
|
||||||
- **Client SDK**: TTL in **seconds** (expiresAt calculation)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Database Performance & Prisma Best Practices
|
|
||||||
|
|
||||||
## Critical Performance Rules
|
|
||||||
|
|
||||||
### Response Count Queries
|
|
||||||
- **NEVER** use `skip`/`offset` with `prisma.response.count()` - this causes expensive subqueries with OFFSET
|
|
||||||
- Always use only `where` clauses for count operations: `prisma.response.count({ where: { ... } })`
|
|
||||||
- For pagination, separate count queries from data queries
|
|
||||||
- Reference: [apps/web/lib/response/service.ts](mdc:apps/web/lib/response/service.ts) line 654-686
|
|
||||||
|
|
||||||
### Prisma Query Optimization
|
|
||||||
- Use proper indexes defined in [packages/database/schema.prisma](mdc:packages/database/schema.prisma)
|
|
||||||
- Leverage existing indexes: `@@index([surveyId, createdAt])`, `@@index([createdAt])`
|
|
||||||
- Use cursor-based pagination for large datasets instead of offset-based
|
|
||||||
- Cache frequently accessed data using React Cache and custom cache tags
|
|
||||||
|
|
||||||
### Date Range Filtering
|
|
||||||
- When filtering by `createdAt`, always use indexed queries
|
|
||||||
- Combine with `surveyId` for optimal performance: `{ surveyId, createdAt: { gte: start, lt: end } }`
|
|
||||||
- Avoid complex WHERE clauses that can't utilize indexes
|
|
||||||
|
|
||||||
### Count vs Data Separation
|
|
||||||
- Always separate count queries from data fetching queries
|
|
||||||
- Use `Promise.all()` to run count and data queries in parallel
|
|
||||||
- Example pattern from [apps/web/modules/api/v2/management/responses/lib/response.ts](mdc:apps/web/modules/api/v2/management/responses/lib/response.ts):
|
|
||||||
```typescript
|
|
||||||
const [responses, totalCount] = await Promise.all([
|
|
||||||
prisma.response.findMany(query),
|
|
||||||
prisma.response.count({ where: whereClause }),
|
|
||||||
]);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Monitoring & Debugging
|
|
||||||
- Monitor AWS RDS Performance Insights for problematic queries
|
|
||||||
- Look for queries with OFFSET in count operations - these indicate performance issues
|
|
||||||
- Use proper error handling with `DatabaseError` for Prisma exceptions
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
---
|
|
||||||
description: >
|
|
||||||
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
|
|
||||||
and data patterns. It should be used **only when the agent explicitly requests database schema-level
|
|
||||||
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
|
|
||||||
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
|
||||||
globs: []
|
|
||||||
alwaysApply: agent-requested
|
|
||||||
---
|
|
||||||
|
|
||||||
# Formbricks Database Schema Reference
|
|
||||||
|
|
||||||
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
|
||||||
|
|
||||||
## Database Overview
|
|
||||||
|
|
||||||
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
|
|
||||||
|
|
||||||
### Core Hierarchy
|
|
||||||
|
|
||||||
```
|
|
||||||
Organization
|
|
||||||
└── Project
|
|
||||||
└── Environment (production/development)
|
|
||||||
├── Survey
|
|
||||||
├── Contact
|
|
||||||
├── ActionClass
|
|
||||||
└── Integration
|
|
||||||
```
|
|
||||||
|
|
||||||
## Schema Reference
|
|
||||||
|
|
||||||
For the complete and up-to-date database schema, please refer to:
|
|
||||||
|
|
||||||
- Main schema: `packages/database/schema.prisma`
|
|
||||||
- JSON type definitions: `packages/database/json-types.ts`
|
|
||||||
|
|
||||||
The schema.prisma file contains all model definitions, relationships, enums, and field types. The json-types.ts file contains TypeScript type definitions for JSON fields.
|
|
||||||
|
|
||||||
## Data Access Patterns
|
|
||||||
|
|
||||||
### Multi-tenancy
|
|
||||||
|
|
||||||
- All data is scoped by Organization
|
|
||||||
- Environment-level isolation for surveys and contacts
|
|
||||||
- Project-level grouping for related surveys
|
|
||||||
|
|
||||||
### Soft Deletion
|
|
||||||
|
|
||||||
Some models use soft deletion patterns:
|
|
||||||
|
|
||||||
- Check `isActive` fields where present
|
|
||||||
- Use proper filtering in queries
|
|
||||||
|
|
||||||
### Cascading Deletes
|
|
||||||
|
|
||||||
Configured cascade relationships:
|
|
||||||
|
|
||||||
- Organization deletion cascades to all child entities
|
|
||||||
- Survey deletion removes responses, displays, triggers
|
|
||||||
- Contact deletion removes attributes and responses
|
|
||||||
|
|
||||||
## Common Query Patterns
|
|
||||||
|
|
||||||
### Survey with Responses
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Include response count and latest responses
|
|
||||||
const survey = await prisma.survey.findUnique({
|
|
||||||
where: { id: surveyId },
|
|
||||||
include: {
|
|
||||||
responses: {
|
|
||||||
take: 10,
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
select: { responses: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Scoping
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Always scope by environment
|
|
||||||
const surveys = await prisma.survey.findMany({
|
|
||||||
where: {
|
|
||||||
environmentId: environmentId,
|
|
||||||
// Additional filters...
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Contact with Attributes
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const contact = await prisma.contact.findUnique({
|
|
||||||
where: { id: contactId },
|
|
||||||
include: {
|
|
||||||
attributes: {
|
|
||||||
include: {
|
|
||||||
attributeKey: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
|
||||||
|
|
||||||
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
title: "FEATURE NAME"
|
|
||||||
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
|
||||||
icon: "link"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
|
||||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt - e.g. if docs describe consecutive steps, always use Mintlify Step component.
|
|
||||||
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case.
|
|
||||||
- The page should never start with H1 headline, because it's already part of the template.
|
|
||||||
- Tonality: Keep it concise and to the point. Avoid Jargon where possible.
|
|
||||||
- If a feature is part of the Enterprise Edition, use this note:
|
|
||||||
|
|
||||||
<Note>
|
|
||||||
FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license)
|
|
||||||
</Note>
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Formbricks Architecture & Patterns
|
|
||||||
|
|
||||||
## Monorepo Structure
|
|
||||||
|
|
||||||
### Apps Directory
|
|
||||||
- `apps/web/` - Main Next.js web application
|
|
||||||
- `packages/` - Shared packages and utilities
|
|
||||||
|
|
||||||
### Key Directories in Web App
|
|
||||||
```
|
|
||||||
apps/web/
|
|
||||||
├── app/ # Next.js 13+ app directory
|
|
||||||
│ ├── (app)/ # Main application routes
|
|
||||||
│ ├── (auth)/ # Authentication routes
|
|
||||||
│ ├── api/ # API routes
|
|
||||||
├── components/ # Shared components
|
|
||||||
├── lib/ # Utility functions and services
|
|
||||||
└── modules/ # Feature-specific modules
|
|
||||||
```
|
|
||||||
|
|
||||||
## Routing Patterns
|
|
||||||
|
|
||||||
### App Router Structure
|
|
||||||
The application uses Next.js 13+ app router with route groups:
|
|
||||||
|
|
||||||
```
|
|
||||||
(app)/environments/[environmentId]/
|
|
||||||
├── surveys/[surveyId]/
|
|
||||||
│ ├── (analysis)/ # Analysis views
|
|
||||||
│ │ ├── responses/ # Response management
|
|
||||||
│ │ ├── summary/ # Survey summary
|
|
||||||
│ │ └── hooks/ # Analysis-specific hooks
|
|
||||||
│ ├── edit/ # Survey editing
|
|
||||||
│ └── settings/ # Survey settings
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dynamic Routes
|
|
||||||
- `[environmentId]` - Environment-specific routes
|
|
||||||
- `[surveyId]` - Survey-specific routes
|
|
||||||
|
|
||||||
## Service Layer Pattern
|
|
||||||
|
|
||||||
### Service Organization
|
|
||||||
Services are organized by domain in `apps/web/lib/`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Example: Response service
|
|
||||||
// apps/web/lib/response/service.ts
|
|
||||||
export const getResponseCountAction = async ({
|
|
||||||
surveyId,
|
|
||||||
filterCriteria,
|
|
||||||
}: {
|
|
||||||
surveyId: string;
|
|
||||||
filterCriteria: any;
|
|
||||||
}) => {
|
|
||||||
// Service implementation
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Action Pattern
|
|
||||||
Server actions follow a consistent pattern:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Action wrapper for service calls
|
|
||||||
export const getResponseCountAction = async (params) => {
|
|
||||||
try {
|
|
||||||
const result = await responseService.getCount(params);
|
|
||||||
return { data: result };
|
|
||||||
} catch (error) {
|
|
||||||
return { error: error.message };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Context Patterns
|
|
||||||
|
|
||||||
### Provider Structure
|
|
||||||
Context providers follow a consistent pattern:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Provider component
|
|
||||||
export const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
const [selectedFilter, setSelectedFilter] = useState(defaultFilter);
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
selectedFilter,
|
|
||||||
setSelectedFilter,
|
|
||||||
// ... other state and methods
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponseFilterContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</ResponseFilterContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hook for consuming context
|
|
||||||
export const useResponseFilter = () => {
|
|
||||||
const context = useContext(ResponseFilterContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useResponseFilter must be used within ResponseFilterProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Context Composition
|
|
||||||
Multiple contexts are often composed together:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Layout component with multiple providers
|
|
||||||
export default function AnalysisLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<ResponseFilterProvider>
|
|
||||||
<ResponseCountProvider>
|
|
||||||
{children}
|
|
||||||
</ResponseCountProvider>
|
|
||||||
</ResponseFilterProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Patterns
|
|
||||||
|
|
||||||
### Page Components
|
|
||||||
Page components are located in the app directory and follow this pattern:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx
|
|
||||||
export default function ResponsesPage() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<ResponsesTable />
|
|
||||||
<ResponsesPagination />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Organization
|
|
||||||
- **Pages** - Route components in app directory
|
|
||||||
- **Components** - Reusable UI components
|
|
||||||
- **Modules** - Feature-specific components and logic
|
|
||||||
|
|
||||||
### Shared Components
|
|
||||||
Common components are in `apps/web/components/`:
|
|
||||||
- UI components (buttons, inputs, modals)
|
|
||||||
- Layout components (headers, sidebars)
|
|
||||||
- Data display components (tables, charts)
|
|
||||||
|
|
||||||
## Hook Patterns
|
|
||||||
|
|
||||||
### Custom Hook Structure
|
|
||||||
Custom hooks follow consistent patterns:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const useResponseCount = ({
|
|
||||||
survey,
|
|
||||||
initialCount
|
|
||||||
}: {
|
|
||||||
survey: TSurvey;
|
|
||||||
initialCount?: number;
|
|
||||||
}) => {
|
|
||||||
const [responseCount, setResponseCount] = useState(initialCount ?? 0);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Hook logic...
|
|
||||||
|
|
||||||
return {
|
|
||||||
responseCount,
|
|
||||||
isLoading,
|
|
||||||
refetch,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hook Dependencies
|
|
||||||
- Use context hooks for shared state
|
|
||||||
- Implement proper cleanup with AbortController
|
|
||||||
- Optimize dependency arrays to prevent unnecessary re-renders
|
|
||||||
|
|
||||||
## Data Fetching Patterns
|
|
||||||
|
|
||||||
### Server Actions
|
|
||||||
The app uses Next.js server actions for data fetching:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Server action
|
|
||||||
export async function getResponsesAction(params: GetResponsesParams) {
|
|
||||||
const responses = await getResponses(params);
|
|
||||||
return { data: responses };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client usage
|
|
||||||
const { data } = await getResponsesAction(params);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
Consistent error handling across the application:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
try {
|
|
||||||
const result = await apiCall();
|
|
||||||
return { data: result };
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Operation failed:", error);
|
|
||||||
return { error: error.message };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Type Safety
|
|
||||||
|
|
||||||
### Type Organization
|
|
||||||
Types are organized in packages:
|
|
||||||
- `@formbricks/types` - Shared type definitions
|
|
||||||
- Local types in component/hook files
|
|
||||||
|
|
||||||
### Common Types
|
|
||||||
```typescript
|
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
|
||||||
import { TResponse } from "@formbricks/types/responses";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
```
|
|
||||||
|
|
||||||
## State Management
|
|
||||||
|
|
||||||
### Local State
|
|
||||||
- Use `useState` for component-specific state
|
|
||||||
- Use `useReducer` for complex state logic
|
|
||||||
- Use refs for mutable values that don't trigger re-renders
|
|
||||||
|
|
||||||
### Global State
|
|
||||||
- React Context for feature-specific shared state
|
|
||||||
- URL state for filters and pagination
|
|
||||||
- Server state through server actions
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### Code Splitting
|
|
||||||
- Dynamic imports for heavy components
|
|
||||||
- Route-based code splitting with app router
|
|
||||||
- Lazy loading for non-critical features
|
|
||||||
|
|
||||||
### Caching Strategy
|
|
||||||
- Server-side caching for database queries
|
|
||||||
- Client-side caching with React Query (where applicable)
|
|
||||||
- Static generation for public pages
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### Test Organization
|
|
||||||
```
|
|
||||||
component/
|
|
||||||
├── Component.tsx
|
|
||||||
├── Component.test.tsx
|
|
||||||
└── hooks/
|
|
||||||
├── useHook.ts
|
|
||||||
└── useHook.test.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Patterns
|
|
||||||
- Unit tests for utilities and services
|
|
||||||
- Integration tests for components with context
|
|
||||||
- Hook tests with proper mocking
|
|
||||||
|
|
||||||
## Build & Deployment
|
|
||||||
|
|
||||||
### Build Process
|
|
||||||
- TypeScript compilation
|
|
||||||
- Next.js build optimization
|
|
||||||
- Asset optimization and bundling
|
|
||||||
|
|
||||||
### Environment Configuration
|
|
||||||
- Environment-specific configurations
|
|
||||||
- Feature flags for gradual rollouts
|
|
||||||
- Database connection management
|
|
||||||
|
|
||||||
## Security Patterns
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
- Session-based authentication
|
|
||||||
- Environment-based access control
|
|
||||||
- API route protection
|
|
||||||
|
|
||||||
### Data Validation
|
|
||||||
- Input validation on both client and server
|
|
||||||
- Type-safe API contracts
|
|
||||||
- Sanitization of user inputs
|
|
||||||
|
|
||||||
## Monitoring & Observability
|
|
||||||
|
|
||||||
### Error Tracking
|
|
||||||
- Client-side error boundaries
|
|
||||||
- Server-side error logging
|
|
||||||
- Performance monitoring
|
|
||||||
|
|
||||||
### Analytics
|
|
||||||
- User interaction tracking
|
|
||||||
- Performance metrics
|
|
||||||
- Database query monitoring
|
|
||||||
|
|
||||||
## Best Practices Summary
|
|
||||||
|
|
||||||
### Code Organization
|
|
||||||
- ✅ Follow the established directory structure
|
|
||||||
- ✅ Use consistent naming conventions
|
|
||||||
- ✅ Separate concerns (UI, logic, data)
|
|
||||||
- ✅ Keep components focused and small
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- ✅ Implement proper loading states
|
|
||||||
- ✅ Use AbortController for async operations
|
|
||||||
- ✅ Optimize database queries
|
|
||||||
- ✅ Implement proper caching strategies
|
|
||||||
|
|
||||||
### Type Safety
|
|
||||||
- ✅ Use TypeScript throughout
|
|
||||||
- ✅ Define proper interfaces for props
|
|
||||||
- ✅ Use type guards for runtime validation
|
|
||||||
- ✅ Leverage shared type packages
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- ✅ Write tests for critical functionality
|
|
||||||
- ✅ Mock external dependencies properly
|
|
||||||
- ✅ Test error scenarios and edge cases
|
|
||||||
- ✅ Maintain good test coverage
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
---
|
|
||||||
description: Security best practices and guidelines for writing GitHub Actions and workflows
|
|
||||||
globs: .github/workflows/*.yml,.github/workflows/*.yaml,.github/actions/*/action.yml,.github/actions/*/action.yaml
|
|
||||||
---
|
|
||||||
|
|
||||||
# GitHub Actions Security Best Practices
|
|
||||||
|
|
||||||
## Required Security Measures
|
|
||||||
|
|
||||||
### 1. Set Minimum GITHUB_TOKEN Permissions
|
|
||||||
|
|
||||||
Always explicitly set the minimum required permissions for GITHUB_TOKEN:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
# Only add additional permissions if absolutely necessary:
|
|
||||||
# pull-requests: write # for commenting on PRs
|
|
||||||
# issues: write # for creating/updating issues
|
|
||||||
# checks: write # for publishing check results
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Add Harden-Runner as First Step
|
|
||||||
|
|
||||||
For **every job** on `ubuntu-latest`, add Harden-Runner as the first step:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Harden the runner
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit # or 'block' for stricter security
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Pin Actions to Full Commit SHA
|
|
||||||
|
|
||||||
**Always** pin third-party actions to their full commit SHA, not tags:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD - uses mutable tag
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# ✅ GOOD - pinned to immutable commit SHA
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Secure Variable Handling
|
|
||||||
|
|
||||||
Prevent command injection by properly quoting variables:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD - potential command injection
|
|
||||||
run: echo "Processing ${{ inputs.user_input }}"
|
|
||||||
|
|
||||||
# ✅ GOOD - properly quoted
|
|
||||||
env:
|
|
||||||
USER_INPUT: ${{ inputs.user_input }}
|
|
||||||
run: echo "Processing ${USER_INPUT}"
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `${VARIABLE}` syntax in shell scripts instead of `$VARIABLE`.
|
|
||||||
|
|
||||||
### 5. Environment Variables for Secrets
|
|
||||||
|
|
||||||
Store sensitive data in environment variables, not inline:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD
|
|
||||||
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" api.example.com
|
|
||||||
|
|
||||||
# ✅ GOOD
|
|
||||||
env:
|
|
||||||
API_TOKEN: ${{ secrets.TOKEN }}
|
|
||||||
run: curl -H "Authorization: Bearer ${API_TOKEN}" api.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow Structure Best Practices
|
|
||||||
|
|
||||||
### Required Workflow Elements
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: "Descriptive Workflow Name"
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Define specific triggers
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
# Always set explicit permissions
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
job-name:
|
|
||||||
name: "Descriptive Job Name"
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30 # tune per job; standardize repo-wide
|
|
||||||
|
|
||||||
# Set job-level permissions if different from workflow level
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# Always start with Harden-Runner on ubuntu-latest
|
|
||||||
- name: Harden the runner
|
|
||||||
uses: step-security/harden-runner@v2
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
# Pin all actions to commit SHA
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Input Validation for Actions
|
|
||||||
|
|
||||||
For composite actions, always validate inputs:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
inputs:
|
|
||||||
user_input:
|
|
||||||
description: "User provided input"
|
|
||||||
required: true
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: "composite"
|
|
||||||
steps:
|
|
||||||
- name: Validate input
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# Harden shell and validate input format/content before use
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
USER_INPUT="${{ inputs.user_input }}"
|
|
||||||
|
|
||||||
if [[ ! "${USER_INPUT}" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
|
||||||
echo "❌ Invalid input format"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Security in Actions
|
|
||||||
|
|
||||||
### Pin Docker Images to Digests
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD - mutable tag
|
|
||||||
container: node:18
|
|
||||||
|
|
||||||
# ✅ GOOD - pinned to digest
|
|
||||||
container: node:18@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d6a37b82dfe1604c4c09cad
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Secure File Operations
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Process files securely
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
FILE_PATH: ${{ inputs.file_path }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail # Fail on errors, undefined vars, pipe failures
|
|
||||||
|
|
||||||
# Use absolute paths and validate
|
|
||||||
SAFE_PATH=$(realpath "${FILE_PATH}")
|
|
||||||
if [[ "$SAFE_PATH" != "${GITHUB_WORKSPACE}"/* ]]; then
|
|
||||||
echo "❌ Path outside workspace"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
### Artifact Handling
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Upload artifacts securely
|
|
||||||
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
|
|
||||||
with:
|
|
||||||
name: build-artifacts
|
|
||||||
path: |
|
|
||||||
dist/
|
|
||||||
!dist/**/*.log # Exclude sensitive files
|
|
||||||
retention-days: 30
|
|
||||||
```
|
|
||||||
|
|
||||||
### GHCR authentication for pulls/scans
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Minimal permissions required for GHCR pulls/scans
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Checklist
|
|
||||||
|
|
||||||
- [ ] Minimum GITHUB_TOKEN permissions set
|
|
||||||
- [ ] Harden-Runner added to all ubuntu-latest jobs
|
|
||||||
- [ ] All third-party actions pinned to commit SHA
|
|
||||||
- [ ] Input validation implemented for custom actions
|
|
||||||
- [ ] Variables properly quoted in shell scripts
|
|
||||||
- [ ] Secrets stored in environment variables
|
|
||||||
- [ ] Docker images pinned to digests (if used)
|
|
||||||
- [ ] Error handling with `set -euo pipefail`
|
|
||||||
- [ ] File paths validated and sanitized
|
|
||||||
- [ ] No sensitive data in logs or outputs
|
|
||||||
- [ ] GHCR login performed before pulls/scans (packages: read)
|
|
||||||
- [ ] Job timeouts configured (`timeout-minutes`)
|
|
||||||
|
|
||||||
## Recommended Additional Workflows
|
|
||||||
|
|
||||||
Consider adding these security-focused workflows to your repository:
|
|
||||||
|
|
||||||
1. **CodeQL Analysis** - Static Application Security Testing (SAST)
|
|
||||||
2. **Dependency Review** - Scan for vulnerable dependencies in PRs
|
|
||||||
3. **Dependabot Configuration** - Automated dependency updates
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- [GitHub Security Hardening Guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions)
|
|
||||||
- [Step Security Harden-Runner](https://github.com/step-security/harden-runner)
|
|
||||||
- [Secure-Repo Best Practices](https://github.com/step-security/secure-repo)
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
---
|
|
||||||
title: i18n Management with Lingo.dev
|
|
||||||
description: Guidelines for managing internationalization (i18n) with Lingo.dev, including translation workflow, key validation, and best practices
|
|
||||||
---
|
|
||||||
|
|
||||||
# i18n Management with Lingo.dev
|
|
||||||
|
|
||||||
This rule defines the workflow and best practices for managing internationalization (i18n) in the Formbricks project using Lingo.dev.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Formbricks uses [Lingo.dev](https://lingo.dev) for managing translations across multiple languages. The translation workflow includes:
|
|
||||||
|
|
||||||
1. **Translation Keys**: Defined in code using the `t()` function from `react-i18next`
|
|
||||||
2. **Translation Files**: JSON files stored in `apps/web/locales/` for each supported language
|
|
||||||
3. **Validation**: Automated scanning to detect missing and unused translation keys
|
|
||||||
4. **CI/CD**: Pre-commit hooks and GitHub Actions to enforce translation quality
|
|
||||||
|
|
||||||
## Translation Workflow
|
|
||||||
|
|
||||||
### 1. Using Translations in Code
|
|
||||||
|
|
||||||
When adding translatable text in the web app, use the `t()` function or `<Trans>` component:
|
|
||||||
|
|
||||||
**Using the `t()` function:**
|
|
||||||
```tsx
|
|
||||||
import { useTranslate } from "@/lib/i18n/translate";
|
|
||||||
|
|
||||||
const MyComponent = () => {
|
|
||||||
const { t } = useTranslate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>{t("common.welcome")}</h1>
|
|
||||||
<p>{t("pages.dashboard.description")}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Using the `<Trans>` component (for text with HTML elements):**
|
|
||||||
```tsx
|
|
||||||
import { Trans } from "react-i18next";
|
|
||||||
|
|
||||||
const MyComponent = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
<Trans
|
|
||||||
i18nKey="auth.terms_agreement"
|
|
||||||
components={{
|
|
||||||
link: <a href="/terms" />,
|
|
||||||
b: <b />
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Naming Conventions:**
|
|
||||||
- Use dot notation for nested keys: `section.subsection.key`
|
|
||||||
- Use descriptive names: `auth.login.success_message` not `auth.msg1`
|
|
||||||
- Group related keys together: `auth.*`, `errors.*`, `common.*`
|
|
||||||
- Use lowercase with underscores: `user_profile_settings` not `UserProfileSettings`
|
|
||||||
|
|
||||||
### 2. Translation File Structure
|
|
||||||
|
|
||||||
Translation files are located in `apps/web/locales/` and use the following naming convention:
|
|
||||||
- `en-US.json` (English - United States, default)
|
|
||||||
- `de-DE.json` (German)
|
|
||||||
- `fr-FR.json` (French)
|
|
||||||
- `pt-BR.json` (Portuguese - Brazil)
|
|
||||||
- etc.
|
|
||||||
|
|
||||||
**File Structure:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"common": {
|
|
||||||
"welcome": "Welcome",
|
|
||||||
"save": "Save",
|
|
||||||
"cancel": "Cancel"
|
|
||||||
},
|
|
||||||
"auth": {
|
|
||||||
"login": {
|
|
||||||
"title": "Login",
|
|
||||||
"email_placeholder": "Enter your email",
|
|
||||||
"password_placeholder": "Enter your password"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Adding New Translation Keys
|
|
||||||
|
|
||||||
When adding new translation keys:
|
|
||||||
|
|
||||||
1. **Add the key in your code** using `t("your.new.key")`
|
|
||||||
2. **Add translation for that key in en-US.json file**
|
|
||||||
3. **Run the translation workflow:**
|
|
||||||
```bash
|
|
||||||
pnpm i18n
|
|
||||||
```
|
|
||||||
This will:
|
|
||||||
- Generate translations for all languages using Lingo.dev
|
|
||||||
- Validate that all keys are present and used
|
|
||||||
|
|
||||||
4. **Review and commit** the generated translation files
|
|
||||||
|
|
||||||
### 4. Available Scripts
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate translations using Lingo.dev
|
|
||||||
pnpm generate-translations
|
|
||||||
|
|
||||||
# Scan and validate translation keys
|
|
||||||
pnpm scan-translations
|
|
||||||
|
|
||||||
# Full workflow: generate + validate
|
|
||||||
pnpm i18n
|
|
||||||
|
|
||||||
# Validate only (without generation)
|
|
||||||
pnpm i18n:validate
|
|
||||||
```
|
|
||||||
|
|
||||||
## Translation Key Validation
|
|
||||||
|
|
||||||
### Automated Validation
|
|
||||||
|
|
||||||
The project includes automated validation that runs:
|
|
||||||
- **Pre-commit hook**: Validates translations before allowing commits (when `LINGODOTDEV_API_KEY` is set)
|
|
||||||
- **GitHub Actions**: Validates translations on every PR and push to main
|
|
||||||
|
|
||||||
### Validation Rules
|
|
||||||
|
|
||||||
The validation script (`scan-translations.ts`) checks for:
|
|
||||||
|
|
||||||
1. **Missing Keys**: Translation keys used in code but not present in translation files
|
|
||||||
2. **Unused Keys**: Translation keys present in translation files but not used in code
|
|
||||||
3. **Incomplete Translations**: Keys that exist in the default language (`en-US`) but are missing in target languages
|
|
||||||
|
|
||||||
**What gets scanned:**
|
|
||||||
- All `.ts` and `.tsx` files in `apps/web/`
|
|
||||||
- Both `t()` function calls and `<Trans i18nKey="">` components
|
|
||||||
- All locale files (`de-DE.json`, `fr-FR.json`, `ja-JP.json`, etc.)
|
|
||||||
|
|
||||||
**What gets excluded:**
|
|
||||||
- Test files (`*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx`)
|
|
||||||
- Build directories (`node_modules`, `dist`, `build`, `.next`, `coverage`)
|
|
||||||
- Locale files themselves (from code scanning)
|
|
||||||
|
|
||||||
**Note:** Test files are excluded because they often use mock or example translation keys for testing purposes that don't need to exist in production translation files.
|
|
||||||
|
|
||||||
### Fixing Validation Errors
|
|
||||||
|
|
||||||
#### Missing Keys
|
|
||||||
|
|
||||||
If you encounter missing key errors:
|
|
||||||
|
|
||||||
```
|
|
||||||
❌ MISSING KEYS (2):
|
|
||||||
|
|
||||||
These keys are used in code but not found in translation files:
|
|
||||||
|
|
||||||
• auth.signup.email_required
|
|
||||||
• settings.profile.update_success
|
|
||||||
```
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
1. Ensure that translations for those keys are present in en-US.json .
|
|
||||||
2. Run `pnpm generate-translations` to have Lingo.dev generate the missing translations
|
|
||||||
3. OR manually add the keys to `apps/web/locales/en-US.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"auth": {
|
|
||||||
"signup": {
|
|
||||||
"email_required": "Email is required"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"profile": {
|
|
||||||
"update_success": "Profile updated successfully"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
3. Run `pnpm scan-translations` to verify
|
|
||||||
4. Commit the changes
|
|
||||||
|
|
||||||
#### Unused Keys
|
|
||||||
|
|
||||||
If you encounter unused key errors:
|
|
||||||
|
|
||||||
```
|
|
||||||
⚠️ UNUSED KEYS (1):
|
|
||||||
|
|
||||||
These keys exist in translation files but are not used in code:
|
|
||||||
|
|
||||||
• old.deprecated.key
|
|
||||||
```
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
1. If the key is truly unused, remove it from all translation files
|
|
||||||
2. If the key should be used, add it to your code using `t("old.deprecated.key")`
|
|
||||||
3. Run `pnpm scan-translations` to verify
|
|
||||||
4. Commit the changes
|
|
||||||
|
|
||||||
#### Incomplete Translations
|
|
||||||
|
|
||||||
If you encounter incomplete translation errors:
|
|
||||||
|
|
||||||
```
|
|
||||||
⚠️ INCOMPLETE TRANSLATIONS:
|
|
||||||
|
|
||||||
Some keys from en-US are missing in target languages:
|
|
||||||
|
|
||||||
📝 de-DE (5 missing keys):
|
|
||||||
• auth.new_feature.title
|
|
||||||
• auth.new_feature.description
|
|
||||||
• settings.advanced.option
|
|
||||||
... and 2 more
|
|
||||||
```
|
|
||||||
|
|
||||||
**Resolution:**
|
|
||||||
1. **Recommended:** Run `pnpm generate-translations` to have Lingo.dev automatically translate the missing keys
|
|
||||||
2. **Manual:** Add the missing keys to the target language files:
|
|
||||||
```bash
|
|
||||||
# Copy the structure from en-US.json and translate the values
|
|
||||||
# For example, in de-DE.json:
|
|
||||||
{
|
|
||||||
"auth": {
|
|
||||||
"new_feature": {
|
|
||||||
"title": "Neues Feature",
|
|
||||||
"description": "Beschreibung des neuen Features"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
3. Run `pnpm scan-translations` to verify all translations are complete
|
|
||||||
4. Commit the changes
|
|
||||||
|
|
||||||
## Pre-commit Hook Behavior
|
|
||||||
|
|
||||||
The pre-commit hook will:
|
|
||||||
|
|
||||||
1. Run `lint-staged` for code formatting
|
|
||||||
2. If `LINGODOTDEV_API_KEY` is set:
|
|
||||||
- Generate translations using Lingo.dev
|
|
||||||
- Validate translation keys
|
|
||||||
- Auto-add updated locale files to the commit
|
|
||||||
- **Block the commit** if validation fails
|
|
||||||
3. If `LINGODOTDEV_API_KEY` is not set:
|
|
||||||
- Skip translation validation (for community contributors)
|
|
||||||
- Show a warning message
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
### LINGODOTDEV_API_KEY
|
|
||||||
|
|
||||||
This is the API key for Lingo.dev integration.
|
|
||||||
|
|
||||||
**For Core Team:**
|
|
||||||
- Add to your local `.env` file
|
|
||||||
- Required for running translation generation
|
|
||||||
|
|
||||||
**For Community Contributors:**
|
|
||||||
- Not required for local development
|
|
||||||
- Translation validation will be skipped
|
|
||||||
- The CI will still validate translations
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### 1. Keep Keys Organized
|
|
||||||
|
|
||||||
Group related keys together:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"auth": {
|
|
||||||
"login": { ... },
|
|
||||||
"signup": { ... },
|
|
||||||
"forgot_password": { ... }
|
|
||||||
},
|
|
||||||
"dashboard": {
|
|
||||||
"header": { ... },
|
|
||||||
"sidebar": { ... }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Avoid Hardcoded Strings
|
|
||||||
|
|
||||||
**❌ Bad:**
|
|
||||||
```tsx
|
|
||||||
<button>Click here</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
**✅ Good:**
|
|
||||||
```tsx
|
|
||||||
<button>{t("common.click_here")}</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Use Interpolation for Dynamic Content
|
|
||||||
|
|
||||||
**❌ Bad:**
|
|
||||||
```tsx
|
|
||||||
{t("welcome")} {userName}!
|
|
||||||
```
|
|
||||||
|
|
||||||
**✅ Good:**
|
|
||||||
```tsx
|
|
||||||
{t("auth.welcome_message", { userName })}
|
|
||||||
```
|
|
||||||
|
|
||||||
With translation:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"auth": {
|
|
||||||
"welcome_message": "Welcome, {userName}!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Avoid Dynamic Key Construction
|
|
||||||
|
|
||||||
**❌ Bad:**
|
|
||||||
```tsx
|
|
||||||
const key = `errors.${errorCode}`;
|
|
||||||
t(key);
|
|
||||||
```
|
|
||||||
|
|
||||||
**✅ Good:**
|
|
||||||
```tsx
|
|
||||||
switch (errorCode) {
|
|
||||||
case "401":
|
|
||||||
return t("errors.unauthorized");
|
|
||||||
case "404":
|
|
||||||
return t("errors.not_found");
|
|
||||||
default:
|
|
||||||
return t("errors.unknown");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Test Translation Keys
|
|
||||||
|
|
||||||
When adding new features:
|
|
||||||
1. Add translation keys
|
|
||||||
2. Test in multiple languages using the language switcher
|
|
||||||
3. Ensure text doesn't overflow in longer translations (German, French)
|
|
||||||
4. Run `pnpm scan-translations` before committing
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: Pre-commit hook fails with validation errors
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
# Run the full i18n workflow
|
|
||||||
pnpm i18n
|
|
||||||
|
|
||||||
# Fix any missing or unused keys
|
|
||||||
# Then commit again
|
|
||||||
git add .
|
|
||||||
git commit -m "your message"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: Translation validation passes locally but fails in CI
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Ensure all translation files are committed
|
|
||||||
- Check that `scan-translations.ts` hasn't been modified
|
|
||||||
- Verify that locale files are properly formatted JSON
|
|
||||||
|
|
||||||
### Issue: Cannot commit because of missing translations
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
# If you have LINGODOTDEV_API_KEY:
|
|
||||||
pnpm generate-translations
|
|
||||||
|
|
||||||
# If you don't have the API key (community contributor):
|
|
||||||
# Manually add the missing keys to en-US.json
|
|
||||||
# Then run validation:
|
|
||||||
pnpm scan-translations
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: Getting "unused keys" for keys that are used
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- The script scans `.ts` and `.tsx` files only
|
|
||||||
- If keys are used in other file types, they may be flagged
|
|
||||||
- Verify the key is actually used with `grep -r "your.key" apps/web/`
|
|
||||||
- If it's a false positive, consider updating the scanning patterns in `scan-translations.ts`
|
|
||||||
|
|
||||||
## AI Assistant Guidelines
|
|
||||||
|
|
||||||
When assisting with i18n-related tasks, always:
|
|
||||||
|
|
||||||
1. **Use the `t()` function** for all user-facing text
|
|
||||||
2. **Follow key naming conventions** (lowercase, dots for nesting)
|
|
||||||
3. **Run validation** after making changes: `pnpm scan-translations`
|
|
||||||
4. **Fix missing keys** by adding them to `en-US.json`
|
|
||||||
5. **Remove unused keys** from all translation files
|
|
||||||
6. **Test the pre-commit hook** if making changes to translation workflow
|
|
||||||
7. **Update this rule file** if translation workflow changes
|
|
||||||
|
|
||||||
### Fixing Missing Translation Keys
|
|
||||||
|
|
||||||
When the AI encounters missing translation key errors:
|
|
||||||
|
|
||||||
1. Identify the missing keys from the error output
|
|
||||||
2. Determine the appropriate section and naming for each key
|
|
||||||
3. Add the keys to `apps/web/locales/en-US.json` with meaningful English text
|
|
||||||
4. Ensure proper JSON structure and nesting
|
|
||||||
5. Run `pnpm scan-translations` to verify
|
|
||||||
6. Inform the user that other language files will be updated via Lingo.dev
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
```typescript
|
|
||||||
// Error: Missing key "settings.api.rate_limit_exceeded"
|
|
||||||
|
|
||||||
// Add to en-US.json:
|
|
||||||
{
|
|
||||||
"settings": {
|
|
||||||
"api": {
|
|
||||||
"rate_limit_exceeded": "API rate limit exceeded. Please try again later."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Removing Unused Translation Keys
|
|
||||||
|
|
||||||
When the AI encounters unused translation key errors:
|
|
||||||
|
|
||||||
1. Verify the keys are truly unused by searching the codebase
|
|
||||||
2. Remove the keys from `apps/web/locales/en-US.json`
|
|
||||||
3. Note that removal from other language files can be handled via Lingo.dev
|
|
||||||
4. Run `pnpm scan-translations` to verify
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
This project previously used Tolgee for translations. As of this migration:
|
|
||||||
|
|
||||||
- **Old scripts**: `tolgee-pull` is deprecated (kept for reference)
|
|
||||||
- **New scripts**: Use `pnpm i18n` or `pnpm generate-translations`
|
|
||||||
- **Old workflows**: `tolgee.yml` and `tolgee-missing-key-check.yml` removed
|
|
||||||
- **New workflow**: `translation-check.yml` handles all validation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** October 14, 2025
|
|
||||||
**Related Files:**
|
|
||||||
- `scan-translations.ts` - Translation validation script
|
|
||||||
- `.husky/pre-commit` - Pre-commit hook with i18n validation
|
|
||||||
- `.github/workflows/translation-check.yml` - CI workflow for translation validation
|
|
||||||
- `apps/web/locales/*.json` - Translation files
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# React Context & Provider Patterns
|
|
||||||
|
|
||||||
## Context Provider Best Practices
|
|
||||||
|
|
||||||
### Provider Implementation
|
|
||||||
- Use TypeScript interfaces for provider props with optional `initialCount` for testing
|
|
||||||
- Implement proper cleanup in `useEffect` to avoid React hooks warnings
|
|
||||||
- Reference: [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx)
|
|
||||||
|
|
||||||
### Cleanup Pattern for Refs
|
|
||||||
```typescript
|
|
||||||
useEffect(() => {
|
|
||||||
const currentPendingRequests = pendingRequests.current;
|
|
||||||
const currentAbortController = abortController.current;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (currentAbortController) {
|
|
||||||
currentAbortController.abort();
|
|
||||||
}
|
|
||||||
currentPendingRequests.clear();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing Context Providers
|
|
||||||
- Always wrap components using context in the provider during tests
|
|
||||||
- Use `initialCount` prop for predictable test scenarios
|
|
||||||
- Mock context dependencies like `useParams`, `useResponseFilter`
|
|
||||||
- Example from [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
render(
|
|
||||||
<ResponseCountProvider survey={dummySurvey} initialCount={5}>
|
|
||||||
<ComponentUnderTest />
|
|
||||||
</ResponseCountProvider>
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Required Mocks for Context Testing
|
|
||||||
- Mock `next/navigation` with `useParams` returning environment and survey IDs
|
|
||||||
- Mock response filter context and actions
|
|
||||||
- Mock API actions that the provider depends on
|
|
||||||
|
|
||||||
### Context Hook Usage
|
|
||||||
- Create custom hooks like `useResponseCountContext()` for consuming context
|
|
||||||
- Provide meaningful error messages when context is used outside provider
|
|
||||||
- Use context for shared state that multiple components need to access
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
---
|
|
||||||
description: Apply these quality standards before finalizing code changes to ensure DRY principles, React best practices, TypeScript conventions, and maintainable code.
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# Review & Refine
|
|
||||||
|
|
||||||
Before finalizing any code changes, review your implementation against these quality standards:
|
|
||||||
|
|
||||||
## Core Principles
|
|
||||||
|
|
||||||
### DRY (Don't Repeat Yourself)
|
|
||||||
|
|
||||||
- Extract duplicated logic into reusable functions or hooks
|
|
||||||
- If the same code appears in multiple places, consolidate it
|
|
||||||
- Create helper functions at appropriate scope (component-level, module-level, or utility files)
|
|
||||||
- Avoid copy-pasting code blocks
|
|
||||||
|
|
||||||
### Code Reduction
|
|
||||||
|
|
||||||
- Remove unnecessary code, comments, and abstractions
|
|
||||||
- Prefer built-in solutions over custom implementations
|
|
||||||
- Consolidate similar logic
|
|
||||||
- Remove dead code and unused imports
|
|
||||||
- Question if every line of code is truly needed
|
|
||||||
|
|
||||||
## React Best Practices
|
|
||||||
|
|
||||||
### Component Design
|
|
||||||
|
|
||||||
- Keep components focused on a single responsibility
|
|
||||||
- Extract complex logic into custom hooks
|
|
||||||
- Prefer composition over prop drilling
|
|
||||||
- Use children props and render props when appropriate
|
|
||||||
- Keep component files under 300 lines when possible
|
|
||||||
|
|
||||||
### Hooks Usage
|
|
||||||
|
|
||||||
- Follow Rules of Hooks (only call at top level, only in React functions)
|
|
||||||
- Extract complex `useEffect` logic into custom hooks
|
|
||||||
- Use `useMemo` and `useCallback` only when you have a measured performance issue
|
|
||||||
- Declare dependencies arrays correctly - don't ignore exhaustive-deps warnings
|
|
||||||
- Keep `useEffect` focused on a single concern
|
|
||||||
|
|
||||||
### State Management
|
|
||||||
|
|
||||||
- Colocate state as close as possible to where it's used
|
|
||||||
- Lift state only when necessary
|
|
||||||
- Use `useReducer` for complex state logic with multiple sub-values
|
|
||||||
- Avoid derived state - compute values during render instead
|
|
||||||
- Don't store values in state that can be computed from props
|
|
||||||
|
|
||||||
### Event Handlers
|
|
||||||
|
|
||||||
- Name event handlers with `handle` prefix (e.g., `handleClick`, `handleSubmit`)
|
|
||||||
- Extract complex event handler logic into separate functions
|
|
||||||
- Avoid inline arrow functions in JSX when they contain complex logic
|
|
||||||
|
|
||||||
## TypeScript Best Practices
|
|
||||||
|
|
||||||
### Type Safety
|
|
||||||
|
|
||||||
- Prefer type inference over explicit types when possible
|
|
||||||
- Use `const` assertions for literal types
|
|
||||||
- Avoid `any` - use `unknown` if type is truly unknown
|
|
||||||
- Use discriminated unions for complex conditional logic
|
|
||||||
- Leverage type guards and narrowing
|
|
||||||
|
|
||||||
### Interface & Type Usage
|
|
||||||
|
|
||||||
- Use existing types from `@formbricks/types` - don't recreate them
|
|
||||||
- Prefer `interface` for object shapes that might be extended
|
|
||||||
- Prefer `type` for unions, intersections, and mapped types
|
|
||||||
- Define types close to where they're used unless they're shared
|
|
||||||
- Export types from index files for shared types
|
|
||||||
|
|
||||||
### Type Assertions
|
|
||||||
|
|
||||||
- Avoid type assertions (`as`) when possible
|
|
||||||
- Use type guards instead of assertions
|
|
||||||
- Only assert when you have more information than TypeScript
|
|
||||||
|
|
||||||
## Code Organization
|
|
||||||
|
|
||||||
### Separation of Concerns
|
|
||||||
|
|
||||||
- Separate business logic from UI rendering
|
|
||||||
- Extract API calls into separate functions or modules
|
|
||||||
- Keep data transformation separate from component logic
|
|
||||||
- Use custom hooks for stateful logic that doesn't render UI
|
|
||||||
|
|
||||||
### Function Clarity
|
|
||||||
|
|
||||||
- Functions should do one thing well
|
|
||||||
- Name functions clearly and descriptively
|
|
||||||
- Keep functions small (aim for under 20 lines)
|
|
||||||
- Extract complex conditionals into named boolean variables or functions
|
|
||||||
- Avoid deep nesting (max 3 levels)
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
|
|
||||||
- Group related functions together
|
|
||||||
- Order declarations logically (types → hooks → helpers → component)
|
|
||||||
- Keep imports organized (external → internal → relative)
|
|
||||||
- Consider splitting large files by concern
|
|
||||||
|
|
||||||
## Additional Quality Checks
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
- Don't optimize prematurely - measure first
|
|
||||||
- Avoid creating new objects/arrays/functions in render unnecessarily
|
|
||||||
- Use keys properly in lists (stable, unique identifiers)
|
|
||||||
- Lazy load heavy components when appropriate
|
|
||||||
|
|
||||||
### Accessibility
|
|
||||||
|
|
||||||
- Use semantic HTML elements
|
|
||||||
- Include ARIA labels where needed
|
|
||||||
- Ensure keyboard navigation works
|
|
||||||
- Check color contrast and focus states
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
- Handle error states in components
|
|
||||||
- Provide user feedback for failed operations
|
|
||||||
- Use error boundaries for component errors
|
|
||||||
- Log errors appropriately (avoid swallowing errors silently)
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
|
|
||||||
- Use descriptive names (avoid abbreviations unless very common)
|
|
||||||
- Boolean variables/props should sound like yes/no questions (`isLoading`, `hasError`, `canEdit`)
|
|
||||||
- Arrays should be plural (`users`, `choices`, `items`)
|
|
||||||
- Event handlers: `handleX` in components, `onX` for props
|
|
||||||
- Constants in UPPER_SNAKE_CASE only for true constants
|
|
||||||
|
|
||||||
### Code Readability
|
|
||||||
|
|
||||||
- Prefer early returns to reduce nesting
|
|
||||||
- Use destructuring to make code clearer
|
|
||||||
- Break complex expressions into named variables
|
|
||||||
- Add comments only when code can't be made self-explanatory
|
|
||||||
- Use whitespace to group related code
|
|
||||||
|
|
||||||
### Testing Considerations
|
|
||||||
|
|
||||||
- Write code that's easy to test (pure functions, clear inputs/outputs)
|
|
||||||
- Avoid hard-to-mock dependencies when possible
|
|
||||||
- Keep side effects at the edges of your code
|
|
||||||
|
|
||||||
## Review Checklist
|
|
||||||
|
|
||||||
Before submitting your changes, ask yourself:
|
|
||||||
|
|
||||||
1. **DRY**: Is there any duplicated logic I can extract?
|
|
||||||
2. **Clarity**: Would another developer understand this code easily?
|
|
||||||
3. **Simplicity**: Is this the simplest solution that works?
|
|
||||||
4. **Types**: Am I using TypeScript effectively?
|
|
||||||
5. **React**: Am I following React idioms and best practices?
|
|
||||||
6. **Performance**: Are there obvious performance issues?
|
|
||||||
7. **Separation**: Are concerns properly separated?
|
|
||||||
8. **Testing**: Is this code testable?
|
|
||||||
9. **Maintenance**: Will this be easy to change in 6 months?
|
|
||||||
10. **Deletion**: Can I remove any code and still accomplish the goal?
|
|
||||||
|
|
||||||
## When to Apply This Rule
|
|
||||||
|
|
||||||
Apply this rule:
|
|
||||||
|
|
||||||
- After implementing a feature but before marking it complete
|
|
||||||
- When you notice your code feels "messy" or complex
|
|
||||||
- Before requesting code review
|
|
||||||
- When you see yourself copy-pasting code
|
|
||||||
- After receiving feedback about code quality
|
|
||||||
|
|
||||||
Don't let perfect be the enemy of good, but always strive for:
|
|
||||||
**Simple, readable, maintainable code that does one thing well.**
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
---
|
|
||||||
description: Migrate deprecated UI components to a unified component
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# Component Migration Automation Rule
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
This rule automates the migration of deprecated components to new component systems in React/TypeScript codebases.
|
|
||||||
|
|
||||||
## Trigger
|
|
||||||
When the user requests component migration (e.g., "migrate [DeprecatedComponent] to [NewComponent]" or "component migration").
|
|
||||||
|
|
||||||
## Process
|
|
||||||
|
|
||||||
### Step 1: Discovery and Planning
|
|
||||||
1. **Identify migration parameters:**
|
|
||||||
- Ask user for deprecated component name (e.g., "Modal")
|
|
||||||
- Ask user for new component name(s) (e.g., "Dialog")
|
|
||||||
- Ask for any components to exclude (e.g., "ModalWithTabs")
|
|
||||||
- Ask for specific import paths if needed
|
|
||||||
|
|
||||||
2. **Scan codebase** for deprecated components:
|
|
||||||
- Search for `import.*[DeprecatedComponent]` patterns
|
|
||||||
- Exclude specified components that should not be migrated
|
|
||||||
- List all found components with file paths
|
|
||||||
- Present numbered list to user for confirmation
|
|
||||||
|
|
||||||
### Step 2: Component-by-Component Migration
|
|
||||||
For each component, follow this exact sequence:
|
|
||||||
|
|
||||||
#### 2.1 Component Migration
|
|
||||||
- **Import changes:**
|
|
||||||
- Ask user to provide the new import structure
|
|
||||||
- Example transformation pattern:
|
|
||||||
```typescript
|
|
||||||
// FROM:
|
|
||||||
import { [DeprecatedComponent] } from "@/components/ui/[DeprecatedComponent]"
|
|
||||||
|
|
||||||
// TO:
|
|
||||||
import {
|
|
||||||
[NewComponent],
|
|
||||||
[NewComponentPart1],
|
|
||||||
[NewComponentPart2],
|
|
||||||
// ... other parts
|
|
||||||
} from "@/components/ui/[NewComponent]"
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Props transformation:**
|
|
||||||
- Ask user for prop mapping rules (e.g., `open` → `open`, `setOpen` → `onOpenChange`)
|
|
||||||
- Ask for props to remove (e.g., `noPadding`, `closeOnOutsideClick`, `size`)
|
|
||||||
- Apply transformations based on user specifications
|
|
||||||
|
|
||||||
- **Structure transformation:**
|
|
||||||
- Ask user for the new component structure pattern
|
|
||||||
- Apply the transformation maintaining all functionality
|
|
||||||
- Preserve all existing logic, state management, and event handlers
|
|
||||||
|
|
||||||
#### 2.2 Wait for User Approval
|
|
||||||
- Present the migration changes
|
|
||||||
- Wait for explicit user approval before proceeding
|
|
||||||
- If rejected, ask for specific feedback and iterate
|
|
||||||
#### 2.3 Re-read and Apply Additional Changes
|
|
||||||
- Re-read the component file to capture any user modifications
|
|
||||||
- Apply any additional improvements the user made
|
|
||||||
- Ensure all changes are incorporated
|
|
||||||
|
|
||||||
#### 2.4 Test File Updates
|
|
||||||
- **Find corresponding test file** (same name with `.test.tsx` or `.test.ts`)
|
|
||||||
- **Update test mocks:**
|
|
||||||
- Ask user for new component mock structure
|
|
||||||
- Replace old component mocks with new ones
|
|
||||||
- Example pattern:
|
|
||||||
```typescript
|
|
||||||
// Add to test setup:
|
|
||||||
jest.mock("@/components/ui/[NewComponent]", () => ({
|
|
||||||
[NewComponent]: ({ children, [props] }: any) => ([mock implementation]),
|
|
||||||
[NewComponentPart1]: ({ children }: any) => <div data-testid="[new-component-part1]">{children}</div>,
|
|
||||||
[NewComponentPart2]: ({ children }: any) => <div data-testid="[new-component-part2]">{children}</div>,
|
|
||||||
// ... other parts
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
- **Update test expectations:**
|
|
||||||
- Change test IDs from old component to new component
|
|
||||||
- Update any component-specific assertions
|
|
||||||
- Ensure all new component parts used in the component are mocked
|
|
||||||
|
|
||||||
#### 2.5 Run Tests and Optimize
|
|
||||||
- Execute `Node package manager test -- ComponentName.test.tsx`
|
|
||||||
- Fix any failing tests
|
|
||||||
- Optimize code quality (imports, formatting, etc.)
|
|
||||||
- Re-run tests until all pass
|
|
||||||
- **Maximum 3 iterations** - if still failing, ask user for guidance
|
|
||||||
|
|
||||||
#### 2.6 Wait for Final Approval
|
|
||||||
- Present test results and any optimizations made
|
|
||||||
- Wait for user approval of the complete migration
|
|
||||||
- If rejected, iterate based on feedback
|
|
||||||
|
|
||||||
#### 2.7 Git Commit
|
|
||||||
- Run: `git add .`
|
|
||||||
- Run: `git commit -m "migrate [ComponentName] from [DeprecatedComponent] to [NewComponent]"`
|
|
||||||
- Confirm commit was successful
|
|
||||||
|
|
||||||
### Step 3: Final Report Generation
|
|
||||||
After all components are migrated, generate a comprehensive GitHub PR report:
|
|
||||||
|
|
||||||
#### PR Title
|
|
||||||
```
|
|
||||||
feat: migrate [DeprecatedComponent] components to [NewComponent] system
|
|
||||||
```
|
|
||||||
|
|
||||||
#### PR Description Template
|
|
||||||
```markdown
|
|
||||||
## 🔄 [DeprecatedComponent] to [NewComponent] Migration
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
Migrated [X] [DeprecatedComponent] components to the new [NewComponent] component system to modernize the UI architecture and improve consistency.
|
|
||||||
|
|
||||||
### Components Migrated
|
|
||||||
[List each component with file path]
|
|
||||||
|
|
||||||
### Technical Changes
|
|
||||||
- **Imports:** Replaced `[DeprecatedComponent]` with `[NewComponent], [NewComponentParts...]`
|
|
||||||
- **Props:** [List prop transformations]
|
|
||||||
- **Structure:** Implemented proper [NewComponent] component hierarchy
|
|
||||||
- **Styling:** [Describe styling changes]
|
|
||||||
- **Tests:** Updated all test mocks and expectations
|
|
||||||
|
|
||||||
### Migration Pattern
|
|
||||||
```typescript
|
|
||||||
// Before
|
|
||||||
<[DeprecatedComponent] [oldProps]>
|
|
||||||
[oldStructure]
|
|
||||||
</[DeprecatedComponent]>
|
|
||||||
|
|
||||||
// After
|
|
||||||
<[NewComponent] [newProps]>
|
|
||||||
[newStructure]
|
|
||||||
</[NewComponent]>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- ✅ All existing tests updated and passing
|
|
||||||
- ✅ Component functionality preserved
|
|
||||||
- ✅ UI/UX behavior maintained
|
|
||||||
|
|
||||||
### How to Test This PR
|
|
||||||
1. **Functional Testing:**
|
|
||||||
- Navigate to each migrated component's usage
|
|
||||||
- Verify [component] opens and closes correctly
|
|
||||||
- Test all interactive elements within [components]
|
|
||||||
- Confirm styling and layout are preserved
|
|
||||||
|
|
||||||
2. **Automated Testing:**
|
|
||||||
```bash
|
|
||||||
Node package manager test
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Visual Testing:**
|
|
||||||
- Check that all [components] maintain proper styling
|
|
||||||
- Verify responsive behavior
|
|
||||||
- Test keyboard navigation and accessibility
|
|
||||||
|
|
||||||
### Breaking Changes
|
|
||||||
[List any breaking changes or state "None - this is a drop-in replacement maintaining all existing functionality."]
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- [Any excluded components] were preserved as they already use [NewComponent] internally
|
|
||||||
- All form validation and complex state management preserved
|
|
||||||
- Enhanced code quality with better imports and formatting
|
|
||||||
```
|
|
||||||
|
|
||||||
## Special Considerations
|
|
||||||
|
|
||||||
### Excluded Components
|
|
||||||
- **DO NOT MIGRATE** components specified by user as exclusions
|
|
||||||
- They may already use the new component internally or have other reasons
|
|
||||||
- Inform user these are skipped and why
|
|
||||||
|
|
||||||
### Complex Components
|
|
||||||
- Preserve all existing functionality (forms, validation, state management)
|
|
||||||
- Maintain prop interfaces
|
|
||||||
- Keep all event handlers and callbacks
|
|
||||||
- Preserve accessibility features
|
|
||||||
|
|
||||||
### Test Coverage
|
|
||||||
- Ensure all new component parts are mocked when used
|
|
||||||
- Mock all new component parts that appear in the component
|
|
||||||
- Update test IDs from old component to new component
|
|
||||||
- Maintain all existing test scenarios
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- If tests fail after 3 iterations, stop and ask user for guidance
|
|
||||||
- If component is too complex, ask user for specific guidance
|
|
||||||
- If unsure about functionality preservation, ask for clarification
|
|
||||||
|
|
||||||
### Migration Patterns
|
|
||||||
- Always ask user for specific migration patterns before starting
|
|
||||||
- Confirm import structures, prop mappings, and component hierarchies
|
|
||||||
- Adapt to different component architectures (simple replacements, complex restructuring, etc.)
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
- All deprecated components successfully migrated to new components
|
|
||||||
- All tests passing
|
|
||||||
- No functionality lost
|
|
||||||
- Code quality maintained or improved
|
|
||||||
- User approval on each component
|
|
||||||
- Successful git commits for each migration
|
|
||||||
- Comprehensive PR report generated
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
- "migrate Modal to Dialog"
|
|
||||||
- "migrate Button to NewButton"
|
|
||||||
- "migrate Card to ModernCard"
|
|
||||||
- "component migration" (will prompt for details)
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
---
|
|
||||||
description: Create a story in Storybook for a given component
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# Formbricks Storybook Stories
|
|
||||||
|
|
||||||
## When generating Storybook stories for Formbricks components:
|
|
||||||
|
|
||||||
### 1. **File Structure**
|
|
||||||
- Create `stories.tsx` (not `.stories.tsx`) in component directory
|
|
||||||
- Use exact import: `import { Meta, StoryObj } from "@storybook/react-vite";`
|
|
||||||
- Import component from `"./index"`
|
|
||||||
|
|
||||||
### 2. **Story Structure Template**
|
|
||||||
```tsx
|
|
||||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
|
||||||
import { ComponentName } from "./index";
|
|
||||||
|
|
||||||
// For complex components with configurable options
|
|
||||||
// consider this as an example the options need to reflect the props types
|
|
||||||
interface StoryOptions {
|
|
||||||
showIcon: boolean;
|
|
||||||
numberOfElements: number;
|
|
||||||
customLabels: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type StoryProps = React.ComponentProps<typeof ComponentName> & StoryOptions;
|
|
||||||
|
|
||||||
const meta: Meta<StoryProps> = {
|
|
||||||
title: "UI/ComponentName",
|
|
||||||
component: ComponentName,
|
|
||||||
tags: ["autodocs"],
|
|
||||||
parameters: {
|
|
||||||
layout: "centered",
|
|
||||||
controls: { sort: "alpha", exclude: [] },
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component: "The **ComponentName** component provides [description].",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
// Organize in exactly these categories: Behavior, Appearance, Content
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof ComponentName> & { args: StoryOptions };
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **ArgTypes Organization**
|
|
||||||
Organize ALL argTypes into exactly three categories:
|
|
||||||
- **Behavior**: disabled, variant, onChange, etc.
|
|
||||||
- **Appearance**: size, color, layout, styling, etc.
|
|
||||||
- **Content**: text, icons, numberOfElements, etc.
|
|
||||||
|
|
||||||
Format:
|
|
||||||
```tsx
|
|
||||||
argTypes: {
|
|
||||||
propName: {
|
|
||||||
control: "select" | "boolean" | "text" | "number",
|
|
||||||
options: ["option1", "option2"], // for select
|
|
||||||
description: "Clear description",
|
|
||||||
table: {
|
|
||||||
category: "Behavior" | "Appearance" | "Content",
|
|
||||||
type: { summary: "string" },
|
|
||||||
defaultValue: { summary: "default" },
|
|
||||||
},
|
|
||||||
order: 1,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **Required Stories**
|
|
||||||
Every component must include:
|
|
||||||
- `Default`: Most common use case
|
|
||||||
- `Disabled`: If component supports disabled state
|
|
||||||
- `WithIcon`: If component supports icons
|
|
||||||
- Variant stories for each variant (Primary, Secondary, Error, etc.)
|
|
||||||
- Edge case stories (ManyElements, LongText, CustomStyling)
|
|
||||||
|
|
||||||
### 5. **Story Format**
|
|
||||||
```tsx
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
// Props with realistic values
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EdgeCase: Story = {
|
|
||||||
args: { /* ... */ },
|
|
||||||
parameters: {
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
story: "Use this when [specific scenario].",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. **Dynamic Content Pattern**
|
|
||||||
For components with dynamic content, create render function:
|
|
||||||
```tsx
|
|
||||||
const renderComponent = (args: StoryProps) => {
|
|
||||||
const { numberOfElements, showIcon, customLabels } = args;
|
|
||||||
|
|
||||||
// Generate dynamic content
|
|
||||||
const elements = Array.from({ length: numberOfElements }, (_, i) => ({
|
|
||||||
id: `element-${i}`,
|
|
||||||
label: customLabels[i] || `Element ${i + 1}`,
|
|
||||||
icon: showIcon ? <IconComponent /> : undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return <ComponentName {...args} elements={elements} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Dynamic: Story = {
|
|
||||||
render: renderComponent,
|
|
||||||
args: {
|
|
||||||
numberOfElements: 3,
|
|
||||||
showIcon: true,
|
|
||||||
customLabels: ["First", "Second", "Third"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. **State Management**
|
|
||||||
For interactive components:
|
|
||||||
```tsx
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
const ComponentWithState = (args: any) => {
|
|
||||||
const [value, setValue] = useState(args.defaultValue);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ComponentName
|
|
||||||
{...args}
|
|
||||||
value={value}
|
|
||||||
onChange={(newValue) => {
|
|
||||||
setValue(newValue);
|
|
||||||
args.onChange?.(newValue);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Interactive: Story = {
|
|
||||||
render: ComponentWithState,
|
|
||||||
args: { defaultValue: "initial" },
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. **Quality Requirements**
|
|
||||||
- Include component description in parameters.docs
|
|
||||||
- Add story documentation for non-obvious use cases
|
|
||||||
- Test edge cases (overflow, empty states, many elements)
|
|
||||||
- Ensure no TypeScript errors
|
|
||||||
- Use realistic prop values
|
|
||||||
- Include at least 3-5 story variants
|
|
||||||
- Example values need to be in the context of survey application
|
|
||||||
|
|
||||||
### 9. **Naming Conventions**
|
|
||||||
- **Story titles**: "UI/ComponentName"
|
|
||||||
- **Story exports**: PascalCase (Default, WithIcon, ManyElements)
|
|
||||||
- **Categories**: "Behavior", "Appearance", "Content" (exact spelling)
|
|
||||||
- **Props**: camelCase matching component props
|
|
||||||
|
|
||||||
### 10. **Special Cases**
|
|
||||||
- **Generic components**: Remove `component` from meta if type conflicts
|
|
||||||
- **Form components**: Include Invalid, WithValue stories
|
|
||||||
- **Navigation**: Include ManyItems stories
|
|
||||||
- **Modals, Dropdowns and Popups **: Include trigger and content structure
|
|
||||||
|
|
||||||
## Generate stories that are comprehensive, well-documented, and reflect all component states and edge cases.
|
|
||||||
12
.env.example
12
.env.example
@@ -9,8 +9,12 @@
|
|||||||
WEBAPP_URL=http://localhost:3000
|
WEBAPP_URL=http://localhost:3000
|
||||||
|
|
||||||
# Required for next-auth. Should be the same as WEBAPP_URL
|
# 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
|
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
|
# Encryption keys
|
||||||
# Please set both for now, we will change this in the future
|
# Please set both for now, we will change this in the future
|
||||||
|
|
||||||
@@ -164,6 +168,9 @@ SLACK_CLIENT_SECRET=
|
|||||||
# Enterprise License Key
|
# Enterprise License Key
|
||||||
ENTERPRISE_LICENSE_KEY=
|
ENTERPRISE_LICENSE_KEY=
|
||||||
|
|
||||||
|
# Internal Environment (production, staging) - used for internal staging environment
|
||||||
|
# ENVIRONMENT=production
|
||||||
|
|
||||||
# Automatically assign new users to a specific organization and role within that organization
|
# Automatically assign new users to a specific organization and role within that organization
|
||||||
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
|
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
|
||||||
# (Role Management is an Enterprise feature)
|
# (Role Management is an Enterprise feature)
|
||||||
@@ -189,8 +196,9 @@ 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)
|
# 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:
|
# REDIS_HTTP_URL:
|
||||||
|
|
||||||
# INTERCOM_APP_ID=
|
# Chatwoot
|
||||||
# INTERCOM_SECRET_KEY=
|
# CHATWOOT_BASE_URL=
|
||||||
|
# CHATWOOT_WEBSITE_TOKEN=
|
||||||
|
|
||||||
# Enable Prometheus metrics
|
# Enable Prometheus metrics
|
||||||
# PROMETHEUS_ENABLED=
|
# PROMETHEUS_ENABLED=
|
||||||
|
|||||||
31
.github/workflows/chromatic.yml
vendored
31
.github/workflows/chromatic.yml
vendored
@@ -13,13 +13,12 @@ jobs:
|
|||||||
chromatic:
|
chromatic:
|
||||||
name: Run Chromatic
|
name: Run Chromatic
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
contents: read
|
||||||
id-token: write
|
|
||||||
actions: read
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
@@ -27,16 +26,34 @@ jobs:
|
|||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: ${{ env.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||||
|
|
||||||
- name: Run Chromatic
|
- name: Run Chromatic
|
||||||
uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # latest
|
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
||||||
with:
|
with:
|
||||||
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
|
|
||||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||||
workingDir: apps/storybook
|
workingDir: apps/storybook
|
||||||
|
zip: true
|
||||||
|
|||||||
54
.github/workflows/e2e.yml
vendored
54
.github/workflows/e2e.yml
vendored
@@ -3,26 +3,20 @@ name: E2E Tests
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
secrets:
|
secrets:
|
||||||
AZURE_CLIENT_ID:
|
|
||||||
required: false
|
|
||||||
AZURE_TENANT_ID:
|
|
||||||
required: false
|
|
||||||
AZURE_SUBSCRIPTION_ID:
|
|
||||||
required: false
|
|
||||||
PLAYWRIGHT_SERVICE_URL:
|
PLAYWRIGHT_SERVICE_URL:
|
||||||
required: false
|
required: false
|
||||||
|
PLAYWRIGHT_SERVICE_ACCESS_TOKEN:
|
||||||
|
required: false
|
||||||
ENTERPRISE_LICENSE_KEY:
|
ENTERPRISE_LICENSE_KEY:
|
||||||
required: true
|
required: true
|
||||||
# Add other secrets if necessary
|
# Add other secrets if necessary
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
TELEMETRY_DISABLED: 1
|
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
|
|
||||||
@@ -115,7 +109,7 @@ jobs:
|
|||||||
- name: Start MinIO Server
|
- name: Start MinIO Server
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Start MinIO server in background
|
# Start MinIO server in background
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name minio-server \
|
--name minio-server \
|
||||||
@@ -125,7 +119,7 @@ jobs:
|
|||||||
-e MINIO_ROOT_PASSWORD=devminio123 \
|
-e MINIO_ROOT_PASSWORD=devminio123 \
|
||||||
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
||||||
server /data --console-address :9001
|
server /data --console-address :9001
|
||||||
|
|
||||||
echo "MinIO server started"
|
echo "MinIO server started"
|
||||||
|
|
||||||
- name: Wait for MinIO and create S3 bucket
|
- name: Wait for MinIO and create S3 bucket
|
||||||
@@ -208,32 +202,30 @@ jobs:
|
|||||||
- name: Install Playwright
|
- name: Install Playwright
|
||||||
run: pnpm exec playwright install --with-deps
|
run: pnpm exec playwright install --with-deps
|
||||||
|
|
||||||
- name: Set Azure Secret Variables
|
- name: Determine Playwright execution mode
|
||||||
run: |
|
shell: bash
|
||||||
if [[ -n "${{ secrets.AZURE_CLIENT_ID }}" && -n "${{ secrets.AZURE_TENANT_ID }}" && -n "${{ secrets.AZURE_SUBSCRIPTION_ID }}" ]]; then
|
|
||||||
echo "AZURE_ENABLED=true" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "AZURE_ENABLED=false" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Azure login
|
|
||||||
if: env.AZURE_ENABLED == 'true'
|
|
||||||
uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
|
|
||||||
with:
|
|
||||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
|
||||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
|
||||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
|
||||||
|
|
||||||
- name: Run E2E Tests (Azure)
|
|
||||||
if: env.AZURE_ENABLED == 'true'
|
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||||
CI: true
|
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
pnpm test-e2e:azure
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ -n "${PLAYWRIGHT_SERVICE_URL}" && -n "${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}" ]]; then
|
||||||
|
echo "PW_MODE=service" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
echo "PW_MODE=local" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run E2E Tests (Playwright Service)
|
||||||
|
if: env.PW_MODE == 'service'
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||||
|
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
|
||||||
|
CI: true
|
||||||
|
run: pnpm test-e2e:azure
|
||||||
|
|
||||||
- name: Run E2E Tests (Local)
|
- name: Run E2E Tests (Local)
|
||||||
if: env.AZURE_ENABLED == 'false'
|
if: env.PW_MODE == 'local'
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
6
.github/workflows/formbricks-release.yml
vendored
6
.github/workflows/formbricks-release.yml
vendored
@@ -89,7 +89,7 @@ jobs:
|
|||||||
- check-latest-release
|
- check-latest-release
|
||||||
with:
|
with:
|
||||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||||
|
|
||||||
docker-build-cloud:
|
docker-build-cloud:
|
||||||
name: Build & push Formbricks Cloud to ECR
|
name: Build & push Formbricks Cloud to ECR
|
||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
|
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||||
needs:
|
needs:
|
||||||
- check-latest-release
|
- check-latest-release
|
||||||
- docker-build-community
|
- docker-build-community
|
||||||
@@ -154,4 +154,4 @@ jobs:
|
|||||||
release_tag: ${{ github.event.release.tag_name }}
|
release_tag: ${{ github.event.release.tag_name }}
|
||||||
commit_sha: ${{ github.sha }}
|
commit_sha: ${{ github.sha }}
|
||||||
is_prerelease: ${{ github.event.release.prerelease }}
|
is_prerelease: ${{ github.event.release.prerelease }}
|
||||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}
|
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||||
|
|||||||
36
.github/workflows/pr-size-check.yml
vendored
36
.github/workflows/pr-size-check.yml
vendored
@@ -111,27 +111,21 @@ jobs:
|
|||||||
const additions = ${{ steps.check-size.outputs.total_additions }};
|
const additions = ${{ steps.check-size.outputs.total_additions }};
|
||||||
const deletions = ${{ steps.check-size.outputs.total_deletions }};
|
const deletions = ${{ steps.check-size.outputs.total_deletions }};
|
||||||
|
|
||||||
const body = `## 🚨 PR Size Warning
|
const body = '## 🚨 PR Size Warning\n\n' +
|
||||||
|
'This PR has approximately **' + totalChanges + ' lines** of changes (' + additions + ' additions, ' + deletions + ' deletions across ' + countedFiles + ' files).\n\n' +
|
||||||
This PR has approximately **${totalChanges} lines** of changes (${additions} additions, ${deletions} deletions across ${countedFiles} files).
|
'Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.\n\n' +
|
||||||
|
'### 💡 Suggestions:\n' +
|
||||||
Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.
|
'- **Split by feature or module** - Break down into logical, independent pieces\n' +
|
||||||
|
'- **Create a sequence of PRs** - Each building on the previous one\n' +
|
||||||
### 💡 Suggestions:
|
'- **Branch off PR branches** - Don\'t wait for reviews to continue dependent work\n\n' +
|
||||||
- **Split by feature or module** - Break down into logical, independent pieces
|
'### 📊 What was counted:\n' +
|
||||||
- **Create a sequence of PRs** - Each building on the previous one
|
'- ✅ Source files, stylesheets, configuration files\n' +
|
||||||
- **Branch off PR branches** - Don't wait for reviews to continue dependent work
|
'- ❌ Excluded ' + excludedFiles + ' files (tests, locales, locks, generated files)\n\n' +
|
||||||
|
'### 📚 Guidelines:\n' +
|
||||||
### 📊 What was counted:
|
'- **Ideal:** 300-500 lines per PR\n' +
|
||||||
- ✅ Source files, stylesheets, configuration files
|
'- **Warning:** 500-800 lines\n' +
|
||||||
- ❌ Excluded ${excludedFiles} files (tests, locales, locks, generated files)
|
'- **Critical:** 800+ lines ⚠️\n\n' +
|
||||||
|
'If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn\'t be split.';
|
||||||
### 📚 Guidelines:
|
|
||||||
- **Ideal:** 300-500 lines per PR
|
|
||||||
- **Warning:** 500-800 lines
|
|
||||||
- **Critical:** 800+ lines ⚠️
|
|
||||||
|
|
||||||
If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn't be split.`;
|
|
||||||
|
|
||||||
// Check if we already commented
|
// Check if we already commented
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -62,3 +62,4 @@ branch.json
|
|||||||
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||||
.cursorrules
|
.cursorrules
|
||||||
i18n.cache
|
i18n.cache
|
||||||
|
stats.html
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#!/bin/sh
|
|
||||||
. "$(dirname "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
# Load environment variables from .env files
|
# Load environment variables from .env files
|
||||||
if [ -f .env ]; then
|
if [ -f .env ]; then
|
||||||
set -a
|
set -a
|
||||||
|
|||||||
54
AGENTS.md
54
AGENTS.md
@@ -18,11 +18,65 @@ Formbricks runs as a pnpm/turbo monorepo. `apps/web` is the Next.js product surf
|
|||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
||||||
|
We are using SonarQube to identify code smells and security hotspots.
|
||||||
|
|
||||||
|
## Architecture & Patterns
|
||||||
|
|
||||||
|
- Next.js app router lives in `apps/web/app` with route groups like `(app)` and `(auth)`. Services live in `apps/web/lib`, feature modules in `apps/web/modules`.
|
||||||
|
- Server actions wrap service calls and return `{ data }` or `{ error }` consistently.
|
||||||
|
- Context providers should guard against missing provider usage and use cleanup patterns that snapshot refs inside `useEffect` to avoid React hooks warnings
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
- Use React `cache()` for request-level dedupe and `cache.withCache()` or explicit Redis for expensive data.
|
||||||
|
- Do not use Next.js `unstable_cache()`.
|
||||||
|
- Always use `createCacheKey.*` utilities for cache keys.
|
||||||
|
|
||||||
|
## i18n (Internationalization)
|
||||||
|
|
||||||
|
- All user-facing text must use the `t()` function from `react-i18next`.
|
||||||
|
- Key naming: use lowercase with dots for nesting (e.g., `common.welcome`).
|
||||||
|
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
||||||
|
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
||||||
|
|
||||||
|
## Database & Prisma Performance
|
||||||
|
|
||||||
|
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
||||||
|
- Soft Deletion: Check for `isActive` or `deletedAt` fields; use proper filtering.
|
||||||
|
- Never use `skip`/`offset` with `prisma.response.count()`; only use `where`.
|
||||||
|
- Separate count and data queries and run in parallel (`Promise.all`).
|
||||||
|
- Prefer cursor pagination for large datasets.
|
||||||
|
- When filtering by `createdAt`, include indexed fields (e.g., `surveyId` + `createdAt`).
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
|
|
||||||
Prefer Vitest with Testing Library for logic in `.ts` files, keeping specs colocated with the code they exercise (`utility.test.ts`). Do not write tests for `.tsx` files—React components are covered by Playwright E2E tests instead. Mock network and storage boundaries through helpers from `@formbricks/*`. Run `pnpm test` before opening a PR and `pnpm test:coverage` when touching critical flows; keep coverage from regressing. End-to-end scenarios belong in `apps/web/playwright`, using descriptive filenames (`billing.spec.ts`) and tagging slow suites with `@slow` when necessary.
|
Prefer Vitest with Testing Library for logic in `.ts` files, keeping specs colocated with the code they exercise (`utility.test.ts`). Do not write tests for `.tsx` files—React components are covered by Playwright E2E tests instead. Mock network and storage boundaries through helpers from `@formbricks/*`. Run `pnpm test` before opening a PR and `pnpm test:coverage` when touching critical flows; keep coverage from regressing. End-to-end scenarios belong in `apps/web/playwright`, using descriptive filenames (`billing.spec.ts`) and tagging slow suites with `@slow` when necessary.
|
||||||
|
|
||||||
|
## Documentation (apps/docs)
|
||||||
|
|
||||||
|
- Add frontmatter with `title`, `description`, and `icon` at the top of the MDX file.
|
||||||
|
- Do not start with an H1; use Camel Case headings (only capitalize the feature name).
|
||||||
|
- Use Mintlify components for steps and callouts.
|
||||||
|
- If Enterprise-only, add the Enterprise note block described in docs.
|
||||||
|
|
||||||
|
## Storybook
|
||||||
|
|
||||||
|
- Stories live in `stories.tsx` in the component folder and import from `"./index"`.
|
||||||
|
- Use `@storybook/react-vite` and organize argTypes into `Behavior`, `Appearance`, `Content`.
|
||||||
|
- Include Default, Disabled (if supported), WithIcon (if supported), all variants, and edge cases.
|
||||||
|
|
||||||
|
## GitHub Actions
|
||||||
|
|
||||||
|
- Always set minimal `permissions` for `GITHUB_TOKEN`.
|
||||||
|
- On `ubuntu-latest`, add `step-security/harden-runner` as the first step.
|
||||||
|
|
||||||
|
## Quality Checklist
|
||||||
|
|
||||||
|
- Keep code DRY and small; remove dead code and unused imports.
|
||||||
|
- Follow React hooks rules, keep effects focused, and avoid unnecessary `useMemo`/`useCallback`.
|
||||||
|
- Prefer type inference, avoid `any`, and use shared types from `@formbricks/types`.
|
||||||
|
- Keep components focused, avoid deep nesting, and ensure basic accessibility.
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.
|
Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.
|
||||||
|
|||||||
@@ -203,6 +203,14 @@ Here are a few options:
|
|||||||
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
## Thanks
|
||||||
|
|
||||||
|
Formbricks is supported by the following companies who provide us with their tools for free as part of their open-source support:
|
||||||
|
|
||||||
|
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" width="153" height="30" alt="Chromatic" /></a>
|
||||||
|
|
||||||
|
<a href="https://sentry.io/"><img src="https://github.com/user-attachments/assets/d743ffd4-b575-4802-a29a-10136be9227e" width="150" height="30" alt="Sentry" /></a>
|
||||||
|
|
||||||
<a id="contact-us"></a>
|
<a id="contact-us"></a>
|
||||||
|
|
||||||
## 📆 Contact us
|
## 📆 Contact us
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { StorybookConfig } from "@storybook/react-vite";
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
import { createRequire } from "module";
|
import { createRequire } from "module";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join, resolve } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
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.
|
* This function is used to resolve the absolute path of a package.
|
||||||
@@ -13,7 +16,7 @@ function getAbsolutePath(value: string): any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||||
addons: [
|
addons: [
|
||||||
getAbsolutePath("@storybook/addon-onboarding"),
|
getAbsolutePath("@storybook/addon-onboarding"),
|
||||||
getAbsolutePath("@storybook/addon-links"),
|
getAbsolutePath("@storybook/addon-links"),
|
||||||
@@ -25,5 +28,25 @@ const config: StorybookConfig = {
|
|||||||
name: getAbsolutePath("@storybook/react-vite"),
|
name: getAbsolutePath("@storybook/react-vite"),
|
||||||
options: {},
|
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;
|
export default config;
|
||||||
|
|||||||
@@ -1,19 +1,6 @@
|
|||||||
import type { Preview } from "@storybook/react-vite";
|
import type { Preview } from "@storybook/react-vite";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { I18nProvider } from "../../web/lingodotdev/client";
|
import "../../../packages/survey-ui/src/styles/globals.css";
|
||||||
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 = {
|
const preview: Preview = {
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -22,9 +9,23 @@ const preview: Preview = {
|
|||||||
color: /(background|color)$/i,
|
color: /(background|color)$/i,
|
||||||
date: /Date$/i,
|
date: /Date$/i,
|
||||||
},
|
},
|
||||||
|
expanded: true,
|
||||||
|
},
|
||||||
|
backgrounds: {
|
||||||
|
default: "light",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
decorators: [withLingodotDev],
|
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)
|
||||||
|
),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default preview;
|
export default preview;
|
||||||
|
|||||||
@@ -11,22 +11,24 @@
|
|||||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eslint-plugin-react-refresh": "0.4.20"
|
"@formbricks/survey-ui": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^4.0.1",
|
"@chromatic-com/storybook": "^5.0.0",
|
||||||
"@storybook/addon-a11y": "9.0.15",
|
"@storybook/addon-a11y": "10.1.11",
|
||||||
"@storybook/addon-links": "9.0.15",
|
"@storybook/addon-links": "10.1.11",
|
||||||
"@storybook/addon-onboarding": "9.0.15",
|
"@storybook/addon-onboarding": "10.1.11",
|
||||||
"@storybook/react-vite": "9.0.15",
|
"@storybook/react-vite": "10.1.11",
|
||||||
"@typescript-eslint/eslint-plugin": "8.32.0",
|
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||||
"@typescript-eslint/parser": "8.32.0",
|
"@tailwindcss/vite": "4.1.18",
|
||||||
"@vitejs/plugin-react": "4.4.1",
|
"@typescript-eslint/parser": "8.53.0",
|
||||||
"esbuild": "0.25.4",
|
"@vitejs/plugin-react": "5.1.2",
|
||||||
"eslint-plugin-storybook": "9.0.15",
|
"esbuild": "0.25.12",
|
||||||
|
"eslint-plugin-react-refresh": "0.4.26",
|
||||||
|
"eslint-plugin-storybook": "10.1.11",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"storybook": "9.0.15",
|
"storybook": "10.1.11",
|
||||||
"vite": "6.4.1",
|
"vite": "7.3.1",
|
||||||
"@storybook/addon-docs": "9.0.15"
|
"@storybook/addon-docs": "10.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
import base from "../web/tailwind.config";
|
import surveyUi from "../../packages/survey-ui/tailwind.config";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
...base,
|
content: [
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"],
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
...surveyUi.theme?.extend,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
define: {
|
define: {
|
||||||
"process.env": {},
|
"process.env": {},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "../web"),
|
"@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
7
apps/web/.eslintignore
Normal file
7
apps/web/.eslintignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
public/
|
||||||
|
playwright/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
vendor/
|
||||||
@@ -37,6 +37,10 @@ ENV NODE_OPTIONS=${NODE_OPTIONS}
|
|||||||
# but needs explicit declaration for some build systems (like Depot)
|
# but needs explicit declaration for some build systems (like Depot)
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
# Base path for the application (optional)
|
||||||
|
ARG BASE_PATH=""
|
||||||
|
ENV BASE_PATH=${BASE_PATH}
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -73,8 +77,8 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
|
|||||||
#
|
#
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
RUN npm install --ignore-scripts -g corepack@latest
|
RUN npm install --ignore-scripts -g corepack@latest && \
|
||||||
RUN corepack enable
|
corepack enable
|
||||||
|
|
||||||
RUN apk add --no-cache curl \
|
RUN apk add --no-cache curl \
|
||||||
&& apk add --no-cache supercronic \
|
&& apk add --no-cache supercronic \
|
||||||
@@ -100,6 +104,9 @@ RUN chown -R nextjs:nextjs ./apps/web/.next/static && chmod -R 755 ./apps/web/.n
|
|||||||
COPY --from=installer /app/apps/web/public ./apps/web/public
|
COPY --from=installer /app/apps/web/public ./apps/web/public
|
||||||
RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
||||||
|
|
||||||
|
# Create packages/database directory structure with proper ownership for runtime migrations
|
||||||
|
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
|
||||||
|
|
||||||
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||||
|
|
||||||
@@ -118,13 +125,16 @@ RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
|
|||||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||||
|
|
||||||
|
COPY --from=installer /app/node_modules/uuid ./node_modules/uuid
|
||||||
|
RUN chmod -R 755 ./node_modules/uuid
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
||||||
RUN chmod -R 755 ./node_modules/@noble/hashes
|
RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||||
RUN chmod -R 755 ./node_modules/zod
|
RUN chmod -R 755 ./node_modules/zod
|
||||||
|
|
||||||
RUN npm install -g prisma
|
RUN npm install -g prisma@6
|
||||||
|
|
||||||
# Create a startup script to handle the conditional logic
|
# Create a startup script to handle the conditional logic
|
||||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
||||||
@@ -134,12 +144,13 @@ EXPOSE 3000
|
|||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
# Prepare volume for uploads
|
# Prepare pnpm as the nextjs user to ensure it's available at runtime
|
||||||
RUN mkdir -p /home/nextjs/apps/web/uploads/
|
# Prepare volumes for uploads and SAML connections
|
||||||
VOLUME /home/nextjs/apps/web/uploads/
|
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 SAML preloaded connection
|
VOLUME /home/nextjs/apps/web/uploads/
|
||||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
|
||||||
VOLUME /home/nextjs/apps/web/saml-connection
|
VOLUME /home/nextjs/apps/web/saml-connection
|
||||||
|
|
||||||
CMD ["/home/nextjs/start.sh"]
|
CMD ["/home/nextjs/start.sh"]
|
||||||
@@ -25,7 +25,7 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = project.config.channel || null;
|
const channel = project.config.channel || null;
|
||||||
@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={`/environments/${environment.id}`}>
|
<Link href={`/environments/${environment.id}`}>
|
||||||
|
|||||||
@@ -32,14 +32,22 @@ const mockProject: TProject = {
|
|||||||
};
|
};
|
||||||
const mockTemplate: TXMTemplate = {
|
const mockTemplate: TXMTemplate = {
|
||||||
name: "$[projectName] Survey",
|
name: "$[projectName] Survey",
|
||||||
questions: [
|
blocks: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "block1",
|
||||||
inputType: "text",
|
name: "Block 1",
|
||||||
type: "email" as any,
|
elements: [
|
||||||
headline: { default: "$[projectName] Question" },
|
{
|
||||||
required: false,
|
id: "q1",
|
||||||
charLimit: { enabled: true, min: 400, max: 1000 },
|
type: "openText" as const,
|
||||||
|
inputType: "text" as const,
|
||||||
|
headline: { default: "$[projectName] Question" },
|
||||||
|
subheader: { default: "" },
|
||||||
|
required: false,
|
||||||
|
placeholder: { default: "" },
|
||||||
|
charLimit: 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
endings: [
|
endings: [
|
||||||
@@ -66,9 +74,9 @@ describe("replacePresetPlaceholders", () => {
|
|||||||
expect(result.name).toBe("Test Project Survey");
|
expect(result.name).toBe("Test Project Survey");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replaces projectName placeholder in question headline", () => {
|
test("replaces projectName placeholder in element headline", () => {
|
||||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||||
expect(result.questions[0].headline.default).toBe("Test Project Question");
|
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns a new object without mutating the original template", () => {
|
test("returns a new object without mutating the original template", () => {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { TProject } from "@formbricks/types/project";
|
import { TProject } from "@formbricks/types/project";
|
||||||
|
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
import { replaceQuestionPresetPlaceholders } from "@/lib/utils/templates";
|
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
|
||||||
|
|
||||||
// replace all occurences of projectName with the actual project name in the current template
|
// replace all occurences of projectName with the actual project name in the current template
|
||||||
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject) => {
|
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
|
||||||
const survey = structuredClone(template);
|
const survey = structuredClone(template);
|
||||||
survey.name = survey.name.replace("$[projectName]", project.name);
|
|
||||||
survey.questions = survey.questions.map((question) => {
|
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
|
||||||
return replaceQuestionPresetPlaceholders(question, project);
|
...block,
|
||||||
});
|
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
|
||||||
return { ...template, ...survey };
|
}));
|
||||||
|
|
||||||
|
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe("xm-templates", () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
name: "",
|
name: "",
|
||||||
endings: expect.any(Array),
|
endings: expect.any(Array),
|
||||||
questions: [],
|
blocks: [],
|
||||||
styling: {
|
styling: {
|
||||||
overwriteThemeStyling: true,
|
overwriteThemeStyling: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,19 +3,21 @@ import { TFunction } from "i18next";
|
|||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
import {
|
import {
|
||||||
buildCTAQuestion,
|
buildBlock,
|
||||||
buildNPSQuestion,
|
buildCTAElement,
|
||||||
buildOpenTextQuestion,
|
buildNPSElement,
|
||||||
buildRatingQuestion,
|
buildOpenTextElement,
|
||||||
getDefaultEndingCard,
|
buildRatingElement,
|
||||||
} from "@/app/lib/survey-builder";
|
createBlockJumpLogic,
|
||||||
|
} from "@/app/lib/survey-block-builder";
|
||||||
|
import { getDefaultEndingCard } from "@/app/lib/survey-builder";
|
||||||
|
|
||||||
export const getXMSurveyDefault = (t: TFunction): TXMTemplate => {
|
export const getXMSurveyDefault = (t: TFunction): TXMTemplate => {
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
name: "",
|
name: "",
|
||||||
endings: [getDefaultEndingCard([], t)],
|
endings: [getDefaultEndingCard([], t)],
|
||||||
questions: [],
|
blocks: [],
|
||||||
styling: {
|
styling: {
|
||||||
overwriteThemeStyling: true,
|
overwriteThemeStyling: true,
|
||||||
},
|
},
|
||||||
@@ -30,25 +32,40 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
return {
|
return {
|
||||||
...getXMSurveyDefault(t),
|
...getXMSurveyDefault(t),
|
||||||
name: t("templates.nps_survey_name"),
|
name: t("templates.nps_survey_name"),
|
||||||
questions: [
|
blocks: [
|
||||||
buildNPSQuestion({
|
buildBlock({
|
||||||
headline: t("templates.nps_survey_question_1_headline"),
|
name: "Block 1",
|
||||||
required: true,
|
elements: [
|
||||||
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
|
buildNPSElement({
|
||||||
upperLabel: t("templates.nps_survey_question_1_upper_label"),
|
headline: t("templates.nps_survey_question_1_headline"),
|
||||||
isColorCodingEnabled: true,
|
required: true,
|
||||||
|
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
|
||||||
|
upperLabel: t("templates.nps_survey_question_1_upper_label"),
|
||||||
|
isColorCodingEnabled: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
headline: t("templates.nps_survey_question_2_headline"),
|
name: "Block 2",
|
||||||
required: false,
|
elements: [
|
||||||
inputType: "text",
|
buildOpenTextElement({
|
||||||
|
headline: t("templates.nps_survey_question_2_headline"),
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
headline: t("templates.nps_survey_question_3_headline"),
|
name: "Block 3",
|
||||||
required: false,
|
elements: [
|
||||||
inputType: "text",
|
buildOpenTextElement({
|
||||||
|
headline: t("templates.nps_survey_question_3_headline"),
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -56,15 +73,27 @@ const npsSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
const reusableElementIds = [createId(), createId(), createId()];
|
||||||
|
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
|
||||||
const defaultSurvey = getXMSurveyDefault(t);
|
const defaultSurvey = getXMSurveyDefault(t);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaultSurvey,
|
...defaultSurvey,
|
||||||
name: t("templates.star_rating_survey_name"),
|
name: t("templates.star_rating_survey_name"),
|
||||||
questions: [
|
blocks: [
|
||||||
buildRatingQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[0],
|
name: "Block 1",
|
||||||
|
elements: [
|
||||||
|
buildRatingElement({
|
||||||
|
id: reusableElementIds[0],
|
||||||
|
range: 5,
|
||||||
|
scale: "number",
|
||||||
|
headline: t("templates.star_rating_survey_question_1_headline"),
|
||||||
|
required: true,
|
||||||
|
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
|
||||||
|
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -75,8 +104,8 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableQuestionIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "question",
|
type: "element",
|
||||||
},
|
},
|
||||||
operator: "isLessThanOrEqual",
|
operator: "isLessThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -89,64 +118,44 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
objective: "jumpToQuestion",
|
objective: "jumpToBlock",
|
||||||
target: reusableQuestionIds[2],
|
target: block3Id,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
range: 5,
|
|
||||||
scale: "number",
|
|
||||||
headline: t("templates.star_rating_survey_question_1_headline"),
|
|
||||||
required: true,
|
|
||||||
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
|
|
||||||
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[1],
|
name: "Block 2",
|
||||||
subheader: t("templates.star_rating_survey_question_2_html"),
|
elements: [
|
||||||
logic: [
|
buildCTAElement({
|
||||||
{
|
id: reusableElementIds[1],
|
||||||
id: createId(),
|
subheader: t("templates.star_rating_survey_question_2_html"),
|
||||||
conditions: {
|
headline: t("templates.star_rating_survey_question_2_headline"),
|
||||||
id: createId(),
|
required: false,
|
||||||
connector: "and",
|
buttonUrl: "https://formbricks.com/github",
|
||||||
conditions: [
|
buttonExternal: true,
|
||||||
{
|
ctaButtonLabel: t("templates.star_rating_survey_question_2_button_label"),
|
||||||
id: createId(),
|
}),
|
||||||
leftOperand: {
|
|
||||||
value: reusableQuestionIds[1],
|
|
||||||
type: "question",
|
|
||||||
},
|
|
||||||
operator: "isClicked",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
id: createId(),
|
|
||||||
objective: "jumpToQuestion",
|
|
||||||
target: defaultSurvey.endings[0].id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
headline: t("templates.star_rating_survey_question_2_headline"),
|
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
|
||||||
required: true,
|
|
||||||
buttonUrl: "https://formbricks.com/github",
|
|
||||||
buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
|
|
||||||
buttonExternal: true,
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[2],
|
id: block3Id,
|
||||||
headline: t("templates.star_rating_survey_question_3_headline"),
|
name: "Block 3",
|
||||||
required: true,
|
elements: [
|
||||||
subheader: t("templates.star_rating_survey_question_3_subheader"),
|
buildOpenTextElement({
|
||||||
|
id: reusableElementIds[2],
|
||||||
|
headline: t("templates.star_rating_survey_question_3_headline"),
|
||||||
|
required: true,
|
||||||
|
subheader: t("templates.star_rating_survey_question_3_subheader"),
|
||||||
|
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
|
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
|
||||||
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
|
|
||||||
inputType: "text",
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -154,15 +163,27 @@ const starRatingSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const csatSurvey = (t: TFunction): TXMTemplate => {
|
const csatSurvey = (t: TFunction): TXMTemplate => {
|
||||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
const reusableElementIds = [createId(), createId(), createId()];
|
||||||
|
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
|
||||||
const defaultSurvey = getXMSurveyDefault(t);
|
const defaultSurvey = getXMSurveyDefault(t);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaultSurvey,
|
...defaultSurvey,
|
||||||
name: t("templates.csat_survey_name"),
|
name: t("templates.csat_survey_name"),
|
||||||
questions: [
|
blocks: [
|
||||||
buildRatingQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[0],
|
name: "Block 1",
|
||||||
|
elements: [
|
||||||
|
buildRatingElement({
|
||||||
|
id: reusableElementIds[0],
|
||||||
|
range: 5,
|
||||||
|
scale: "smiley",
|
||||||
|
headline: t("templates.csat_survey_question_1_headline"),
|
||||||
|
required: true,
|
||||||
|
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
|
||||||
|
upperLabel: t("templates.csat_survey_question_1_upper_label"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -173,8 +194,8 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableQuestionIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "question",
|
type: "element",
|
||||||
},
|
},
|
||||||
operator: "isLessThanOrEqual",
|
operator: "isLessThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -187,60 +208,40 @@ const csatSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
objective: "jumpToQuestion",
|
objective: "jumpToBlock",
|
||||||
target: reusableQuestionIds[2],
|
target: block3Id,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
range: 5,
|
|
||||||
scale: "smiley",
|
|
||||||
headline: t("templates.csat_survey_question_1_headline"),
|
|
||||||
required: true,
|
|
||||||
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
|
|
||||||
upperLabel: t("templates.csat_survey_question_1_upper_label"),
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[1],
|
name: "Block 2",
|
||||||
logic: [
|
elements: [
|
||||||
{
|
buildOpenTextElement({
|
||||||
id: createId(),
|
id: reusableElementIds[1],
|
||||||
conditions: {
|
headline: t("templates.csat_survey_question_2_headline"),
|
||||||
id: createId(),
|
required: false,
|
||||||
connector: "and",
|
placeholder: t("templates.csat_survey_question_2_placeholder"),
|
||||||
conditions: [
|
inputType: "text",
|
||||||
{
|
}),
|
||||||
id: createId(),
|
|
||||||
leftOperand: {
|
|
||||||
value: reusableQuestionIds[1],
|
|
||||||
type: "question",
|
|
||||||
},
|
|
||||||
operator: "isSubmitted",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
id: createId(),
|
|
||||||
objective: "jumpToQuestion",
|
|
||||||
target: defaultSurvey.endings[0].id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
headline: t("templates.csat_survey_question_2_headline"),
|
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isSubmitted")],
|
||||||
required: false,
|
|
||||||
placeholder: t("templates.csat_survey_question_2_placeholder"),
|
|
||||||
inputType: "text",
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[2],
|
id: block3Id,
|
||||||
headline: t("templates.csat_survey_question_3_headline"),
|
name: "Block 3",
|
||||||
required: false,
|
elements: [
|
||||||
placeholder: t("templates.csat_survey_question_3_placeholder"),
|
buildOpenTextElement({
|
||||||
inputType: "text",
|
id: reusableElementIds[2],
|
||||||
|
headline: t("templates.csat_survey_question_3_headline"),
|
||||||
|
required: false,
|
||||||
|
placeholder: t("templates.csat_survey_question_3_placeholder"),
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -251,21 +252,31 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
return {
|
return {
|
||||||
...getXMSurveyDefault(t),
|
...getXMSurveyDefault(t),
|
||||||
name: t("templates.cess_survey_name"),
|
name: t("templates.cess_survey_name"),
|
||||||
questions: [
|
blocks: [
|
||||||
buildRatingQuestion({
|
buildBlock({
|
||||||
range: 5,
|
name: "Block 1",
|
||||||
scale: "number",
|
elements: [
|
||||||
headline: t("templates.cess_survey_question_1_headline"),
|
buildRatingElement({
|
||||||
required: true,
|
range: 5,
|
||||||
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
|
scale: "number",
|
||||||
upperLabel: t("templates.cess_survey_question_1_upper_label"),
|
headline: t("templates.cess_survey_question_1_headline"),
|
||||||
|
required: true,
|
||||||
|
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
|
||||||
|
upperLabel: t("templates.cess_survey_question_1_upper_label"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
headline: t("templates.cess_survey_question_2_headline"),
|
name: "Block 2",
|
||||||
required: true,
|
elements: [
|
||||||
placeholder: t("templates.cess_survey_question_2_placeholder"),
|
buildOpenTextElement({
|
||||||
inputType: "text",
|
headline: t("templates.cess_survey_question_2_headline"),
|
||||||
|
required: true,
|
||||||
|
placeholder: t("templates.cess_survey_question_2_placeholder"),
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -273,15 +284,27 @@ const cessSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
||||||
const reusableQuestionIds = [createId(), createId(), createId()];
|
const reusableElementIds = [createId(), createId(), createId()];
|
||||||
|
const block3Id = createId(); // Pre-generate Block 3 ID for logic reference
|
||||||
const defaultSurvey = getXMSurveyDefault(t);
|
const defaultSurvey = getXMSurveyDefault(t);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...defaultSurvey,
|
...defaultSurvey,
|
||||||
name: t("templates.smileys_survey_name"),
|
name: t("templates.smileys_survey_name"),
|
||||||
questions: [
|
blocks: [
|
||||||
buildRatingQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[0],
|
name: "Block 1",
|
||||||
|
elements: [
|
||||||
|
buildRatingElement({
|
||||||
|
id: reusableElementIds[0],
|
||||||
|
range: 5,
|
||||||
|
scale: "smiley",
|
||||||
|
headline: t("templates.smileys_survey_question_1_headline"),
|
||||||
|
required: true,
|
||||||
|
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
|
||||||
|
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
|
||||||
|
}),
|
||||||
|
],
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -292,8 +315,8 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
leftOperand: {
|
leftOperand: {
|
||||||
value: reusableQuestionIds[0],
|
value: reusableElementIds[0],
|
||||||
type: "question",
|
type: "element",
|
||||||
},
|
},
|
||||||
operator: "isLessThanOrEqual",
|
operator: "isLessThanOrEqual",
|
||||||
rightOperand: {
|
rightOperand: {
|
||||||
@@ -306,64 +329,44 @@ const smileysRatingSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
objective: "jumpToQuestion",
|
objective: "jumpToBlock",
|
||||||
target: reusableQuestionIds[2],
|
target: block3Id,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
range: 5,
|
|
||||||
scale: "smiley",
|
|
||||||
headline: t("templates.smileys_survey_question_1_headline"),
|
|
||||||
required: true,
|
|
||||||
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
|
|
||||||
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[1],
|
name: "Block 2",
|
||||||
subheader: t("templates.smileys_survey_question_2_html"),
|
elements: [
|
||||||
logic: [
|
buildCTAElement({
|
||||||
{
|
id: reusableElementIds[1],
|
||||||
id: createId(),
|
subheader: t("templates.smileys_survey_question_2_html"),
|
||||||
conditions: {
|
headline: t("templates.smileys_survey_question_2_headline"),
|
||||||
id: createId(),
|
required: false,
|
||||||
connector: "and",
|
buttonUrl: "https://formbricks.com/github",
|
||||||
conditions: [
|
buttonExternal: true,
|
||||||
{
|
ctaButtonLabel: t("templates.smileys_survey_question_2_button_label"),
|
||||||
id: createId(),
|
}),
|
||||||
leftOperand: {
|
|
||||||
value: reusableQuestionIds[1],
|
|
||||||
type: "question",
|
|
||||||
},
|
|
||||||
operator: "isClicked",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
id: createId(),
|
|
||||||
objective: "jumpToQuestion",
|
|
||||||
target: defaultSurvey.endings[0].id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
headline: t("templates.smileys_survey_question_2_headline"),
|
logic: [createBlockJumpLogic(reusableElementIds[1], defaultSurvey.endings[0].id, "isClicked")],
|
||||||
required: true,
|
|
||||||
buttonUrl: "https://formbricks.com/github",
|
|
||||||
buttonLabel: t("templates.smileys_survey_question_2_button_label"),
|
|
||||||
buttonExternal: true,
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
id: reusableQuestionIds[2],
|
id: block3Id,
|
||||||
headline: t("templates.smileys_survey_question_3_headline"),
|
name: "Block 3",
|
||||||
required: true,
|
elements: [
|
||||||
subheader: t("templates.smileys_survey_question_3_subheader"),
|
buildOpenTextElement({
|
||||||
|
id: reusableElementIds[2],
|
||||||
|
headline: t("templates.smileys_survey_question_3_headline"),
|
||||||
|
required: true,
|
||||||
|
subheader: t("templates.smileys_survey_question_3_subheader"),
|
||||||
|
placeholder: t("templates.smileys_survey_question_3_placeholder"),
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
|
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
|
||||||
placeholder: t("templates.smileys_survey_question_3_placeholder"),
|
|
||||||
inputType: "text",
|
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -374,25 +377,40 @@ const enpsSurvey = (t: TFunction): TXMTemplate => {
|
|||||||
return {
|
return {
|
||||||
...getXMSurveyDefault(t),
|
...getXMSurveyDefault(t),
|
||||||
name: t("templates.enps_survey_name"),
|
name: t("templates.enps_survey_name"),
|
||||||
questions: [
|
blocks: [
|
||||||
buildNPSQuestion({
|
buildBlock({
|
||||||
headline: t("templates.enps_survey_question_1_headline"),
|
name: "Block 1",
|
||||||
required: false,
|
elements: [
|
||||||
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
|
buildNPSElement({
|
||||||
upperLabel: t("templates.enps_survey_question_1_upper_label"),
|
headline: t("templates.enps_survey_question_1_headline"),
|
||||||
isColorCodingEnabled: true,
|
required: false,
|
||||||
|
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
|
||||||
|
upperLabel: t("templates.enps_survey_question_1_upper_label"),
|
||||||
|
isColorCodingEnabled: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
headline: t("templates.enps_survey_question_2_headline"),
|
name: "Block 2",
|
||||||
required: false,
|
elements: [
|
||||||
inputType: "text",
|
buildOpenTextElement({
|
||||||
|
headline: t("templates.enps_survey_question_2_headline"),
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
buildOpenTextQuestion({
|
buildBlock({
|
||||||
headline: t("templates.enps_survey_question_3_headline"),
|
name: "Block 3",
|
||||||
required: false,
|
elements: [
|
||||||
inputType: "text",
|
buildOpenTextElement({
|
||||||
|
headline: t("templates.enps_survey_question_3_headline"),
|
||||||
|
required: false,
|
||||||
|
inputType: "text",
|
||||||
|
}),
|
||||||
|
],
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
|||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await getUserProjects(session.user.id, organizationId);
|
const projects = await getUserProjects(session.user.id, organizationId);
|
||||||
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
|||||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||||
{projects.length >= 2 && (
|
{projects.length >= 2 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={`/environments/${environment.id}/surveys`}>
|
<Link href={`/environments/${environment.id}/surveys`}>
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ const Page = async (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||||
<Header
|
<Header
|
||||||
title={t("organizations.landing.no_projects_warning_title")}
|
title={t("organizations.landing.no_workspaces_warning_title")}
|
||||||
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
|
subtitle={t("organizations.landing.no_workspaces_warning_subtitle")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
import { AuthorizationError } from "@formbricks/types/errors";
|
||||||
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
|
|
||||||
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
|
|
||||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
@@ -40,14 +38,6 @@ const ProjectOnboardingLayout = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 bg-slate-50">
|
<div className="flex-1 bg-slate-50">
|
||||||
<PosthogIdentify
|
|
||||||
session={session}
|
|
||||||
user={user}
|
|
||||||
organizationId={organization.id}
|
|
||||||
organizationName={organization.name}
|
|
||||||
organizationBilling={organization.billing}
|
|
||||||
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
|
|
||||||
/>
|
|
||||||
<ToasterClient />
|
<ToasterClient />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,16 +26,16 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const channelOptions = [
|
const channelOptions = [
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.channel.link_and_email_surveys"),
|
title: t("organizations.workspaces.new.channel.link_and_email_surveys"),
|
||||||
description: t("organizations.projects.new.channel.link_and_email_surveys_description"),
|
description: t("organizations.workspaces.new.channel.link_and_email_surveys_description"),
|
||||||
icon: SendIcon,
|
icon: SendIcon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/settings?channel=link`,
|
href: `/organizations/${params.organizationId}/workspaces/new/settings?channel=link`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.channel.in_product_surveys"),
|
title: t("organizations.workspaces.new.channel.in_product_surveys"),
|
||||||
description: t("organizations.projects.new.channel.in_product_surveys_description"),
|
description: t("organizations.workspaces.new.channel.in_product_surveys_description"),
|
||||||
icon: PictureInPicture2Icon,
|
icon: PictureInPicture2Icon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/settings?channel=app`,
|
href: `/organizations/${params.organizationId}/workspaces/new/settings?channel=app`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -44,13 +44,13 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header
|
<Header
|
||||||
title={t("organizations.projects.new.channel.channel_select_title")}
|
title={t("organizations.workspaces.new.channel.channel_select_title")}
|
||||||
subtitle={t("organizations.projects.new.channel.channel_select_subtitle")}
|
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
|
||||||
/>
|
/>
|
||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
@@ -15,7 +15,7 @@ const OnboardingLayout = async (props) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session || !session.user) {
|
if (!session?.user) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,16 +26,16 @@ const Page = async (props: ModePageProps) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const channelOptions = [
|
const channelOptions = [
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.mode.formbricks_surveys"),
|
title: t("organizations.workspaces.new.mode.formbricks_surveys"),
|
||||||
description: t("organizations.projects.new.mode.formbricks_surveys_description"),
|
description: t("organizations.workspaces.new.mode.formbricks_surveys_description"),
|
||||||
icon: ListTodoIcon,
|
icon: ListTodoIcon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/channel`,
|
href: `/organizations/${params.organizationId}/workspaces/new/channel`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.mode.formbricks_cx"),
|
title: t("organizations.workspaces.new.mode.formbricks_cx"),
|
||||||
description: t("organizations.projects.new.mode.formbricks_cx_description"),
|
description: t("organizations.workspaces.new.mode.formbricks_cx_description"),
|
||||||
icon: HeartIcon,
|
icon: HeartIcon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/settings?mode=cx`,
|
href: `/organizations/${params.organizationId}/workspaces/new/settings?mode=cx`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -43,11 +43,11 @@ const Page = async (props: ModePageProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header title={t("organizations.projects.new.mode.what_are_you_here_for")} />
|
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
|
||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
@@ -44,6 +44,7 @@ interface ProjectSettingsProps {
|
|||||||
organizationTeams: TOrganizationTeam[];
|
organizationTeams: TOrganizationTeam[];
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
userProjectsCount: number;
|
userProjectsCount: number;
|
||||||
|
publicDomain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectSettings = ({
|
export const ProjectSettings = ({
|
||||||
@@ -55,6 +56,7 @@ export const ProjectSettings = ({
|
|||||||
organizationTeams,
|
organizationTeams,
|
||||||
isAccessControlAllowed = false,
|
isAccessControlAllowed = false,
|
||||||
userProjectsCount,
|
userProjectsCount,
|
||||||
|
publicDomain,
|
||||||
}: ProjectSettingsProps) => {
|
}: ProjectSettingsProps) => {
|
||||||
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -77,7 +79,7 @@ export const ProjectSettings = ({
|
|||||||
(environment) => environment.type === "production"
|
(environment) => environment.type === "production"
|
||||||
);
|
);
|
||||||
if (productionEnvironment) {
|
if (productionEnvironment) {
|
||||||
if (typeof window !== "undefined") {
|
if (globalThis.window !== undefined) {
|
||||||
// Rmove filters when creating a new project
|
// Rmove filters when creating a new project
|
||||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||||
}
|
}
|
||||||
@@ -94,7 +96,7 @@ export const ProjectSettings = ({
|
|||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("organizations.projects.new.settings.project_creation_failed"));
|
toast.error(t("organizations.workspaces.new.settings.workspace_creation_failed"));
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -105,7 +107,6 @@ export const ProjectSettings = ({
|
|||||||
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
||||||
teamIds: [],
|
teamIds: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
resolver: zodResolver(ZProjectUpdateInput),
|
resolver: zodResolver(ZProjectUpdateInput),
|
||||||
});
|
});
|
||||||
const projectName = form.watch("name");
|
const projectName = form.watch("name");
|
||||||
@@ -129,9 +130,9 @@ export const ProjectSettings = ({
|
|||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<FormItem className="w-full space-y-4">
|
<FormItem className="w-full space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>{t("organizations.projects.new.settings.brand_color")}</FormLabel>
|
<FormLabel>{t("organizations.workspaces.new.settings.brand_color")}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("organizations.projects.new.settings.brand_color_description")}
|
{t("organizations.workspaces.new.settings.brand_color_description")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -153,9 +154,9 @@ export const ProjectSettings = ({
|
|||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<FormItem className="w-full space-y-4">
|
<FormItem className="w-full space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>{t("organizations.projects.new.settings.project_name")}</FormLabel>
|
<FormLabel>{t("organizations.workspaces.new.settings.workspace_name")}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("organizations.projects.new.settings.project_name_description")}
|
{t("organizations.workspaces.new.settings.workspace_name_description")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -184,7 +185,7 @@ export const ProjectSettings = ({
|
|||||||
<div>
|
<div>
|
||||||
<FormLabel>{t("common.teams")}</FormLabel>
|
<FormLabel>{t("common.teams")}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("organizations.projects.new.settings.team_description")}
|
{t("organizations.workspaces.new.settings.team_description")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -192,7 +193,7 @@ export const ProjectSettings = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCreateTeamModalOpen(true)}>
|
onClick={() => setCreateTeamModalOpen(true)}>
|
||||||
{t("organizations.projects.new.settings.create_new_team")}
|
{t("organizations.workspaces.new.settings.create_new_team")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -225,12 +226,13 @@ export const ProjectSettings = ({
|
|||||||
alt="Logo"
|
alt="Logo"
|
||||||
width={256}
|
width={256}
|
||||||
height={56}
|
height={56}
|
||||||
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||||
<div className="z-0 h-3/4 w-3/4">
|
<div className="z-0 h-3/4 w-3/4">
|
||||||
<SurveyInline
|
<SurveyInline
|
||||||
|
appUrl={publicDomain}
|
||||||
isPreviewMode={true}
|
isPreviewMode={true}
|
||||||
survey={previewSurvey(projectName || "my Product", t)}
|
survey={previewSurvey(projectName || "my Product", t)}
|
||||||
styling={{ brandColor: { light: brandColor } }}
|
styling={{ brandColor: { light: brandColor } }}
|
||||||
@@ -3,8 +3,9 @@ import Link from "next/link";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
||||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||||
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -47,11 +48,13 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
throw new Error(t("common.organization_teams_not_found"));
|
throw new Error(t("common.organization_teams_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const publicDomain = getPublicDomain();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header
|
<Header
|
||||||
title={t("organizations.projects.new.settings.project_settings_title")}
|
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
|
||||||
subtitle={t("organizations.projects.new.settings.project_settings_subtitle")}
|
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
|
||||||
/>
|
/>
|
||||||
<ProjectSettings
|
<ProjectSettings
|
||||||
organizationId={params.organizationId}
|
organizationId={params.organizationId}
|
||||||
@@ -62,10 +65,11 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
organizationTeams={organizationTeams}
|
organizationTeams={organizationTeams}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
userProjectsCount={projects.length}
|
userProjectsCount={projects.length}
|
||||||
|
publicDomain={publicDomain}
|
||||||
/>
|
/>
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
|
||||||
|
|
||||||
const SurveyEditorEnvironmentLayout = async (props) => {
|
const SurveyEditorEnvironmentLayout = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
@@ -25,15 +24,9 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EnvironmentIdBaseLayout
|
<div className="flex h-screen flex-col">
|
||||||
environmentId={params.environmentId}
|
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||||
session={session}
|
</div>
|
||||||
user={user}
|
|
||||||
organization={organization}>
|
|
||||||
<div className="flex h-screen flex-col">
|
|
||||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
|
||||||
</div>
|
|
||||||
</EnvironmentIdBaseLayout>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { AttributesPage as default } from "@/modules/ee/contacts/attributes/page";
|
||||||
@@ -57,7 +57,7 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
|||||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
||||||
|
|
||||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
if (organizationProjectsCount >= organizationProjectsLimit) {
|
||||||
throw new OperationNotAllowedError("Organization project limit reached");
|
throw new OperationNotAllowedError("Organization workspace limit reached");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,520 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { ChartType } from "@prisma/client";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { ZId } from "@formbricks/types/common";
|
||||||
|
import { executeQuery } from "@/app/api/analytics/_lib/cube-client";
|
||||||
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
|
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||||
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
|
|
||||||
|
const ZCreateChartAction = z.object({
|
||||||
|
environmentId: ZId,
|
||||||
|
name: z.string().min(1),
|
||||||
|
type: z.enum(["area", "bar", "line", "pie", "big_number", "big_number_total", "table", "funnel", "map"]),
|
||||||
|
query: z.record(z.any()),
|
||||||
|
config: z.record(z.any()).optional().default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createChartAction = authenticatedActionClient.schema(ZCreateChartAction).action(
|
||||||
|
withAuditLogging(
|
||||||
|
"created",
|
||||||
|
"chart",
|
||||||
|
async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZCreateChartAction> }) => {
|
||||||
|
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "projectTeam",
|
||||||
|
minPermission: "readWrite",
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const chart = await prisma.chart.create({
|
||||||
|
data: {
|
||||||
|
name: parsedInput.name,
|
||||||
|
type: parsedInput.type as ChartType,
|
||||||
|
projectId,
|
||||||
|
query: parsedInput.query,
|
||||||
|
config: parsedInput.config || {},
|
||||||
|
createdBy: ctx.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
|
ctx.auditLoggingCtx.projectId = projectId;
|
||||||
|
ctx.auditLoggingCtx.newObject = chart;
|
||||||
|
return chart;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const ZUpdateChartAction = z.object({
|
||||||
|
environmentId: ZId,
|
||||||
|
chartId: ZId,
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
type: z
|
||||||
|
.enum(["area", "bar", "line", "pie", "big_number", "big_number_total", "table", "funnel", "map"])
|
||||||
|
.optional(),
|
||||||
|
query: z.record(z.any()).optional(),
|
||||||
|
config: z.record(z.any()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateChartAction = authenticatedActionClient.schema(ZUpdateChartAction).action(
|
||||||
|
withAuditLogging(
|
||||||
|
"updated",
|
||||||
|
"chart",
|
||||||
|
async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZUpdateChartAction> }) => {
|
||||||
|
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "projectTeam",
|
||||||
|
minPermission: "readWrite",
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify chart belongs to the project
|
||||||
|
const chart = await prisma.chart.findFirst({
|
||||||
|
where: { id: parsedInput.chartId, projectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!chart) {
|
||||||
|
throw new Error("Chart not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (parsedInput.name !== undefined) updateData.name = parsedInput.name;
|
||||||
|
if (parsedInput.type !== undefined) updateData.type = parsedInput.type as ChartType;
|
||||||
|
if (parsedInput.query !== undefined) updateData.query = parsedInput.query;
|
||||||
|
if (parsedInput.config !== undefined) updateData.config = parsedInput.config;
|
||||||
|
|
||||||
|
const updatedChart = await prisma.chart.update({
|
||||||
|
where: { id: parsedInput.chartId },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
|
ctx.auditLoggingCtx.projectId = projectId;
|
||||||
|
ctx.auditLoggingCtx.oldObject = chart;
|
||||||
|
ctx.auditLoggingCtx.newObject = updatedChart;
|
||||||
|
return updatedChart;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const ZAddChartToDashboardAction = z.object({
|
||||||
|
environmentId: ZId,
|
||||||
|
chartId: ZId,
|
||||||
|
dashboardId: ZId,
|
||||||
|
title: z.string().optional(),
|
||||||
|
layout: z
|
||||||
|
.object({
|
||||||
|
x: z.number(),
|
||||||
|
y: z.number(),
|
||||||
|
w: z.number(),
|
||||||
|
h: z.number(),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.default({ x: 0, y: 0, w: 4, h: 3 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addChartToDashboardAction = authenticatedActionClient.schema(ZAddChartToDashboardAction).action(
|
||||||
|
withAuditLogging(
|
||||||
|
"created",
|
||||||
|
"dashboardWidget",
|
||||||
|
async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZAddChartToDashboardAction> }) => {
|
||||||
|
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "projectTeam",
|
||||||
|
minPermission: "readWrite",
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify chart and dashboard belong to the same project
|
||||||
|
const [chart, dashboard] = await Promise.all([
|
||||||
|
prisma.chart.findFirst({
|
||||||
|
where: { id: parsedInput.chartId, projectId },
|
||||||
|
}),
|
||||||
|
prisma.dashboard.findFirst({
|
||||||
|
where: { id: parsedInput.dashboardId, projectId },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!chart) {
|
||||||
|
throw new Error("Chart not found");
|
||||||
|
}
|
||||||
|
if (!dashboard) {
|
||||||
|
throw new Error("Dashboard not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the max order for widgets in this dashboard
|
||||||
|
const maxOrder = await prisma.dashboardWidget.aggregate({
|
||||||
|
where: { dashboardId: parsedInput.dashboardId },
|
||||||
|
_max: { order: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const widget = await prisma.dashboardWidget.create({
|
||||||
|
data: {
|
||||||
|
dashboardId: parsedInput.dashboardId,
|
||||||
|
chartId: parsedInput.chartId,
|
||||||
|
type: "chart",
|
||||||
|
title: parsedInput.title,
|
||||||
|
layout: parsedInput.layout,
|
||||||
|
order: (maxOrder._max.order ?? -1) + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
|
ctx.auditLoggingCtx.projectId = projectId;
|
||||||
|
ctx.auditLoggingCtx.newObject = widget;
|
||||||
|
return widget;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const ZCreateDashboardAction = z.object({
|
||||||
|
environmentId: ZId,
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createDashboardAction = authenticatedActionClient.schema(ZCreateDashboardAction).action(
|
||||||
|
withAuditLogging(
|
||||||
|
"created",
|
||||||
|
"dashboard",
|
||||||
|
async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZCreateDashboardAction> }) => {
|
||||||
|
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "projectTeam",
|
||||||
|
minPermission: "readWrite",
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const dashboard = await prisma.dashboard.create({
|
||||||
|
data: {
|
||||||
|
name: parsedInput.name,
|
||||||
|
description: parsedInput.description,
|
||||||
|
projectId,
|
||||||
|
status: "draft",
|
||||||
|
createdBy: ctx.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
|
ctx.auditLoggingCtx.projectId = projectId;
|
||||||
|
ctx.auditLoggingCtx.newObject = dashboard;
|
||||||
|
return dashboard;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const ZUpdateDashboardAction = z.object({
|
||||||
|
environmentId: ZId,
|
||||||
|
dashboardId: ZId,
|
||||||
|
name: z.string().min(1).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
status: z.enum(["draft", "published"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateDashboardAction = authenticatedActionClient.schema(ZUpdateDashboardAction).action(
|
||||||
|
withAuditLogging(
|
||||||
|
"updated",
|
||||||
|
"dashboard",
|
||||||
|
async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZUpdateDashboardAction> }) => {
|
||||||
|
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "projectTeam",
|
||||||
|
minPermission: "readWrite",
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify dashboard belongs to the project
|
||||||
|
const dashboard = await prisma.dashboard.findFirst({
|
||||||
|
where: { id: parsedInput.dashboardId, projectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dashboard) {
|
||||||
|
throw new Error("Dashboard not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = {};
|
||||||
|
if (parsedInput.name !== undefined) updateData.name = parsedInput.name;
|
||||||
|
if (parsedInput.description !== undefined) updateData.description = parsedInput.description;
|
||||||
|
if (parsedInput.status !== undefined) updateData.status = parsedInput.status;
|
||||||
|
|
||||||
|
const updatedDashboard = await prisma.dashboard.update({
|
||||||
|
where: { id: parsedInput.dashboardId },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
|
ctx.auditLoggingCtx.projectId = projectId;
|
||||||
|
ctx.auditLoggingCtx.oldObject = dashboard;
|
||||||
|
ctx.auditLoggingCtx.newObject = updatedDashboard;
|
||||||
|
return updatedDashboard;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const ZGetDashboardsAction = z.object({
|
||||||
|
environmentId: ZId,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getDashboardsAction = authenticatedActionClient
|
||||||
|
.schema(ZGetDashboardsAction)
|
||||||
|
.action(async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZGetDashboardsAction> }) => {
|
||||||
|
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "projectTeam",
|
||||||
|
minPermission: "read",
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const dashboards = await prisma.dashboard.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return dashboards;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZGetChartAction = z.object({
|
||||||
|
environmentId: ZId,
|
||||||
|
chartId: ZId,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getChartAction = authenticatedActionClient
|
||||||
|
.schema(ZGetChartAction)
|
||||||
|
.action(async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZGetChartAction> }) => {
|
||||||
|
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "projectTeam",
|
||||||
|
minPermission: "read",
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const chart = await prisma.chart.findFirst({
|
||||||
|
where: { id: parsedInput.chartId, projectId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
query: true,
|
||||||
|
config: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!chart) {
|
||||||
|
throw new Error("Chart not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return chart;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZGetChartsAction = z.object({
|
||||||
|
environmentId: ZId,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getChartsAction = authenticatedActionClient
|
||||||
|
.schema(ZGetChartsAction)
|
||||||
|
.action(async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZGetChartsAction> }) => {
|
||||||
|
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "projectTeam",
|
||||||
|
minPermission: "read",
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const charts = await prisma.chart.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
query: true,
|
||||||
|
config: true,
|
||||||
|
widgets: {
|
||||||
|
select: {
|
||||||
|
dashboardId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return charts;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZExecuteQueryAction = z.object({
|
||||||
|
environmentId: ZId,
|
||||||
|
query: z.object({
|
||||||
|
measures: z.array(z.string()),
|
||||||
|
dimensions: z.array(z.string()).optional(),
|
||||||
|
timeDimensions: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
dimension: z.string(),
|
||||||
|
granularity: z.enum(["hour", "day", "week", "month", "quarter", "year"]).optional(),
|
||||||
|
dateRange: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
filters: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
member: z.string(),
|
||||||
|
operator: z.enum([
|
||||||
|
"equals",
|
||||||
|
"notEquals",
|
||||||
|
"contains",
|
||||||
|
"notContains",
|
||||||
|
"set",
|
||||||
|
"notSet",
|
||||||
|
"gt",
|
||||||
|
"gte",
|
||||||
|
"lt",
|
||||||
|
"lte",
|
||||||
|
]),
|
||||||
|
values: z.array(z.string()).optional().nullable(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const executeQueryAction = authenticatedActionClient
|
||||||
|
.schema(ZExecuteQueryAction)
|
||||||
|
.action(async ({ ctx, parsedInput }: { ctx: any; parsedInput: z.infer<typeof ZExecuteQueryAction> }) => {
|
||||||
|
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "projectTeam",
|
||||||
|
minPermission: "read",
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await executeQuery(parsedInput.query);
|
||||||
|
return { data };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { error: error.message || "Failed to execute query" };
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ActivityIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||||
|
|
||||||
|
interface AIQuerySectionProps {
|
||||||
|
onChartGenerated: (data: AnalyticsResponse) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AIQuerySection({ onChartGenerated }: AIQuerySectionProps) {
|
||||||
|
const [userQuery, setUserQuery] = useState("");
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
if (!userQuery.trim()) return;
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/analytics/query", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ prompt: userQuery }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
toast.error(data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.data) {
|
||||||
|
onChartGenerated(data);
|
||||||
|
} else {
|
||||||
|
toast.error("No data returned from query");
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || "Failed to generate chart");
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<div className="bg-brand-dark/10 flex h-8 w-8 items-center justify-center rounded-full">
|
||||||
|
<ActivityIcon className="text-brand-dark h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-gray-900">Ask your data</h2>
|
||||||
|
<p className="text-sm text-gray-500">Describe what you want to see and let AI build the chart.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. How many users signed up last week?"
|
||||||
|
value={userQuery}
|
||||||
|
onChange={(e) => setUserQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && userQuery.trim() && !isGenerating) {
|
||||||
|
handleGenerate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
disabled={isGenerating}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={!userQuery.trim() || isGenerating}
|
||||||
|
loading={isGenerating}
|
||||||
|
className="bg-brand-dark hover:bg-brand-dark/90"
|
||||||
|
onClick={handleGenerate}>
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isGenerating && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<LoadingSpinner className="h-8 w-8" />
|
||||||
|
<span className="ml-3 text-sm text-gray-500">Generating chart...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/modules/ui/components/select";
|
||||||
|
|
||||||
|
interface Dashboard {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddToDashboardDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
chartName: string;
|
||||||
|
onChartNameChange: (name: string) => void;
|
||||||
|
dashboards: Dashboard[];
|
||||||
|
selectedDashboardId: string;
|
||||||
|
onDashboardSelect: (id: string) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddToDashboardDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
chartName,
|
||||||
|
onChartNameChange,
|
||||||
|
dashboards,
|
||||||
|
selectedDashboardId,
|
||||||
|
onDashboardSelect,
|
||||||
|
onAdd,
|
||||||
|
isSaving,
|
||||||
|
}: AddToDashboardDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Chart to Dashboard</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Select a dashboard to add this chart to. The chart will be saved automatically.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="chart-name" className="mb-2 block text-sm font-medium text-gray-700">
|
||||||
|
Chart Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="chart-name"
|
||||||
|
placeholder="Chart name"
|
||||||
|
value={chartName}
|
||||||
|
onChange={(e) => onChartNameChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="dashboard-select" className="mb-2 block text-sm font-medium text-gray-700">
|
||||||
|
Dashboard
|
||||||
|
</label>
|
||||||
|
<Select value={selectedDashboardId} onValueChange={onDashboardSelect}>
|
||||||
|
<SelectTrigger id="dashboard-select" className="w-full bg-white">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={dashboards.length === 0 ? "No dashboards available" : "Select a dashboard"}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="popper" className="z-[100] max-h-[200px]">
|
||||||
|
{dashboards.length === 0 ? (
|
||||||
|
<div className="px-2 py-1.5 text-sm text-gray-500">No dashboards available</div>
|
||||||
|
) : (
|
||||||
|
dashboards.map((dashboard) => (
|
||||||
|
<SelectItem key={dashboard.id} value={dashboard.id}>
|
||||||
|
{dashboard.name}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{dashboards.length === 0 && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Create a dashboard first to add charts to it.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onAdd} loading={isSaving} disabled={!selectedDashboardId}>
|
||||||
|
Add to Dashboard
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,617 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||||
|
import { CodeIcon, DatabaseIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import React, { useEffect, useReducer, useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||||
|
import {
|
||||||
|
addChartToDashboardAction,
|
||||||
|
createChartAction,
|
||||||
|
executeQueryAction,
|
||||||
|
getDashboardsAction,
|
||||||
|
} from "../../actions";
|
||||||
|
import { CHART_TYPES } from "../lib/chart-types";
|
||||||
|
|
||||||
|
// Filter out table, map, and scatter charts
|
||||||
|
const AVAILABLE_CHART_TYPES = CHART_TYPES.filter(
|
||||||
|
(type) => !["table", "map", "scatter"].includes(type.id)
|
||||||
|
);
|
||||||
|
import { mapChartType } from "../lib/chart-utils";
|
||||||
|
import {
|
||||||
|
ChartBuilderState,
|
||||||
|
CustomMeasure,
|
||||||
|
FilterRow,
|
||||||
|
TimeDimensionConfig,
|
||||||
|
buildCubeQuery,
|
||||||
|
parseQueryToState,
|
||||||
|
} from "../lib/query-builder";
|
||||||
|
import { AddToDashboardDialog } from "./AddToDashboardDialog";
|
||||||
|
import { ChartRenderer } from "./ChartRenderer";
|
||||||
|
import { DimensionsPanel } from "./DimensionsPanel";
|
||||||
|
import { FiltersPanel } from "./FiltersPanel";
|
||||||
|
import { MeasuresPanel } from "./MeasuresPanel";
|
||||||
|
import { SaveChartDialog } from "./SaveChartDialog";
|
||||||
|
import { TimeDimensionPanel } from "./TimeDimensionPanel";
|
||||||
|
|
||||||
|
interface AdvancedChartBuilderProps {
|
||||||
|
environmentId: string;
|
||||||
|
initialChartType?: string;
|
||||||
|
initialQuery?: any; // Prefill with AI-generated query
|
||||||
|
hidePreview?: boolean; // Hide internal preview when using unified preview
|
||||||
|
onChartGenerated?: (data: AnalyticsResponse) => void;
|
||||||
|
onSave?: (chartId: string) => void;
|
||||||
|
onAddToDashboard?: (chartId: string, dashboardId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "SET_CHART_TYPE"; payload: string }
|
||||||
|
| { type: "ADD_MEASURE"; payload: string }
|
||||||
|
| { type: "REMOVE_MEASURE"; payload: string }
|
||||||
|
| { type: "SET_MEASURES"; payload: string[] }
|
||||||
|
| { type: "ADD_CUSTOM_MEASURE"; payload: CustomMeasure }
|
||||||
|
| { type: "UPDATE_CUSTOM_MEASURE"; payload: { index: number; measure: CustomMeasure } }
|
||||||
|
| { type: "REMOVE_CUSTOM_MEASURE"; payload: number }
|
||||||
|
| { type: "SET_CUSTOM_MEASURES"; payload: CustomMeasure[] }
|
||||||
|
| { type: "SET_DIMENSIONS"; payload: string[] }
|
||||||
|
| { type: "ADD_FILTER"; payload: FilterRow }
|
||||||
|
| { type: "UPDATE_FILTER"; payload: { index: number; filter: FilterRow } }
|
||||||
|
| { type: "REMOVE_FILTER"; payload: number }
|
||||||
|
| { type: "SET_FILTERS"; payload: FilterRow[] }
|
||||||
|
| { type: "SET_FILTER_LOGIC"; payload: "and" | "or" }
|
||||||
|
| { type: "SET_TIME_DIMENSION"; payload: TimeDimensionConfig | null };
|
||||||
|
|
||||||
|
const initialState: ChartBuilderState = {
|
||||||
|
chartType: "",
|
||||||
|
selectedMeasures: [],
|
||||||
|
customMeasures: [],
|
||||||
|
selectedDimensions: [],
|
||||||
|
filters: [],
|
||||||
|
filterLogic: "and",
|
||||||
|
timeDimension: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function chartBuilderReducer(state: ChartBuilderState, action: Action): ChartBuilderState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "SET_CHART_TYPE":
|
||||||
|
return { ...state, chartType: action.payload };
|
||||||
|
case "SET_MEASURES":
|
||||||
|
return { ...state, selectedMeasures: action.payload };
|
||||||
|
case "SET_CUSTOM_MEASURES":
|
||||||
|
return { ...state, customMeasures: action.payload };
|
||||||
|
case "SET_DIMENSIONS":
|
||||||
|
return { ...state, selectedDimensions: action.payload };
|
||||||
|
case "SET_FILTERS":
|
||||||
|
return { ...state, filters: action.payload };
|
||||||
|
case "SET_FILTER_LOGIC":
|
||||||
|
return { ...state, filterLogic: action.payload };
|
||||||
|
case "SET_TIME_DIMENSION":
|
||||||
|
return { ...state, timeDimension: action.payload };
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdvancedChartBuilder({
|
||||||
|
environmentId,
|
||||||
|
initialChartType,
|
||||||
|
initialQuery,
|
||||||
|
hidePreview = false,
|
||||||
|
onChartGenerated,
|
||||||
|
onSave,
|
||||||
|
onAddToDashboard,
|
||||||
|
}: AdvancedChartBuilderProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Initialize state from initialQuery if provided
|
||||||
|
const getInitialState = (): ChartBuilderState => {
|
||||||
|
if (initialQuery) {
|
||||||
|
const parsedState = parseQueryToState(initialQuery, initialChartType);
|
||||||
|
return {
|
||||||
|
...initialState,
|
||||||
|
...parsedState,
|
||||||
|
chartType: parsedState.chartType || initialChartType || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...initialState,
|
||||||
|
chartType: initialChartType || "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const [state, dispatch] = useReducer(chartBuilderReducer, getInitialState());
|
||||||
|
const [chartData, setChartData] = useState<Record<string, any>[] | null>(null);
|
||||||
|
const [query, setQuery] = useState<any>(initialQuery || null);
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
const lastStateRef = React.useRef<string>("");
|
||||||
|
|
||||||
|
// Sync initialChartType prop changes to state
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialChartType && initialChartType !== state.chartType) {
|
||||||
|
dispatch({ type: "SET_CHART_TYPE", payload: initialChartType });
|
||||||
|
// If there's no initialQuery, mark as initialized so reactive updates can work
|
||||||
|
if (!initialQuery && !isInitialized) {
|
||||||
|
setIsInitialized(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialChartType, state.chartType, initialQuery, isInitialized]);
|
||||||
|
|
||||||
|
// Initialize: If initialQuery is provided (from AI), execute it and set chart data
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialQuery && !isInitialized) {
|
||||||
|
setIsInitialized(true);
|
||||||
|
executeQueryAction({
|
||||||
|
environmentId,
|
||||||
|
query: initialQuery,
|
||||||
|
}).then((result) => {
|
||||||
|
if (result?.data?.data) {
|
||||||
|
const data = Array.isArray(result.data.data) ? result.data.data : [];
|
||||||
|
setChartData(data);
|
||||||
|
setQuery(initialQuery);
|
||||||
|
// Set initial state hash to prevent reactive update on initial load
|
||||||
|
lastStateRef.current = JSON.stringify({
|
||||||
|
chartType: state.chartType,
|
||||||
|
measures: state.selectedMeasures,
|
||||||
|
dimensions: state.selectedDimensions,
|
||||||
|
filters: state.filters,
|
||||||
|
timeDimension: state.timeDimension,
|
||||||
|
});
|
||||||
|
// Call onChartGenerated if provided
|
||||||
|
if (onChartGenerated) {
|
||||||
|
const analyticsResponse: AnalyticsResponse = {
|
||||||
|
query: initialQuery,
|
||||||
|
chartType: state.chartType as any,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
onChartGenerated(analyticsResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [initialQuery, environmentId, isInitialized, state.chartType, state.selectedMeasures, state.selectedDimensions, state.filters, state.timeDimension, onChartGenerated]);
|
||||||
|
|
||||||
|
// Update preview reactively when state changes (after initialization)
|
||||||
|
useEffect(() => {
|
||||||
|
// Skip if not initialized or no chart type selected
|
||||||
|
if (!isInitialized || !state.chartType) return;
|
||||||
|
|
||||||
|
// Create a hash of relevant state to detect changes
|
||||||
|
const stateHash = JSON.stringify({
|
||||||
|
chartType: state.chartType,
|
||||||
|
measures: state.selectedMeasures,
|
||||||
|
dimensions: state.selectedDimensions,
|
||||||
|
filters: state.filters,
|
||||||
|
timeDimension: state.timeDimension,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only update if state actually changed
|
||||||
|
if (stateHash === lastStateRef.current) return;
|
||||||
|
lastStateRef.current = stateHash;
|
||||||
|
|
||||||
|
// If chart type changed but we have existing data, update the chart type in preview immediately
|
||||||
|
// This handles the case where user changes chart type from ManualChartBuilder
|
||||||
|
if (chartData && Array.isArray(chartData) && chartData.length > 0 && query) {
|
||||||
|
if (onChartGenerated) {
|
||||||
|
const analyticsResponse: AnalyticsResponse = {
|
||||||
|
query: query, // Keep existing query
|
||||||
|
chartType: state.chartType as any, // Update chart type
|
||||||
|
data: chartData, // Keep existing data
|
||||||
|
};
|
||||||
|
onChartGenerated(analyticsResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only execute query if we have measures configured
|
||||||
|
if (state.selectedMeasures.length === 0 && state.customMeasures.length === 0) {
|
||||||
|
return; // Don't execute query without measures
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and execute query with current state
|
||||||
|
const updatedQuery = buildCubeQuery(state);
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
executeQueryAction({
|
||||||
|
environmentId,
|
||||||
|
query: updatedQuery,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
if (result?.data?.data) {
|
||||||
|
const data = Array.isArray(result.data.data) ? result.data.data : [];
|
||||||
|
setChartData(data);
|
||||||
|
setQuery(updatedQuery);
|
||||||
|
// Call onChartGenerated to update parent preview
|
||||||
|
if (onChartGenerated) {
|
||||||
|
const analyticsResponse: AnalyticsResponse = {
|
||||||
|
query: updatedQuery,
|
||||||
|
chartType: state.chartType as any,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
onChartGenerated(analyticsResponse);
|
||||||
|
}
|
||||||
|
} else if (result?.serverError) {
|
||||||
|
setError(result.serverError);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
setError(err.message || "Failed to execute query");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [state.chartType, state.selectedMeasures, state.selectedDimensions, state.filters, state.timeDimension, isInitialized, environmentId, onChartGenerated, chartData, query]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||||
|
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
|
||||||
|
const [chartName, setChartName] = useState("");
|
||||||
|
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
|
||||||
|
const [selectedDashboardId, setSelectedDashboardId] = useState<string>("");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showQuery, setShowQuery] = useState(false);
|
||||||
|
const [showData, setShowData] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAddToDashboardDialogOpen) {
|
||||||
|
getDashboardsAction({ environmentId }).then((result) => {
|
||||||
|
if (result?.data) {
|
||||||
|
setDashboards(result.data);
|
||||||
|
} else if (result?.serverError) {
|
||||||
|
toast.error(result.serverError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isAddToDashboardDialogOpen, environmentId]);
|
||||||
|
|
||||||
|
const handleRunQuery = async () => {
|
||||||
|
if (!state.chartType) {
|
||||||
|
toast.error("Please select a chart type");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.selectedMeasures.length === 0 && state.customMeasures.length === 0) {
|
||||||
|
toast.error("Please select at least one measure");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cubeQuery = buildCubeQuery(state);
|
||||||
|
setQuery(cubeQuery);
|
||||||
|
|
||||||
|
const result = await executeQueryAction({
|
||||||
|
environmentId,
|
||||||
|
query: cubeQuery,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.serverError) {
|
||||||
|
setError(result.serverError);
|
||||||
|
toast.error(result.serverError);
|
||||||
|
setChartData(null);
|
||||||
|
} else if (result?.data?.data) {
|
||||||
|
// Ensure data is always an array - result.data.data contains the actual array
|
||||||
|
const data = Array.isArray(result.data.data) ? result.data.data : [];
|
||||||
|
setChartData(data);
|
||||||
|
setError(null);
|
||||||
|
toast.success("Query executed successfully");
|
||||||
|
|
||||||
|
// Call onChartGenerated callback if provided
|
||||||
|
if (onChartGenerated) {
|
||||||
|
const analyticsResponse: AnalyticsResponse = {
|
||||||
|
query: cubeQuery,
|
||||||
|
chartType: state.chartType as any,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
onChartGenerated(analyticsResponse);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("No data returned");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMessage = err.message || "Failed to execute query";
|
||||||
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
|
setChartData(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveChart = async () => {
|
||||||
|
if (!chartData || !chartName.trim()) {
|
||||||
|
toast.error("Please enter a chart name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!query) {
|
||||||
|
toast.error("Please run a query first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await createChartAction({
|
||||||
|
environmentId,
|
||||||
|
name: chartName,
|
||||||
|
type: mapChartType(state.chartType),
|
||||||
|
query,
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.data) {
|
||||||
|
toast.error(result?.serverError || "Failed to save chart");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Chart saved successfully!");
|
||||||
|
setIsSaveDialogOpen(false);
|
||||||
|
if (onSave) {
|
||||||
|
onSave(result.data.id);
|
||||||
|
} else {
|
||||||
|
router.push(`/environments/${environmentId}/analysis/charts`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || "Failed to save chart");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToDashboard = async () => {
|
||||||
|
if (!chartData || !selectedDashboardId) {
|
||||||
|
toast.error("Please select a dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!query) {
|
||||||
|
toast.error("Please run a query first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
console.log(query);
|
||||||
|
try {
|
||||||
|
const chartResult = await createChartAction({
|
||||||
|
environmentId,
|
||||||
|
name: chartName || `Chart ${new Date().toLocaleString()}`,
|
||||||
|
type: mapChartType(state.chartType),
|
||||||
|
query,
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!chartResult?.data) {
|
||||||
|
toast.error(chartResult?.serverError || "Failed to save chart");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgetResult = await addChartToDashboardAction({
|
||||||
|
environmentId,
|
||||||
|
chartId: chartResult.data.id,
|
||||||
|
dashboardId: selectedDashboardId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!widgetResult?.data) {
|
||||||
|
toast.error(widgetResult?.serverError || "Failed to add chart to dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Chart added to dashboard!");
|
||||||
|
setIsAddToDashboardDialogOpen(false);
|
||||||
|
if (onAddToDashboard) {
|
||||||
|
onAddToDashboard(chartResult.data.id, selectedDashboardId);
|
||||||
|
} else {
|
||||||
|
router.push(`/environments/${environmentId}/analysis/dashboard/${selectedDashboardId}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || "Failed to add chart to dashboard");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={hidePreview ? "space-y-4" : "grid gap-4 lg:grid-cols-2"}>
|
||||||
|
{/* Left Column: Configuration */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Chart Type Selection - Hidden when hidePreview is true (unified flow) */}
|
||||||
|
{!hidePreview && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="font-medium text-gray-900">Choose chart type</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||||
|
{AVAILABLE_CHART_TYPES.map((chart) => {
|
||||||
|
const isSelected = state.chartType === chart.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={chart.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => dispatch({ type: "SET_CHART_TYPE", payload: chart.id })}
|
||||||
|
className={`rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 ${isSelected
|
||||||
|
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
|
||||||
|
: "border-gray-200 hover:border-gray-300"
|
||||||
|
}`}>
|
||||||
|
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
|
||||||
|
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-gray-700">{chart.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Measures Panel */}
|
||||||
|
<MeasuresPanel
|
||||||
|
selectedMeasures={state.selectedMeasures}
|
||||||
|
customMeasures={state.customMeasures}
|
||||||
|
onMeasuresChange={(measures) => dispatch({ type: "SET_MEASURES", payload: measures })}
|
||||||
|
onCustomMeasuresChange={(measures) => dispatch({ type: "SET_CUSTOM_MEASURES", payload: measures })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dimensions Panel */}
|
||||||
|
<DimensionsPanel
|
||||||
|
selectedDimensions={state.selectedDimensions}
|
||||||
|
onDimensionsChange={(dimensions) => dispatch({ type: "SET_DIMENSIONS", payload: dimensions })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Time Dimension Panel */}
|
||||||
|
<TimeDimensionPanel
|
||||||
|
timeDimension={state.timeDimension}
|
||||||
|
onTimeDimensionChange={(config) => dispatch({ type: "SET_TIME_DIMENSION", payload: config })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filters Panel */}
|
||||||
|
<FiltersPanel
|
||||||
|
filters={state.filters}
|
||||||
|
filterLogic={state.filterLogic}
|
||||||
|
onFiltersChange={(filters) => dispatch({ type: "SET_FILTERS", payload: filters })}
|
||||||
|
onFilterLogicChange={(logic) => dispatch({ type: "SET_FILTER_LOGIC", payload: logic })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={handleRunQuery} disabled={isLoading || !state.chartType}>
|
||||||
|
{isLoading ? <LoadingSpinner /> : "Run Query"}
|
||||||
|
</Button>
|
||||||
|
{chartData && !onSave && !onAddToDashboard && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setIsSaveDialogOpen(true)}>
|
||||||
|
Save Chart
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setIsAddToDashboardDialogOpen(true)}>
|
||||||
|
Add to Dashboard
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Preview - Hidden when hidePreview is true */}
|
||||||
|
{!hidePreview && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-medium text-gray-900">Chart Preview</h3>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chartData && Array.isArray(chartData) && chartData.length > 0 && !isLoading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
|
<ChartRenderer chartType={state.chartType} data={chartData} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Query Viewer */}
|
||||||
|
<Collapsible.Root open={showQuery} onOpenChange={setShowQuery}>
|
||||||
|
<Collapsible.CollapsibleTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-full justify-start">
|
||||||
|
<CodeIcon className="mr-2 h-4 w-4" />
|
||||||
|
{showQuery ? "Hide" : "View"} Query
|
||||||
|
</Button>
|
||||||
|
</Collapsible.CollapsibleTrigger>
|
||||||
|
<Collapsible.CollapsibleContent className="mt-2">
|
||||||
|
<pre className="max-h-64 overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-4 text-xs">
|
||||||
|
{JSON.stringify(query, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</Collapsible.CollapsibleContent>
|
||||||
|
</Collapsible.Root>
|
||||||
|
|
||||||
|
{/* Data Viewer */}
|
||||||
|
<Collapsible.Root open={showData} onOpenChange={setShowData}>
|
||||||
|
<Collapsible.CollapsibleTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-full justify-start">
|
||||||
|
<DatabaseIcon className="mr-2 h-4 w-4" />
|
||||||
|
{showData ? "Hide" : "View"} Data
|
||||||
|
</Button>
|
||||||
|
</Collapsible.CollapsibleTrigger>
|
||||||
|
<Collapsible.CollapsibleContent className="mt-2">
|
||||||
|
<div className="max-h-64 overflow-auto rounded-lg border border-gray-200">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
{Array.isArray(chartData) &&
|
||||||
|
chartData.length > 0 &&
|
||||||
|
Object.keys(chartData[0]).map((key) => (
|
||||||
|
<th
|
||||||
|
key={key}
|
||||||
|
className="border-b border-gray-200 px-3 py-2 text-left font-medium">
|
||||||
|
{key}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.isArray(chartData) &&
|
||||||
|
chartData.slice(0, 10).map((row, idx) => {
|
||||||
|
// Create a unique key from row data
|
||||||
|
const rowKey = Object.values(row)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((v) => String(v || ""))
|
||||||
|
.join("-");
|
||||||
|
return (
|
||||||
|
<tr key={`row-${idx}-${rowKey}`} className="border-b border-gray-100">
|
||||||
|
{Object.entries(row).map(([key, value]) => (
|
||||||
|
<td key={`${rowKey}-${key}`} className="px-3 py-2">
|
||||||
|
{value?.toString() || "-"}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{Array.isArray(chartData) && chartData.length > 10 && (
|
||||||
|
<div className="bg-gray-50 px-3 py-2 text-xs text-gray-500">
|
||||||
|
Showing first 10 of {chartData.length} rows
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Collapsible.CollapsibleContent>
|
||||||
|
</Collapsible.Root>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!chartData && !isLoading && !error && (
|
||||||
|
<div className="flex h-64 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-gray-500">
|
||||||
|
Configure your chart and click "Run Query" to preview
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialogs - Only render when callbacks are not provided (standalone mode) */}
|
||||||
|
{!onSave && (
|
||||||
|
<SaveChartDialog
|
||||||
|
open={isSaveDialogOpen}
|
||||||
|
onOpenChange={setIsSaveDialogOpen}
|
||||||
|
chartName={chartName}
|
||||||
|
onChartNameChange={setChartName}
|
||||||
|
onSave={handleSaveChart}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!onAddToDashboard && (
|
||||||
|
<AddToDashboardDialog
|
||||||
|
open={isAddToDashboardDialogOpen}
|
||||||
|
onOpenChange={setIsAddToDashboardDialogOpen}
|
||||||
|
chartName={chartName}
|
||||||
|
onChartNameChange={setChartName}
|
||||||
|
dashboards={dashboards}
|
||||||
|
selectedDashboardId={selectedDashboardId}
|
||||||
|
onDashboardSelect={setSelectedDashboardId}
|
||||||
|
onAdd={handleAddToDashboard}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
|
||||||
|
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||||
|
import {
|
||||||
|
addChartToDashboardAction,
|
||||||
|
createChartAction,
|
||||||
|
executeQueryAction,
|
||||||
|
getChartAction,
|
||||||
|
getDashboardsAction,
|
||||||
|
updateChartAction,
|
||||||
|
} from "../../actions";
|
||||||
|
import { mapChartType, mapDatabaseChartTypeToApi } from "../lib/chart-utils";
|
||||||
|
import { AIQuerySection } from "./AIQuerySection";
|
||||||
|
import { AddToDashboardDialog } from "./AddToDashboardDialog";
|
||||||
|
import { AdvancedChartBuilder } from "./AdvancedChartBuilder";
|
||||||
|
import { ChartPreview } from "./ChartPreview";
|
||||||
|
import { ConfigureChartDialog } from "./ConfigureChartDialog";
|
||||||
|
import { ManualChartBuilder } from "./ManualChartBuilder";
|
||||||
|
import { SaveChartDialog } from "./SaveChartDialog";
|
||||||
|
|
||||||
|
interface ChartBuilderClientProps {
|
||||||
|
environmentId: string;
|
||||||
|
chartId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartBuilderClient({ environmentId, chartId }: ChartBuilderClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedChartType, setSelectedChartType] = useState<string>("");
|
||||||
|
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
|
||||||
|
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||||
|
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
|
||||||
|
const [isConfigureDialogOpen, setIsConfigureDialogOpen] = useState(false);
|
||||||
|
const [chartName, setChartName] = useState("");
|
||||||
|
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
|
||||||
|
const [selectedDashboardId, setSelectedDashboardId] = useState<string>("");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showData, setShowData] = useState(false);
|
||||||
|
const [configuredChartType, setConfiguredChartType] = useState<string | null>(null);
|
||||||
|
const [showAdvancedBuilder, setShowAdvancedBuilder] = useState(false);
|
||||||
|
const [isLoadingChart, setIsLoadingChart] = useState(false);
|
||||||
|
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAddToDashboardDialogOpen) {
|
||||||
|
getDashboardsAction({ environmentId }).then((result) => {
|
||||||
|
if (result?.data) {
|
||||||
|
setDashboards(result.data);
|
||||||
|
} else if (result?.serverError) {
|
||||||
|
toast.error(result.serverError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isAddToDashboardDialogOpen, environmentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chartId) {
|
||||||
|
setIsLoadingChart(true);
|
||||||
|
getChartAction({ environmentId, chartId })
|
||||||
|
.then(async (result) => {
|
||||||
|
if (result?.data) {
|
||||||
|
const chart = result.data;
|
||||||
|
setChartName(chart.name);
|
||||||
|
|
||||||
|
// Execute the chart's query to get the data
|
||||||
|
const queryResult = await executeQueryAction({
|
||||||
|
environmentId,
|
||||||
|
query: chart.query as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (queryResult?.error || queryResult?.serverError) {
|
||||||
|
toast.error(queryResult.error || queryResult.serverError || "Failed to load chart data");
|
||||||
|
setIsLoadingChart(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryResult?.data?.data) {
|
||||||
|
// Format as AnalyticsResponse
|
||||||
|
const chartData: AnalyticsResponse = {
|
||||||
|
query: chart.query as any,
|
||||||
|
chartType: mapDatabaseChartTypeToApi(chart.type),
|
||||||
|
data: Array.isArray(queryResult.data.data) ? queryResult.data.data : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
setChartData(chartData);
|
||||||
|
setConfiguredChartType(mapDatabaseChartTypeToApi(chart.type));
|
||||||
|
setCurrentChartId(chart.id);
|
||||||
|
} else {
|
||||||
|
toast.error("No data returned for chart");
|
||||||
|
}
|
||||||
|
} else if (result?.serverError) {
|
||||||
|
toast.error(result.serverError);
|
||||||
|
}
|
||||||
|
setIsLoadingChart(false);
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
toast.error(error.message || "Failed to load chart");
|
||||||
|
setIsLoadingChart(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [chartId, environmentId]);
|
||||||
|
|
||||||
|
const handleChartGenerated = (data: AnalyticsResponse) => {
|
||||||
|
setChartData(data);
|
||||||
|
setChartName(data.chartType ? `Chart ${new Date().toLocaleString()}` : "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveChart = async () => {
|
||||||
|
if (!chartData || !chartName.trim()) {
|
||||||
|
toast.error("Please enter a chart name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
// If we have a currentChartId, update the existing chart; otherwise create a new one
|
||||||
|
if (currentChartId) {
|
||||||
|
const result = await updateChartAction({
|
||||||
|
environmentId,
|
||||||
|
chartId: currentChartId,
|
||||||
|
name: chartName.trim(),
|
||||||
|
type: mapChartType(chartData.chartType),
|
||||||
|
query: chartData.query,
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.data) {
|
||||||
|
toast.error(result?.serverError || "Failed to update chart");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Chart updated successfully!");
|
||||||
|
setIsSaveDialogOpen(false);
|
||||||
|
router.push(`/environments/${environmentId}/analysis/charts`);
|
||||||
|
} else {
|
||||||
|
const result = await createChartAction({
|
||||||
|
environmentId,
|
||||||
|
name: chartName.trim(),
|
||||||
|
type: mapChartType(chartData.chartType),
|
||||||
|
query: chartData.query,
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.data) {
|
||||||
|
toast.error(result?.serverError || "Failed to save chart");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentChartId(result.data.id);
|
||||||
|
toast.success("Chart saved successfully!");
|
||||||
|
setIsSaveDialogOpen(false);
|
||||||
|
router.push(`/environments/${environmentId}/analysis/charts`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || "Failed to save chart");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToDashboard = async () => {
|
||||||
|
if (!chartData || !selectedDashboardId) {
|
||||||
|
toast.error("Please select a dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
let chartIdToUse = currentChartId;
|
||||||
|
|
||||||
|
// If we don't have a chartId (creating new chart), create it first
|
||||||
|
if (!chartIdToUse) {
|
||||||
|
if (!chartName.trim()) {
|
||||||
|
toast.error("Please enter a chart name");
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartResult = await createChartAction({
|
||||||
|
environmentId,
|
||||||
|
name: chartName.trim(),
|
||||||
|
type: mapChartType(chartData.chartType),
|
||||||
|
query: chartData.query,
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!chartResult?.data) {
|
||||||
|
toast.error(chartResult?.serverError || "Failed to save chart");
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chartIdToUse = chartResult.data.id;
|
||||||
|
setCurrentChartId(chartIdToUse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the chart (existing or newly created) to the dashboard
|
||||||
|
const widgetResult = await addChartToDashboardAction({
|
||||||
|
environmentId,
|
||||||
|
chartId: chartIdToUse,
|
||||||
|
dashboardId: selectedDashboardId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!widgetResult?.data) {
|
||||||
|
toast.error(widgetResult?.serverError || "Failed to add chart to dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Chart added to dashboard!");
|
||||||
|
setIsAddToDashboardDialogOpen(false);
|
||||||
|
router.push(`/environments/${environmentId}/analysis/dashboard/${selectedDashboardId}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || "Failed to add chart to dashboard");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualCreate = () => {
|
||||||
|
if (!selectedChartType) {
|
||||||
|
toast.error("Please select a chart type first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowAdvancedBuilder(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// If loading an existing chart, show loading state
|
||||||
|
if (chartId && isLoadingChart) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If viewing an existing chart, show only the chart preview
|
||||||
|
if (chartId && chartData) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-8">
|
||||||
|
<ChartPreview
|
||||||
|
chartData={chartData}
|
||||||
|
configuredChartType={configuredChartType}
|
||||||
|
showData={showData}
|
||||||
|
onToggleData={() => setShowData(!showData)}
|
||||||
|
onConfigure={() => setIsConfigureDialogOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
<SaveChartDialog
|
||||||
|
open={isSaveDialogOpen}
|
||||||
|
onOpenChange={setIsSaveDialogOpen}
|
||||||
|
chartName={chartName}
|
||||||
|
onChartNameChange={setChartName}
|
||||||
|
onSave={handleSaveChart}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddToDashboardDialog
|
||||||
|
open={isAddToDashboardDialogOpen}
|
||||||
|
onOpenChange={setIsAddToDashboardDialogOpen}
|
||||||
|
chartName={chartName}
|
||||||
|
onChartNameChange={setChartName}
|
||||||
|
dashboards={dashboards}
|
||||||
|
selectedDashboardId={selectedDashboardId}
|
||||||
|
onDashboardSelect={setSelectedDashboardId}
|
||||||
|
onAdd={handleAddToDashboard}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfigureChartDialog
|
||||||
|
open={isConfigureDialogOpen}
|
||||||
|
onOpenChange={setIsConfigureDialogOpen}
|
||||||
|
currentChartType={chartData?.chartType || "bar"}
|
||||||
|
configuredChartType={configuredChartType}
|
||||||
|
onChartTypeSelect={setConfiguredChartType}
|
||||||
|
onReset={() => setConfiguredChartType(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-8">
|
||||||
|
{/* Option 1: Ask AI */}
|
||||||
|
<AIQuerySection onChartGenerated={handleChartGenerated} />
|
||||||
|
|
||||||
|
{/* Chart Preview */}
|
||||||
|
{chartData && (
|
||||||
|
<ChartPreview
|
||||||
|
chartData={chartData}
|
||||||
|
configuredChartType={configuredChartType}
|
||||||
|
showData={showData}
|
||||||
|
onToggleData={() => setShowData(!showData)}
|
||||||
|
onConfigure={() => setIsConfigureDialogOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||||
|
<div className="w-full border-t border-gray-200" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<span className="bg-gray-50 px-2 text-sm text-gray-500">OR</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Option 2: Build Manually */}
|
||||||
|
{showAdvancedBuilder ? (
|
||||||
|
<AdvancedChartBuilder environmentId={environmentId} initialChartType={selectedChartType} />
|
||||||
|
) : (
|
||||||
|
<ManualChartBuilder
|
||||||
|
selectedChartType={selectedChartType}
|
||||||
|
onChartTypeSelect={setSelectedChartType}
|
||||||
|
onCreate={handleManualCreate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
<SaveChartDialog
|
||||||
|
open={isSaveDialogOpen}
|
||||||
|
onOpenChange={setIsSaveDialogOpen}
|
||||||
|
chartName={chartName}
|
||||||
|
onChartNameChange={setChartName}
|
||||||
|
onSave={handleSaveChart}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddToDashboardDialog
|
||||||
|
open={isAddToDashboardDialogOpen}
|
||||||
|
onOpenChange={setIsAddToDashboardDialogOpen}
|
||||||
|
chartName={chartName}
|
||||||
|
onChartNameChange={setChartName}
|
||||||
|
dashboards={dashboards}
|
||||||
|
selectedDashboardId={selectedDashboardId}
|
||||||
|
onDashboardSelect={setSelectedDashboardId}
|
||||||
|
onAdd={handleAddToDashboard}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfigureChartDialog
|
||||||
|
open={isConfigureDialogOpen}
|
||||||
|
onOpenChange={setIsConfigureDialogOpen}
|
||||||
|
currentChartType={chartData?.chartType || "bar"}
|
||||||
|
configuredChartType={configuredChartType}
|
||||||
|
onChartTypeSelect={setConfiguredChartType}
|
||||||
|
onReset={() => setConfiguredChartType(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { BarChart, DatabaseIcon } from "lucide-react";
|
||||||
|
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||||
|
import { ChartRenderer } from "./ChartRenderer";
|
||||||
|
import { DataViewer } from "./DataViewer";
|
||||||
|
|
||||||
|
interface ChartPreviewProps {
|
||||||
|
chartData: AnalyticsResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartPreview({ chartData }: ChartPreviewProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<"chart" | "data">("chart");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-6">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h3 className="font-semibold text-gray-900">Chart Preview</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "chart" | "data")}>
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="chart" icon={<BarChart className="h-4 w-4" />}>
|
||||||
|
Chart
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="data" icon={<DatabaseIcon className="h-4 w-4" />}>
|
||||||
|
Data
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="chart" className="mt-0">
|
||||||
|
<ChartRenderer chartType={chartData.chartType} data={chartData.data || []} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="data" className="mt-0">
|
||||||
|
<DataViewer data={chartData.data || []} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
Pie,
|
||||||
|
PieChart,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/modules/ui/components/chart";
|
||||||
|
|
||||||
|
// Formbricks brand colors
|
||||||
|
const BRAND_DARK = "#00C4B8";
|
||||||
|
const BRAND_LIGHT = "#00E6CA";
|
||||||
|
|
||||||
|
interface ChartRendererProps {
|
||||||
|
chartType: string;
|
||||||
|
data: Record<string, any>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartRenderer({ chartType, data }: ChartRendererProps) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return <div className="flex h-64 items-center justify-center text-gray-500">No data available</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first data point to determine keys
|
||||||
|
const firstRow = data[0];
|
||||||
|
const allKeys = Object.keys(firstRow);
|
||||||
|
const keys = allKeys.filter((key) => key !== "date" && key !== "time");
|
||||||
|
|
||||||
|
// For pie charts, we need to identify dimension (nameKey) and measure (dataKey)
|
||||||
|
let xAxisKey = "key";
|
||||||
|
let dataKey = "value";
|
||||||
|
|
||||||
|
if (chartType === "pie" || chartType === "donut") {
|
||||||
|
// Find first numeric key (measure)
|
||||||
|
const numericKey = keys.find((key) => {
|
||||||
|
const firstValue = firstRow[key];
|
||||||
|
if (firstValue === null || firstValue === undefined || firstValue === "") return false;
|
||||||
|
const numValue = Number(firstValue);
|
||||||
|
return !Number.isNaN(numValue) && Number.isFinite(numValue);
|
||||||
|
});
|
||||||
|
// Find first non-numeric key (dimension)
|
||||||
|
const nonNumericKey = keys.find((key) => {
|
||||||
|
if (key === numericKey) return false;
|
||||||
|
const firstValue = firstRow[key];
|
||||||
|
return firstValue !== undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
xAxisKey = nonNumericKey || (numericKey ? keys.find((k) => k !== numericKey) : null) || keys[0] || "key";
|
||||||
|
dataKey = numericKey || keys[1] || keys[0] || "value";
|
||||||
|
} else {
|
||||||
|
// For other chart types, use existing logic
|
||||||
|
if (firstRow.date) {
|
||||||
|
xAxisKey = "date";
|
||||||
|
} else if (firstRow.time) {
|
||||||
|
xAxisKey = "time";
|
||||||
|
} else if (keys[0]) {
|
||||||
|
xAxisKey = keys[0];
|
||||||
|
}
|
||||||
|
dataKey = keys.find((k) => k !== xAxisKey) || keys[0] || "value";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (chartType) {
|
||||||
|
case "bar":
|
||||||
|
return (
|
||||||
|
<div className="h-64 min-h-[256px] w-full">
|
||||||
|
<ChartContainer
|
||||||
|
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
|
||||||
|
className="h-full w-full">
|
||||||
|
<BarChart data={data}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey={xAxisKey} tickLine={false} tickMargin={10} axisLine={false} />
|
||||||
|
<YAxis tickLine={false} axisLine={false} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Bar dataKey={dataKey} fill={BRAND_DARK} radius={4} />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "line":
|
||||||
|
return (
|
||||||
|
<div className="h-64 min-h-[256px] w-full">
|
||||||
|
<ChartContainer
|
||||||
|
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
|
||||||
|
className="h-full w-full">
|
||||||
|
<LineChart data={data}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey={xAxisKey} tickLine={false} tickMargin={10} axisLine={false} />
|
||||||
|
<YAxis tickLine={false} axisLine={false} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey={dataKey}
|
||||||
|
stroke={BRAND_DARK}
|
||||||
|
strokeWidth={3}
|
||||||
|
dot={{ fill: BRAND_DARK, r: 4 }}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "area":
|
||||||
|
return (
|
||||||
|
<div className="h-64 min-h-[256px] w-full">
|
||||||
|
<ChartContainer
|
||||||
|
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
|
||||||
|
className="h-full w-full">
|
||||||
|
<AreaChart data={data}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey={xAxisKey} tickLine={false} tickMargin={10} axisLine={false} />
|
||||||
|
<YAxis tickLine={false} axisLine={false} />
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey={dataKey}
|
||||||
|
stroke={BRAND_DARK}
|
||||||
|
fill={BRAND_LIGHT}
|
||||||
|
fillOpacity={0.4}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "pie":
|
||||||
|
case "donut": {
|
||||||
|
if (!dataKey || !xAxisKey) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center text-gray-500">
|
||||||
|
Unable to determine chart data structure
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out rows where the dataKey value is null, undefined, or empty
|
||||||
|
const validData = data.filter((row) => {
|
||||||
|
const value = row[dataKey];
|
||||||
|
if (value === null || value === undefined || value === "") return false;
|
||||||
|
const numValue = Number(value);
|
||||||
|
return !Number.isNaN(numValue) && Number.isFinite(numValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert dataKey values to numbers for proper rendering
|
||||||
|
const processedData = validData.map((row) => ({
|
||||||
|
...row,
|
||||||
|
[dataKey]: Number(row[dataKey]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (processedData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center text-gray-500">No valid data to display</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate colors using Formbricks brand palette
|
||||||
|
const colors = processedData.map((_, index) => {
|
||||||
|
const hue = 180; // Teal base hue
|
||||||
|
const saturation = 70 + (index % 3) * 10; // Vary saturation
|
||||||
|
const lightness = 45 + (index % 2) * 15; // Vary lightness
|
||||||
|
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||||
|
});
|
||||||
|
// Use brand colors for first two slices
|
||||||
|
if (colors.length > 0) colors[0] = BRAND_DARK;
|
||||||
|
if (colors.length > 1) colors[1] = BRAND_LIGHT;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-64 min-h-[256px] w-full min-w-0">
|
||||||
|
<ChartContainer
|
||||||
|
config={{ [dataKey]: { label: dataKey, color: BRAND_DARK } }}
|
||||||
|
className="h-full w-full min-w-0">
|
||||||
|
<PieChart width={400} height={256}>
|
||||||
|
<Pie
|
||||||
|
data={processedData}
|
||||||
|
dataKey={dataKey}
|
||||||
|
nameKey={xAxisKey}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={80}
|
||||||
|
label={({ name, percent }) => {
|
||||||
|
if (!percent) return "";
|
||||||
|
return `${name}: ${(percent * 100).toFixed(0)}%`;
|
||||||
|
}}>
|
||||||
|
{processedData.map((row, index) => {
|
||||||
|
const rowKey = row[xAxisKey] ?? `row-${index}`;
|
||||||
|
const uniqueKey = `${xAxisKey}-${String(rowKey)}-${index}`;
|
||||||
|
return <Cell key={uniqueKey} fill={colors[index] || BRAND_DARK} />;
|
||||||
|
})}
|
||||||
|
</Pie>
|
||||||
|
<ChartTooltip
|
||||||
|
content={<ChartTooltipContent />}
|
||||||
|
formatter={(value: any, name: string) => {
|
||||||
|
const numValue = Number(value);
|
||||||
|
return [numValue.toLocaleString(), name];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "kpi":
|
||||||
|
case "big_number": {
|
||||||
|
const total = data.reduce((sum, row) => sum + (Number(row[dataKey]) || 0), 0);
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-4xl font-bold text-gray-900">{total.toLocaleString()}</div>
|
||||||
|
<div className="mt-2 text-sm text-gray-500">{dataKey}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center text-gray-500">
|
||||||
|
Chart type "{chartType}" not yet supported
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import { CHART_TYPES } from "../lib/chart-types";
|
||||||
|
|
||||||
|
interface ConfigureChartDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
currentChartType: string;
|
||||||
|
configuredChartType: string | null;
|
||||||
|
onChartTypeSelect: (type: string) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigureChartDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
currentChartType,
|
||||||
|
configuredChartType,
|
||||||
|
onChartTypeSelect,
|
||||||
|
onReset,
|
||||||
|
}: ConfigureChartDialogProps) {
|
||||||
|
const availableTypes = CHART_TYPES.filter((type) =>
|
||||||
|
["bar", "line", "area", "pie", "big_number"].includes(type.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Configure Chart</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Modify the chart type and other settings for this visualization.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-3 text-sm font-medium text-gray-900">Chart Type</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||||
|
{availableTypes.map((chart) => {
|
||||||
|
const isSelected = (configuredChartType || currentChartType) === chart.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={chart.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChartTypeSelect(chart.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-2 rounded-lg border p-4 transition-all hover:bg-gray-50",
|
||||||
|
isSelected
|
||||||
|
? "border-brand-dark bg-brand-dark/5 ring-brand-dark ring-2"
|
||||||
|
: "border-gray-200"
|
||||||
|
)}>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded bg-gray-100">
|
||||||
|
<chart.icon className="h-5 w-5 text-gray-600" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-gray-700">{chart.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onReset} className="text-xs">
|
||||||
|
Reset to AI suggestion
|
||||||
|
</Button>
|
||||||
|
{configuredChartType && (
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Original: {CHART_TYPES.find((t) => t.id === currentChartType)?.name || currentChartType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onOpenChange(false)}>Apply Changes</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { DatabaseIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface DataViewerProps {
|
||||||
|
data: Record<string, any>[];
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataViewer({ data }: Omit<DataViewerProps, "isOpen" | "onOpenChange">) {
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
|
<p className="text-sm text-gray-500">No data available</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = Object.keys(data[0]);
|
||||||
|
const displayData = data.slice(0, 50);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<DatabaseIcon className="h-4 w-4 text-gray-600" />
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900">Chart Data</h4>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-auto rounded bg-white">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-gray-100">
|
||||||
|
<tr>
|
||||||
|
{columns.map((key) => (
|
||||||
|
<th key={key} className="border-b border-gray-200 px-3 py-2 text-left font-semibold">
|
||||||
|
{key}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{displayData.map((row, index) => {
|
||||||
|
const rowKey = Object.values(row)[0] ? String(Object.values(row)[0]) : `row-${index}`;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={`data-row-${rowKey}-${index}`}
|
||||||
|
className="border-b border-gray-100 hover:bg-gray-50">
|
||||||
|
{Object.entries(row).map(([key, value]) => (
|
||||||
|
<td key={`cell-${key}-${rowKey}`} className="px-3 py-2">
|
||||||
|
{typeof value === "object" ? JSON.stringify(value) : String(value)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{data.length > 50 && (
|
||||||
|
<div className="px-3 py-2 text-xs text-gray-500">Showing first 50 of {data.length} rows</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||||
|
import { FEEDBACK_FIELDS } from "../lib/schema-definition";
|
||||||
|
|
||||||
|
interface DimensionsPanelProps {
|
||||||
|
selectedDimensions: string[];
|
||||||
|
onDimensionsChange: (dimensions: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DimensionsPanel({ selectedDimensions, onDimensionsChange }: DimensionsPanelProps) {
|
||||||
|
const dimensionOptions = FEEDBACK_FIELDS.dimensions.map((d) => ({
|
||||||
|
value: d.id,
|
||||||
|
label: `${d.label}${d.description ? ` - ${d.description}` : ""}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-medium text-gray-900">Dimensions</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Group By</label>
|
||||||
|
<MultiSelect
|
||||||
|
options={dimensionOptions}
|
||||||
|
value={selectedDimensions}
|
||||||
|
onChange={onDimensionsChange}
|
||||||
|
placeholder="Select dimensions to group by..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Select dimensions to break down your data. The order matters for multi-dimensional charts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, TrashIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/modules/ui/components/select";
|
||||||
|
import { FilterRow } from "../lib/query-builder";
|
||||||
|
import { FEEDBACK_FIELDS, getFieldById, getFilterOperatorsForType } from "../lib/schema-definition";
|
||||||
|
|
||||||
|
interface FiltersPanelProps {
|
||||||
|
filters: FilterRow[];
|
||||||
|
filterLogic: "and" | "or";
|
||||||
|
onFiltersChange: (filters: FilterRow[]) => void;
|
||||||
|
onFilterLogicChange: (logic: "and" | "or") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FiltersPanel({
|
||||||
|
filters,
|
||||||
|
filterLogic,
|
||||||
|
onFiltersChange,
|
||||||
|
onFilterLogicChange,
|
||||||
|
}: FiltersPanelProps) {
|
||||||
|
const fieldOptions = [
|
||||||
|
...FEEDBACK_FIELDS.dimensions.map((d) => ({
|
||||||
|
value: d.id,
|
||||||
|
label: d.label,
|
||||||
|
type: d.type,
|
||||||
|
})),
|
||||||
|
...FEEDBACK_FIELDS.measures.map((m) => ({
|
||||||
|
value: m.id,
|
||||||
|
label: m.label,
|
||||||
|
type: m.type === "count" ? "number" : "number",
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleAddFilter = () => {
|
||||||
|
const firstField = fieldOptions[0];
|
||||||
|
onFiltersChange([
|
||||||
|
...filters,
|
||||||
|
{
|
||||||
|
field: firstField?.value || "",
|
||||||
|
operator: "equals",
|
||||||
|
values: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFilter = (index: number) => {
|
||||||
|
onFiltersChange(filters.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateFilter = (index: number, updates: Partial<FilterRow>) => {
|
||||||
|
const updated = [...filters];
|
||||||
|
updated[index] = { ...updated[index], ...updates };
|
||||||
|
// Reset values if operator changed to set/notSet
|
||||||
|
if (updates.operator && (updates.operator === "set" || updates.operator === "notSet")) {
|
||||||
|
updated[index].values = null;
|
||||||
|
}
|
||||||
|
onFiltersChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValueInput = (filter: FilterRow, index: number) => {
|
||||||
|
const field = getFieldById(filter.field);
|
||||||
|
const fieldType = field?.type || "string";
|
||||||
|
const operators = getFilterOperatorsForType(fieldType as "string" | "number" | "time");
|
||||||
|
|
||||||
|
// For set/notSet operators, no value input needed
|
||||||
|
if (filter.operator === "set" || filter.operator === "notSet") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For number fields with comparison operators, use number input
|
||||||
|
if (
|
||||||
|
fieldType === "number" &&
|
||||||
|
(filter.operator === "gt" ||
|
||||||
|
filter.operator === "gte" ||
|
||||||
|
filter.operator === "lt" ||
|
||||||
|
filter.operator === "lte")
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Enter value"
|
||||||
|
value={filter.values?.[0] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateFilter(index, {
|
||||||
|
values: e.target.value ? [Number(e.target.value)] : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-[150px]"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For equals/notEquals with string fields, allow single value
|
||||||
|
if ((filter.operator === "equals" || filter.operator === "notEquals") && fieldType === "string") {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
placeholder="Enter value"
|
||||||
|
value={filter.values?.[0] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateFilter(index, {
|
||||||
|
values: e.target.value ? [e.target.value] : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-[200px]"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For contains/notContains, allow multiple values (multi-select)
|
||||||
|
if (filter.operator === "contains" || filter.operator === "notContains") {
|
||||||
|
// For now, use a simple input - could be enhanced with multi-select
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
placeholder="Enter value"
|
||||||
|
value={filter.values?.[0] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateFilter(index, {
|
||||||
|
values: e.target.value ? [e.target.value] : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-[200px]"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: single value input
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
placeholder="Enter value"
|
||||||
|
value={filter.values?.[0] || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdateFilter(index, {
|
||||||
|
values: e.target.value ? [e.target.value] : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-[200px]"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium text-gray-900">Filters</h3>
|
||||||
|
<Select value={filterLogic} onValueChange={(value) => onFilterLogicChange(value as "and" | "or")}>
|
||||||
|
<SelectTrigger className="w-[100px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="and">AND</SelectItem>
|
||||||
|
<SelectItem value="or">OR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filters.map((filter, index) => {
|
||||||
|
const field = getFieldById(filter.field);
|
||||||
|
const fieldType = field?.type || "string";
|
||||||
|
const operators = getFilterOperatorsForType(fieldType as "string" | "number" | "time");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-3">
|
||||||
|
<Select
|
||||||
|
value={filter.field}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newField = getFieldById(value);
|
||||||
|
const newType = newField?.type || "string";
|
||||||
|
const newOperators = getFilterOperatorsForType(newType as "string" | "number" | "time");
|
||||||
|
handleUpdateFilter(index, {
|
||||||
|
field: value,
|
||||||
|
operator: newOperators[0] || "equals",
|
||||||
|
values: null,
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<SelectValue placeholder="Select field" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fieldOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filter.operator}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleUpdateFilter(index, {
|
||||||
|
operator: value as FilterRow["operator"],
|
||||||
|
})
|
||||||
|
}>
|
||||||
|
<SelectTrigger className="w-[150px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{operators.map((op) => (
|
||||||
|
<SelectItem key={op} value={op}>
|
||||||
|
{op === "equals" && "equals"}
|
||||||
|
{op === "notEquals" && "not equals"}
|
||||||
|
{op === "contains" && "contains"}
|
||||||
|
{op === "notContains" && "not contains"}
|
||||||
|
{op === "set" && "is set"}
|
||||||
|
{op === "notSet" && "is not set"}
|
||||||
|
{op === "gt" && "greater than"}
|
||||||
|
{op === "gte" && "greater than or equal"}
|
||||||
|
{op === "lt" && "less than"}
|
||||||
|
{op === "lte" && "less than or equal"}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{getValueInput(filter, index)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleRemoveFilter(index)}
|
||||||
|
className="h-8 w-8">
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleAddFilter} className="h-8">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { CHART_TYPES } from "../lib/chart-types";
|
||||||
|
|
||||||
|
interface ManualChartBuilderProps {
|
||||||
|
selectedChartType: string;
|
||||||
|
onChartTypeSelect: (type: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out table, map, and scatter charts
|
||||||
|
const AVAILABLE_CHART_TYPES = CHART_TYPES.filter(
|
||||||
|
(type) => !["table", "map", "scatter"].includes(type.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
export function ManualChartBuilder({
|
||||||
|
selectedChartType,
|
||||||
|
onChartTypeSelect,
|
||||||
|
}: Omit<ManualChartBuilderProps, "onCreate">) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="font-medium text-gray-900">Choose chart type</h2>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||||
|
{AVAILABLE_CHART_TYPES.map((chart) => {
|
||||||
|
const isSelected = selectedChartType === chart.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={chart.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChartTypeSelect(chart.id)}
|
||||||
|
className={cn(
|
||||||
|
"focus:ring-brand-dark rounded-md border p-4 text-center transition-all hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2",
|
||||||
|
isSelected
|
||||||
|
? "border-brand-dark ring-brand-dark bg-brand-dark/5 ring-1"
|
||||||
|
: "border-gray-200 hover:border-gray-300"
|
||||||
|
)}>
|
||||||
|
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded bg-gray-100">
|
||||||
|
<chart.icon className="h-6 w-6 text-gray-600" strokeWidth={1.5} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-gray-700">{chart.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus, TrashIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/modules/ui/components/select";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { FEEDBACK_FIELDS } from "../lib/schema-definition";
|
||||||
|
import { CustomMeasure } from "../lib/query-builder";
|
||||||
|
|
||||||
|
interface MeasuresPanelProps {
|
||||||
|
selectedMeasures: string[];
|
||||||
|
customMeasures: CustomMeasure[];
|
||||||
|
onMeasuresChange: (measures: string[]) => void;
|
||||||
|
onCustomMeasuresChange: (measures: CustomMeasure[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MeasuresPanel({
|
||||||
|
selectedMeasures,
|
||||||
|
customMeasures,
|
||||||
|
onMeasuresChange,
|
||||||
|
onCustomMeasuresChange,
|
||||||
|
}: MeasuresPanelProps) {
|
||||||
|
const measureOptions = FEEDBACK_FIELDS.measures.map((m) => ({
|
||||||
|
value: m.id,
|
||||||
|
label: `${m.label}${m.description ? ` - ${m.description}` : ""}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dimensionOptions = FEEDBACK_FIELDS.dimensions
|
||||||
|
.filter((d) => d.type === "number")
|
||||||
|
.map((d) => ({
|
||||||
|
value: d.id,
|
||||||
|
label: d.label,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const aggregationOptions = FEEDBACK_FIELDS.customAggregations.map((agg) => ({
|
||||||
|
value: agg,
|
||||||
|
label: agg.charAt(0).toUpperCase() + agg.slice(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleAddCustomMeasure = () => {
|
||||||
|
onCustomMeasuresChange([
|
||||||
|
...customMeasures,
|
||||||
|
{
|
||||||
|
field: dimensionOptions[0]?.value || "",
|
||||||
|
aggregation: "avg",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCustomMeasure = (index: number) => {
|
||||||
|
onCustomMeasuresChange(customMeasures.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateCustomMeasure = (index: number, updates: Partial<CustomMeasure>) => {
|
||||||
|
const updated = [...customMeasures];
|
||||||
|
updated[index] = { ...updated[index], ...updates };
|
||||||
|
onCustomMeasuresChange(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-medium text-gray-900">Measures</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Predefined Measures */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Predefined Measures</label>
|
||||||
|
<MultiSelect
|
||||||
|
options={measureOptions}
|
||||||
|
value={selectedMeasures}
|
||||||
|
onChange={(selected) => onMeasuresChange(selected)}
|
||||||
|
placeholder="Select measures..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Measures */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Custom Aggregations</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddCustomMeasure}
|
||||||
|
className="h-8">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Custom Measure
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customMeasures.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{customMeasures.map((measure, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white p-3">
|
||||||
|
<Select
|
||||||
|
value={measure.field}
|
||||||
|
onValueChange={(value) => handleUpdateCustomMeasure(index, { field: value })}>
|
||||||
|
<SelectTrigger className="w-[200px]">
|
||||||
|
<SelectValue placeholder="Select field" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{dimensionOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={measure.aggregation}
|
||||||
|
onValueChange={(value) => handleUpdateCustomMeasure(index, { aggregation: value })}>
|
||||||
|
<SelectTrigger className="w-[150px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{aggregationOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Alias (optional)"
|
||||||
|
value={measure.alias || ""}
|
||||||
|
onChange={(e) => handleUpdateCustomMeasure(index, { alias: e.target.value })}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleRemoveCustomMeasure(index)}
|
||||||
|
className="h-8 w-8">
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CodeIcon } from "lucide-react";
|
||||||
|
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||||
|
|
||||||
|
interface QueryViewerProps {
|
||||||
|
query: Record<string, any>;
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QueryViewer({ query, isOpen, onOpenChange }: QueryViewerProps) {
|
||||||
|
return (
|
||||||
|
<Collapsible.Root open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<Collapsible.CollapsibleContent className="mt-4">
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<CodeIcon className="h-4 w-4 text-gray-600" />
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900">Cube.js Query</h4>
|
||||||
|
</div>
|
||||||
|
<pre className="max-h-64 overflow-auto rounded bg-white p-3 text-xs">
|
||||||
|
{JSON.stringify(query, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</Collapsible.CollapsibleContent>
|
||||||
|
</Collapsible.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Dialog, DialogBody, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
|
||||||
|
interface SaveChartDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
chartName: string;
|
||||||
|
onChartNameChange: (name: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
isSaving: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveChartDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
chartName,
|
||||||
|
onChartNameChange,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
}: SaveChartDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Save Chart</DialogTitle>
|
||||||
|
<DialogDescription>Enter a name for your chart to save it.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
<Input
|
||||||
|
placeholder="Chart name"
|
||||||
|
value={chartName}
|
||||||
|
onChange={(e) => onChartNameChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && chartName.trim() && !isSaving) {
|
||||||
|
onSave();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onSave} loading={isSaving} disabled={!chartName.trim()}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CalendarIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/modules/ui/components/select";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||||
|
import Calendar from "react-calendar";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { FEEDBACK_FIELDS, TIME_GRANULARITIES, DATE_PRESETS } from "../lib/schema-definition";
|
||||||
|
import { TimeDimensionConfig, getDateRangeFromPreset } from "../lib/query-builder";
|
||||||
|
import "@/modules/ui/components/date-picker/styles.css";
|
||||||
|
|
||||||
|
interface TimeDimensionPanelProps {
|
||||||
|
timeDimension: TimeDimensionConfig | null;
|
||||||
|
onTimeDimensionChange: (config: TimeDimensionConfig | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeDimensionPanel({
|
||||||
|
timeDimension,
|
||||||
|
onTimeDimensionChange,
|
||||||
|
}: TimeDimensionPanelProps) {
|
||||||
|
const [dateRangeType, setDateRangeType] = useState<"preset" | "custom">(
|
||||||
|
timeDimension && typeof timeDimension.dateRange === "string" ? "preset" : "custom"
|
||||||
|
);
|
||||||
|
const [customStartDate, setCustomStartDate] = useState<Date | null>(
|
||||||
|
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[0] : null
|
||||||
|
);
|
||||||
|
const [customEndDate, setCustomEndDate] = useState<Date | null>(
|
||||||
|
timeDimension && Array.isArray(timeDimension.dateRange) ? timeDimension.dateRange[1] : null
|
||||||
|
);
|
||||||
|
const [presetValue, setPresetValue] = useState<string>(
|
||||||
|
timeDimension && typeof timeDimension.dateRange === "string" ? timeDimension.dateRange : ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeFieldOptions = FEEDBACK_FIELDS.dimensions.filter((d) => d.type === "time");
|
||||||
|
|
||||||
|
const handleEnableTimeDimension = () => {
|
||||||
|
if (!timeDimension) {
|
||||||
|
onTimeDimensionChange({
|
||||||
|
dimension: "FeedbackRecords.collectedAt",
|
||||||
|
granularity: "day",
|
||||||
|
dateRange: "last 30 days",
|
||||||
|
});
|
||||||
|
setPresetValue("last 30 days");
|
||||||
|
setDateRangeType("preset");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisableTimeDimension = () => {
|
||||||
|
onTimeDimensionChange(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDimensionChange = (dimension: string) => {
|
||||||
|
if (timeDimension) {
|
||||||
|
onTimeDimensionChange({ ...timeDimension, dimension });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGranularityChange = (granularity: TimeDimensionConfig["granularity"]) => {
|
||||||
|
if (timeDimension) {
|
||||||
|
onTimeDimensionChange({ ...timeDimension, granularity });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePresetChange = (preset: string) => {
|
||||||
|
setPresetValue(preset);
|
||||||
|
if (timeDimension) {
|
||||||
|
onTimeDimensionChange({ ...timeDimension, dateRange: preset });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomDateChange = () => {
|
||||||
|
if (customStartDate && customEndDate && timeDimension) {
|
||||||
|
onTimeDimensionChange({
|
||||||
|
...timeDimension,
|
||||||
|
dateRange: [customStartDate, customEndDate],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!timeDimension) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-medium text-gray-900">Time Dimension</h3>
|
||||||
|
<div>
|
||||||
|
<Button type="button" variant="outline" onClick={handleEnableTimeDimension}>
|
||||||
|
Enable Time Dimension
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-medium text-gray-900">Time Dimension</h3>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={handleDisableTimeDimension}>
|
||||||
|
Disable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Field Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Field</label>
|
||||||
|
<Select value={timeDimension.dimension} onValueChange={handleDimensionChange}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{timeFieldOptions.map((field) => (
|
||||||
|
<SelectItem key={field.id} value={field.id}>
|
||||||
|
{field.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Granularity Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Granularity</label>
|
||||||
|
<Select
|
||||||
|
value={timeDimension.granularity}
|
||||||
|
onValueChange={(value) => handleGranularityChange(value as TimeDimensionConfig["granularity"])}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TIME_GRANULARITIES.map((gran) => (
|
||||||
|
<SelectItem key={gran} value={gran}>
|
||||||
|
{gran.charAt(0).toUpperCase() + gran.slice(1)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Range */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Date Range</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Select value={dateRangeType} onValueChange={(value) => setDateRangeType(value as "preset" | "custom")}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="preset">Preset</SelectItem>
|
||||||
|
<SelectItem value="custom">Custom Range</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{dateRangeType === "preset" ? (
|
||||||
|
<Select value={presetValue} onValueChange={handlePresetChange}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select preset" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DATE_PRESETS.map((preset) => (
|
||||||
|
<SelectItem key={preset.value} value={preset.value}>
|
||||||
|
{preset.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-full justify-start text-left font-normal">
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{customStartDate ? format(customStartDate, "MMM dd, yyyy") : "Start date"}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
onChange={(date: Date) => {
|
||||||
|
setCustomStartDate(date);
|
||||||
|
if (date && customEndDate) {
|
||||||
|
handleCustomDateChange();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={customStartDate || undefined}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-full justify-start text-left font-normal">
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{customEndDate ? format(customEndDate, "MMM dd, yyyy") : "End date"}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
onChange={(date: Date) => {
|
||||||
|
setCustomEndDate(date);
|
||||||
|
if (customStartDate && date) {
|
||||||
|
handleCustomDateChange();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={customEndDate || undefined}
|
||||||
|
minDate={customStartDate || undefined}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { ChartRenderer } from "./ChartRenderer";
|
||||||
|
export { QueryViewer } from "./QueryViewer";
|
||||||
|
export { DataViewer } from "./DataViewer";
|
||||||
|
export { AIQuerySection } from "./AIQuerySection";
|
||||||
|
export { ManualChartBuilder } from "./ManualChartBuilder";
|
||||||
|
export { ChartPreview } from "./ChartPreview";
|
||||||
|
export { SaveChartDialog } from "./SaveChartDialog";
|
||||||
|
export { AddToDashboardDialog } from "./AddToDashboardDialog";
|
||||||
|
export { ConfigureChartDialog } from "./ConfigureChartDialog";
|
||||||
|
export { ChartBuilderClient } from "./ChartBuilderClient";
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
AreaChartIcon,
|
||||||
|
BarChart3Icon,
|
||||||
|
LineChartIcon,
|
||||||
|
MapIcon,
|
||||||
|
PieChartIcon,
|
||||||
|
ScatterChart,
|
||||||
|
TableIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export const CHART_TYPES = [
|
||||||
|
{ id: "area", name: "Area Chart", icon: AreaChartIcon },
|
||||||
|
{ id: "bar", name: "Bar Chart", icon: BarChart3Icon },
|
||||||
|
{ id: "line", name: "Line Chart", icon: LineChartIcon },
|
||||||
|
{ id: "pie", name: "Pie Chart", icon: PieChartIcon },
|
||||||
|
{ id: "table", name: "Table", icon: TableIcon },
|
||||||
|
{ id: "big_number", name: "Big Number", icon: ActivityIcon },
|
||||||
|
{ id: "scatter", name: "Scatter Plot", icon: ScatterChart },
|
||||||
|
{ id: "map", name: "World Map", icon: MapIcon },
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Chart type mapping from API to database
|
||||||
|
export const mapChartType = (
|
||||||
|
apiType: string
|
||||||
|
): "area" | "bar" | "line" | "pie" | "big_number" | "big_number_total" | "table" | "funnel" | "map" => {
|
||||||
|
const mapping: Record<
|
||||||
|
string,
|
||||||
|
"area" | "bar" | "line" | "pie" | "big_number" | "big_number_total" | "table" | "funnel" | "map"
|
||||||
|
> = {
|
||||||
|
bar: "bar",
|
||||||
|
line: "line",
|
||||||
|
area: "area",
|
||||||
|
pie: "pie",
|
||||||
|
donut: "pie",
|
||||||
|
kpi: "big_number",
|
||||||
|
};
|
||||||
|
return mapping[apiType] || "bar";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reverse mapping from database chart type to API chart type
|
||||||
|
export const mapDatabaseChartTypeToApi = (
|
||||||
|
dbType: string
|
||||||
|
): "bar" | "line" | "donut" | "kpi" | "area" | "pie" => {
|
||||||
|
const mapping: Record<string, "bar" | "line" | "donut" | "kpi" | "area" | "pie"> = {
|
||||||
|
bar: "bar",
|
||||||
|
line: "line",
|
||||||
|
area: "area",
|
||||||
|
pie: "pie",
|
||||||
|
big_number: "kpi",
|
||||||
|
big_number_total: "kpi",
|
||||||
|
table: "bar", // Default fallback
|
||||||
|
funnel: "bar", // Default fallback
|
||||||
|
map: "bar", // Default fallback
|
||||||
|
};
|
||||||
|
return mapping[dbType] || "bar";
|
||||||
|
};
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* Query builder utility to construct Cube.js queries from chart builder state
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CubeQuery, TimeDimension, Filter } from "@/app/api/analytics/_lib/types";
|
||||||
|
|
||||||
|
export interface CustomMeasure {
|
||||||
|
field: string; // e.g., "FeedbackRecords.npsValue"
|
||||||
|
aggregation: string; // e.g., "avg", "sum", "countDistinct"
|
||||||
|
alias?: string; // optional display name
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterRow {
|
||||||
|
field: string;
|
||||||
|
operator: Filter["operator"];
|
||||||
|
values: string[] | number[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeDimensionConfig {
|
||||||
|
dimension: string;
|
||||||
|
granularity: "hour" | "day" | "week" | "month" | "quarter" | "year";
|
||||||
|
dateRange: string | [Date, Date]; // "last 7 days" or [startDate, endDate]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartBuilderState {
|
||||||
|
chartType: string;
|
||||||
|
selectedMeasures: string[];
|
||||||
|
customMeasures: CustomMeasure[];
|
||||||
|
selectedDimensions: string[];
|
||||||
|
filters: FilterRow[];
|
||||||
|
filterLogic: "and" | "or";
|
||||||
|
timeDimension: TimeDimensionConfig | null;
|
||||||
|
limit?: number;
|
||||||
|
orderBy?: { field: string; direction: "asc" | "desc" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Cube.js query from chart builder state
|
||||||
|
*/
|
||||||
|
export function buildCubeQuery(config: ChartBuilderState): CubeQuery {
|
||||||
|
const query: CubeQuery = {
|
||||||
|
measures: [
|
||||||
|
...config.selectedMeasures,
|
||||||
|
// Custom measures would need to be handled differently in Cube.js
|
||||||
|
// For now, we'll just include the predefined measures
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.selectedDimensions.length > 0) {
|
||||||
|
query.dimensions = config.selectedDimensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.timeDimension) {
|
||||||
|
const timeDim: TimeDimension = {
|
||||||
|
dimension: config.timeDimension.dimension,
|
||||||
|
granularity: config.timeDimension.granularity,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle date range
|
||||||
|
if (typeof config.timeDimension.dateRange === "string") {
|
||||||
|
timeDim.dateRange = config.timeDimension.dateRange;
|
||||||
|
} else if (Array.isArray(config.timeDimension.dateRange)) {
|
||||||
|
// Convert Date objects to ISO strings (Cube.js expects YYYY-MM-DD format or ISO strings)
|
||||||
|
const [startDate, endDate] = config.timeDimension.dateRange;
|
||||||
|
// Format as YYYY-MM-DD for better compatibility
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
timeDim.dateRange = [formatDate(startDate), formatDate(endDate)];
|
||||||
|
}
|
||||||
|
|
||||||
|
query.timeDimensions = [timeDim];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.filters.length > 0) {
|
||||||
|
query.filters = config.filters.map((f) => {
|
||||||
|
const filter: Filter = {
|
||||||
|
member: f.field,
|
||||||
|
operator: f.operator,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include values if operator requires them
|
||||||
|
if (f.operator !== "set" && f.operator !== "notSet" && f.values) {
|
||||||
|
filter.values = f.values.map((v) => String(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Cube.js query back into ChartBuilderState
|
||||||
|
*/
|
||||||
|
export function parseQueryToState(query: CubeQuery, chartType?: string): Partial<ChartBuilderState> {
|
||||||
|
const state: Partial<ChartBuilderState> = {
|
||||||
|
chartType: chartType || "",
|
||||||
|
selectedMeasures: query.measures || [],
|
||||||
|
customMeasures: [],
|
||||||
|
selectedDimensions: query.dimensions || [],
|
||||||
|
filters: [],
|
||||||
|
filterLogic: "and",
|
||||||
|
timeDimension: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse filters
|
||||||
|
if (query.filters && query.filters.length > 0) {
|
||||||
|
state.filters = query.filters.map((f) => ({
|
||||||
|
field: f.member,
|
||||||
|
operator: f.operator,
|
||||||
|
values: f.values || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse time dimensions
|
||||||
|
if (query.timeDimensions && query.timeDimensions.length > 0) {
|
||||||
|
const timeDim = query.timeDimensions[0];
|
||||||
|
state.timeDimension = {
|
||||||
|
dimension: timeDim.dimension,
|
||||||
|
granularity: (timeDim.granularity || "day") as TimeDimensionConfig["granularity"],
|
||||||
|
dateRange: timeDim.dateRange || "last 30 days",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert date preset string to date range
|
||||||
|
*/
|
||||||
|
export function getDateRangeFromPreset(preset: string): [Date, Date] | null {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
|
switch (preset) {
|
||||||
|
case "today": {
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
return [today, tomorrow];
|
||||||
|
}
|
||||||
|
case "yesterday": {
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
return [yesterday, today];
|
||||||
|
}
|
||||||
|
case "last 7 days": {
|
||||||
|
const sevenDaysAgo = new Date(today);
|
||||||
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||||
|
return [sevenDaysAgo, today];
|
||||||
|
}
|
||||||
|
case "last 30 days": {
|
||||||
|
const thirtyDaysAgo = new Date(today);
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
return [thirtyDaysAgo, today];
|
||||||
|
}
|
||||||
|
case "this month": {
|
||||||
|
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
return [firstDay, lastDay];
|
||||||
|
}
|
||||||
|
case "last month": {
|
||||||
|
const firstDayLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||||
|
const firstDayThisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
return [firstDayLastMonth, firstDayThisMonth];
|
||||||
|
}
|
||||||
|
case "this quarter": {
|
||||||
|
const quarter = Math.floor(now.getMonth() / 3);
|
||||||
|
const firstDay = new Date(now.getFullYear(), quarter * 3, 1);
|
||||||
|
const lastDay = new Date(now.getFullYear(), (quarter + 1) * 3, 1);
|
||||||
|
return [firstDay, lastDay];
|
||||||
|
}
|
||||||
|
case "this year": {
|
||||||
|
const firstDay = new Date(now.getFullYear(), 0, 1);
|
||||||
|
const lastDay = new Date(now.getFullYear() + 1, 0, 1);
|
||||||
|
return [firstDay, lastDay];
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* Schema definitions for FeedbackRecords fields
|
||||||
|
* Used by the advanced chart builder to provide field metadata and operators
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FieldDefinition {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: "string" | "number" | "time";
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeasureDefinition {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: "count" | "number";
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FEEDBACK_FIELDS = {
|
||||||
|
dimensions: [
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.sentiment",
|
||||||
|
label: "Sentiment",
|
||||||
|
type: "string",
|
||||||
|
description: "Sentiment extracted from feedback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.sourceType",
|
||||||
|
label: "Source Type",
|
||||||
|
type: "string",
|
||||||
|
description: "Source type of the feedback (e.g., nps_campaign, survey)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.sourceName",
|
||||||
|
label: "Source Name",
|
||||||
|
type: "string",
|
||||||
|
description: "Human-readable name of the source",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.fieldType",
|
||||||
|
label: "Field Type",
|
||||||
|
type: "string",
|
||||||
|
description: "Type of feedback field (e.g., nps, text, rating)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.emotion",
|
||||||
|
label: "Emotion",
|
||||||
|
type: "string",
|
||||||
|
description: "Emotion extracted from metadata JSONB field",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.userIdentifier",
|
||||||
|
label: "User Identifier",
|
||||||
|
type: "string",
|
||||||
|
description: "Identifier of the user who provided feedback",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.responseId",
|
||||||
|
label: "Response ID",
|
||||||
|
type: "string",
|
||||||
|
description: "Unique identifier linking related feedback records",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.npsValue",
|
||||||
|
label: "NPS Value",
|
||||||
|
type: "number",
|
||||||
|
description: "Raw NPS score value (0-10)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.collectedAt",
|
||||||
|
label: "Collected At",
|
||||||
|
type: "time",
|
||||||
|
description: "Timestamp when the feedback was collected",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "TopicsUnnested.topic",
|
||||||
|
label: "Topic",
|
||||||
|
type: "string",
|
||||||
|
description: "Individual topic from the topics array",
|
||||||
|
},
|
||||||
|
] as FieldDefinition[],
|
||||||
|
measures: [
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.count",
|
||||||
|
label: "Count",
|
||||||
|
type: "count",
|
||||||
|
description: "Total number of feedback responses",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.promoterCount",
|
||||||
|
label: "Promoter Count",
|
||||||
|
type: "count",
|
||||||
|
description: "Number of promoters (NPS score 9-10)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.detractorCount",
|
||||||
|
label: "Detractor Count",
|
||||||
|
type: "count",
|
||||||
|
description: "Number of detractors (NPS score 0-6)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.passiveCount",
|
||||||
|
label: "Passive Count",
|
||||||
|
type: "count",
|
||||||
|
description: "Number of passives (NPS score 7-8)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.npsScore",
|
||||||
|
label: "NPS Score",
|
||||||
|
type: "number",
|
||||||
|
description: "Net Promoter Score: ((Promoters - Detractors) / Total) * 100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "FeedbackRecords.averageScore",
|
||||||
|
label: "Average Score",
|
||||||
|
type: "number",
|
||||||
|
description: "Average NPS score",
|
||||||
|
},
|
||||||
|
] as MeasureDefinition[],
|
||||||
|
customAggregations: ["count", "countDistinct", "sum", "avg", "min", "max"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FilterOperator =
|
||||||
|
| "equals"
|
||||||
|
| "notEquals"
|
||||||
|
| "contains"
|
||||||
|
| "notContains"
|
||||||
|
| "set"
|
||||||
|
| "notSet"
|
||||||
|
| "gt"
|
||||||
|
| "gte"
|
||||||
|
| "lt"
|
||||||
|
| "lte";
|
||||||
|
|
||||||
|
export const FILTER_OPERATORS: Record<string, FilterOperator[]> = {
|
||||||
|
string: ["equals", "notEquals", "contains", "notContains", "set", "notSet"],
|
||||||
|
number: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
|
||||||
|
time: ["equals", "notEquals", "gt", "gte", "lt", "lte", "set", "notSet"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TIME_GRANULARITIES = ["hour", "day", "week", "month", "quarter", "year"] as const;
|
||||||
|
|
||||||
|
export type TimeGranularity = (typeof TIME_GRANULARITIES)[number];
|
||||||
|
|
||||||
|
export const DATE_PRESETS = [
|
||||||
|
{ label: "Today", value: "today" },
|
||||||
|
{ label: "Yesterday", value: "yesterday" },
|
||||||
|
{ label: "Last 7 days", value: "last 7 days" },
|
||||||
|
{ label: "Last 30 days", value: "last 30 days" },
|
||||||
|
{ label: "This month", value: "this month" },
|
||||||
|
{ label: "Last month", value: "last month" },
|
||||||
|
{ label: "This quarter", value: "this quarter" },
|
||||||
|
{ label: "This year", value: "this year" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filter operators for a given field type
|
||||||
|
*/
|
||||||
|
export function getFilterOperatorsForType(type: "string" | "number" | "time"): FilterOperator[] {
|
||||||
|
return FILTER_OPERATORS[type] || FILTER_OPERATORS.string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get field definition by ID
|
||||||
|
*/
|
||||||
|
export function getFieldById(id: string): FieldDefinition | MeasureDefinition | undefined {
|
||||||
|
const dimension = FEEDBACK_FIELDS.dimensions.find((d) => d.id === id);
|
||||||
|
if (dimension) return dimension;
|
||||||
|
return FEEDBACK_FIELDS.measures.find((m) => m.id === id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { TChart } from "../../types/analysis";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
|
|
||||||
|
interface ChartDropdownMenuProps {
|
||||||
|
environmentId: string;
|
||||||
|
chart: TChart;
|
||||||
|
disabled?: boolean;
|
||||||
|
deleteChart: (chartId: string) => void;
|
||||||
|
onEdit?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChartDropdownMenu = ({
|
||||||
|
environmentId,
|
||||||
|
chart,
|
||||||
|
disabled,
|
||||||
|
deleteChart,
|
||||||
|
onEdit,
|
||||||
|
}: ChartDropdownMenuProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleDeleteChart = async (chartId: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// TODO: Implement deleteChartAction
|
||||||
|
// await deleteChartAction({ chartId });
|
||||||
|
deleteChart(chartId);
|
||||||
|
toast.success("Chart deleted successfully");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Error deleting chart");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={`${chart.name.toLowerCase().split(" ").join("-")}-chart-actions`}
|
||||||
|
data-testid="chart-dropdown-menu">
|
||||||
|
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||||
|
<DropdownMenuTrigger className="z-10" asChild disabled={disabled}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-white p-2",
|
||||||
|
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-slate-50"
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
<span className="sr-only">Open options</span>
|
||||||
|
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="inline-block w-auto min-w-max">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDropDownOpen(false);
|
||||||
|
onEdit?.();
|
||||||
|
}}>
|
||||||
|
<SquarePenIcon className="mr-2 size-4" />
|
||||||
|
{t("common.edit")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDropDownOpen(false);
|
||||||
|
// TODO: Implement duplicate functionality
|
||||||
|
toast.success("Duplicate functionality coming soon");
|
||||||
|
}}>
|
||||||
|
<CopyIcon className="mr-2 h-4 w-4" />
|
||||||
|
{t("common.duplicate")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDropDownOpen(false);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}}>
|
||||||
|
<TrashIcon className="mr-2 h-4 w-4" />
|
||||||
|
{t("common.delete")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<DeleteDialog
|
||||||
|
deleteWhat="Chart"
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
setOpen={setDeleteDialogOpen}
|
||||||
|
onDelete={() => handleDeleteChart(chart.id)}
|
||||||
|
text="Are you sure you want to delete this chart? This action cannot be undone."
|
||||||
|
isDeleting={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
AreaChartIcon,
|
||||||
|
BarChart3Icon,
|
||||||
|
LineChartIcon,
|
||||||
|
MapIcon,
|
||||||
|
PieChartIcon,
|
||||||
|
ScatterChart,
|
||||||
|
TableIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { TChart } from "../../types/analysis";
|
||||||
|
import { ChartDropdownMenu } from "./ChartDropdownMenu";
|
||||||
|
import { CreateChartDialog } from "./CreateChartDialog";
|
||||||
|
|
||||||
|
interface ChartsListClientProps {
|
||||||
|
charts: TChart[];
|
||||||
|
dashboards: any[];
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHART_TYPE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
area: AreaChartIcon,
|
||||||
|
bar: BarChart3Icon,
|
||||||
|
line: LineChartIcon,
|
||||||
|
pie: PieChartIcon,
|
||||||
|
table: TableIcon,
|
||||||
|
big_number: ActivityIcon,
|
||||||
|
big_number_total: ActivityIcon,
|
||||||
|
scatter: ScatterChart,
|
||||||
|
map: MapIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChartsListClient({ charts: initialCharts, dashboards, environmentId }: ChartsListClientProps) {
|
||||||
|
const [charts, setCharts] = useState(initialCharts);
|
||||||
|
const [editingChartId, setEditingChartId] = useState<string | undefined>(undefined);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const filteredCharts = charts;
|
||||||
|
|
||||||
|
const deleteChart = (chartId: string) => {
|
||||||
|
setCharts(charts.filter((c) => c.id !== chartId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChartIcon = (type: string) => {
|
||||||
|
const IconComponent = CHART_TYPE_ICONS[type] || BarChart3Icon;
|
||||||
|
return <IconComponent className="h-5 w-5" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChartClick = (chartId: string) => {
|
||||||
|
setEditingChartId(chartId);
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSuccess = () => {
|
||||||
|
// Refresh charts list if needed
|
||||||
|
setIsEditDialogOpen(false);
|
||||||
|
setEditingChartId(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="grid h-12 grid-cols-7 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||||
|
<div className="col-span-3 pl-6">Title</div>
|
||||||
|
<div className="col-span-1 hidden text-center sm:block">Created By</div>
|
||||||
|
<div className="col-span-1 hidden text-center sm:block">Created</div>
|
||||||
|
<div className="col-span-1 hidden text-center sm:block">Updated</div>
|
||||||
|
<div className="col-span-1"></div>
|
||||||
|
</div>
|
||||||
|
{filteredCharts.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-slate-400">No charts found.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{filteredCharts.map((chart) => (
|
||||||
|
<div
|
||||||
|
key={chart.id}
|
||||||
|
onClick={() => handleChartClick(chart.id)}
|
||||||
|
className="grid h-12 w-full cursor-pointer grid-cols-7 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-100">
|
||||||
|
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
|
||||||
|
{getChartIcon(chart.type)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="ph-no-capture font-medium text-slate-900">{chart.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap text-slate-500 sm:block">
|
||||||
|
<div className="ph-no-capture text-slate-900">{chart.createdByName || "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-normal text-slate-500 sm:block">
|
||||||
|
<div className="ph-no-capture text-slate-900">
|
||||||
|
{format(new Date(chart.createdAt), "do 'of' MMMM, yyyy")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
|
||||||
|
<div className="ph-no-capture text-slate-900">
|
||||||
|
{formatDistanceToNow(new Date(chart.updatedAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
}).replace("about", "")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="col-span-1 my-auto flex items-center justify-end pr-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
<ChartDropdownMenu
|
||||||
|
environmentId={environmentId}
|
||||||
|
chart={chart}
|
||||||
|
deleteChart={deleteChart}
|
||||||
|
onEdit={() => {
|
||||||
|
setEditingChartId(chart.id);
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<CreateChartDialog
|
||||||
|
open={isEditDialogOpen}
|
||||||
|
onOpenChange={setIsEditDialogOpen}
|
||||||
|
environmentId={environmentId}
|
||||||
|
chartId={editingChartId}
|
||||||
|
onSuccess={handleEditSuccess}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { CreateChartDialog } from "./CreateChartDialog";
|
||||||
|
|
||||||
|
interface CreateChartButtonProps {
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateChartButton({ environmentId }: CreateChartButtonProps) {
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setIsDialogOpen(true)}>
|
||||||
|
<PlusIcon className="mr-2 h-4 w-4" />
|
||||||
|
Chart
|
||||||
|
</Button>
|
||||||
|
<CreateChartDialog
|
||||||
|
open={isDialogOpen}
|
||||||
|
onOpenChange={setIsDialogOpen}
|
||||||
|
environmentId={environmentId}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { AnalyticsResponse } from "@/app/api/analytics/_lib/types";
|
||||||
|
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import {
|
||||||
|
addChartToDashboardAction,
|
||||||
|
createChartAction,
|
||||||
|
executeQueryAction,
|
||||||
|
getChartAction,
|
||||||
|
getDashboardsAction,
|
||||||
|
updateChartAction,
|
||||||
|
} from "../../actions";
|
||||||
|
import { PlusIcon, SaveIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { mapChartType, mapDatabaseChartTypeToApi } from "../../chart-builder/lib/chart-utils";
|
||||||
|
import { AIQuerySection } from "../../chart-builder/components/AIQuerySection";
|
||||||
|
import { AddToDashboardDialog } from "../../chart-builder/components/AddToDashboardDialog";
|
||||||
|
import { AdvancedChartBuilder } from "../../chart-builder/components/AdvancedChartBuilder";
|
||||||
|
import { ChartPreview } from "../../chart-builder/components/ChartPreview";
|
||||||
|
import { ManualChartBuilder } from "../../chart-builder/components/ManualChartBuilder";
|
||||||
|
import { SaveChartDialog } from "../../chart-builder/components/SaveChartDialog";
|
||||||
|
|
||||||
|
interface CreateChartDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
environmentId: string;
|
||||||
|
chartId?: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateChartDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
environmentId,
|
||||||
|
chartId,
|
||||||
|
onSuccess,
|
||||||
|
}: CreateChartDialogProps) {
|
||||||
|
const [selectedChartType, setSelectedChartType] = useState<string>("");
|
||||||
|
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
|
||||||
|
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||||
|
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
|
||||||
|
const [chartName, setChartName] = useState("");
|
||||||
|
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
|
||||||
|
const [selectedDashboardId, setSelectedDashboardId] = useState<string>("");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isLoadingChart, setIsLoadingChart] = useState(false);
|
||||||
|
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
|
||||||
|
|
||||||
|
// Determine if we should show AdvancedChartBuilder
|
||||||
|
const shouldShowAdvancedBuilder = !!selectedChartType || !!chartData;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAddToDashboardDialogOpen) {
|
||||||
|
getDashboardsAction({ environmentId }).then((result) => {
|
||||||
|
if (result?.data) {
|
||||||
|
setDashboards(result.data);
|
||||||
|
} else if (result?.serverError) {
|
||||||
|
toast.error(result.serverError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isAddToDashboardDialogOpen, environmentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && chartId) {
|
||||||
|
setIsLoadingChart(true);
|
||||||
|
getChartAction({ environmentId, chartId })
|
||||||
|
.then(async (result) => {
|
||||||
|
if (result?.data) {
|
||||||
|
const chart = result.data;
|
||||||
|
setChartName(chart.name);
|
||||||
|
|
||||||
|
// Execute the chart's query to get the data
|
||||||
|
const queryResult = await executeQueryAction({
|
||||||
|
environmentId,
|
||||||
|
query: chart.query as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (queryResult?.error || queryResult?.serverError) {
|
||||||
|
toast.error(queryResult.error || queryResult.serverError || "Failed to load chart data");
|
||||||
|
setIsLoadingChart(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryResult?.data?.data) {
|
||||||
|
// Format as AnalyticsResponse
|
||||||
|
const chartData: AnalyticsResponse = {
|
||||||
|
query: chart.query as any,
|
||||||
|
chartType: mapDatabaseChartTypeToApi(chart.type),
|
||||||
|
data: Array.isArray(queryResult.data.data) ? queryResult.data.data : [],
|
||||||
|
};
|
||||||
|
|
||||||
|
setChartData(chartData);
|
||||||
|
setCurrentChartId(chart.id);
|
||||||
|
} else {
|
||||||
|
toast.error("No data returned for chart");
|
||||||
|
}
|
||||||
|
} else if (result?.serverError) {
|
||||||
|
toast.error(result.serverError);
|
||||||
|
}
|
||||||
|
setIsLoadingChart(false);
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
toast.error(error.message || "Failed to load chart");
|
||||||
|
setIsLoadingChart(false);
|
||||||
|
});
|
||||||
|
} else if (open && !chartId) {
|
||||||
|
// Reset state for new chart
|
||||||
|
setChartData(null);
|
||||||
|
setChartName("");
|
||||||
|
setSelectedChartType("");
|
||||||
|
setCurrentChartId(undefined);
|
||||||
|
}
|
||||||
|
}, [open, chartId, environmentId]);
|
||||||
|
|
||||||
|
const handleChartGenerated = (data: AnalyticsResponse) => {
|
||||||
|
setChartData(data);
|
||||||
|
setChartName(data.chartType ? `Chart ${new Date().toLocaleString()}` : "");
|
||||||
|
// Set chart type so AdvancedChartBuilder shows with the AI-generated chart type
|
||||||
|
if (data.chartType) {
|
||||||
|
setSelectedChartType(data.chartType);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveChart = async () => {
|
||||||
|
if (!chartData || !chartName.trim()) {
|
||||||
|
toast.error("Please enter a chart name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
// If we have a currentChartId, update the existing chart; otherwise create a new one
|
||||||
|
if (currentChartId) {
|
||||||
|
const result = await updateChartAction({
|
||||||
|
environmentId,
|
||||||
|
chartId: currentChartId,
|
||||||
|
name: chartName.trim(),
|
||||||
|
type: mapChartType(chartData.chartType),
|
||||||
|
query: chartData.query,
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.data) {
|
||||||
|
toast.error(result?.serverError || "Failed to update chart");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Chart updated successfully!");
|
||||||
|
setIsSaveDialogOpen(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
const result = await createChartAction({
|
||||||
|
environmentId,
|
||||||
|
name: chartName.trim(),
|
||||||
|
type: mapChartType(chartData.chartType),
|
||||||
|
query: chartData.query,
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.data) {
|
||||||
|
toast.error(result?.serverError || "Failed to save chart");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentChartId(result.data.id);
|
||||||
|
toast.success("Chart saved successfully!");
|
||||||
|
setIsSaveDialogOpen(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess?.();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || "Failed to save chart");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToDashboard = async () => {
|
||||||
|
if (!chartData || !selectedDashboardId) {
|
||||||
|
toast.error("Please select a dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
let chartIdToUse = currentChartId;
|
||||||
|
|
||||||
|
// If we don't have a chartId (creating new chart), create it first
|
||||||
|
if (!chartIdToUse) {
|
||||||
|
if (!chartName.trim()) {
|
||||||
|
toast.error("Please enter a chart name");
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartResult = await createChartAction({
|
||||||
|
environmentId,
|
||||||
|
name: chartName.trim(),
|
||||||
|
type: mapChartType(chartData.chartType),
|
||||||
|
query: chartData.query,
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!chartResult?.data) {
|
||||||
|
toast.error(chartResult?.serverError || "Failed to save chart");
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chartIdToUse = chartResult.data.id;
|
||||||
|
setCurrentChartId(chartIdToUse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the chart (existing or newly created) to the dashboard
|
||||||
|
const widgetResult = await addChartToDashboardAction({
|
||||||
|
environmentId,
|
||||||
|
chartId: chartIdToUse,
|
||||||
|
dashboardId: selectedDashboardId,
|
||||||
|
title: chartName.trim(),
|
||||||
|
layout: { x: 0, y: 0, w: 4, h: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!widgetResult?.data) {
|
||||||
|
toast.error(widgetResult?.serverError || "Failed to add chart to dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Chart added to dashboard!");
|
||||||
|
setIsAddToDashboardDialogOpen(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess?.();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || "Failed to add chart to dashboard");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!isSaving) {
|
||||||
|
setChartData(null);
|
||||||
|
setChartName("");
|
||||||
|
setSelectedChartType("");
|
||||||
|
setCurrentChartId(undefined);
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If loading an existing chart, show loading state
|
||||||
|
if (chartId && isLoadingChart) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-[90vw] max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If viewing an existing chart, show only the chart preview
|
||||||
|
if (chartId && chartData) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-7xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Chart</DialogTitle>
|
||||||
|
<DialogDescription>View and edit your chart configuration.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
<ChartPreview chartData={chartData} />
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsAddToDashboardDialogOpen(true)} disabled={isSaving}>
|
||||||
|
<PlusIcon className="mr-2 h-4 w-4" />
|
||||||
|
Add to Dashboard
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setIsSaveDialogOpen(true)} disabled={isSaving}>
|
||||||
|
<SaveIcon className="mr-2 h-4 w-4" />
|
||||||
|
Save Chart
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
<SaveChartDialog
|
||||||
|
open={isSaveDialogOpen}
|
||||||
|
onOpenChange={setIsSaveDialogOpen}
|
||||||
|
chartName={chartName}
|
||||||
|
onChartNameChange={setChartName}
|
||||||
|
onSave={handleSaveChart}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddToDashboardDialog
|
||||||
|
open={isAddToDashboardDialogOpen}
|
||||||
|
onOpenChange={setIsAddToDashboardDialogOpen}
|
||||||
|
chartName={chartName}
|
||||||
|
onChartNameChange={setChartName}
|
||||||
|
dashboards={dashboards}
|
||||||
|
selectedDashboardId={selectedDashboardId}
|
||||||
|
onDashboardSelect={setSelectedDashboardId}
|
||||||
|
onAdd={handleAddToDashboard}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{chartId ? "Edit Chart" : "Create Chart"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{chartId
|
||||||
|
? "View and edit your chart configuration."
|
||||||
|
: "Use AI to generate a chart or build one manually."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{/* AI Query Section */}
|
||||||
|
<AIQuerySection onChartGenerated={handleChartGenerated} />
|
||||||
|
|
||||||
|
{/* OR Separator */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||||
|
<div className="w-full border-t border-gray-200" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center">
|
||||||
|
<span className="bg-gray-50 px-2 text-sm text-gray-500">OR</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart Type Selection */}
|
||||||
|
<ManualChartBuilder
|
||||||
|
selectedChartType={selectedChartType}
|
||||||
|
onChartTypeSelect={setSelectedChartType}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Advanced Builder - shown when chart type selected OR AI chart generated */}
|
||||||
|
{shouldShowAdvancedBuilder && (
|
||||||
|
<AdvancedChartBuilder
|
||||||
|
environmentId={environmentId}
|
||||||
|
initialChartType={selectedChartType || chartData?.chartType || ""}
|
||||||
|
initialQuery={chartData?.query}
|
||||||
|
hidePreview={true}
|
||||||
|
onChartGenerated={(data) => {
|
||||||
|
setChartData(data);
|
||||||
|
setChartName(data.chartType ? `Chart ${new Date().toLocaleString()}` : "");
|
||||||
|
// Update selectedChartType when chart type changes in AdvancedChartBuilder
|
||||||
|
if (data.chartType) {
|
||||||
|
setSelectedChartType(data.chartType);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSave={(chartId) => {
|
||||||
|
setCurrentChartId(chartId);
|
||||||
|
setIsSaveDialogOpen(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess?.();
|
||||||
|
}}
|
||||||
|
onAddToDashboard={(chartId, dashboardId) => {
|
||||||
|
setCurrentChartId(chartId);
|
||||||
|
setIsAddToDashboardDialogOpen(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Single Chart Preview - shown when chartData exists */}
|
||||||
|
{chartData && <ChartPreview chartData={chartData} />}
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
|
||||||
|
{chartData && (
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsAddToDashboardDialogOpen(true)} disabled={isSaving}>
|
||||||
|
<PlusIcon className="mr-2 h-4 w-4" />
|
||||||
|
Add to Dashboard
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setIsSaveDialogOpen(true)} disabled={isSaving}>
|
||||||
|
<SaveIcon className="mr-2 h-4 w-4" />
|
||||||
|
Save Chart
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
<SaveChartDialog
|
||||||
|
open={isSaveDialogOpen}
|
||||||
|
onOpenChange={setIsSaveDialogOpen}
|
||||||
|
chartName={chartName}
|
||||||
|
onChartNameChange={setChartName}
|
||||||
|
onSave={handleSaveChart}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddToDashboardDialog
|
||||||
|
open={isAddToDashboardDialogOpen}
|
||||||
|
onOpenChange={setIsAddToDashboardDialogOpen}
|
||||||
|
chartName={chartName}
|
||||||
|
onChartNameChange={setChartName}
|
||||||
|
dashboards={dashboards}
|
||||||
|
selectedDashboardId={selectedDashboardId}
|
||||||
|
onDashboardSelect={setSelectedDashboardId}
|
||||||
|
onAdd={handleAddToDashboard}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { getCharts, getDashboards } from "../lib/data";
|
||||||
|
import { ChartsListClient } from "./components/ChartsListClient";
|
||||||
|
|
||||||
|
export default async function ChartsListPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ environmentId: string }>;
|
||||||
|
}) {
|
||||||
|
const { environmentId } = await params;
|
||||||
|
const [charts, dashboards] = await Promise.all([getCharts(environmentId), getDashboards(environmentId)]);
|
||||||
|
|
||||||
|
return <ChartsListClient charts={charts} dashboards={dashboards} environmentId={environmentId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { use } from "react";
|
||||||
|
import { CreateChartButton } from "../charts/components/CreateChartButton";
|
||||||
|
import { CreateDashboardButton } from "../dashboards/components/CreateDashboardButton";
|
||||||
|
import { AnalysisPageLayout } from "./analysis-page-layout";
|
||||||
|
|
||||||
|
interface AnalysisLayoutClientProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ environmentId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalysisLayoutClient({ children, params }: AnalysisLayoutClientProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { environmentId } = use(params);
|
||||||
|
|
||||||
|
// Determine active tab based on pathname
|
||||||
|
let activeId = "dashboards"; // default
|
||||||
|
if (pathname?.includes("/charts")) {
|
||||||
|
activeId = "charts";
|
||||||
|
} else if (pathname?.includes("/dashboards") || pathname?.includes("/dashboard/")) {
|
||||||
|
activeId = "dashboards";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show CTA button based on current page
|
||||||
|
const isDashboardsPage = pathname?.includes("/dashboards") && !pathname?.includes("/dashboard/");
|
||||||
|
const isChartsPage = pathname?.includes("/charts");
|
||||||
|
|
||||||
|
let cta;
|
||||||
|
if (isDashboardsPage) {
|
||||||
|
cta = <CreateDashboardButton environmentId={environmentId} />;
|
||||||
|
} else if (isChartsPage) {
|
||||||
|
cta = <CreateChartButton environmentId={environmentId} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnalysisPageLayout pageTitle="Analysis" activeId={activeId} environmentId={environmentId} cta={cta}>
|
||||||
|
{children}
|
||||||
|
</AnalysisPageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { AnalysisSecondaryNavigation } from "./analysis-secondary-navigation";
|
||||||
|
|
||||||
|
interface AnalysisPageLayoutProps {
|
||||||
|
pageTitle: string;
|
||||||
|
activeId: string;
|
||||||
|
environmentId: string;
|
||||||
|
cta?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalysisPageLayout({
|
||||||
|
pageTitle,
|
||||||
|
activeId,
|
||||||
|
environmentId,
|
||||||
|
cta,
|
||||||
|
children,
|
||||||
|
}: AnalysisPageLayoutProps) {
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<PageHeader pageTitle={pageTitle} cta={cta}>
|
||||||
|
<AnalysisSecondaryNavigation activeId={activeId} environmentId={environmentId} />
|
||||||
|
</PageHeader>
|
||||||
|
{children}
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||||
|
|
||||||
|
interface AnalysisSecondaryNavigationProps {
|
||||||
|
activeId: string;
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalysisSecondaryNavigation({
|
||||||
|
activeId,
|
||||||
|
environmentId,
|
||||||
|
}: AnalysisSecondaryNavigationProps) {
|
||||||
|
const navigation = [
|
||||||
|
{
|
||||||
|
id: "dashboards",
|
||||||
|
label: "Dashboards",
|
||||||
|
href: `/environments/${environmentId}/analysis/dashboards`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "charts",
|
||||||
|
label: "Charts",
|
||||||
|
href: `/environments/${environmentId}/analysis/charts`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return <SecondaryNavigation navigation={navigation} activeId={activeId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CopyIcon, PencilIcon, TrashIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TDashboard } from "../../../types/analysis";
|
||||||
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
|
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||||
|
import { EditDashboardDialog } from "./EditDashboardDialog";
|
||||||
|
|
||||||
|
interface DashboardControlBarProps {
|
||||||
|
environmentId: string;
|
||||||
|
dashboard: TDashboard;
|
||||||
|
onDashboardUpdate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardControlBar = ({
|
||||||
|
environmentId,
|
||||||
|
dashboard,
|
||||||
|
onDashboardUpdate,
|
||||||
|
}: DashboardControlBarProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleDeleteDashboard = async () => {
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
// TODO: Implement deleteDashboardAction when available
|
||||||
|
// const result = await deleteDashboardAction({ environmentId, dashboardId: dashboard.id });
|
||||||
|
// if (result?.data) {
|
||||||
|
// router.push(`/environments/${environmentId}/analysis/dashboards`);
|
||||||
|
// toast.success("Dashboard deleted successfully");
|
||||||
|
// } else {
|
||||||
|
// toast.error(result?.serverError || "Failed to delete dashboard");
|
||||||
|
// }
|
||||||
|
toast.error("Delete functionality coming soon");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || "Failed to delete dashboard");
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = async () => {
|
||||||
|
// TODO: Implement duplicate functionality
|
||||||
|
toast.success("Duplicate functionality coming soon");
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconActions = [
|
||||||
|
{
|
||||||
|
icon: PencilIcon,
|
||||||
|
tooltip: t("common.edit"),
|
||||||
|
onClick: () => {
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
},
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: CopyIcon,
|
||||||
|
tooltip: t("common.duplicate"),
|
||||||
|
onClick: handleDuplicate,
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: TrashIcon,
|
||||||
|
tooltip: t("common.delete"),
|
||||||
|
onClick: () => {
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
},
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconBar actions={iconActions} />
|
||||||
|
<DeleteDialog
|
||||||
|
deleteWhat="Dashboard"
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
setOpen={setDeleteDialogOpen}
|
||||||
|
onDelete={handleDeleteDashboard}
|
||||||
|
text="Are you sure you want to delete this dashboard? This action cannot be undone."
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
|
<EditDashboardDialog
|
||||||
|
open={isEditDialogOpen}
|
||||||
|
onOpenChange={setIsEditDialogOpen}
|
||||||
|
dashboardId={dashboard.id}
|
||||||
|
environmentId={environmentId}
|
||||||
|
initialName={dashboard.name}
|
||||||
|
initialDescription={dashboard.description}
|
||||||
|
onSuccess={() => {
|
||||||
|
setIsEditDialogOpen(false);
|
||||||
|
onDashboardUpdate?.();
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||||
|
import { TDashboard } from "../../../types/analysis";
|
||||||
|
import { CreateChartButton } from "../../../charts/components/CreateChartButton";
|
||||||
|
import { DashboardWidget } from "./DashboardWidget";
|
||||||
|
import { DashboardControlBar } from "./DashboardControlBar";
|
||||||
|
|
||||||
|
interface DashboardDetailClientProps {
|
||||||
|
dashboard: TDashboard;
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardDetailClient({ dashboard: initialDashboard, environmentId }: DashboardDetailClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [dashboard] = useState(initialDashboard);
|
||||||
|
const isEmpty = dashboard.widgets.length === 0;
|
||||||
|
|
||||||
|
const handleDashboardUpdate = () => {
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate grid column span based on widget layout width
|
||||||
|
const getColSpan = (w: number) => {
|
||||||
|
// Assuming w is in a 12-column grid system
|
||||||
|
// Map widget width to Tailwind col-span classes
|
||||||
|
if (w <= 2) return "col-span-12 md:col-span-2";
|
||||||
|
if (w <= 3) return "col-span-12 md:col-span-3";
|
||||||
|
if (w <= 4) return "col-span-12 md:col-span-4";
|
||||||
|
if (w <= 6) return "col-span-12 md:col-span-6";
|
||||||
|
if (w <= 8) return "col-span-12 md:col-span-8";
|
||||||
|
if (w <= 9) return "col-span-12 md:col-span-9";
|
||||||
|
return "col-span-12";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<GoBackButton url={`/environments/${environmentId}/analysis/dashboards`} />
|
||||||
|
<PageHeader
|
||||||
|
pageTitle={dashboard.name}
|
||||||
|
cta={
|
||||||
|
<DashboardControlBar
|
||||||
|
environmentId={environmentId}
|
||||||
|
dashboard={dashboard}
|
||||||
|
onDashboardUpdate={handleDashboardUpdate}
|
||||||
|
/>
|
||||||
|
}>
|
||||||
|
{dashboard.description && (
|
||||||
|
<p className="mt-2 text-sm text-gray-500">{dashboard.description}</p>
|
||||||
|
)}
|
||||||
|
</PageHeader>
|
||||||
|
<section className="pt-6 pb-24">
|
||||||
|
{isEmpty ? (
|
||||||
|
// Empty State
|
||||||
|
<div className="flex h-[400px] flex-col items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-white/50">
|
||||||
|
<div className="mb-4 rounded-full bg-gray-100 p-4">
|
||||||
|
<div className="h-12 w-12 rounded-md bg-gray-300 opacity-20" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">No Data</h3>
|
||||||
|
<p className="mt-2 max-w-sm text-center text-gray-500">
|
||||||
|
There is currently no information to display. Add charts to build your dashboard.
|
||||||
|
</p>
|
||||||
|
<CreateChartButton environmentId={environmentId} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Grid Layout - Render widgets dynamically
|
||||||
|
<div className="grid grid-cols-12 gap-6">
|
||||||
|
{dashboard.widgets.map((widget) => (
|
||||||
|
<div key={widget.id} className={getColSpan(widget.layout.w)}>
|
||||||
|
<DashboardWidget widget={widget} environmentId={environmentId} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { MoreHorizontalIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
|
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||||
|
import { executeQueryAction } from "../../../actions";
|
||||||
|
import { ChartRenderer } from "../../../chart-builder/components/ChartRenderer";
|
||||||
|
import { TDashboardWidget } from "../../../types/analysis";
|
||||||
|
|
||||||
|
interface DashboardWidgetProps {
|
||||||
|
widget: TDashboardWidget;
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardWidget({ widget, environmentId }: DashboardWidgetProps) {
|
||||||
|
const [chartData, setChartData] = useState<Record<string, any>[] | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (widget.type === "chart" && widget.chart) {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
executeQueryAction({
|
||||||
|
environmentId,
|
||||||
|
query: widget.chart.query,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
if (result?.serverError || result?.error) {
|
||||||
|
setError(result.serverError || result.error || "Failed to load chart data");
|
||||||
|
setChartData(null);
|
||||||
|
} else if (result?.data?.data) {
|
||||||
|
const data = Array.isArray(result.data.data) ? result.data.data : [];
|
||||||
|
setChartData(data);
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
setError("No data returned");
|
||||||
|
setChartData(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
setError(err.message || "Failed to load chart data");
|
||||||
|
setChartData(null);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [widget, environmentId]);
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (widget.type === "chart") {
|
||||||
|
if (!widget.chart) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-gray-500">
|
||||||
|
Chart not found
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-start justify-center rounded-md border border-red-100 bg-red-50 p-4">
|
||||||
|
<div className="mb-1 flex items-center gap-2 font-semibold text-red-700">
|
||||||
|
<div className="rounded-full bg-red-600 p-0.5">
|
||||||
|
<span className="block h-3 w-3 text-center text-[10px] leading-3 text-white">✕</span>
|
||||||
|
</div>
|
||||||
|
Data error
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chartData || chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-gray-500">
|
||||||
|
No data available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ChartRenderer chartType={widget.chart.type} data={chartData} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.type === "markdown") {
|
||||||
|
return (
|
||||||
|
<div className="prose prose-sm max-w-none">
|
||||||
|
{/* TODO: Render markdown content */}
|
||||||
|
<p className="text-gray-500">Markdown content not yet implemented</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.type === "header") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center">
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-900">{widget.title || "Header"}</h2>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.type === "divider") {
|
||||||
|
return <div className="h-full w-full border-t border-gray-200" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col rounded-sm border border-gray-200 bg-white shadow-sm ring-1 ring-black/5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-100 px-4 py-2">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800">
|
||||||
|
{widget.title || widget.chart?.name || "Widget"}
|
||||||
|
</h3>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6 text-gray-400 hover:text-gray-600">
|
||||||
|
<MoreHorizontalIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>Force refresh</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>View as table</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>Maximize</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="relative min-h-[300px] flex-1 p-4">{renderContent()}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { updateDashboardAction } from "../../../actions";
|
||||||
|
|
||||||
|
interface EditDashboardDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
dashboardId: string;
|
||||||
|
environmentId: string;
|
||||||
|
initialName: string;
|
||||||
|
initialDescription?: string;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditDashboardDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
dashboardId,
|
||||||
|
environmentId,
|
||||||
|
initialName,
|
||||||
|
initialDescription,
|
||||||
|
onSuccess,
|
||||||
|
}: EditDashboardDialogProps) {
|
||||||
|
const [dashboardName, setDashboardName] = useState(initialName);
|
||||||
|
const [dashboardDescription, setDashboardDescription] = useState(initialDescription || "");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setDashboardName(initialName);
|
||||||
|
setDashboardDescription(initialDescription || "");
|
||||||
|
}
|
||||||
|
}, [open, initialName, initialDescription]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!dashboardName.trim()) {
|
||||||
|
toast.error("Please enter a dashboard name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await updateDashboardAction({
|
||||||
|
environmentId,
|
||||||
|
dashboardId,
|
||||||
|
name: dashboardName.trim(),
|
||||||
|
description: dashboardDescription.trim() || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.data) {
|
||||||
|
toast.error(result?.serverError || "Failed to update dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Dashboard updated successfully!");
|
||||||
|
onSuccess();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || "Failed to update dashboard");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Dashboard</DialogTitle>
|
||||||
|
<DialogDescription>Update dashboard name and description.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="edit-dashboard-name" className="text-sm font-medium text-gray-900">
|
||||||
|
Dashboard Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="edit-dashboard-name"
|
||||||
|
placeholder="Dashboard name"
|
||||||
|
value={dashboardName}
|
||||||
|
onChange={(e) => setDashboardName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && dashboardName.trim() && !isSaving) {
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="edit-dashboard-description" className="text-sm font-medium text-gray-900">
|
||||||
|
Description (Optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="edit-dashboard-description"
|
||||||
|
placeholder="Dashboard description"
|
||||||
|
value={dashboardDescription}
|
||||||
|
onChange={(e) => setDashboardDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} loading={isSaving} disabled={!dashboardName.trim()}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
// This layout bypasses the analysis layout, allowing the dashboard page to have its own layout
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getDashboard } from "../../lib/data";
|
||||||
|
import { DashboardDetailClient } from "./components/DashboardDetailClient";
|
||||||
|
|
||||||
|
export default async function DashboardPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ environmentId: string; dashboardId: string }>;
|
||||||
|
}) {
|
||||||
|
const { environmentId, dashboardId } = await params;
|
||||||
|
const dashboard = await getDashboard(environmentId, dashboardId);
|
||||||
|
|
||||||
|
if (!dashboard) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DashboardDetailClient dashboard={dashboard} environmentId={environmentId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PlusIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "react-hot-toast";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { createDashboardAction } from "../../actions";
|
||||||
|
import { CreateDashboardDialog } from "./CreateDashboardDialog";
|
||||||
|
|
||||||
|
interface CreateDashboardButtonProps {
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateDashboardButton({ environmentId }: CreateDashboardButtonProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
const [dashboardName, setDashboardName] = useState("");
|
||||||
|
const [dashboardDescription, setDashboardDescription] = useState("");
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
const handleCreateDashboard = () => {
|
||||||
|
setIsCreateDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!dashboardName.trim()) {
|
||||||
|
toast.error("Please enter a dashboard name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const result = await createDashboardAction({
|
||||||
|
environmentId,
|
||||||
|
name: dashboardName.trim(),
|
||||||
|
description: dashboardDescription.trim() || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result?.data) {
|
||||||
|
toast.error(result?.serverError || "Failed to create dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Dashboard created successfully!");
|
||||||
|
setIsCreateDialogOpen(false);
|
||||||
|
setDashboardName("");
|
||||||
|
setDashboardDescription("");
|
||||||
|
// Navigate to the new dashboard
|
||||||
|
router.push(`/environments/${environmentId}/analysis/dashboard/${result.data.id}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error.message || "Failed to create dashboard");
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={handleCreateDashboard}>
|
||||||
|
<PlusIcon className="mr-2 h-4 w-4" />
|
||||||
|
Create Dashboard
|
||||||
|
</Button>
|
||||||
|
<CreateDashboardDialog
|
||||||
|
open={isCreateDialogOpen}
|
||||||
|
onOpenChange={setIsCreateDialogOpen}
|
||||||
|
dashboardName={dashboardName}
|
||||||
|
onDashboardNameChange={setDashboardName}
|
||||||
|
dashboardDescription={dashboardDescription}
|
||||||
|
onDashboardDescriptionChange={setDashboardDescription}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
isCreating={isCreating}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogBody,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
|
||||||
|
interface CreateDashboardDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
dashboardName: string;
|
||||||
|
onDashboardNameChange: (name: string) => void;
|
||||||
|
dashboardDescription: string;
|
||||||
|
onDashboardDescriptionChange: (description: string) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
isCreating: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateDashboardDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
dashboardName,
|
||||||
|
onDashboardNameChange,
|
||||||
|
dashboardDescription,
|
||||||
|
onDashboardDescriptionChange,
|
||||||
|
onCreate,
|
||||||
|
isCreating,
|
||||||
|
}: CreateDashboardDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Dashboard</DialogTitle>
|
||||||
|
<DialogDescription>Enter a name for your dashboard to create it.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="dashboard-name" className="text-sm font-medium text-gray-900">
|
||||||
|
Dashboard Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="dashboard-name"
|
||||||
|
placeholder="Dashboard name"
|
||||||
|
value={dashboardName}
|
||||||
|
onChange={(e) => onDashboardNameChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && dashboardName.trim() && !isCreating) {
|
||||||
|
onCreate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="dashboard-description" className="text-sm font-medium text-gray-900">
|
||||||
|
Description (Optional)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="dashboard-description"
|
||||||
|
placeholder="Dashboard description"
|
||||||
|
value={dashboardDescription}
|
||||||
|
onChange={(e) => onDashboardDescriptionChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isCreating}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onCreate} loading={isCreating} disabled={!dashboardName.trim()}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { TDashboard } from "../../types/analysis";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
|
|
||||||
|
interface DashboardDropdownMenuProps {
|
||||||
|
environmentId: string;
|
||||||
|
dashboard: TDashboard;
|
||||||
|
disabled?: boolean;
|
||||||
|
deleteDashboard: (dashboardId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardDropdownMenu = ({
|
||||||
|
environmentId,
|
||||||
|
dashboard,
|
||||||
|
disabled,
|
||||||
|
deleteDashboard,
|
||||||
|
}: DashboardDropdownMenuProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleDeleteDashboard = async (dashboardId: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// TODO: Implement deleteDashboardAction
|
||||||
|
// await deleteDashboardAction({ dashboardId });
|
||||||
|
deleteDashboard(dashboardId);
|
||||||
|
toast.success("Dashboard deleted successfully");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Error deleting dashboard");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={`${dashboard.name.toLowerCase().split(" ").join("-")}-dashboard-actions`}
|
||||||
|
data-testid="dashboard-dropdown-menu">
|
||||||
|
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||||
|
<DropdownMenuTrigger className="z-10" asChild disabled={disabled}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-white p-2",
|
||||||
|
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-slate-50"
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
<span className="sr-only">Open options</span>
|
||||||
|
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="inline-block w-auto min-w-max">
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Link
|
||||||
|
className="flex w-full items-center"
|
||||||
|
href={`/environments/${environmentId}/analysis/dashboard/${dashboard.id}`}>
|
||||||
|
<SquarePenIcon className="mr-2 size-4" />
|
||||||
|
{t("common.edit")}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDropDownOpen(false);
|
||||||
|
// TODO: Implement duplicate functionality
|
||||||
|
toast.success("Duplicate functionality coming soon");
|
||||||
|
}}>
|
||||||
|
<CopyIcon className="mr-2 h-4 w-4" />
|
||||||
|
{t("common.duplicate")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDropDownOpen(false);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}}>
|
||||||
|
<TrashIcon className="mr-2 h-4 w-4" />
|
||||||
|
{t("common.delete")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<DeleteDialog
|
||||||
|
deleteWhat="Dashboard"
|
||||||
|
open={isDeleteDialogOpen}
|
||||||
|
setOpen={setDeleteDialogOpen}
|
||||||
|
onDelete={() => handleDeleteDashboard(dashboard.id)}
|
||||||
|
text="Are you sure you want to delete this dashboard? This action cannot be undone."
|
||||||
|
isDeleting={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
|
import { BarChart3Icon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { TDashboard } from "../../types/analysis";
|
||||||
|
import { DashboardDropdownMenu } from "./DashboardDropdownMenu";
|
||||||
|
|
||||||
|
interface DashboardsListClientProps {
|
||||||
|
dashboards: TDashboard[];
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardsListClient({
|
||||||
|
dashboards: initialDashboards,
|
||||||
|
environmentId,
|
||||||
|
}: DashboardsListClientProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [dashboards, setDashboards] = useState(initialDashboards);
|
||||||
|
|
||||||
|
const filteredDashboards = dashboards.filter((dashboard) =>
|
||||||
|
dashboard.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteDashboard = (dashboardId: string) => {
|
||||||
|
setDashboards(dashboards.filter((d) => d.id !== dashboardId));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="grid h-12 grid-cols-8 content-center border-b text-left text-sm font-semibold text-slate-900">
|
||||||
|
<div className="col-span-3 pl-6">Title</div>
|
||||||
|
<div className="col-span-1 hidden text-center sm:block">Charts</div>
|
||||||
|
<div className="col-span-1 hidden text-center sm:block">Created By</div>
|
||||||
|
<div className="col-span-1 hidden text-center sm:block">Created</div>
|
||||||
|
<div className="col-span-1 hidden text-center sm:block">Updated</div>
|
||||||
|
<div className="col-span-1"></div>
|
||||||
|
</div>
|
||||||
|
{filteredDashboards.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-slate-400">No dashboards found.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{filteredDashboards.map((dashboard) => (
|
||||||
|
<Link
|
||||||
|
key={dashboard.id}
|
||||||
|
href={`dashboard/${dashboard.id}`}
|
||||||
|
className="grid h-12 w-full cursor-pointer grid-cols-8 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-100">
|
||||||
|
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="ph-no-capture w-8 flex-shrink-0 text-slate-500">
|
||||||
|
<BarChart3Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="ph-no-capture font-medium text-slate-900">{dashboard.name}</div>
|
||||||
|
{dashboard.description && (
|
||||||
|
<div className="ph-no-capture text-xs font-medium text-slate-500">
|
||||||
|
{dashboard.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap text-slate-500 sm:block">
|
||||||
|
<div className="ph-no-capture text-slate-900">{dashboard.chartCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap text-slate-500 sm:block">
|
||||||
|
<div className="ph-no-capture text-slate-900">{dashboard.createdByName || "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-normal text-slate-500 sm:block">
|
||||||
|
<div className="ph-no-capture text-slate-900">
|
||||||
|
{format(new Date(dashboard.createdAt), "do 'of' MMMM, yyyy")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
|
||||||
|
<div className="ph-no-capture text-slate-900">
|
||||||
|
{formatDistanceToNow(new Date(dashboard.updatedAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
}).replace("about", "")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="col-span-1 my-auto flex items-center justify-end pr-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
<DashboardDropdownMenu
|
||||||
|
environmentId={environmentId}
|
||||||
|
dashboard={dashboard}
|
||||||
|
deleteDashboard={deleteDashboard}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { getDashboards } from "../lib/data";
|
||||||
|
import { DashboardsListClient } from "./components/DashboardsListClient";
|
||||||
|
|
||||||
|
export default async function DashboardsListPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ environmentId: string }>;
|
||||||
|
}) {
|
||||||
|
const { environmentId } = await params;
|
||||||
|
const dashboards = await getDashboards(environmentId);
|
||||||
|
|
||||||
|
return <DashboardsListClient dashboards={dashboards} environmentId={environmentId} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { AnalysisLayoutClient } from "./components/analysis-layout-client";
|
||||||
|
|
||||||
|
export default function AnalysisLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ environmentId: string }>;
|
||||||
|
}) {
|
||||||
|
return <AnalysisLayoutClient params={params}>{children}</AnalysisLayoutClient>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { cache as reactCache } from "react";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
import { getUser } from "@/lib/user/service";
|
||||||
|
import { TDashboard, TChart } from "../types/analysis";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all dashboards for the given environment
|
||||||
|
*/
|
||||||
|
export const getDashboards = reactCache(async (environmentId: string): Promise<TDashboard[]> => {
|
||||||
|
const { project } = await getEnvironmentAuth(environmentId);
|
||||||
|
|
||||||
|
const dashboards = await prisma.dashboard.findMany({
|
||||||
|
where: { projectId: project.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
widgets: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
title: true,
|
||||||
|
chartId: true,
|
||||||
|
layout: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch user names for createdBy fields
|
||||||
|
const userIds = [...new Set(dashboards.map((d) => d.createdBy).filter(Boolean) as string[])];
|
||||||
|
const users = await Promise.all(userIds.map((id) => getUser(id)));
|
||||||
|
const userMap = new Map(users.filter(Boolean).map((u) => [u!.id, u!.name]));
|
||||||
|
|
||||||
|
// Transform to match TDashboard type
|
||||||
|
return dashboards.map((dashboard) => {
|
||||||
|
const chartCount = dashboard.widgets.filter((widget) => widget.type === "chart").length;
|
||||||
|
const createdByName = dashboard.createdBy ? userMap.get(dashboard.createdBy) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dashboard.id,
|
||||||
|
name: dashboard.name,
|
||||||
|
description: dashboard.description || undefined,
|
||||||
|
status: dashboard.status,
|
||||||
|
owners: [], // TODO: Fetch owners if needed
|
||||||
|
lastModified: dashboard.updatedAt.toISOString(),
|
||||||
|
createdAt: dashboard.createdAt.toISOString(),
|
||||||
|
updatedAt: dashboard.updatedAt.toISOString(),
|
||||||
|
createdBy: dashboard.createdBy || undefined,
|
||||||
|
createdByName,
|
||||||
|
chartCount,
|
||||||
|
isFavorite: false, // TODO: Add favorite functionality if needed
|
||||||
|
widgets: dashboard.widgets.map((widget) => ({
|
||||||
|
id: widget.id,
|
||||||
|
type: widget.type as "chart" | "markdown" | "header" | "divider",
|
||||||
|
title: widget.title || undefined,
|
||||||
|
chartId: widget.chartId || undefined,
|
||||||
|
layout: widget.layout as { x: number; y: number; w: number; h: number },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all charts for the given environment
|
||||||
|
*/
|
||||||
|
export const getCharts = reactCache(async (environmentId: string): Promise<TChart[]> => {
|
||||||
|
const { project } = await getEnvironmentAuth(environmentId);
|
||||||
|
|
||||||
|
const charts = await prisma.chart.findMany({
|
||||||
|
where: { projectId: project.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
widgets: {
|
||||||
|
select: {
|
||||||
|
dashboardId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch user names for createdBy fields
|
||||||
|
const userIds = [...new Set(charts.map((c) => c.createdBy).filter(Boolean) as string[])];
|
||||||
|
const users = await Promise.all(userIds.map((id) => getUser(id)));
|
||||||
|
const userMap = new Map(users.filter(Boolean).map((u) => [u!.id, u!.name]));
|
||||||
|
|
||||||
|
// Transform to match TChart type
|
||||||
|
return charts.map((chart) => {
|
||||||
|
const createdByName = chart.createdBy ? userMap.get(chart.createdBy) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: chart.id,
|
||||||
|
name: chart.name,
|
||||||
|
type: chart.type as TChart["type"],
|
||||||
|
dataset: "FeedbackRecords", // TODO: Make this dynamic if needed
|
||||||
|
owners: [], // TODO: Fetch owners if needed
|
||||||
|
lastModified: chart.updatedAt.toISOString(),
|
||||||
|
createdAt: chart.createdAt.toISOString(),
|
||||||
|
updatedAt: chart.updatedAt.toISOString(),
|
||||||
|
createdBy: chart.createdBy || undefined,
|
||||||
|
createdByName,
|
||||||
|
dashboardIds: chart.widgets.map((widget) => widget.dashboardId),
|
||||||
|
config: (chart.config as Record<string, any>) || {},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a single dashboard by ID
|
||||||
|
*/
|
||||||
|
export const getDashboard = reactCache(
|
||||||
|
async (environmentId: string, dashboardId: string): Promise<TDashboard | null> => {
|
||||||
|
const { project } = await getEnvironmentAuth(environmentId);
|
||||||
|
|
||||||
|
const dashboard = await prisma.dashboard.findFirst({
|
||||||
|
where: {
|
||||||
|
id: dashboardId,
|
||||||
|
projectId: project.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
widgets: {
|
||||||
|
include: {
|
||||||
|
chart: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
type: true,
|
||||||
|
query: true,
|
||||||
|
config: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
order: "asc",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dashboard) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartCount = dashboard.widgets.filter((widget) => widget.type === "chart").length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dashboard.id,
|
||||||
|
name: dashboard.name,
|
||||||
|
description: dashboard.description || undefined,
|
||||||
|
status: dashboard.status,
|
||||||
|
owners: [], // TODO: Fetch owners if needed
|
||||||
|
lastModified: dashboard.updatedAt.toISOString(),
|
||||||
|
createdAt: dashboard.createdAt.toISOString(),
|
||||||
|
updatedAt: dashboard.updatedAt.toISOString(),
|
||||||
|
createdBy: dashboard.createdBy || undefined,
|
||||||
|
createdByName: undefined, // Will be fetched if needed
|
||||||
|
chartCount,
|
||||||
|
isFavorite: false, // TODO: Add favorite functionality if needed
|
||||||
|
widgets: dashboard.widgets.map((widget) => ({
|
||||||
|
id: widget.id,
|
||||||
|
type: widget.type as "chart" | "markdown" | "header" | "divider",
|
||||||
|
title: widget.title || undefined,
|
||||||
|
chartId: widget.chartId || undefined,
|
||||||
|
layout: widget.layout as { x: number; y: number; w: number; h: number },
|
||||||
|
chart: widget.chart
|
||||||
|
? {
|
||||||
|
id: widget.chart.id,
|
||||||
|
name: widget.chart.name,
|
||||||
|
type: widget.chart.type as TChart["type"],
|
||||||
|
query: widget.chart.query as Record<string, any>,
|
||||||
|
config: (widget.chart.config as Record<string, any>) || {},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function AnalysisPage({ params }: { params: Promise<{ environmentId: string }> }) {
|
||||||
|
const { environmentId } = await params;
|
||||||
|
if (!environmentId || environmentId === "undefined") {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
redirect(`/environments/${environmentId}/analysis/dashboards`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
export type TDashboardStatus = "published" | "draft";
|
||||||
|
|
||||||
|
export interface TAnalysisUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TDashboard {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
status: TDashboardStatus;
|
||||||
|
owners: TAnalysisUser[];
|
||||||
|
lastModified: string; // ISO Date string
|
||||||
|
createdAt: string; // ISO Date string
|
||||||
|
updatedAt: string; // ISO Date string
|
||||||
|
createdBy?: string; // User ID
|
||||||
|
createdByName?: string; // User name for display
|
||||||
|
chartCount: number;
|
||||||
|
isFavorite: boolean;
|
||||||
|
widgets: TDashboardWidget[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TDashboardWidget {
|
||||||
|
id: string;
|
||||||
|
type: "chart" | "markdown" | "header" | "divider";
|
||||||
|
title?: string;
|
||||||
|
chartId?: string; // If type is chart
|
||||||
|
layout: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
};
|
||||||
|
chart?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: TChartType;
|
||||||
|
query: Record<string, any>;
|
||||||
|
config: Record<string, any>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TChartType =
|
||||||
|
| "area"
|
||||||
|
| "bar"
|
||||||
|
| "line"
|
||||||
|
| "pie"
|
||||||
|
| "big_number"
|
||||||
|
| "big_number_total"
|
||||||
|
| "table"
|
||||||
|
| "funnel"
|
||||||
|
| "map";
|
||||||
|
|
||||||
|
export interface TChart {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: TChartType;
|
||||||
|
dataset: string;
|
||||||
|
owners: TAnalysisUser[];
|
||||||
|
lastModified: string;
|
||||||
|
createdAt: string; // ISO Date string
|
||||||
|
updatedAt: string; // ISO Date string
|
||||||
|
createdBy?: string; // User ID
|
||||||
|
createdByName?: string; // User name for display
|
||||||
|
dashboardIds: string[];
|
||||||
|
config: Record<string, any>; // Flexible config for specific chart props
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TAnalysisState {
|
||||||
|
dashboards: TDashboard[];
|
||||||
|
charts: TChart[];
|
||||||
|
activeDashboard: TDashboard | null;
|
||||||
|
layoutMode: "view" | "edit";
|
||||||
|
isLoading: boolean;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setDashboards: (dashboards: TDashboard[]) => void;
|
||||||
|
setCharts: (charts: TChart[]) => void;
|
||||||
|
setActiveDashboard: (dashboard: TDashboard | null) => void;
|
||||||
|
setLayoutMode: (mode: "view" | "edit") => void;
|
||||||
|
addDashboard: (dashboard: TDashboard) => void;
|
||||||
|
updateDashboard: (id: string, updates: Partial<TDashboard>) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./analysis";
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -15,6 +16,7 @@ interface EnvironmentLayoutProps {
|
|||||||
|
|
||||||
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
|
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
const publicDomain = getPublicDomain();
|
||||||
|
|
||||||
// Destructure all data from props (NO database queries)
|
// Destructure all data from props (NO database queries)
|
||||||
const {
|
const {
|
||||||
@@ -41,7 +43,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
|
|
||||||
// Validate that project permission exists for members
|
// Validate that project permission exists for members
|
||||||
if (isMember && !projectPermission) {
|
if (isMember && !projectPermission) {
|
||||||
throw new Error(t("common.project_permission_not_found"));
|
throw new Error(t("common.workspace_permission_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,6 +74,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
isDevelopment={IS_DEVELOPMENT}
|
isDevelopment={IS_DEVELOPMENT}
|
||||||
membershipRole={membership.role}
|
membershipRole={membership.role}
|
||||||
|
publicDomain={publicDomain}
|
||||||
/>
|
/>
|
||||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||||
<TopControlBar
|
<TopControlBar
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
MessageCircle,
|
MessageCircle,
|
||||||
PanelLeftCloseIcon,
|
PanelLeftCloseIcon,
|
||||||
PanelLeftOpenIcon,
|
PanelLeftOpenIcon,
|
||||||
|
PieChart,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
UserCircleIcon,
|
UserCircleIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
@@ -46,6 +47,7 @@ interface NavigationProps {
|
|||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isDevelopment: boolean;
|
isDevelopment: boolean;
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
|
publicDomain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MainNavigation = ({
|
export const MainNavigation = ({
|
||||||
@@ -56,6 +58,7 @@ export const MainNavigation = ({
|
|||||||
membershipRole,
|
membershipRole,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
|
publicDomain,
|
||||||
}: NavigationProps) => {
|
}: NavigationProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -103,6 +106,13 @@ export const MainNavigation = ({
|
|||||||
isActive: pathname?.includes("/surveys"),
|
isActive: pathname?.includes("/surveys"),
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: t("common.analysis"),
|
||||||
|
href: `/environments/${environment.id}/analysis`,
|
||||||
|
icon: PieChart,
|
||||||
|
isActive: pathname?.includes("/analysis"),
|
||||||
|
isHidden: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: `/environments/${environment.id}/contacts`,
|
href: `/environments/${environment.id}/contacts`,
|
||||||
name: t("common.contacts"),
|
name: t("common.contacts"),
|
||||||
@@ -111,7 +121,7 @@ export const MainNavigation = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t("common.configuration"),
|
name: t("common.configuration"),
|
||||||
href: `/environments/${environment.id}/project/general`,
|
href: `/environments/${environment.id}/workspace/general`,
|
||||||
icon: Cog,
|
icon: Cog,
|
||||||
isActive: pathname?.includes("/project"),
|
isActive: pathname?.includes("/project"),
|
||||||
},
|
},
|
||||||
@@ -162,7 +172,7 @@ export const MainNavigation = ({
|
|||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
||||||
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
|
isCollapsed ? "w-sidebar-expanded" : "w-sidebar-collapsed"
|
||||||
)}>
|
)}>
|
||||||
<div>
|
<div>
|
||||||
{/* Logo and Toggle */}
|
{/* Logo and Toggle */}
|
||||||
@@ -286,15 +296,16 @@ export const MainNavigation = ({
|
|||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
const loginUrl = `${publicDomain}/auth/login`;
|
||||||
const route = await signOutWithAudit({
|
const route = await signOutWithAudit({
|
||||||
reason: "user_initiated",
|
reason: "user_initiated",
|
||||||
redirectUrl: "/auth/login",
|
redirectUrl: loginUrl,
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
redirect: false,
|
redirect: false,
|
||||||
callbackUrl: "/auth/login",
|
callbackUrl: loginUrl,
|
||||||
clearEnvironmentId: true,
|
clearEnvironmentId: true,
|
||||||
});
|
});
|
||||||
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
|
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
|
||||||
}}
|
}}
|
||||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||||
{t("common.logout")}
|
{t("common.logout")}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { Session } from "next-auth";
|
|
||||||
import { usePostHog } from "posthog-js/react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
|
||||||
|
|
||||||
interface PosthogIdentifyProps {
|
|
||||||
session: Session;
|
|
||||||
user: TUser;
|
|
||||||
environmentId?: string;
|
|
||||||
organizationId?: string;
|
|
||||||
organizationName?: string;
|
|
||||||
organizationBilling?: TOrganizationBilling;
|
|
||||||
isPosthogEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PosthogIdentify = ({
|
|
||||||
session,
|
|
||||||
user,
|
|
||||||
environmentId,
|
|
||||||
organizationId,
|
|
||||||
organizationName,
|
|
||||||
organizationBilling,
|
|
||||||
isPosthogEnabled,
|
|
||||||
}: PosthogIdentifyProps) => {
|
|
||||||
const posthog = usePostHog();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isPosthogEnabled && session.user && posthog) {
|
|
||||||
posthog.identify(session.user.id, {
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
});
|
|
||||||
if (environmentId) {
|
|
||||||
posthog.group("environment", environmentId, { name: environmentId });
|
|
||||||
}
|
|
||||||
if (organizationId) {
|
|
||||||
posthog.group("organization", organizationId, {
|
|
||||||
name: organizationName,
|
|
||||||
plan: organizationBilling?.plan,
|
|
||||||
responseLimit: organizationBilling?.limits.monthly.responses,
|
|
||||||
miuLimit: organizationBilling?.limits.monthly.miu,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
posthog,
|
|
||||||
session.user,
|
|
||||||
environmentId,
|
|
||||||
organizationId,
|
|
||||||
organizationName,
|
|
||||||
organizationBilling,
|
|
||||||
user.name,
|
|
||||||
user.email,
|
|
||||||
isPosthogEnabled,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -17,13 +17,13 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
|||||||
const stati = {
|
const stati = {
|
||||||
notImplemented: {
|
notImplemented: {
|
||||||
icon: AlertTriangleIcon,
|
icon: AlertTriangleIcon,
|
||||||
title: t("environments.project.app-connection.formbricks_sdk_not_connected"),
|
title: t("environments.workspace.app-connection.formbricks_sdk_not_connected"),
|
||||||
subtitle: t("environments.project.app-connection.formbricks_sdk_not_connected_description"),
|
subtitle: t("environments.workspace.app-connection.formbricks_sdk_not_connected_description"),
|
||||||
},
|
},
|
||||||
running: {
|
running: {
|
||||||
icon: CheckIcon,
|
icon: CheckIcon,
|
||||||
title: t("environments.project.app-connection.receiving_data"),
|
title: t("environments.workspace.app-connection.receiving_data"),
|
||||||
subtitle: t("environments.project.app-connection.formbricks_sdk_connected"),
|
subtitle: t("environments.workspace.app-connection.formbricks_sdk_connected"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,11 +53,11 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
|||||||
<currentStatus.icon />
|
<currentStatus.icon />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||||
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
||||||
{status === "notImplemented" && (
|
{status === "notImplemented" && (
|
||||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||||
<RotateCcwIcon />
|
<RotateCcwIcon />
|
||||||
{t("environments.project.app-connection.recheck")}
|
{t("environments.workspace.app-connection.recheck")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import {
|
import {
|
||||||
BuildingIcon,
|
Building2Icon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -135,7 +135,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "teams",
|
id: "teams",
|
||||||
label: t("common.teams"),
|
label: t("common.members_and_teams"),
|
||||||
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -144,6 +144,12 @@ export const OrganizationBreadcrumb = ({
|
|||||||
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
||||||
hidden: !isOwnerOrManager,
|
hidden: !isOwnerOrManager,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "domain",
|
||||||
|
label: t("common.domain"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/settings/domain`,
|
||||||
|
hidden: isFormbricksCloud,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "billing",
|
id: "billing",
|
||||||
label: t("common.billing"),
|
label: t("common.billing"),
|
||||||
@@ -166,7 +172,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
id="organizationDropdownTrigger"
|
id="organizationDropdownTrigger"
|
||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
|
<Building2Icon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{organizationName}</span>
|
<span>{organizationName}</span>
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isOrganizationDropdownOpen ? (
|
{isOrganizationDropdownOpen ? (
|
||||||
@@ -180,7 +186,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
{showOrganizationDropdown && (
|
{showOrganizationDropdown && (
|
||||||
<>
|
<>
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<BuildingIcon className="mr-2 inline h-4 w-4" />
|
<Building2Icon className="mr-2 inline h-4 w-4" />
|
||||||
{t("common.choose_organization")}
|
{t("common.choose_organization")}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingOrganizations && (
|
{isLoadingOrganizations && (
|
||||||
@@ -203,7 +209,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
)}
|
)}
|
||||||
{!isLoadingOrganizations && !loadError && (
|
{!isLoadingOrganizations && !loadError && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||||
{organizations.map((org) => (
|
{organizations.map((org) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={org.id}
|
key={org.id}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -36,12 +36,12 @@ interface ProjectBreadcrumbProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||||
// Match /project/{settingId} or /project/{settingId}/... but exclude settings paths
|
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
|
||||||
if (pathname.includes("/settings/")) {
|
if (pathname.includes("/settings/")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Check if path matches /project/{settingId} (with optional trailing path)
|
// Check if path matches /workspace/{settingId} (with optional trailing path)
|
||||||
const pattern = new RegExp(`/project/${settingId}(?:/|$)`);
|
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
|
||||||
return pattern.test(pathname);
|
return pattern.test(pathname);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
const error = new Error(errorMessage);
|
const error = new Error(errorMessage);
|
||||||
logger.error(error, "Failed to load projects");
|
logger.error(error, "Failed to load projects");
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
setLoadError(errorMessage || t("common.failed_to_load_projects"));
|
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
|
||||||
}
|
}
|
||||||
setIsLoadingProjects(false);
|
setIsLoadingProjects(false);
|
||||||
});
|
});
|
||||||
@@ -101,42 +101,42 @@ export const ProjectBreadcrumb = ({
|
|||||||
{
|
{
|
||||||
id: "general",
|
id: "general",
|
||||||
label: t("common.general"),
|
label: t("common.general"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/general`,
|
href: `/environments/${currentEnvironmentId}/workspace/general`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "look",
|
id: "look",
|
||||||
label: t("common.look_and_feel"),
|
label: t("common.look_and_feel"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/look`,
|
href: `/environments/${currentEnvironmentId}/workspace/look`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "app-connection",
|
id: "app-connection",
|
||||||
label: t("common.website_and_app_connection"),
|
label: t("common.website_and_app_connection"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/app-connection`,
|
href: `/environments/${currentEnvironmentId}/workspace/app-connection`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "integrations",
|
id: "integrations",
|
||||||
label: t("common.integrations"),
|
label: t("common.integrations"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/integrations`,
|
href: `/environments/${currentEnvironmentId}/workspace/integrations`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "teams",
|
id: "teams",
|
||||||
label: t("common.team_access"),
|
label: t("common.team_access"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/teams`,
|
href: `/environments/${currentEnvironmentId}/workspace/teams`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "languages",
|
id: "languages",
|
||||||
label: t("common.survey_languages"),
|
label: t("common.survey_languages"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/languages`,
|
href: `/environments/${currentEnvironmentId}/workspace/languages`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tags",
|
id: "tags",
|
||||||
label: t("common.tags"),
|
label: t("common.tags"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/tags`,
|
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
const errorMessage = `Project not found for project id: ${currentProjectId}`;
|
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
||||||
logger.error(errorMessage);
|
logger.error(errorMessage);
|
||||||
Sentry.captureException(new Error(errorMessage));
|
Sentry.captureException(new Error(errorMessage));
|
||||||
return;
|
return;
|
||||||
@@ -145,7 +145,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
const handleProjectChange = (projectId: string) => {
|
const handleProjectChange = (projectId: string) => {
|
||||||
if (projectId === currentProjectId) return;
|
if (projectId === currentProjectId) return;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`/projects/${projectId}/`);
|
router.push(`/workspaces/${projectId}/`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
|
|
||||||
const handleProjectSettingsNavigation = (settingId: string) => {
|
const handleProjectSettingsNavigation = (settingId: string) => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`/environments/${currentEnvironmentId}/project/${settingId}`);
|
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -198,21 +198,21 @@ export const ProjectBreadcrumb = ({
|
|||||||
id="projectDropdownTrigger"
|
id="projectDropdownTrigger"
|
||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
|
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{projectName}</span>
|
<span>{projectName}</span>
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isProjectDropdownOpen ? (
|
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
||||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
) : (
|
) : (
|
||||||
isEnvironmentBreadcrumbVisible && <ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent align="start" className="mt-2">
|
<DropdownMenuContent align="start" className="mt-2">
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
{t("common.choose_project")}
|
{t("common.choose_workspace")}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingProjects && (
|
{isLoadingProjects && (
|
||||||
<div className="flex items-center justify-center py-2">
|
<div className="flex items-center justify-center py-2">
|
||||||
@@ -234,7 +234,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
)}
|
)}
|
||||||
{!isLoadingProjects && !loadError && (
|
{!isLoadingProjects && !loadError && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||||
{projects.map((proj) => (
|
{projects.map((proj) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={proj.id}
|
key={proj.id}
|
||||||
@@ -251,7 +251,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
onClick={handleAddProject}
|
onClick={handleAddProject}
|
||||||
className="w-full cursor-pointer justify-between">
|
className="w-full cursor-pointer justify-between">
|
||||||
<span>{t("common.add_new_project")}</span>
|
<span>{t("common.add_new_workspace")}</span>
|
||||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
)}
|
)}
|
||||||
@@ -261,7 +261,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
{t("common.project_configuration")}
|
{t("common.workspace_configuration")}
|
||||||
</div>
|
</div>
|
||||||
{projectSettings.map((setting) => (
|
{projectSettings.map((setting) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/comp
|
|||||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
||||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
|
||||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||||
|
|
||||||
const EnvLayout = async (props: {
|
const EnvLayout = async (props: {
|
||||||
@@ -12,6 +11,12 @@ const EnvLayout = async (props: {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
const { environmentId } = params;
|
||||||
|
|
||||||
|
if (environmentId === "undefined") {
|
||||||
|
return redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
// Check session first (required for userId)
|
// Check session first (required for userId)
|
||||||
@@ -24,11 +29,7 @@ const EnvLayout = async (props: {
|
|||||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EnvironmentIdBaseLayout
|
<>
|
||||||
environmentId={params.environmentId}
|
|
||||||
session={layoutData.session}
|
|
||||||
user={layoutData.user}
|
|
||||||
organization={layoutData.organization}>
|
|
||||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||||
<EnvironmentContextWrapper
|
<EnvironmentContextWrapper
|
||||||
environment={layoutData.environment}
|
environment={layoutData.environment}
|
||||||
@@ -36,7 +37,7 @@ const EnvLayout = async (props: {
|
|||||||
organization={layoutData.organization}>
|
organization={layoutData.organization}>
|
||||||
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
||||||
</EnvironmentContextWrapper>
|
</EnvironmentContextWrapper>
|
||||||
</EnvironmentIdBaseLayout>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user