mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-01 12:27:31 -05:00
Compare commits
87 Commits
fix-editor
...
chore/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bb8e49711 | ||
|
|
0156a9e8ea | ||
|
|
57a5c3ce76 | ||
|
|
a771ae189a | ||
|
|
029e069af6 | ||
|
|
71cca557fc | ||
|
|
1500b6f7f3 | ||
|
|
2c9fbf83e4 | ||
|
|
59cc9c564e | ||
|
|
20dc147682 | ||
|
|
2bb7a6f277 | ||
|
|
81272b96e1 | ||
|
|
deb062dd03 | ||
|
|
474be86d33 | ||
|
|
e7ca66ed77 | ||
|
|
2b49dbecd3 | ||
|
|
6da4c6f352 | ||
|
|
659b240fca | ||
|
|
19c0b1d14d | ||
|
|
b4472f48e9 | ||
|
|
d197271771 | ||
|
|
37f652c70e | ||
|
|
645f0ab0d1 | ||
|
|
389a7d9e7b | ||
|
|
c4cf468c7e | ||
|
|
cbc3e923e4 | ||
|
|
a96ba8b1e7 | ||
|
|
e830871361 | ||
|
|
998e5c0819 | ||
|
|
13a56b0237 | ||
|
|
0b5418a03a | ||
|
|
0d8a338965 | ||
|
|
d3250736a9 | ||
|
|
e6ee6a6b0d | ||
|
|
c0b097f929 | ||
|
|
78d336f8c7 | ||
|
|
95a7a265b9 | ||
|
|
136e59da68 | ||
|
|
eb0a87cf80 | ||
|
|
0dcb98ac29 | ||
|
|
540f7aaae7 | ||
|
|
2d4614a0bd | ||
|
|
633bf18204 | ||
|
|
9a6cbd05b6 | ||
|
|
94b0248075 | ||
|
|
082de1042d | ||
|
|
8c19587baa | ||
|
|
433750d3fe | ||
|
|
61befd5ffd | ||
|
|
1e7817fb69 | ||
|
|
f250bc7e88 | ||
|
|
c7faa29437 | ||
|
|
a51a006c26 | ||
|
|
ce96cb0b89 | ||
|
|
fb265d9dba | ||
|
|
e4c155b501 | ||
|
|
2dc5c50f4d | ||
|
|
bddcec0466 | ||
|
|
92677e1ec0 | ||
|
|
b12228e305 | ||
|
|
91be2af30b | ||
|
|
84c668be86 | ||
|
|
4015c76f2b | ||
|
|
a7b2ade4a9 | ||
|
|
75f44952c7 | ||
|
|
0df5e26381 | ||
|
|
89bb3bcd84 | ||
|
|
30fdb72c09 | ||
|
|
cb58cf5825 | ||
|
|
99bd2ba256 | ||
|
|
9df423073f | ||
|
|
3e3c696972 | ||
|
|
cb41e2d344 | ||
|
|
1e19cca7d9 | ||
|
|
fa882dd4cc | ||
|
|
0b82c6de77 | ||
|
|
a944d7626e | ||
|
|
d1b12dc228 | ||
|
|
9f7d6038b1 | ||
|
|
1da92addd2 | ||
|
|
1e4aa5f54b | ||
|
|
96f173c3b1 | ||
|
|
9c9e55fba6 | ||
|
|
42541f86fd | ||
|
|
0ba469a73d | ||
|
|
afa192e5b9 | ||
|
|
4860a9a5cf |
12
.env.example
12
.env.example
@@ -38,6 +38,15 @@ LOG_LEVEL=info
|
||||
|
||||
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
|
||||
|
||||
#################
|
||||
# HUB (DEV) #
|
||||
#################
|
||||
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
|
||||
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
|
||||
HUB_API_KEY=dev-api-key
|
||||
HUB_API_URL=http://localhost:8080
|
||||
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
|
||||
################
|
||||
# MAIL SETUP #
|
||||
################
|
||||
@@ -150,6 +159,7 @@ NOTION_OAUTH_CLIENT_ID=
|
||||
NOTION_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# Stripe Billing Variables
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
@@ -230,4 +240,4 @@ REDIS_URL=redis://localhost:6379
|
||||
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGODOTDEV_API_KEY=your_api_key_here
|
||||
LINGO_API_KEY=your_api_key_here
|
||||
|
||||
@@ -285,12 +285,14 @@ runs:
|
||||
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||
redis_url=${{ env.DUMMY_REDIS_URL }}
|
||||
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
|
||||
posthog_key=${{ env.POSTHOG_KEY }}
|
||||
env:
|
||||
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
|
||||
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
|
||||
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
|
||||
|
||||
- name: Sign GHCR image (GHCR only)
|
||||
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
|
||||
|
||||
1
.github/workflows/build-and-push-ecr.yml
vendored
1
.github/workflows/build-and-push-ecr.yml
vendored
@@ -92,3 +92,4 @@ jobs:
|
||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
|
||||
@@ -52,6 +52,14 @@ We are using SonarQube to identify code smells and security hotspots.
|
||||
- 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.
|
||||
|
||||
## Date and Time Rendering
|
||||
|
||||
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
|
||||
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
|
||||
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
|
||||
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
|
||||
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
|
||||
|
||||
## Database & Prisma Performance
|
||||
|
||||
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@storybook/addon-a11y": "10.2.14",
|
||||
"@storybook/addon-links": "10.2.14",
|
||||
"@storybook/addon-onboarding": "10.2.14",
|
||||
"@storybook/react-vite": "10.2.14",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@storybook/addon-a11y": "10.2.17",
|
||||
"@storybook/addon-links": "10.2.17",
|
||||
"@storybook/addon-onboarding": "10.2.17",
|
||||
"@storybook/react-vite": "10.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.2.14",
|
||||
"storybook": "10.2.14",
|
||||
"eslint-plugin-storybook": "10.2.17",
|
||||
"storybook": "10.2.17",
|
||||
"vite": "7.3.1",
|
||||
"@storybook/addon-docs": "10.2.14"
|
||||
"@storybook/addon-docs": "10.2.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ FROM node:24-alpine3.23 AS base
|
||||
FROM base AS installer
|
||||
|
||||
# Enable corepack and prepare pnpm
|
||||
RUN npm install --ignore-scripts -g corepack@latest
|
||||
RUN npm install --ignore-scripts -g corepack@latest
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
@@ -67,6 +67,7 @@ RUN --mount=type=secret,id=database_url \
|
||||
--mount=type=secret,id=encryption_key \
|
||||
--mount=type=secret,id=redis_url \
|
||||
--mount=type=secret,id=sentry_auth_token \
|
||||
--mount=type=secret,id=posthog_key \
|
||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||
|
||||
#
|
||||
@@ -74,9 +75,10 @@ RUN --mount=type=secret,id=database_url \
|
||||
#
|
||||
FROM base AS runner
|
||||
|
||||
# Update npm to latest, then create user
|
||||
# Upgrade Alpine system packages to pick up security patches, update npm to latest, then create user
|
||||
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
|
||||
RUN npm install --ignore-scripts -g npm@latest \
|
||||
RUN apk update && apk upgrade --no-cache \
|
||||
&& npm install --ignore-scripts -g npm@latest \
|
||||
&& addgroup -S nextjs \
|
||||
&& adduser -S -u 1001 -G nextjs nextjs
|
||||
|
||||
@@ -120,8 +122,11 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
|
||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./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
|
||||
# Runtime migrations import uuid v7 from the database package, so copy the
|
||||
# database package's resolved install instead of the repo-root hoisted version.
|
||||
COPY --from=installer /app/packages/database/node_modules/uuid ./node_modules/uuid
|
||||
RUN chmod -R 755 ./node_modules/uuid \
|
||||
&& node --input-type=module -e "import('uuid').then((module) => { if (typeof module.v7 !== 'function') throw new Error('uuid v7 missing in runtime image'); })"
|
||||
|
||||
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
||||
RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
@@ -164,4 +169,4 @@ RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
|
||||
VOLUME /home/nextjs/apps/web/uploads/
|
||||
VOLUME /home/nextjs/apps/web/saml-connection
|
||||
|
||||
CMD ["/home/nextjs/start.sh"]
|
||||
CMD ["/home/nextjs/start.sh"]
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProjectConfigChannel } from "@formbricks/types/project";
|
||||
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
||||
@@ -14,7 +14,7 @@ interface ConnectWithFormbricksProps {
|
||||
environment: TEnvironment;
|
||||
publicDomain: string;
|
||||
appSetupCompleted: boolean;
|
||||
channel: TProjectConfigChannel;
|
||||
channel: TWorkspaceConfigChannel;
|
||||
}
|
||||
|
||||
export const ConnectWithFormbricks = ({
|
||||
|
||||
@@ -5,7 +5,7 @@ import "prismjs/themes/prism.css";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TProjectConfigChannel } from "@formbricks/types/project";
|
||||
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { CodeBlock } from "@/modules/ui/components/code-block";
|
||||
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
|
||||
@@ -19,7 +19,7 @@ const tabs = [
|
||||
interface OnboardingSetupInstructionsProps {
|
||||
environmentId: string;
|
||||
publicDomain: string;
|
||||
channel: TProjectConfigChannel;
|
||||
channel: TWorkspaceConfigChannel;
|
||||
appSetupCompleted: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
@@ -20,15 +21,15 @@ const Page = async (props: ConnectPageProps) => {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
}
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
const workspace = await getWorkspaceByEnvironmentId(environment.id);
|
||||
if (!workspace) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const channel = project.config.channel || null;
|
||||
const channel = workspace.config.channel || null;
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const OnboardingLayout = async (props) => {
|
||||
const OnboardingLayout = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
@@ -5,10 +5,10 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { TWorkspace } from "@formbricks/types/workspace";
|
||||
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
|
||||
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
@@ -16,12 +16,12 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
||||
|
||||
interface XMTemplateListProps {
|
||||
project: TProject;
|
||||
workspace: TWorkspace;
|
||||
user: TUser;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
|
||||
export const XMTemplateList = ({ workspace, user, environmentId }: XMTemplateListProps) => {
|
||||
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -48,7 +48,7 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
|
||||
const handleTemplateClick = (templateIdx: number) => {
|
||||
setActiveTemplateId(templateIdx);
|
||||
const template = getXMTemplates(t)[templateIdx];
|
||||
const newTemplate = replacePresetPlaceholders(template, project);
|
||||
const newTemplate = replacePresetPlaceholders(template, workspace);
|
||||
createSurvey(newTemplate);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import { TWorkspace } from "@formbricks/types/workspace";
|
||||
import { replacePresetPlaceholders } from "./utils";
|
||||
|
||||
// Mock data
|
||||
const mockProject: TProject = {
|
||||
id: "project1",
|
||||
const mockWorkspace: TWorkspace = {
|
||||
id: "workspace1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Project",
|
||||
name: "Test Workspace",
|
||||
organizationId: "org1",
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
@@ -31,7 +32,7 @@ const mockProject: TProject = {
|
||||
logo: null,
|
||||
};
|
||||
const mockTemplate: TXMTemplate = {
|
||||
name: "$[projectName] Survey",
|
||||
name: "$[workspaceName] Survey",
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
@@ -39,13 +40,13 @@ const mockTemplate: TXMTemplate = {
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: "openText" as const,
|
||||
type: "openText" as TSurveyElementTypeEnum.OpenText,
|
||||
inputType: "text" as const,
|
||||
headline: { default: "$[projectName] Question" },
|
||||
headline: { default: "$[workspaceName] Question" },
|
||||
subheader: { default: "" },
|
||||
required: false,
|
||||
placeholder: { default: "" },
|
||||
charLimit: 1000,
|
||||
charLimit: { enabled: true, max: 1000 },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -69,19 +70,19 @@ describe("replacePresetPlaceholders", () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("replaces projectName placeholder in template name", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result.name).toBe("Test Project Survey");
|
||||
test("replaces workspaceName placeholder in template name", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
|
||||
expect(result.name).toBe("Test Workspace Survey");
|
||||
});
|
||||
|
||||
test("replaces projectName placeholder in element headline", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
|
||||
test("replaces workspaceName placeholder in element headline", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
|
||||
expect(result.blocks[0].elements[0].headline.default).toBe("Test Workspace Question");
|
||||
});
|
||||
|
||||
test("returns a new object without mutating the original template", () => {
|
||||
const originalTemplate = structuredClone(mockTemplate);
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
|
||||
expect(result).not.toBe(mockTemplate);
|
||||
expect(mockTemplate).toEqual(originalTemplate);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import { TWorkspace } from "@formbricks/types/workspace";
|
||||
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
|
||||
|
||||
// replace all occurences of projectName with the actual project name in the current template
|
||||
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
|
||||
// replace all occurences of workspaceName with the actual workspace name in the current template
|
||||
export const replacePresetPlaceholders = (template: TXMTemplate, workspace: TWorkspace): TXMTemplate => {
|
||||
const survey = structuredClone(template);
|
||||
|
||||
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
|
||||
...block,
|
||||
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
|
||||
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, workspace)),
|
||||
}));
|
||||
|
||||
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
|
||||
return { ...survey, name: survey.name.replace("$[workspaceName]", workspace.name), blocks: modifiedBlocks };
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("xm-templates", () => {
|
||||
});
|
||||
|
||||
test("getXMSurveyDefault returns default survey template", () => {
|
||||
const tMock = vi.fn((key) => key) as TFunction;
|
||||
const tMock = vi.fn((key: string) => key) as unknown as TFunction;
|
||||
const result = getXMSurveyDefault(tMock);
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -29,7 +29,7 @@ describe("xm-templates", () => {
|
||||
});
|
||||
|
||||
test("getXMTemplates returns all templates", () => {
|
||||
const tMock = vi.fn((key) => key) as TFunction;
|
||||
const tMock = vi.fn((key: string) => key) as unknown as TFunction;
|
||||
const result = getXMTemplates(tMock);
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
@@ -44,7 +44,7 @@ describe("xm-templates", () => {
|
||||
test("getXMTemplates handles errors gracefully", async () => {
|
||||
const tMock = vi.fn(() => {
|
||||
throw new Error("Test error");
|
||||
}) as TFunction;
|
||||
}) as unknown as TFunction;
|
||||
|
||||
const result = getXMTemplates(tMock);
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import Link from "next/link";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { getUserWorkspaces, getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -23,31 +24,31 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
const t = await getTranslate();
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
const workspace = await getWorkspaceByEnvironmentId(environment.id);
|
||||
if (!workspace) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const projects = await getUserProjects(session.user.id, organizationId);
|
||||
const workspaces = await getUserWorkspaces(session.user.id, organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header title={t("environments.xm-templates.headline")} />
|
||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||
{projects.length >= 2 && (
|
||||
<XMTemplateList workspace={workspace} user={user} environmentId={environment.id} />
|
||||
{workspaces.length >= 2 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
|
||||
@@ -19,8 +19,8 @@ describe("getTeamsByOrganizationId", () => {
|
||||
|
||||
test("returns mapped teams", async () => {
|
||||
const mockTeams = [
|
||||
{ id: "t1", name: "Team 1" },
|
||||
{ id: "t2", name: "Team 2" },
|
||||
{ id: "t1", name: "Team 1", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
|
||||
{ id: "t2", name: "Team 2", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
|
||||
];
|
||||
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
|
||||
const result = await getTeamsByOrganizationId("org1");
|
||||
|
||||
@@ -22,12 +22,10 @@ export const getTeamsByOrganizationId = reactCache(
|
||||
},
|
||||
});
|
||||
|
||||
const projectTeams = teams.map((team) => ({
|
||||
return teams.map((team: TOrganizationTeam) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
}));
|
||||
|
||||
return projectTeams;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -2,10 +2,13 @@ import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const LandingLayout = async (props) => {
|
||||
const LandingLayout = async (props: {
|
||||
params: Promise<{ organizationId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
@@ -21,11 +24,11 @@ const LandingLayout = async (props) => {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
||||
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
||||
|
||||
if (projects.length !== 0) {
|
||||
const firstProject = projects[0];
|
||||
const environments = await getEnvironments(firstProject.id);
|
||||
if (workspaces.length !== 0) {
|
||||
const firstWorkspace = workspaces[0];
|
||||
const environments = await getEnvironments(firstWorkspace.id);
|
||||
const prodEnvironment = environments.find((e) => e.type === "production");
|
||||
|
||||
if (prodEnvironment) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
||||
import { WorkspaceAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/workspace-and-org-switch";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
@@ -10,7 +10,7 @@ import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
|
||||
const Page = async (props) => {
|
||||
const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
@@ -34,12 +34,12 @@ const Page = async (props) => {
|
||||
<div className="flex-1">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="p-6">
|
||||
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
|
||||
<ProjectAndOrgSwitch
|
||||
{/* we only need to render organization breadcrumb on this page, organizations/workspaces are lazy-loaded */}
|
||||
<WorkspaceAndOrgSwitch
|
||||
currentOrganizationId={organization.id}
|
||||
currentOrganizationName={organization.name}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={0}
|
||||
organizationWorkspacesLimit={0}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isLicenseActive={false}
|
||||
isOwnerOrManager={false}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -8,7 +8,10 @@ import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||
|
||||
const ProjectOnboardingLayout = async (props) => {
|
||||
const WorkspaceOnboardingLayout = async (props: {
|
||||
params: Promise<{ organizationId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
@@ -22,7 +25,7 @@ const ProjectOnboardingLayout = async (props) => {
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
||||
@@ -33,7 +36,7 @@ const ProjectOnboardingLayout = async (props) => {
|
||||
|
||||
const organization = await getOrganization(params.organizationId);
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -44,4 +47,4 @@ const ProjectOnboardingLayout = async (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectOnboardingLayout;
|
||||
export default WorkspaceOnboardingLayout;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -39,7 +39,7 @@ const Page = async (props: ChannelPageProps) => {
|
||||
},
|
||||
];
|
||||
|
||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
||||
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
@@ -48,7 +48,7 @@ const Page = async (props: ChannelPageProps) => {
|
||||
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
|
||||
/>
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
{workspaces.length >= 1 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
const OnboardingLayout = async (props) => {
|
||||
const OnboardingLayout = async (props: {
|
||||
params: Promise<{ organizationId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
@@ -25,13 +29,15 @@ const OnboardingLayout = async (props) => {
|
||||
|
||||
const organization = await getOrganization(params.organizationId);
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
||||
}
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
||||
const [organizationWorkspacesLimit, organizationWorkspacesCount] = await Promise.all([
|
||||
getOrganizationWorkspacesLimit(organization.id),
|
||||
getOrganizationWorkspacesCount(organization.id),
|
||||
]);
|
||||
|
||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
||||
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
|
||||
return redirect(`/`);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -39,13 +39,13 @@ const Page = async (props: ModePageProps) => {
|
||||
},
|
||||
];
|
||||
|
||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
||||
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
{workspaces.length >= 1 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
|
||||
interface SelectPlanOnboardingProps {
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboardingProps) => {
|
||||
const t = await getTranslate();
|
||||
const nextUrl = `/organizations/${organizationId}/workspaces/new/mode`;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
|
||||
<Header
|
||||
title={t("environments.settings.billing.select_plan_header_title")}
|
||||
subtitle={t("environments.settings.billing.select_plan_header_subtitle")}
|
||||
/>
|
||||
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TCloudBillingPlan } from "@formbricks/types/organizations";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
|
||||
|
||||
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
|
||||
|
||||
interface PlanPageProps {
|
||||
params: Promise<{
|
||||
organizationId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const Page = async (props: PlanPageProps) => {
|
||||
const params = await props.params;
|
||||
|
||||
if (!IS_FORMBRICKS_CLOUD) {
|
||||
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
|
||||
}
|
||||
|
||||
const { session } = await getOrganizationAuth(params.organizationId);
|
||||
|
||||
if (!session?.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
// Users with an existing paid/trial subscription should not be shown the trial page.
|
||||
// Redirect them directly to the next onboarding step.
|
||||
const billing = await getOrganizationBillingWithReadThroughSync(params.organizationId);
|
||||
const currentPlan = billing?.stripe?.plan;
|
||||
const hasExistingSubscription = currentPlan !== undefined && PAID_PLANS.has(currentPlan);
|
||||
|
||||
if (hasExistingSubscription) {
|
||||
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
|
||||
}
|
||||
|
||||
return <SelectPlanOnboarding organizationId={params.organizationId} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -8,19 +8,19 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TProjectConfigChannel,
|
||||
TProjectConfigIndustry,
|
||||
TProjectMode,
|
||||
TProjectUpdateInput,
|
||||
ZProjectUpdateInput,
|
||||
} from "@formbricks/types/project";
|
||||
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
TWorkspaceConfigChannel,
|
||||
TWorkspaceConfigIndustry,
|
||||
TWorkspaceMode,
|
||||
TWorkspaceUpdateInput,
|
||||
ZWorkspaceUpdateInput,
|
||||
} from "@formbricks/types/workspace";
|
||||
import { createWorkspaceAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { previewSurvey } from "@/app/lib/templates";
|
||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
||||
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ColorPicker } from "@/modules/ui/components/color-picker";
|
||||
import {
|
||||
@@ -36,34 +36,34 @@ import { Input } from "@/modules/ui/components/input";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
import { SurveyInline } from "@/modules/ui/components/survey";
|
||||
|
||||
interface ProjectSettingsProps {
|
||||
interface WorkspaceSettingsProps {
|
||||
organizationId: string;
|
||||
projectMode: TProjectMode;
|
||||
channel: TProjectConfigChannel;
|
||||
industry: TProjectConfigIndustry;
|
||||
workspaceMode: TWorkspaceMode;
|
||||
channel: TWorkspaceConfigChannel;
|
||||
industry: TWorkspaceConfigIndustry;
|
||||
defaultBrandColor: string;
|
||||
organizationTeams: TOrganizationTeam[];
|
||||
isAccessControlAllowed: boolean;
|
||||
userProjectsCount: number;
|
||||
userWorkspacesCount: number;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
export const ProjectSettings = ({
|
||||
export const WorkspaceSettings = ({
|
||||
organizationId,
|
||||
projectMode,
|
||||
workspaceMode,
|
||||
channel,
|
||||
industry,
|
||||
defaultBrandColor,
|
||||
organizationTeams,
|
||||
isAccessControlAllowed = false,
|
||||
userProjectsCount,
|
||||
userWorkspacesCount,
|
||||
publicDomain,
|
||||
}: ProjectSettingsProps) => {
|
||||
}: WorkspaceSettingsProps) => {
|
||||
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const addProject = async (data: TProjectUpdateInput) => {
|
||||
const addWorkspace = async (data: TWorkspaceUpdateInput) => {
|
||||
try {
|
||||
// Build the full styling from the chosen brand color so all derived
|
||||
// colours (question, button, input, option, progress, etc.) are persisted.
|
||||
@@ -71,7 +71,7 @@ export const ProjectSettings = ({
|
||||
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
|
||||
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
|
||||
|
||||
const createProjectResponse = await createProjectAction({
|
||||
const createWorkspaceResponse = await createWorkspaceAction({
|
||||
organizationId,
|
||||
data: {
|
||||
...data,
|
||||
@@ -81,14 +81,14 @@ export const ProjectSettings = ({
|
||||
},
|
||||
});
|
||||
|
||||
if (createProjectResponse?.data) {
|
||||
if (createWorkspaceResponse?.data) {
|
||||
// get production environment
|
||||
const productionEnvironment = createProjectResponse.data.environments.find(
|
||||
(environment) => environment.type === "production"
|
||||
const productionEnvironment = createWorkspaceResponse.data.environments.find(
|
||||
(environment: { type: string }) => environment.type === "production"
|
||||
);
|
||||
if (productionEnvironment) {
|
||||
if (globalThis.window !== undefined) {
|
||||
// Rmove filters when creating a new project
|
||||
// Remove filters when creating a new workspace
|
||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
}
|
||||
}
|
||||
@@ -96,11 +96,11 @@ export const ProjectSettings = ({
|
||||
router.push(`/environments/${productionEnvironment?.id}/connect`);
|
||||
} else if (channel === "link") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/surveys`);
|
||||
} else if (projectMode === "cx") {
|
||||
} else if (workspaceMode === "cx") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createProjectResponse);
|
||||
const errorMessage = getFormattedErrorMessage(createWorkspaceResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -109,15 +109,15 @@ export const ProjectSettings = ({
|
||||
}
|
||||
};
|
||||
|
||||
const form = useForm<TProjectUpdateInput>({
|
||||
const form = useForm<TWorkspaceUpdateInput>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
||||
teamIds: [],
|
||||
},
|
||||
resolver: zodResolver(ZProjectUpdateInput),
|
||||
resolver: zodResolver(ZWorkspaceUpdateInput),
|
||||
});
|
||||
const projectName = form.watch("name");
|
||||
const workspaceName = form.watch("name");
|
||||
const logoUrl = form.watch("logo.url");
|
||||
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
|
||||
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
|
||||
@@ -132,7 +132,7 @@ export const ProjectSettings = ({
|
||||
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
|
||||
<div className="flex w-1/2 flex-col space-y-4">
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(addProject)} className="w-full space-y-4">
|
||||
<form onSubmit={form.handleSubmit(addWorkspace)} className="w-full space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="styling.brandColor.light"
|
||||
@@ -184,7 +184,7 @@ export const ProjectSettings = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAccessControlAllowed && userProjectsCount > 0 && (
|
||||
{isAccessControlAllowed && userWorkspacesCount > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="teamIds"
|
||||
@@ -242,7 +242,7 @@ export const ProjectSettings = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={previewSurvey(projectName || t("common.my_product"), t)}
|
||||
survey={previewSurvey(workspaceName || t("common.my_product"), t)}
|
||||
styling={previewStyling}
|
||||
isBrandingEnabled={false}
|
||||
languageCode="default"
|
||||
@@ -1,30 +1,35 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TWorkspaceConfigChannel,
|
||||
TWorkspaceConfigIndustry,
|
||||
TWorkspaceMode,
|
||||
} from "@formbricks/types/workspace";
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
||||
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings";
|
||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
|
||||
interface ProjectSettingsPageProps {
|
||||
interface WorkspaceSettingsPageProps {
|
||||
params: Promise<{
|
||||
organizationId: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
channel?: TProjectConfigChannel;
|
||||
industry?: TProjectConfigIndustry;
|
||||
mode?: TProjectMode;
|
||||
channel?: TWorkspaceConfigChannel;
|
||||
industry?: TWorkspaceConfigIndustry;
|
||||
mode?: TWorkspaceMode;
|
||||
}>;
|
||||
}
|
||||
|
||||
const Page = async (props: ProjectSettingsPageProps) => {
|
||||
const Page = async (props: WorkspaceSettingsPageProps) => {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
@@ -38,14 +43,14 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
const channel = searchParams.channel ?? null;
|
||||
const industry = searchParams.industry ?? null;
|
||||
const mode = searchParams.mode ?? "surveys";
|
||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
||||
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
||||
|
||||
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
||||
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||
|
||||
if (!organizationTeams) {
|
||||
throw new Error(t("common.organization_teams_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.team"), null);
|
||||
}
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
@@ -56,18 +61,18 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
|
||||
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
|
||||
/>
|
||||
<ProjectSettings
|
||||
<WorkspaceSettings
|
||||
organizationId={params.organizationId}
|
||||
projectMode={mode}
|
||||
workspaceMode={mode}
|
||||
channel={channel}
|
||||
industry={industry}
|
||||
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
||||
organizationTeams={organizationTeams}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
userProjectsCount={projects.length}
|
||||
userWorkspacesCount={workspaces.length}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
{projects.length >= 1 && (
|
||||
{workspaces.length >= 1 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
|
||||
@@ -16,7 +16,7 @@ interface OnboardingOptionsContainerProps {
|
||||
}
|
||||
|
||||
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
|
||||
const getOptionCard = (option) => {
|
||||
const getOptionCard = (option: OnboardingOptionsContainerProps["options"][number]) => {
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<OptionCard
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZOrganizationTeam = z.object({
|
||||
id: z.string().cuid2(),
|
||||
id: z.cuid2(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
|
||||
const SurveyEditorEnvironmentLayout = async (props) => {
|
||||
const SurveyEditorEnvironmentLayout = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
@@ -14,13 +18,13 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,15 +6,26 @@ import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Confetti } from "@/modules/ui/components/confetti";
|
||||
|
||||
interface ConfirmationPageProps {
|
||||
environmentId: string;
|
||||
}
|
||||
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
|
||||
|
||||
export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
|
||||
export const ConfirmationPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setShowConfetti(true);
|
||||
|
||||
if (globalThis.window === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
|
||||
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
|
||||
);
|
||||
if (storedEnvironmentId) {
|
||||
setResolvedEnvironmentId(storedEnvironmentId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -30,7 +41,12 @@ export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild className="w-full justify-center">
|
||||
<Link href={`/environments/${environmentId}/settings/billing`}>
|
||||
<Link
|
||||
href={
|
||||
resolvedEnvironmentId
|
||||
? `/environments/${resolvedEnvironmentId}/settings/billing`
|
||||
: "/environments"
|
||||
}>
|
||||
{t("billing_confirmation.back_to_billing_overview")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -3,13 +3,10 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const Page = async (props) => {
|
||||
const searchParams = await props.searchParams;
|
||||
const { environmentId } = searchParams;
|
||||
|
||||
const Page = async () => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<ConfirmationPage environmentId={environmentId?.toString()} />
|
||||
<ConfirmationPage />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,90 +2,89 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||
import {
|
||||
AuthorizationError,
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import {
|
||||
getAccessControlPermission,
|
||||
getOrganizationProjectsLimit,
|
||||
getOrganizationWorkspacesLimit,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||
import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
|
||||
import { getOrganizationsByUserId } from "./lib/organization";
|
||||
import { getProjectsByUserId } from "./lib/project";
|
||||
import { getWorkspacesByUserId } from "./lib/workspace";
|
||||
|
||||
const ZCreateProjectAction = z.object({
|
||||
const ZCreateWorkspaceAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZProjectUpdateInput,
|
||||
data: ZWorkspaceUpdateInput,
|
||||
});
|
||||
|
||||
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"project",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const { user } = ctx;
|
||||
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
|
||||
withAuditLogging("created", "workspace", async ({ ctx, parsedInput }) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const organizationId = parsedInput.organizationId;
|
||||
const organizationId = parsedInput.organizationId;
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZProjectUpdateInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
||||
|
||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
||||
throw new OperationNotAllowedError("Organization workspace limit reached");
|
||||
}
|
||||
|
||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
|
||||
|
||||
if (!isAccessControlAllowed) {
|
||||
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
||||
}
|
||||
}
|
||||
|
||||
const project = await createProject(parsedInput.organizationId, parsedInput.data);
|
||||
const updatedNotificationSettings = {
|
||||
...user.notificationSettings,
|
||||
alert: {
|
||||
...user.notificationSettings?.alert,
|
||||
await checkAuthorizationUpdated({
|
||||
userId: user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZWorkspaceUpdateInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
};
|
||||
],
|
||||
});
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = project.id;
|
||||
ctx.auditLoggingCtx.newObject = project;
|
||||
return project;
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
)
|
||||
|
||||
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
|
||||
const organizationWorkspacesCount = await getOrganizationWorkspacesCount(organization.id);
|
||||
|
||||
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
|
||||
throw new OperationNotAllowedError("Organization workspace limit reached");
|
||||
}
|
||||
|
||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||
|
||||
if (!isAccessControlAllowed) {
|
||||
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
||||
}
|
||||
}
|
||||
|
||||
const workspace = await createWorkspace(parsedInput.organizationId, parsedInput.data);
|
||||
const updatedNotificationSettings = {
|
||||
...user.notificationSettings,
|
||||
alert: {
|
||||
...user.notificationSettings?.alert,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspace.id;
|
||||
ctx.auditLoggingCtx.newObject = workspace;
|
||||
return workspace;
|
||||
})
|
||||
);
|
||||
|
||||
const ZGetOrganizationsForSwitcherAction = z.object({
|
||||
@@ -97,7 +96,7 @@ const ZGetOrganizationsForSwitcherAction = z.object({
|
||||
* Called on-demand when user opens the organization switcher.
|
||||
*/
|
||||
export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
||||
.schema(ZGetOrganizationsForSwitcherAction)
|
||||
.inputSchema(ZGetOrganizationsForSwitcherAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -113,16 +112,16 @@ export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
||||
return await getOrganizationsByUserId(ctx.user.id);
|
||||
});
|
||||
|
||||
const ZGetProjectsForSwitcherAction = z.object({
|
||||
const ZGetWorkspacesForSwitcherAction = z.object({
|
||||
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches projects list for switcher dropdown.
|
||||
* Called on-demand when user opens the project switcher.
|
||||
* Called on-demand when user opens the workspace switcher.
|
||||
*/
|
||||
export const getProjectsForSwitcherAction = authenticatedActionClient
|
||||
.schema(ZGetProjectsForSwitcherAction)
|
||||
export const getWorkspacesForSwitcherAction = authenticatedActionClient
|
||||
.inputSchema(ZGetWorkspacesForSwitcherAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -135,11 +134,11 @@ export const getProjectsForSwitcherAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
// Need membership for getProjectsByUserId (1 DB query)
|
||||
// Need membership for getWorkspacesByUserId (1 DB query)
|
||||
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
|
||||
if (!membership) {
|
||||
throw new AuthorizationError("Membership not found");
|
||||
}
|
||||
|
||||
return await getProjectsByUserId(ctx.user.id, membership);
|
||||
return await getWorkspacesByUserId(ctx.user.id, membership);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||
@@ -24,12 +25,11 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
environment,
|
||||
organization,
|
||||
membership,
|
||||
project, // Current project details
|
||||
environments, // All project environments (for environment switcher)
|
||||
workspace, // Current workspace details
|
||||
environments, // All workspace environments (for environment switcher)
|
||||
isAccessControlAllowed,
|
||||
projectPermission,
|
||||
workspacePermission,
|
||||
license,
|
||||
peopleCount,
|
||||
responseCount,
|
||||
} = layoutData;
|
||||
|
||||
@@ -38,12 +38,12 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
|
||||
const { features, lastChecked, isPendingDowngrade, active, status } = license;
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
|
||||
// Validate that project permission exists for members
|
||||
if (isMember && !projectPermission) {
|
||||
throw new Error(t("common.workspace_permission_not_found"));
|
||||
// Validate that workspace permission exists for members
|
||||
if (isMember && !workspacePermission) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -52,7 +52,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
<LimitsReachedBanner
|
||||
organization={organization}
|
||||
environmentId={environment.id}
|
||||
peopleCount={peopleCount}
|
||||
responseCount={responseCount}
|
||||
/>
|
||||
)}
|
||||
@@ -71,7 +70,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
environment={environment}
|
||||
organization={organization}
|
||||
user={user}
|
||||
project={{ id: project.id, name: project.name }}
|
||||
workspace={{ id: workspace.id, name: workspace.name }}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membership.role}
|
||||
@@ -81,9 +80,9 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
<TopControlBar
|
||||
environments={environments}
|
||||
currentOrganizationId={organization.id}
|
||||
currentProjectId={project.id}
|
||||
currentWorkspaceId={workspace.id}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
organizationWorkspacesLimit={organizationWorkspacesLimit}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isLicenseActive={active}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RocketIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -27,7 +28,7 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -36,13 +37,14 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/workspaces/settings/(setup)/app-connection/actions";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
interface NavigationProps {
|
||||
environment: TEnvironment;
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
project: { id: string; name: string };
|
||||
workspace: { id: string; name: string };
|
||||
isFormbricksCloud: boolean;
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
@@ -53,7 +55,7 @@ export const MainNavigation = ({
|
||||
environment,
|
||||
organization,
|
||||
user,
|
||||
project,
|
||||
workspace,
|
||||
membershipRole,
|
||||
isFormbricksCloud,
|
||||
isDevelopment,
|
||||
@@ -90,7 +92,7 @@ export const MainNavigation = ({
|
||||
}, [isCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
// Auto collapse project navbar on org and account settings
|
||||
// Auto collapse workspace navbar on org and account settings
|
||||
if (pathname?.includes("/settings")) {
|
||||
setIsCollapsed(true);
|
||||
}
|
||||
@@ -114,14 +116,21 @@ export const MainNavigation = ({
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
},
|
||||
{
|
||||
name: t("common.workflows"),
|
||||
href: `/environments/${environment.id}/workflows`,
|
||||
icon: WorkflowIcon,
|
||||
isActive: pathname?.includes("/workflows"),
|
||||
isHidden: !isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
icon: Cog,
|
||||
isActive: pathname?.includes("/project"),
|
||||
isActive: pathname?.includes("/workspace"),
|
||||
},
|
||||
],
|
||||
[t, environment.id, pathname]
|
||||
[t, environment.id, pathname, isFormbricksCloud]
|
||||
);
|
||||
|
||||
const dropdownNavigation = [
|
||||
@@ -159,11 +168,25 @@ export const MainNavigation = ({
|
||||
if (isOwnerOrManager) loadReleases();
|
||||
}, [isOwnerOrManager]);
|
||||
|
||||
const trialDaysRemaining = useMemo(() => {
|
||||
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
|
||||
const trialEnd = organization.billing.stripe.trialEnd;
|
||||
if (!trialEnd) return null;
|
||||
const ts = new Date(trialEnd).getTime();
|
||||
if (!Number.isFinite(ts)) return null;
|
||||
const msPerDay = 86_400_000;
|
||||
return Math.ceil((ts - Date.now()) / msPerDay);
|
||||
}, [
|
||||
isFormbricksCloud,
|
||||
organization.billing?.stripe?.subscriptionStatus,
|
||||
organization.billing?.stripe?.trialEnd,
|
||||
]);
|
||||
|
||||
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{project && (
|
||||
{workspace && (
|
||||
<aside
|
||||
className={cn(
|
||||
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
||||
@@ -233,6 +256,13 @@ export const MainNavigation = ({
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Trial Days Remaining */}
|
||||
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
|
||||
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
|
||||
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* User Switch */}
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
||||
import { WorkspaceAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/workspace-and-org-switch";
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
|
||||
interface TopControlBarProps {
|
||||
environments: TEnvironment[];
|
||||
currentOrganizationId: string;
|
||||
currentProjectId: string;
|
||||
currentWorkspaceId: string;
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
organizationWorkspacesLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
@@ -22,9 +22,9 @@ interface TopControlBarProps {
|
||||
export const TopControlBar = ({
|
||||
environments,
|
||||
currentOrganizationId,
|
||||
currentProjectId,
|
||||
currentWorkspaceId,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
organizationWorkspacesLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
isOwnerOrManager,
|
||||
@@ -38,13 +38,13 @@ export const TopControlBar = ({
|
||||
<div
|
||||
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
|
||||
data-testid="fb__global-top-control-bar">
|
||||
<ProjectAndOrgSwitch
|
||||
<WorkspaceAndOrgSwitch
|
||||
currentEnvironmentId={environment.id}
|
||||
environments={environments}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
currentProjectId={currentProjectId}
|
||||
currentWorkspaceId={currentWorkspaceId}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
organizationWorkspacesLimit={organizationWorkspacesLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface ProjectNavItemProps {
|
||||
interface WorkspaceNavItemProps {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export const ProjectNavItem = ({ href, children, isActive }: ProjectNavItemProps) => {
|
||||
export const WorkspaceNavItem = ({ href, children, isActive }: WorkspaceNavItemProps) => {
|
||||
const activeClass = "bg-slate-50 font-semibold";
|
||||
const inactiveClass = "hover:bg-slate-50";
|
||||
|
||||
@@ -138,6 +138,12 @@ export const OrganizationBreadcrumb = ({
|
||||
label: t("common.members_and_teams"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("environments.settings.feedback_record_directories.nav_label"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/feedback-record-directories`,
|
||||
hidden: isMember,
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
|
||||
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
||||
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
|
||||
import { WorkspaceBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/workspace-breadcrumb";
|
||||
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
||||
|
||||
interface ProjectAndOrgSwitchProps {
|
||||
interface WorkspaceAndOrgSwitchProps {
|
||||
currentOrganizationId: string;
|
||||
currentOrganizationName?: string; // Optional: for pages without context
|
||||
currentProjectId?: string;
|
||||
currentProjectName?: string; // Optional: for pages without context
|
||||
currentWorkspaceId?: string;
|
||||
currentWorkspaceName?: string; // Optional: for pages without context
|
||||
currentEnvironmentId?: string;
|
||||
environments: { id: string; type: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
organizationWorkspacesLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
@@ -21,21 +21,21 @@ interface ProjectAndOrgSwitchProps {
|
||||
isAccessControlAllowed: boolean;
|
||||
}
|
||||
|
||||
export const ProjectAndOrgSwitch = ({
|
||||
export const WorkspaceAndOrgSwitch = ({
|
||||
currentOrganizationId,
|
||||
currentOrganizationName,
|
||||
currentProjectId,
|
||||
currentProjectName,
|
||||
currentWorkspaceId,
|
||||
currentWorkspaceName,
|
||||
currentEnvironmentId,
|
||||
environments,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
organizationWorkspacesLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
isOwnerOrManager,
|
||||
isAccessControlAllowed,
|
||||
isMember,
|
||||
}: ProjectAndOrgSwitchProps) => {
|
||||
}: WorkspaceAndOrgSwitchProps) => {
|
||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||
|
||||
@@ -51,14 +51,14 @@ export const ProjectAndOrgSwitch = ({
|
||||
isMember={isMember}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
/>
|
||||
{currentProjectId && currentEnvironmentId && (
|
||||
<ProjectBreadcrumb
|
||||
currentProjectId={currentProjectId}
|
||||
currentProjectName={currentProjectName}
|
||||
{currentWorkspaceId && currentEnvironmentId && (
|
||||
<WorkspaceBreadcrumb
|
||||
currentWorkspaceId={currentWorkspaceId}
|
||||
currentWorkspaceName={currentWorkspaceName}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
organizationWorkspacesLimit={organizationWorkspacesLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
@@ -6,10 +6,8 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { getWorkspacesForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -20,13 +18,15 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { useProject } from "../context/environment-context";
|
||||
import { CreateWorkspaceModal } from "@/modules/workspaces/components/create-workspace-modal";
|
||||
import { WorkspaceLimitModal } from "@/modules/workspaces/components/workspace-limit-modal";
|
||||
import { useWorkspace } from "../context/environment-context";
|
||||
|
||||
interface ProjectBreadcrumbProps {
|
||||
currentProjectId: string;
|
||||
currentProjectName?: string; // Optional: pass directly if context not available
|
||||
interface WorkspaceBreadcrumbProps {
|
||||
currentWorkspaceId: string;
|
||||
currentWorkspaceName?: string; // Optional: pass directly if context not available
|
||||
isOwnerOrManager: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
organizationWorkspacesLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
currentOrganizationId: string;
|
||||
@@ -35,7 +35,7 @@ interface ProjectBreadcrumbProps {
|
||||
isEnvironmentBreadcrumbVisible: boolean;
|
||||
}
|
||||
|
||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||
const isActiveWorkspaceSetting = (pathname: string, settingId: string): boolean => {
|
||||
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
|
||||
if (pathname.includes("/settings/")) {
|
||||
return false;
|
||||
@@ -45,59 +45,59 @@ const isActiveProjectSetting = (pathname: string, settingId: string): boolean =>
|
||||
return pattern.test(pathname);
|
||||
};
|
||||
|
||||
export const ProjectBreadcrumb = ({
|
||||
currentProjectId,
|
||||
currentProjectName,
|
||||
export const WorkspaceBreadcrumb = ({
|
||||
currentWorkspaceId,
|
||||
currentWorkspaceName,
|
||||
isOwnerOrManager,
|
||||
organizationProjectsLimit,
|
||||
organizationWorkspacesLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
currentOrganizationId,
|
||||
currentEnvironmentId,
|
||||
isAccessControlAllowed,
|
||||
isEnvironmentBreadcrumbVisible,
|
||||
}: ProjectBreadcrumbProps) => {
|
||||
}: WorkspaceBreadcrumbProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
||||
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
|
||||
const [openCreateWorkspaceModal, setOpenCreateWorkspaceModal] = useState(false);
|
||||
const [openLimitModal, setOpenLimitModal] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
||||
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false);
|
||||
const [workspaces, setWorkspaces] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Get current project name from context OR prop
|
||||
// Get current workspace name from context OR prop
|
||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||
const { project: currentProject } = useProject();
|
||||
const projectName = currentProject?.name || currentProjectName || "";
|
||||
const { workspace: currentWorkspace } = useWorkspace();
|
||||
const workspaceName = currentWorkspace?.name || currentWorkspaceName || "";
|
||||
|
||||
// Lazy-load projects when dropdown opens
|
||||
// Lazy-load workspaces when dropdown opens
|
||||
useEffect(() => {
|
||||
// Only fetch when dropdown opened for first time (and no error state)
|
||||
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
|
||||
setIsLoadingProjects(true);
|
||||
if (isWorkspaceDropdownOpen && workspaces.length === 0 && !isLoadingWorkspaces && !loadError) {
|
||||
setIsLoadingWorkspaces(true);
|
||||
setLoadError(null); // Clear any previous errors
|
||||
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
getWorkspacesForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
if (result?.data) {
|
||||
// Sort projects by name
|
||||
// Sort workspaces by name
|
||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||
setProjects(sorted);
|
||||
setWorkspaces(sorted);
|
||||
} else {
|
||||
// Handle server errors or validation errors
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
const error = new Error(errorMessage);
|
||||
logger.error(error, "Failed to load projects");
|
||||
logger.error(error, "Failed to load workspaces");
|
||||
Sentry.captureException(error);
|
||||
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
|
||||
}
|
||||
setIsLoadingProjects(false);
|
||||
setIsLoadingWorkspaces(false);
|
||||
});
|
||||
}
|
||||
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
|
||||
}, [isWorkspaceDropdownOpen, currentOrganizationId, workspaces.length, isLoadingWorkspaces, loadError, t]);
|
||||
|
||||
const projectSettings = [
|
||||
const workspaceSettings = [
|
||||
{
|
||||
id: "general",
|
||||
label: t("common.general"),
|
||||
@@ -135,29 +135,29 @@ export const ProjectBreadcrumb = ({
|
||||
},
|
||||
];
|
||||
|
||||
if (!currentProject) {
|
||||
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
||||
if (!currentWorkspace) {
|
||||
const errorMessage = `Workspace not found for workspace id: ${currentWorkspaceId}`;
|
||||
logger.error(errorMessage);
|
||||
Sentry.captureException(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
if (projectId === currentProjectId) return;
|
||||
const handleWorkspaceChange = (workspaceId: string) => {
|
||||
if (workspaceId === currentWorkspaceId) return;
|
||||
startTransition(() => {
|
||||
router.push(`/workspaces/${projectId}/`);
|
||||
router.push(`/workspaces/${workspaceId}/`);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddProject = () => {
|
||||
if (projects.length >= organizationProjectsLimit) {
|
||||
const handleAddWorkspace = () => {
|
||||
if (workspaces.length >= organizationWorkspacesLimit) {
|
||||
setOpenLimitModal(true);
|
||||
return;
|
||||
}
|
||||
setOpenCreateProjectModal(true);
|
||||
setOpenCreateWorkspaceModal(true);
|
||||
};
|
||||
|
||||
const handleProjectSettingsNavigation = (settingId: string) => {
|
||||
const handleWorkspaceSettingsNavigation = (settingId: string) => {
|
||||
startTransition(() => {
|
||||
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
|
||||
});
|
||||
@@ -191,17 +191,17 @@ export const ProjectBreadcrumb = ({
|
||||
];
|
||||
};
|
||||
return (
|
||||
<BreadcrumbItem isActive={isProjectDropdownOpen}>
|
||||
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
|
||||
<BreadcrumbItem isActive={isWorkspaceDropdownOpen}>
|
||||
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className="flex cursor-pointer items-center gap-1 outline-none"
|
||||
id="projectDropdownTrigger"
|
||||
id="workspaceDropdownTrigger"
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{projectName}</span>
|
||||
<span>{workspaceName}</span>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
||||
{isEnvironmentBreadcrumbVisible && !isWorkspaceDropdownOpen ? (
|
||||
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
@@ -214,32 +214,32 @@ export const ProjectBreadcrumb = ({
|
||||
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.choose_workspace")}
|
||||
</div>
|
||||
{isLoadingProjects && (
|
||||
{isLoadingWorkspaces && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProjects && loadError && (
|
||||
{!isLoadingWorkspaces && loadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoadError(null);
|
||||
setProjects([]);
|
||||
setWorkspaces([]);
|
||||
}}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("common.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingProjects && !loadError && (
|
||||
{!isLoadingWorkspaces && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{projects.map((proj) => (
|
||||
{workspaces.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
checked={proj.id === currentProjectId}
|
||||
onClick={() => handleProjectChange(proj.id)}
|
||||
checked={proj.id === currentWorkspaceId}
|
||||
onClick={() => handleWorkspaceChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{proj.name}</span>
|
||||
@@ -249,7 +249,7 @@ export const ProjectBreadcrumb = ({
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddProject}
|
||||
onClick={handleAddWorkspace}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_workspace")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
@@ -263,11 +263,11 @@ export const ProjectBreadcrumb = ({
|
||||
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.workspace_configuration")}
|
||||
</div>
|
||||
{projectSettings.map((setting) => (
|
||||
{workspaceSettings.map((setting) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={setting.id}
|
||||
checked={isActiveProjectSetting(pathname, setting.id)}
|
||||
onClick={() => handleProjectSettingsNavigation(setting.id)}
|
||||
checked={isActiveWorkspaceSetting(pathname, setting.id)}
|
||||
onClick={() => handleWorkspaceSettingsNavigation(setting.id)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
@@ -277,17 +277,17 @@ export const ProjectBreadcrumb = ({
|
||||
</DropdownMenu>
|
||||
{/* Modals */}
|
||||
{openLimitModal && (
|
||||
<ProjectLimitModal
|
||||
<WorkspaceLimitModal
|
||||
open={openLimitModal}
|
||||
setOpen={setOpenLimitModal}
|
||||
buttons={LimitModalButtons()}
|
||||
projectLimit={organizationProjectsLimit}
|
||||
workspaceLimit={organizationWorkspacesLimit}
|
||||
/>
|
||||
)}
|
||||
{openCreateProjectModal && (
|
||||
<CreateProjectModal
|
||||
open={openCreateProjectModal}
|
||||
setOpen={setOpenCreateProjectModal}
|
||||
{openCreateWorkspaceModal && (
|
||||
<CreateWorkspaceModal
|
||||
open={openCreateWorkspaceModal}
|
||||
setOpen={setOpenCreateWorkspaceModal}
|
||||
organizationId={currentOrganizationId}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
@@ -3,11 +3,11 @@
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TWorkspace } from "@formbricks/types/workspace";
|
||||
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
workspace: TWorkspace;
|
||||
organization: TOrganization;
|
||||
organizationId: string;
|
||||
}
|
||||
@@ -22,12 +22,12 @@ export const useEnvironment = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useProject = () => {
|
||||
export const useWorkspace = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
return { project: null };
|
||||
return { workspace: null };
|
||||
}
|
||||
return { project: context.project };
|
||||
return { workspace: context.workspace };
|
||||
};
|
||||
|
||||
export const useOrganization = () => {
|
||||
@@ -41,25 +41,25 @@ export const useOrganization = () => {
|
||||
// Client wrapper component to be used in server components
|
||||
interface EnvironmentContextWrapperProps {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
workspace: TWorkspace;
|
||||
organization: TOrganization;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentContextWrapper = ({
|
||||
environment,
|
||||
project,
|
||||
workspace,
|
||||
organization,
|
||||
children,
|
||||
}: EnvironmentContextWrapperProps) => {
|
||||
const environmentContextValue = useMemo(
|
||||
() => ({
|
||||
environment,
|
||||
project,
|
||||
workspace,
|
||||
organization,
|
||||
organizationId: project.organizationId,
|
||||
organizationId: workspace.organizationId,
|
||||
}),
|
||||
[environment, project, organization]
|
||||
[environment, workspace, organization]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,7 +27,7 @@ const EnvLayout = async (props: {
|
||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||
<EnvironmentContextWrapper
|
||||
environment={layoutData.environment}
|
||||
project={layoutData.project}
|
||||
workspace={layoutData.workspace}
|
||||
organization={layoutData.organization}>
|
||||
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
||||
</EnvironmentContextWrapper>
|
||||
|
||||
@@ -3,18 +3,18 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { getProjectsByUserId } from "./project";
|
||||
import { getWorkspacesByUserId } from "./workspace";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
project: {
|
||||
workspace: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Project", () => {
|
||||
describe("getUserProjects", () => {
|
||||
describe("Workspace", () => {
|
||||
describe("getUserWorkspaces", () => {
|
||||
const mockAdminMembership: TMembership = {
|
||||
role: "manager",
|
||||
organizationId: "org1",
|
||||
@@ -29,17 +29,17 @@ describe("Project", () => {
|
||||
accepted: true,
|
||||
};
|
||||
|
||||
test("should return projects for admin role", async () => {
|
||||
const mockProjects = [
|
||||
{ id: "project1", name: "Project 1" },
|
||||
{ id: "project2", name: "Project 2" },
|
||||
test("should return workspaces for admin role", async () => {
|
||||
const mockWorkspaces = [
|
||||
{ id: "workspace1", name: "Workspace 1" },
|
||||
{ id: "workspace2", name: "Workspace 2" },
|
||||
];
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
||||
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
|
||||
|
||||
const result = await getProjectsByUserId("user1", mockAdminMembership);
|
||||
const result = await getWorkspacesByUserId("user1", mockAdminMembership);
|
||||
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: "org1",
|
||||
},
|
||||
@@ -48,20 +48,20 @@ describe("Project", () => {
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(result).toEqual(mockWorkspaces);
|
||||
});
|
||||
|
||||
test("should return projects for member role with team restrictions", async () => {
|
||||
const mockProjects = [{ id: "project1", name: "Project 1" }];
|
||||
test("should return workspaces for member role with team restrictions", async () => {
|
||||
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }];
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
||||
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
|
||||
|
||||
const result = await getProjectsByUserId("user1", mockMemberMembership);
|
||||
const result = await getWorkspacesByUserId("user1", mockMemberMembership);
|
||||
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: "org1",
|
||||
projectTeams: {
|
||||
workspaceTeams: {
|
||||
some: {
|
||||
team: {
|
||||
teamUsers: {
|
||||
@@ -78,13 +78,13 @@ describe("Project", () => {
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(result).toEqual(mockWorkspaces);
|
||||
});
|
||||
|
||||
test("should return empty array when no projects found", async () => {
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue([]);
|
||||
test("should return empty array when no workspaces found", async () => {
|
||||
vi.mocked(prisma.workspace.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getProjectsByUserId("user1", mockAdminMembership);
|
||||
const result = await getWorkspacesByUserId("user1", mockAdminMembership);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
@@ -95,27 +95,27 @@ describe("Project", () => {
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
|
||||
vi.mocked(prisma.workspace.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(
|
||||
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow(
|
||||
new DatabaseError("Database error")
|
||||
);
|
||||
});
|
||||
|
||||
test("should re-throw unknown errors", async () => {
|
||||
const unknownError = new Error("Unknown error");
|
||||
vi.mocked(prisma.project.findMany).mockRejectedValue(unknownError);
|
||||
vi.mocked(prisma.workspace.findMany).mockRejectedValue(unknownError);
|
||||
|
||||
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
|
||||
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
|
||||
});
|
||||
|
||||
test("should validate inputs correctly", async () => {
|
||||
await expect(getProjectsByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
|
||||
await expect(getWorkspacesByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should validate membership input correctly", async () => {
|
||||
const invalidMembership = {} as TMembership;
|
||||
await expect(getProjectsByUserId("user1", invalidMembership)).rejects.toThrow();
|
||||
await expect(getWorkspacesByUserId("user1", invalidMembership)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should handle owner role like manager", async () => {
|
||||
@@ -126,12 +126,12 @@ describe("Project", () => {
|
||||
accepted: true,
|
||||
};
|
||||
|
||||
const mockProjects = [{ id: "project1", name: "Project 1" }];
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
||||
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }];
|
||||
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
|
||||
|
||||
const result = await getProjectsByUserId("user1", mockOwnerMembership);
|
||||
const result = await getWorkspacesByUserId("user1", mockOwnerMembership);
|
||||
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId: "org1",
|
||||
},
|
||||
@@ -140,7 +140,7 @@ describe("Project", () => {
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(result).toEqual(mockWorkspaces);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,15 +6,15 @@ import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TMembership, ZMembership } from "@formbricks/types/memberships";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export const getProjectsByUserId = reactCache(
|
||||
export const getWorkspacesByUserId = reactCache(
|
||||
async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => {
|
||||
validateInputs([userId, ZString], [orgMembership, ZMembership]);
|
||||
|
||||
let projectWhereClause: Prisma.ProjectWhereInput = {};
|
||||
let workspaceWhereClause: Prisma.WorkspaceWhereInput = {};
|
||||
|
||||
if (orgMembership.role === "member") {
|
||||
projectWhereClause = {
|
||||
projectTeams: {
|
||||
workspaceWhereClause = {
|
||||
workspaceTeams: {
|
||||
some: {
|
||||
team: {
|
||||
teamUsers: {
|
||||
@@ -29,17 +29,17 @@ export const getProjectsByUserId = reactCache(
|
||||
}
|
||||
|
||||
try {
|
||||
const projects = await prisma.project.findMany({
|
||||
const workspaces = await prisma.workspace.findMany({
|
||||
where: {
|
||||
organizationId: orgMembership.organizationId,
|
||||
...projectWhereClause,
|
||||
...workspaceWhereClause,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
return projects;
|
||||
return workspaces;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
@@ -4,7 +4,7 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
|
||||
const EnvironmentPage = async (props) => {
|
||||
const EnvironmentPage = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const { session, organization } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const AccountSettingsLayout = async (props) => {
|
||||
const AccountSettingsLayout = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const t = await getTranslate();
|
||||
const [organization, project, session] = await Promise.all([
|
||||
const [organization, workspace, session] = await Promise.all([
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
getWorkspaceByEnvironmentId(params.environmentId),
|
||||
getServerSession(authOptions),
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
if (!workspace) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
||||
import { ZUserNotificationSettings } from "@formbricks/types/user";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
|
||||
const ZUpdateNotificationSettingsAction = z.object({
|
||||
@@ -12,26 +11,16 @@ const ZUpdateNotificationSettingsAction = z.object({
|
||||
});
|
||||
|
||||
export const updateNotificationSettingsAction = authenticatedActionClient
|
||||
.schema(ZUpdateNotificationSettingsAction)
|
||||
.inputSchema(ZUpdateNotificationSettingsAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
const result = await updateUser(ctx.user.id, {
|
||||
notificationSettings: parsedInput.notificationSettings,
|
||||
});
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
const result = await updateUser(ctx.user.id, {
|
||||
notificationSettings: parsedInput.notificationSettings,
|
||||
});
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ export const EditAlerts = ({
|
||||
{t("environments.settings.notifications.auto_subscribe_to_new_surveys")}
|
||||
</p>
|
||||
<NotificationSwitch
|
||||
surveyOrProjectOrOrganizationId={membership.organization.id}
|
||||
surveyOrWorkspaceOrOrganizationId={membership.organization.id}
|
||||
notificationSettings={user.notificationSettings!}
|
||||
notificationType={"unsubscribedOrganizationIds"}
|
||||
autoDisableNotificationType={autoDisableNotificationType}
|
||||
@@ -66,13 +66,13 @@ export const EditAlerts = ({
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{membership.organization.projects.some((project) =>
|
||||
project.environments.some((environment) => environment.surveys.length > 0)
|
||||
{membership.organization.workspaces.some((workspace) =>
|
||||
workspace.environments.some((environment) => environment.surveys.length > 0)
|
||||
) ? (
|
||||
<div className="grid-cols-8 space-y-1 p-2">
|
||||
{membership.organization.projects.map((project) => (
|
||||
<div key={project.id}>
|
||||
{project.environments.map((environment) => (
|
||||
{membership.organization.workspaces.map((workspace) => (
|
||||
<div key={workspace.id}>
|
||||
{workspace.environments.map((environment) => (
|
||||
<div key={environment.id}>
|
||||
{environment.surveys.map((survey) => (
|
||||
<div
|
||||
@@ -80,11 +80,11 @@ export const EditAlerts = ({
|
||||
key={survey.name}>
|
||||
<div className="col-span-2 text-left">
|
||||
<div className="font-medium text-slate-900">{survey.name}</div>
|
||||
<div className="text-xs text-slate-400">{project.name}</div>
|
||||
<div className="text-xs text-slate-400">{workspace.name}</div>
|
||||
</div>
|
||||
<div className="col-span-1 text-center">
|
||||
<NotificationSwitch
|
||||
surveyOrProjectOrOrganizationId={survey.id}
|
||||
surveyOrWorkspaceOrOrganizationId={survey.id}
|
||||
notificationSettings={user.notificationSettings!}
|
||||
notificationType={"alert"}
|
||||
autoDisableNotificationType={autoDisableNotificationType}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Switch } from "@/modules/ui/components/switch";
|
||||
import { updateNotificationSettingsAction } from "../actions";
|
||||
|
||||
interface NotificationSwitchProps {
|
||||
surveyOrProjectOrOrganizationId: string;
|
||||
surveyOrWorkspaceOrOrganizationId: string;
|
||||
notificationSettings: TUserNotificationSettings;
|
||||
notificationType: "alert" | "unsubscribedOrganizationIds";
|
||||
autoDisableNotificationType?: string;
|
||||
@@ -18,7 +18,7 @@ interface NotificationSwitchProps {
|
||||
}
|
||||
|
||||
export const NotificationSwitch = ({
|
||||
surveyOrProjectOrOrganizationId,
|
||||
surveyOrWorkspaceOrOrganizationId,
|
||||
notificationSettings,
|
||||
notificationType,
|
||||
autoDisableNotificationType,
|
||||
@@ -29,8 +29,8 @@ export const NotificationSwitch = ({
|
||||
const router = useRouter();
|
||||
const isChecked =
|
||||
notificationType === "unsubscribedOrganizationIds"
|
||||
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
|
||||
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
|
||||
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId)
|
||||
: notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true;
|
||||
|
||||
const handleSwitchChange = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -38,21 +38,21 @@ export const NotificationSwitch = ({
|
||||
let updatedNotificationSettings = { ...notificationSettings };
|
||||
if (notificationType === "unsubscribedOrganizationIds") {
|
||||
const unsubscribedOrganizationIds = updatedNotificationSettings.unsubscribedOrganizationIds ?? [];
|
||||
if (unsubscribedOrganizationIds.includes(surveyOrProjectOrOrganizationId)) {
|
||||
if (unsubscribedOrganizationIds.includes(surveyOrWorkspaceOrOrganizationId)) {
|
||||
updatedNotificationSettings.unsubscribedOrganizationIds = unsubscribedOrganizationIds.filter(
|
||||
(id) => id !== surveyOrProjectOrOrganizationId
|
||||
(id) => id !== surveyOrWorkspaceOrOrganizationId
|
||||
);
|
||||
} else {
|
||||
updatedNotificationSettings.unsubscribedOrganizationIds = [
|
||||
...unsubscribedOrganizationIds,
|
||||
surveyOrProjectOrOrganizationId,
|
||||
surveyOrWorkspaceOrOrganizationId,
|
||||
];
|
||||
}
|
||||
} else {
|
||||
updatedNotificationSettings[notificationType] = {
|
||||
...updatedNotificationSettings[notificationType],
|
||||
[surveyOrProjectOrOrganizationId]:
|
||||
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
|
||||
[surveyOrWorkspaceOrOrganizationId]:
|
||||
!updatedNotificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,12 +76,12 @@ export const NotificationSwitch = ({
|
||||
useEffect(() => {
|
||||
if (
|
||||
autoDisableNotificationType &&
|
||||
autoDisableNotificationElementId === surveyOrProjectOrOrganizationId &&
|
||||
autoDisableNotificationElementId === surveyOrWorkspaceOrOrganizationId &&
|
||||
isChecked
|
||||
) {
|
||||
switch (notificationType) {
|
||||
case "alert":
|
||||
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
|
||||
if (notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true) {
|
||||
handleSwitchChange();
|
||||
toast.success(
|
||||
t(
|
||||
@@ -95,7 +95,9 @@ export const NotificationSwitch = ({
|
||||
break;
|
||||
|
||||
case "unsubscribedOrganizationIds":
|
||||
if (!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)) {
|
||||
if (
|
||||
!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId)
|
||||
) {
|
||||
handleSwitchChange();
|
||||
toast.success(
|
||||
t(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
@@ -16,17 +17,18 @@ const setCompleteNotificationSettings = (
|
||||
notificationSettings: TUserNotificationSettings,
|
||||
memberships: Membership[]
|
||||
): TUserNotificationSettings => {
|
||||
const newNotificationSettings = {
|
||||
alert: {},
|
||||
const newNotificationSettings: TUserNotificationSettings = {
|
||||
alert: {} as Record<string, boolean>,
|
||||
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
||||
};
|
||||
for (const membership of memberships) {
|
||||
for (const project of membership.organization.projects) {
|
||||
for (const workspace of membership.organization.workspaces) {
|
||||
// set default values for alerts
|
||||
for (const environment of project.environments) {
|
||||
for (const environment of workspace.environments) {
|
||||
for (const survey of environment.surveys) {
|
||||
newNotificationSettings.alert[survey.id] =
|
||||
notificationSettings[survey.id]?.responseFinished ||
|
||||
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
|
||||
?.responseFinished ||
|
||||
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
|
||||
false; // check for legacy notification settings w/o "alerts" key
|
||||
}
|
||||
@@ -45,17 +47,17 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
// Fetch all projects if user role is owner or manager
|
||||
// Fetch all workspaces if user role is owner or manager
|
||||
role: {
|
||||
in: ["owner", "manager"],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Filter projects based on team membership if user is not owner or manager
|
||||
// Filter workspaces based on team membership if user is not owner or manager
|
||||
organization: {
|
||||
projects: {
|
||||
workspaces: {
|
||||
some: {
|
||||
projectTeams: {
|
||||
workspaceTeams: {
|
||||
some: {
|
||||
team: {
|
||||
teamUsers: {
|
||||
@@ -77,12 +79,12 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
projects: {
|
||||
workspaces: {
|
||||
// Apply conditional filtering based on user's role
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
// Fetch all projects if user is owner or manager
|
||||
// Fetch all workspaces if user is owner or manager
|
||||
organization: {
|
||||
memberships: {
|
||||
some: {
|
||||
@@ -95,8 +97,8 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
||||
},
|
||||
},
|
||||
{
|
||||
// Only include projects accessible through teams if user is not owner or manager
|
||||
projectTeams: {
|
||||
// Only include workspaces accessible through teams if user is not owner or manager
|
||||
workspaceTeams: {
|
||||
some: {
|
||||
team: {
|
||||
teamUsers: {
|
||||
@@ -136,24 +138,27 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
||||
return memberships;
|
||||
};
|
||||
|
||||
const Page = async (props) => {
|
||||
const Page = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) => {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
const autoDisableNotificationType = searchParams["type"];
|
||||
const autoDisableNotificationElementId = searchParams["elementId"];
|
||||
|
||||
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
if (!memberships) {
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.membership"), null);
|
||||
}
|
||||
|
||||
if (user?.notificationSettings) {
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface Membership {
|
||||
organization: {
|
||||
id: string;
|
||||
name: string;
|
||||
projects: {
|
||||
workspaces: {
|
||||
id: string;
|
||||
name: string;
|
||||
environments: {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||
|
||||
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
|
||||
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
|
||||
return {
|
||||
...(parsedInput.name && { name: parsedInput.name }),
|
||||
...(parsedInput.locale && { locale: parsedInput.locale }),
|
||||
@@ -63,50 +63,36 @@ async function handleEmailUpdate({
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: TUserPersonalInfoUpdateInput;
|
||||
}) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
let payload = buildUserUpdatePayload(parsedInput);
|
||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action(
|
||||
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
let payload = buildUserUpdatePayload(parsedInput);
|
||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
let newObject = oldObject;
|
||||
if (Object.keys(payload).length > 0) {
|
||||
newObject = await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = newObject;
|
||||
|
||||
return true;
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
let newObject = oldObject;
|
||||
if (Object.keys(payload).length > 0) {
|
||||
newObject = await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
)
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = newObject;
|
||||
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
export const resetPasswordAction = authenticatedActionClient.action(
|
||||
withAuditLogging(
|
||||
"passwordReset",
|
||||
"user",
|
||||
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
||||
}
|
||||
|
||||
await sendForgotPasswordEmail(ctx.user);
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
|
||||
return { success: true };
|
||||
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
||||
}
|
||||
)
|
||||
|
||||
await sendForgotPasswordEmail(ctx.user);
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
|
||||
return { success: true };
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
@@ -28,7 +29,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const user = session?.user ? await getUser(session.user.id) : null;
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||
@@ -60,7 +61,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
buttons={[
|
||||
{
|
||||
text: IS_FORMBRICKS_CLOUD
|
||||
? t("common.start_free_trial")
|
||||
? t("common.upgrade_plan")
|
||||
: t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${params.environmentId}/settings/billing`
|
||||
|
||||
@@ -40,6 +40,13 @@ export const OrganizationSettingsNavbar = ({
|
||||
href: `/environments/${environmentId}/settings/teams`,
|
||||
current: pathname?.includes("/teams"),
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("environments.settings.feedback_record_directories.nav_label"),
|
||||
href: `/environments/${environmentId}/settings/feedback-record-directories`,
|
||||
current: pathname?.includes("/feedback-record-directories"),
|
||||
hidden: isMember,
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
|
||||
@@ -14,7 +14,7 @@ interface SurveyWithSlug {
|
||||
environment: {
|
||||
id: string;
|
||||
type: "production" | "development";
|
||||
project: {
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
@@ -40,7 +40,7 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
|
||||
},
|
||||
{
|
||||
label: t("environments.settings.domain.workspace"),
|
||||
key: "project",
|
||||
key: "workspace",
|
||||
},
|
||||
{
|
||||
label: t("environments.settings.domain.pretty_url"),
|
||||
@@ -81,7 +81,7 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
|
||||
{survey.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{survey.environment.project.name}</TableCell>
|
||||
<TableCell>{survey.environment.workspace.name}</TableCell>
|
||||
<TableCell>
|
||||
<IdBadge id={survey.slug ?? ""} />
|
||||
</TableCell>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -25,10 +26,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
|
||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
|
||||
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
|
||||
|
||||
type TFeatureDefinition = {
|
||||
key: TPublicLicenseFeatureKey;
|
||||
labelKey: string;
|
||||
docsUrl: string;
|
||||
};
|
||||
|
||||
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
|
||||
return [
|
||||
{
|
||||
key: "contacts",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
|
||||
docsUrl:
|
||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
|
||||
},
|
||||
{
|
||||
key: "workspaces",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_workspaces"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
|
||||
},
|
||||
{
|
||||
key: "whitelabel",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
|
||||
docsUrl:
|
||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
|
||||
},
|
||||
{
|
||||
key: "removeBranding",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
|
||||
docsUrl:
|
||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
|
||||
},
|
||||
{
|
||||
key: "twoFactorAuth",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
|
||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
|
||||
},
|
||||
{
|
||||
key: "sso",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_sso"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
|
||||
},
|
||||
{
|
||||
key: "saml",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_saml"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
|
||||
},
|
||||
{
|
||||
key: "spamProtection",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
|
||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
|
||||
},
|
||||
{
|
||||
key: "auditLogs",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
|
||||
},
|
||||
{
|
||||
key: "accessControl",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
|
||||
},
|
||||
{
|
||||
key: "quotas",
|
||||
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
|
||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
interface EnterpriseLicenseFeaturesTableProps {
|
||||
features: TEnterpriseLicenseFeatures;
|
||||
}
|
||||
|
||||
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
title={t("environments.settings.enterprise.license_features_table_title")}
|
||||
description={t("environments.settings.enterprise.license_features_table_description")}
|
||||
noPadding>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-white">
|
||||
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
|
||||
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
|
||||
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
|
||||
<TableHead>{t("common.documentation")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{getFeatureDefinitions(t).map((feature) => {
|
||||
const value = features[feature.key];
|
||||
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
|
||||
let displayValue: number | string = "—";
|
||||
|
||||
if (typeof value === "number") {
|
||||
displayValue = value;
|
||||
} else if (value === null) {
|
||||
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow key={feature.key} className="hover:bg-white">
|
||||
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
type={isEnabled ? "success" : "gray"}
|
||||
size="normal"
|
||||
text={
|
||||
isEnabled
|
||||
? t("environments.settings.enterprise.license_features_table_enabled")
|
||||
: t("environments.settings.enterprise.license_features_table_disabled")
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-600">{displayValue}</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={feature.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
|
||||
{t("common.read_docs")}
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
@@ -6,22 +6,23 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
||||
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { SettingsCard } from "../../../components/SettingsCard";
|
||||
|
||||
type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
|
||||
|
||||
interface EnterpriseLicenseStatusProps {
|
||||
status: LicenseStatus;
|
||||
status: TLicenseStatus;
|
||||
lastChecked: Date;
|
||||
gracePeriodEnd?: Date;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
const getBadgeConfig = (
|
||||
status: LicenseStatus,
|
||||
status: TLicenseStatus,
|
||||
t: TFunction
|
||||
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
|
||||
switch (status) {
|
||||
@@ -29,6 +30,11 @@ const getBadgeConfig = (
|
||||
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
|
||||
case "expired":
|
||||
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
|
||||
case "instance_mismatch":
|
||||
return {
|
||||
type: "error",
|
||||
label: t("environments.settings.enterprise.license_status_instance_mismatch"),
|
||||
};
|
||||
case "unreachable":
|
||||
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
|
||||
case "invalid_license":
|
||||
@@ -40,10 +46,12 @@ const getBadgeConfig = (
|
||||
|
||||
export const EnterpriseLicenseStatus = ({
|
||||
status,
|
||||
lastChecked,
|
||||
gracePeriodEnd,
|
||||
environmentId,
|
||||
}: EnterpriseLicenseStatusProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
const router = useRouter();
|
||||
const [isRechecking, setIsRechecking] = useState(false);
|
||||
|
||||
@@ -59,6 +67,8 @@ export const EnterpriseLicenseStatus = ({
|
||||
if (result?.data) {
|
||||
if (result.data.status === "unreachable") {
|
||||
toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
|
||||
} else if (result.data.status === "instance_mismatch") {
|
||||
toast.error(t("environments.settings.enterprise.recheck_license_instance_mismatch"));
|
||||
} else if (result.data.status === "invalid_license") {
|
||||
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
|
||||
} else {
|
||||
@@ -86,7 +96,12 @@ export const EnterpriseLicenseStatus = ({
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
||||
<span className="text-sm text-slate-500">
|
||||
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -112,7 +127,7 @@ export const EnterpriseLicenseStatus = ({
|
||||
<Alert variant="warning" size="small">
|
||||
<AlertDescription className="overflow-visible whitespace-normal">
|
||||
{t("environments.settings.enterprise.license_unreachable_grace_period", {
|
||||
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
|
||||
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -128,6 +143,13 @@ export const EnterpriseLicenseStatus = ({
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{status === "instance_mismatch" && (
|
||||
<Alert variant="error" size="small">
|
||||
<AlertDescription className="overflow-visible whitespace-normal">
|
||||
{t("environments.settings.enterprise.license_instance_mismatch_description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
|
||||
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
|
||||
<a
|
||||
|
||||
@@ -10,8 +10,9 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
|
||||
|
||||
const Page = async (props) => {
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
@@ -93,15 +94,19 @@ const Page = async (props) => {
|
||||
/>
|
||||
</PageHeader>
|
||||
{hasLicense ? (
|
||||
<EnterpriseLicenseStatus
|
||||
status={licenseState.status as "active" | "expired" | "unreachable" | "invalid_license"}
|
||||
gracePeriodEnd={
|
||||
licenseState.status === "unreachable"
|
||||
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
||||
: undefined
|
||||
}
|
||||
environmentId={params.environmentId}
|
||||
/>
|
||||
<>
|
||||
<EnterpriseLicenseStatus
|
||||
status={licenseState.status}
|
||||
lastChecked={licenseState.lastChecked}
|
||||
gracePeriodEnd={
|
||||
licenseState.status === "unreachable"
|
||||
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
||||
: undefined
|
||||
}
|
||||
environmentId={params.environmentId}
|
||||
/>
|
||||
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { FeedbackRecordDirectoriesPage as default } from "@/modules/ee/feedback-record-directory/page";
|
||||
@@ -7,7 +7,6 @@ import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
@@ -17,49 +16,38 @@ const ZUpdateOrganizationNameAction = z.object({
|
||||
});
|
||||
|
||||
export const updateOrganizationNameAction = authenticatedActionClient
|
||||
.schema(ZUpdateOrganizationNameAction)
|
||||
.inputSchema(ZUpdateOrganizationNameAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"organization",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
data: parsedInput.data,
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
}
|
||||
)
|
||||
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
data: parsedInput.data,
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const ZDeleteOrganizationAction = z.object({
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
|
||||
withAuditLogging(
|
||||
"deleted",
|
||||
"organization",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
export const deleteOrganizationAction = authenticatedActionClient
|
||||
.inputSchema(ZDeleteOrganizationAction)
|
||||
.action(
|
||||
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
||||
|
||||
@@ -77,6 +65,5 @@ export const deleteOrganizationAction = authenticatedActionClient.schema(ZDelete
|
||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
return await deleteOrganization(parsedInput.organizationId);
|
||||
}
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -107,7 +107,7 @@ const DeleteOrganizationModal = ({
|
||||
}: DeleteOrganizationModalProps) => {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const { t } = useTranslation();
|
||||
const handleInputChange = (e) => {
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
toast.error(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
||||
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
|
||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
||||
|
||||
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
|
||||
const currentUserRole = currentUserMembership?.role;
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const Layout = async (props) => {
|
||||
const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const t = await getTranslate();
|
||||
const [organization, project, session] = await Promise.all([
|
||||
const [organization, workspace, session] = await Promise.all([
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
getWorkspaceByEnvironmentId(params.environmentId),
|
||||
getServerSession(authOptions),
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
if (!workspace) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export const SettingsTitle = ({ title }) => {
|
||||
export const SettingsTitle = ({ title }: { title: string }) => {
|
||||
return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const Page = async (props) => {
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
return redirect(`/environments/${params.environmentId}/settings/profile`);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { getSurveySummary } from "./summary/lib/surveySummary";
|
||||
|
||||
export const revalidateSurveyIdPath = async (environmentId: string, surveyId: string) => {
|
||||
@@ -23,7 +23,7 @@ const ZGetResponsesAction = z.object({
|
||||
});
|
||||
|
||||
export const getResponsesAction = authenticatedActionClient
|
||||
.schema(ZGetResponsesAction)
|
||||
.inputSchema(ZGetResponsesAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -36,9 +36,9 @@ export const getResponsesAction = authenticatedActionClient
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
type: "workspaceTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -57,7 +57,7 @@ const ZGetSurveySummaryAction = z.object({
|
||||
});
|
||||
|
||||
export const getSurveySummaryAction = authenticatedActionClient
|
||||
.schema(ZGetSurveySummaryAction)
|
||||
.inputSchema(ZGetSurveySummaryAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -70,9 +70,9 @@ export const getSurveySummaryAction = authenticatedActionClient
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
type: "workspaceTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -85,7 +85,7 @@ const ZGetResponseCountAction = z.object({
|
||||
});
|
||||
|
||||
export const getResponseCountAction = authenticatedActionClient
|
||||
.schema(ZGetResponseCountAction)
|
||||
.inputSchema(ZGetResponseCountAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -98,9 +98,9 @@ export const getResponseCountAction = authenticatedActionClient
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
type: "workspaceTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -110,12 +110,12 @@ export const getResponseCountAction = authenticatedActionClient
|
||||
|
||||
const ZGetDisplaysWithContactAction = z.object({
|
||||
surveyId: ZId,
|
||||
limit: z.number().int().min(1).max(100),
|
||||
offset: z.number().int().nonnegative(),
|
||||
limit: z.int().min(1).max(100),
|
||||
offset: z.int().nonnegative(),
|
||||
});
|
||||
|
||||
export const getDisplaysWithContactAction = authenticatedActionClient
|
||||
.schema(ZGetDisplaysWithContactAction)
|
||||
.inputSchema(ZGetDisplaysWithContactAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -126,9 +126,9 @@ export const getDisplaysWithContactAction = authenticatedActionClient
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
type: "workspaceTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
|
||||
};
|
||||
};
|
||||
|
||||
const SurveyLayout = async ({ children }) => {
|
||||
const SurveyLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
|
||||
};
|
||||
|
||||
|
||||
@@ -96,8 +96,8 @@ export const ResponseTable = ({
|
||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||
// Generate columns
|
||||
const columns = useMemo(
|
||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
|
||||
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
|
||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
|
||||
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
|
||||
);
|
||||
|
||||
// Save settings to localStorage when they change
|
||||
@@ -205,11 +205,11 @@ export const ResponseTable = ({
|
||||
};
|
||||
|
||||
// Handle downloading selected responses
|
||||
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
|
||||
const downloadSelectedRows = async (responseIds: string[], format: "xlsx" | "csv") => {
|
||||
try {
|
||||
const downloadResponse = await getResponsesDownloadUrlAction({
|
||||
surveyId: survey.id,
|
||||
format: format,
|
||||
format,
|
||||
filterCriteria: { responseIds },
|
||||
});
|
||||
|
||||
@@ -300,7 +300,6 @@ export const ResponseTable = ({
|
||||
<DataTableSettingsModal
|
||||
open={isTableSettingsModalOpen}
|
||||
setOpen={setIsTableSettingsModalOpen}
|
||||
survey={survey}
|
||||
table={table}
|
||||
columnOrder={columnOrder}
|
||||
handleDragEnd={handleDragEnd}
|
||||
|
||||
@@ -5,13 +5,14 @@ import { TFunction } from "i18next";
|
||||
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
@@ -34,6 +35,7 @@ const getElementColumnsData = (
|
||||
element: TSurveyElement,
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
locale: TUserLocale,
|
||||
t: TFunction
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
||||
@@ -41,7 +43,7 @@ const getElementColumnsData = (
|
||||
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
||||
|
||||
// Helper function to create consistent column headers
|
||||
const createElementHeader = (elementType: string, headline: string, suffix?: string) => {
|
||||
const createElementHeader = (elementType: TSurveyElementTypeEnum, headline: string, suffix?: string) => {
|
||||
const title = suffix ? `${headline} - ${suffix}` : headline;
|
||||
const ElementHeader = () => (
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -167,6 +169,7 @@ const getElementColumnsData = (
|
||||
survey={survey}
|
||||
responseData={responseValue}
|
||||
language={language}
|
||||
locale={locale}
|
||||
isExpanded={isExpanded}
|
||||
showId={false}
|
||||
/>
|
||||
@@ -218,6 +221,7 @@ const getElementColumnsData = (
|
||||
survey={survey}
|
||||
responseData={responseValue}
|
||||
language={language}
|
||||
locale={locale}
|
||||
isExpanded={isExpanded}
|
||||
showId={false}
|
||||
/>
|
||||
@@ -232,7 +236,7 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
|
||||
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
|
||||
|
||||
METADATA_FIELDS.forEach((label) => {
|
||||
const IconComponent = COLUMNS_ICON_MAP[label];
|
||||
const IconComponent = COLUMNS_ICON_MAP[label as keyof typeof COLUMNS_ICON_MAP];
|
||||
|
||||
metadataColumns.push({
|
||||
accessorKey: "METADATA_" + label,
|
||||
@@ -259,11 +263,14 @@ export const generateResponseTableColumns = (
|
||||
survey: TSurvey,
|
||||
isExpanded: boolean,
|
||||
isReadOnly: boolean,
|
||||
locale: TUserLocale,
|
||||
t: TFunction,
|
||||
showQuotasColumn: boolean
|
||||
): ColumnDef<TResponseTableData>[] => {
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
|
||||
const elementColumns = elements.flatMap((element) =>
|
||||
getElementColumnsData(element, survey, isExpanded, locale, t)
|
||||
);
|
||||
|
||||
const dateColumn: ColumnDef<TResponseTableData> = {
|
||||
accessorKey: "createdAt",
|
||||
@@ -271,7 +278,7 @@ export const generateResponseTableColumns = (
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.original.createdAt);
|
||||
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
|
||||
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
@@ -38,7 +39,7 @@ describe("utils", () => {
|
||||
"environments.surveys.responses.source": "Source",
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
}) as unknown as TFunction;
|
||||
|
||||
describe("getAddressFieldLabel", () => {
|
||||
test("returns correct label for addressLine1", () => {
|
||||
|
||||
@@ -80,9 +80,24 @@ export const COLUMNS_ICON_MAP = {
|
||||
const userAgentFields = ["browser", "os", "device"];
|
||||
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
|
||||
|
||||
export const getMetadataValue = (meta: TResponseMeta, label: string) => {
|
||||
if (userAgentFields.includes(label)) {
|
||||
return meta.userAgent?.[label];
|
||||
export const getMetadataValue = (
|
||||
meta: TResponseMeta,
|
||||
label: (typeof METADATA_FIELDS)[number]
|
||||
): string | undefined => {
|
||||
switch (label) {
|
||||
case "browser":
|
||||
return meta.userAgent?.browser;
|
||||
case "os":
|
||||
return meta.userAgent?.os;
|
||||
case "device":
|
||||
return meta.userAgent?.device;
|
||||
case "action":
|
||||
return meta.action;
|
||||
case "country":
|
||||
return meta.country;
|
||||
case "source":
|
||||
return meta.source;
|
||||
case "url":
|
||||
return meta.url;
|
||||
}
|
||||
return meta[label];
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
@@ -7,7 +8,6 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -17,31 +17,30 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
|
||||
const Page = async (props) => {
|
||||
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
|
||||
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
|
||||
getSurvey(params.surveyId),
|
||||
getUser(session.user.id),
|
||||
getTagsByEnvironmentId(params.environmentId),
|
||||
getIsContactsEnabled(),
|
||||
getIsContactsEnabled(organization.id),
|
||||
getResponseCountBySurveyId(params.surveyId),
|
||||
findMatchingLocale(),
|
||||
]);
|
||||
|
||||
if (!survey) {
|
||||
throw new Error(t("common.survey_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
}
|
||||
|
||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||
@@ -50,10 +49,10 @@ const Page = async (props) => {
|
||||
|
||||
const organizationBilling = await getOrganizationBilling(organization.id);
|
||||
if (!organizationBilling) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), organization.id);
|
||||
}
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
|
||||
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
|
||||
|
||||
// Fetch initial responses on the server to prevent duplicate client-side fetch
|
||||
@@ -86,7 +85,7 @@ const Page = async (props) => {
|
||||
environmentTags={tags}
|
||||
user={user}
|
||||
responsesPerPage={RESPONSES_PER_PAGE}
|
||||
locale={locale}
|
||||
locale={user.locale}
|
||||
isReadOnly={isReadOnly}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
quotas={quotas}
|
||||
|
||||
@@ -7,9 +7,8 @@ import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/s
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { convertToCsv } from "@/lib/utils/file-conversion";
|
||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -22,7 +21,7 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
||||
});
|
||||
|
||||
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
||||
.schema(ZSendEmbedSurveyPreviewEmailAction)
|
||||
.inputSchema(ZSendEmbedSurveyPreviewEmailAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
|
||||
@@ -36,9 +35,9 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
type: "workspaceTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -65,57 +64,49 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
||||
|
||||
const ZResetSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
organizationId: ZId,
|
||||
projectId: ZId,
|
||||
workspaceId: ZId,
|
||||
});
|
||||
|
||||
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZResetSurveyAction>;
|
||||
}) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: parsedInput.projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
|
||||
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = null;
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
|
||||
parsedInput.surveyId
|
||||
);
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = null;
|
||||
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
deletedResponsesCount: deletedResponsesCount,
|
||||
deletedDisplaysCount: deletedDisplaysCount,
|
||||
};
|
||||
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
|
||||
parsedInput.surveyId
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedResponsesCount: deletedResponsesCount,
|
||||
deletedDisplaysCount: deletedDisplaysCount,
|
||||
};
|
||||
}
|
||||
)
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
deletedResponsesCount: deletedResponsesCount,
|
||||
deletedDisplaysCount: deletedDisplaysCount,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedResponsesCount: deletedResponsesCount,
|
||||
deletedDisplaysCount: deletedDisplaysCount,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const ZGetEmailHtmlAction = z.object({
|
||||
@@ -123,7 +114,7 @@ const ZGetEmailHtmlAction = z.object({
|
||||
});
|
||||
|
||||
export const getEmailHtmlAction = authenticatedActionClient
|
||||
.schema(ZGetEmailHtmlAction)
|
||||
.inputSchema(ZGetEmailHtmlAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -134,9 +125,9 @@ export const getEmailHtmlAction = authenticatedActionClient
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
type: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -152,9 +143,10 @@ const ZGeneratePersonalLinksAction = z.object({
|
||||
});
|
||||
|
||||
export const generatePersonalLinksAction = authenticatedActionClient
|
||||
.schema(ZGeneratePersonalLinksAction)
|
||||
.inputSchema(ZGeneratePersonalLinksAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
if (!isContactsEnabled) {
|
||||
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
|
||||
}
|
||||
@@ -168,8 +160,8 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
type: "workspaceTeam",
|
||||
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
@@ -231,7 +223,7 @@ const ZUpdateSingleUseLinksAction = z.object({
|
||||
});
|
||||
|
||||
export const updateSingleUseLinksAction = authenticatedActionClient
|
||||
.schema(ZUpdateSingleUseLinksAction)
|
||||
.inputSchema(ZUpdateSingleUseLinksAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -242,8 +234,8 @@ export const updateSingleUseLinksAction = authenticatedActionClient
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
type: "workspaceTeam",
|
||||
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
@@ -32,13 +32,14 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
||||
};
|
||||
|
||||
const renderResponseValue = (value: string) => {
|
||||
const parsedDate = new Date(value);
|
||||
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
|
||||
|
||||
const formattedDate = isNaN(parsedDate.getTime())
|
||||
? `${t("common.invalid_date")}(${value})`
|
||||
: formatDateWithOrdinal(parsedDate);
|
||||
|
||||
return formattedDate;
|
||||
return (
|
||||
formattedDate ??
|
||||
t("common.invalid_date_with_value", {
|
||||
value,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -59,7 +60,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
||||
elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<div
|
||||
key={response.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.contact ? (
|
||||
<Link
|
||||
@@ -84,7 +85,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{renderResponseValue(response.value)}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
<div className="px-4 md:px-6">
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,9 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
||||
},
|
||||
};
|
||||
|
||||
const filter = filters[group];
|
||||
const filter = (filters as Record<string, { comparison: string; values: string | string[] | undefined }>)[
|
||||
group
|
||||
];
|
||||
|
||||
if (filter) {
|
||||
setFilter(
|
||||
@@ -104,7 +106,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
||||
<TabsContent value="aggregated" className="mt-4">
|
||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||
<div className="space-y-5 text-sm md:text-base">
|
||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||
{(["promoters", "passives", "detractors", "dismissed"] as const).map((group) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={group}
|
||||
|
||||
@@ -15,7 +15,7 @@ interface SummaryMetadataProps {
|
||||
isQuotasAllowed: boolean;
|
||||
}
|
||||
|
||||
const formatTime = (ttc) => {
|
||||
const formatTime = (ttc: number) => {
|
||||
const seconds = ttc / 1000;
|
||||
let formattedValue;
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ export const SummaryPage = ({
|
||||
setDisplays(data);
|
||||
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
|
||||
} catch (error) {
|
||||
toast.error(error);
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
setDisplays([]);
|
||||
setHasMoreDisplays(false);
|
||||
} finally {
|
||||
|
||||
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const { organizationId, project } = useEnvironment();
|
||||
const { workspace } = useEnvironment();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||
|
||||
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
@@ -128,8 +128,7 @@ export const SurveyAnalysisCTA = ({
|
||||
setIsResetting(true);
|
||||
const result = await resetSurveyAction({
|
||||
surveyId: survey.id,
|
||||
organizationId: organizationId,
|
||||
projectId: project.id,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
if (result?.data) {
|
||||
toast.success(
|
||||
@@ -213,7 +212,7 @@ export const SurveyAnalysisCTA = ({
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isReadOnly={isReadOnly}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
projectCustomScripts={project.customHeadScripts}
|
||||
workspaceCustomScripts={workspace.customHeadScripts}
|
||||
/>
|
||||
)}
|
||||
<SuccessMessage environment={environment} survey={survey} />
|
||||
|
||||
@@ -53,7 +53,7 @@ interface ShareSurveyModalProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isReadOnly: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
projectCustomScripts?: string | null;
|
||||
workspaceCustomScripts?: string | null;
|
||||
}
|
||||
|
||||
export const ShareSurveyModal = ({
|
||||
@@ -68,24 +68,14 @@ export const ShareSurveyModal = ({
|
||||
isFormbricksCloud,
|
||||
isReadOnly,
|
||||
isStorageConfigured,
|
||||
projectCustomScripts,
|
||||
workspaceCustomScripts,
|
||||
}: ShareSurveyModalProps) => {
|
||||
const environmentId = survey.environmentId;
|
||||
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
||||
const [showView, setShowView] = useState<ModalView>(modalView);
|
||||
const { email } = user;
|
||||
const { t } = useTranslation();
|
||||
const linkTabs: {
|
||||
id: ShareViaType | ShareSettingsType;
|
||||
type: LinkTabsType;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
componentType: React.ComponentType<unknown>;
|
||||
componentProps: unknown;
|
||||
disabled?: boolean;
|
||||
}[] = useMemo(() => {
|
||||
const linkTabs = useMemo(() => {
|
||||
const tabs = [
|
||||
{
|
||||
id: ShareViaType.ANON_LINKS,
|
||||
@@ -203,7 +193,7 @@ export const ShareSurveyModal = ({
|
||||
title: t("environments.surveys.share.custom_html.nav_title"),
|
||||
description: t("environments.surveys.share.custom_html.description"),
|
||||
componentType: CustomHtmlTab,
|
||||
componentProps: { projectCustomScripts, isReadOnly },
|
||||
componentProps: { workspaceCustomScripts, isReadOnly },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -226,7 +216,7 @@ export const ShareSurveyModal = ({
|
||||
isFormbricksCloud,
|
||||
email,
|
||||
isStorageConfigured,
|
||||
projectCustomScripts,
|
||||
workspaceCustomScripts,
|
||||
]);
|
||||
|
||||
const getDefaultActiveId = useCallback(() => {
|
||||
|
||||
@@ -47,6 +47,7 @@ const createNoCodeConfigType = (t: ReturnType<typeof useTranslation>["t"]) => ({
|
||||
pageView: t("environments.actions.page_view"),
|
||||
exitIntent: t("environments.actions.exit_intent"),
|
||||
fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"),
|
||||
pageDwell: t("environments.actions.time_on_page"),
|
||||
});
|
||||
|
||||
const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslation>["t"]) => {
|
||||
@@ -87,7 +88,7 @@ const DisplayCriteriaItem = ({ icon, title, titleSuffix, description }: DisplayC
|
||||
|
||||
export const AppTab = () => {
|
||||
const { t } = useTranslation();
|
||||
const { environment, project } = useEnvironment();
|
||||
const { environment, workspace } = useEnvironment();
|
||||
const { survey } = useSurvey();
|
||||
|
||||
const documentationLinks = useMemo(() => createDocumentationLinks(t), [t]);
|
||||
@@ -97,8 +98,8 @@ export const AppTab = () => {
|
||||
if (survey.recontactDays !== null) {
|
||||
return formatRecontactDaysString(survey.recontactDays, t);
|
||||
}
|
||||
if (project.recontactDays !== null) {
|
||||
return formatRecontactDaysString(project.recontactDays, t);
|
||||
if (workspace.recontactDays !== null) {
|
||||
return formatRecontactDaysString(workspace.recontactDays, t);
|
||||
}
|
||||
return t("environments.surveys.summary.in_app.display_criteria.time_based_always");
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { TabToggle } from "@/modules/ui/components/tab-toggle";
|
||||
|
||||
interface CustomHtmlTabProps {
|
||||
projectCustomScripts: string | null | undefined;
|
||||
workspaceCustomScripts: string | null | undefined;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ interface CustomHtmlFormData {
|
||||
customHeadScriptsMode: TSurvey["customHeadScriptsMode"];
|
||||
}
|
||||
|
||||
export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTabProps) => {
|
||||
export const CustomHtmlTab = ({ workspaceCustomScripts, isReadOnly }: CustomHtmlTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { survey } = useSurvey();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -101,18 +101,18 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
|
||||
</div>
|
||||
|
||||
{/* Workspace Scripts Preview */}
|
||||
{projectCustomScripts && (
|
||||
{workspaceCustomScripts && (
|
||||
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
|
||||
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
|
||||
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs text-slate-600">
|
||||
{projectCustomScripts}
|
||||
{workspaceCustomScripts}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!projectCustomScripts && (
|
||||
{!workspaceCustomScripts && (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("environments.surveys.share.custom_html.no_workspace_scripts")}
|
||||
|
||||
@@ -165,7 +165,7 @@ export const PersonalLinksTab = ({
|
||||
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
|
||||
buttons={[
|
||||
{
|
||||
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
|
||||
@@ -39,7 +39,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to generate QR code:", error);
|
||||
logger.error(error as Error, "Failed to generate QR code");
|
||||
setHasError(true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -66,7 +66,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
|
||||
downloadInstance.download({ name: "survey-qr-code", extension: "png" });
|
||||
toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon"));
|
||||
} catch (error) {
|
||||
logger.error("Failed to download QR code:", error);
|
||||
logger.error(error as Error, "Failed to download QR code");
|
||||
toast.error(t("environments.surveys.summary.qr_code_download_failed"));
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
|
||||
@@ -4,6 +4,10 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import {
|
||||
ShareSettingsType,
|
||||
ShareViaType,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
|
||||
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
|
||||
@@ -13,9 +17,9 @@ interface SuccessViewProps {
|
||||
publicDomain: string;
|
||||
setSurveyUrl: (url: string) => void;
|
||||
user: TUser;
|
||||
tabs: { id: string; label: string; icon: React.ElementType }[];
|
||||
handleViewChange: (view: string) => void;
|
||||
handleEmbedViewWithTab: (tabId: string) => void;
|
||||
tabs: { id: ShareViaType | ShareSettingsType; label: string; icon: React.ElementType }[];
|
||||
handleViewChange: (view: "start" | "share") => void;
|
||||
handleEmbedViewWithTab: (tabId: ShareViaType | ShareSettingsType) => void;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getStyling } from "@/lib/utils/styling";
|
||||
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
|
||||
|
||||
@@ -9,14 +10,14 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
||||
const t = await getTranslate();
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
throw new Error("Survey not found");
|
||||
throw new ResourceNotFoundError(t("common.survey"), surveyId);
|
||||
}
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
if (!project) {
|
||||
throw new Error("Workspace not found");
|
||||
const workspace = await getWorkspaceByEnvironmentId(survey.environmentId);
|
||||
if (!workspace) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const styling = getStyling(project, survey);
|
||||
const styling = getStyling(workspace, survey);
|
||||
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
|
||||
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
|
||||
const doctype =
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("Tests for getQuotasSummary service", () => {
|
||||
_count: {
|
||||
quotaLinks: 0,
|
||||
},
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number],
|
||||
]);
|
||||
|
||||
const result = await getQuotasSummary(surveyId);
|
||||
@@ -120,7 +120,7 @@ describe("Tests for getQuotasSummary service", () => {
|
||||
_count: {
|
||||
quotaLinks: 0,
|
||||
},
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number],
|
||||
]);
|
||||
|
||||
const result = await getQuotasSummary(surveyId);
|
||||
|
||||
@@ -2,17 +2,16 @@ import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TLanguage } from "@formbricks/types/project";
|
||||
import { TResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TLanguage } from "@formbricks/types/workspace";
|
||||
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import {
|
||||
getElementSummary,
|
||||
getResponsesForSummary,
|
||||
@@ -44,7 +43,7 @@ vi.mock("@/lib/survey/service", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/surveyLogic/utils", () => ({
|
||||
evaluateLogic: vi.fn(),
|
||||
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })),
|
||||
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredElementIds: [], calculations: {} })),
|
||||
}));
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
@@ -100,7 +99,7 @@ const mockBaseSurvey: TSurvey = {
|
||||
createdBy: "user_123",
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
projectOverwrites: null,
|
||||
workspaceOverwrites: null,
|
||||
showLanguageSwitch: false,
|
||||
isBackButtonHidden: false,
|
||||
followUps: [],
|
||||
@@ -229,12 +228,6 @@ describe("getSurveySummaryDropOff", () => {
|
||||
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
|
||||
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
|
||||
);
|
||||
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
|
||||
vi.mocked(performActions).mockReturnValue({
|
||||
jumpTarget: undefined,
|
||||
requiredElementIds: [],
|
||||
calculations: {},
|
||||
});
|
||||
});
|
||||
|
||||
test("calculates dropOff correctly with welcome card disabled", () => {
|
||||
@@ -246,7 +239,7 @@ describe("getSurveySummaryDropOff", () => {
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: { q1: 10 },
|
||||
ttc: { q1: 10, q2: 5 }, // Saw q2 but didn't answer it
|
||||
finished: false,
|
||||
}, // Dropped at q2
|
||||
{
|
||||
@@ -269,22 +262,55 @@ describe("getSurveySummaryDropOff", () => {
|
||||
);
|
||||
|
||||
expect(dropOff.length).toBe(2);
|
||||
// Q1
|
||||
// Q1: welcome card disabled so impressions = displayCount
|
||||
expect(dropOff[0].elementId).toBe("q1");
|
||||
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
|
||||
expect(dropOff[0].impressions).toBe(displayCount);
|
||||
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
|
||||
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
|
||||
expect(dropOff[0].ttc).toBe(10);
|
||||
|
||||
// Q2
|
||||
// Q2: both responses saw q2 (r1 has ttc for q2, r2 answered q2)
|
||||
expect(dropOff[1].elementId).toBe("q2");
|
||||
expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
|
||||
expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
|
||||
expect(dropOff[1].impressions).toBe(2);
|
||||
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
|
||||
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
|
||||
expect(dropOff[1].ttc).toBe(10);
|
||||
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
|
||||
});
|
||||
|
||||
test("handles logic jumps", () => {
|
||||
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
|
||||
// Welcome card enabled so first element drop-off is NOT overridden by displayCount
|
||||
const surveyWithWelcome: TSurvey = {
|
||||
...surveyWithBlocks,
|
||||
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
|
||||
};
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { q1: "a" },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: { q1: 10 }, // Only saw q1, never reached q2
|
||||
finished: false,
|
||||
},
|
||||
] as any;
|
||||
const displayCount = 1;
|
||||
const dropOff = getSurveySummaryDropOff(
|
||||
surveyWithWelcome,
|
||||
getElementsFromBlocks(surveyWithWelcome.blocks),
|
||||
responses,
|
||||
displayCount
|
||||
);
|
||||
|
||||
expect(dropOff[0].impressions).toBe(1); // Saw q1
|
||||
expect(dropOff[0].dropOffCount).toBe(1); // Dropped at q1 (last seen element)
|
||||
expect(dropOff[1].impressions).toBe(0); // Never saw q2
|
||||
expect(dropOff[1].dropOffCount).toBe(0);
|
||||
});
|
||||
|
||||
test("handles logic jumps — impressions based on actual ttc/data, not logic replay", () => {
|
||||
// Survey with 4 questions across 4 blocks, logic on block2 jumps q2->q4 (skipping q3)
|
||||
const surveyWithLogic: TSurvey = {
|
||||
...mockBaseSurvey,
|
||||
blocks: [
|
||||
@@ -315,36 +341,6 @@ describe("getSurveySummaryDropOff", () => {
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
] as TSurveyElement[],
|
||||
logic: [
|
||||
{
|
||||
id: "logic1",
|
||||
conditions: {
|
||||
id: "condition1",
|
||||
connector: "and" as const,
|
||||
conditions: [
|
||||
{
|
||||
id: "c1",
|
||||
leftOperand: {
|
||||
type: "element" as const,
|
||||
value: "q2",
|
||||
},
|
||||
operator: "equals" as const,
|
||||
rightOperand: {
|
||||
type: "static" as const,
|
||||
value: "b",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: "action1",
|
||||
objective: "jumpToBlock" as const,
|
||||
target: "q4",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "block3",
|
||||
@@ -377,28 +373,21 @@ describe("getSurveySummaryDropOff", () => {
|
||||
],
|
||||
questions: [],
|
||||
};
|
||||
|
||||
// Response where user answered q1, q2, then logic jumped to q4 (skipping q3).
|
||||
// The ttc/data reflects exactly what elements were shown — no logic replay needed.
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { q1: "a", q2: "b" },
|
||||
data: { q1: "a", q2: "b", q4: "d" },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: { q1: 10, q2: 10 },
|
||||
ttc: { q1: 10, q2: 10, q4: 10 }, // q3 has no ttc entry — was skipped by logic
|
||||
finished: false,
|
||||
}, // Jumps from q2 to q4, drops at q4
|
||||
},
|
||||
];
|
||||
vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => {
|
||||
// Simulate logic on q2 triggering
|
||||
return data.q2 === "b";
|
||||
});
|
||||
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
|
||||
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
|
||||
return { jumpTarget: actions[0].target, requiredElementIds: [], calculations: {} };
|
||||
}
|
||||
return { jumpTarget: undefined, requiredElementIds: [], calculations: {} };
|
||||
});
|
||||
|
||||
const dropOff = getSurveySummaryDropOff(
|
||||
surveyWithLogic,
|
||||
@@ -407,11 +396,11 @@ describe("getSurveySummaryDropOff", () => {
|
||||
1
|
||||
);
|
||||
|
||||
expect(dropOff[0].impressions).toBe(1); // q1
|
||||
expect(dropOff[1].impressions).toBe(1); // q2
|
||||
expect(dropOff[2].impressions).toBe(0); // q3 (skipped)
|
||||
expect(dropOff[3].impressions).toBe(1); // q4 (jumped to)
|
||||
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4
|
||||
expect(dropOff[0].impressions).toBe(1); // q1: seen
|
||||
expect(dropOff[1].impressions).toBe(1); // q2: seen
|
||||
expect(dropOff[2].impressions).toBe(0); // q3: skipped by logic (no ttc, no data)
|
||||
expect(dropOff[3].impressions).toBe(1); // q4: jumped to, seen
|
||||
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 (last seen element, not finished)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -662,17 +651,23 @@ describe("getQuestionSummary", () => {
|
||||
expect((summary[0] as any).choices).toHaveLength(3);
|
||||
|
||||
// Item 1 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
|
||||
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
|
||||
const item1 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
|
||||
);
|
||||
expect(item1.count).toBe(2);
|
||||
expect(item1.avgRanking).toBe(1.5);
|
||||
|
||||
// Item 2 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
|
||||
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
|
||||
const item2 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
|
||||
);
|
||||
expect(item2.count).toBe(2);
|
||||
expect(item2.avgRanking).toBe(1.5);
|
||||
|
||||
// Item 3 is in position 3 twice, so avg ranking should be 3
|
||||
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
|
||||
const item3 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
|
||||
);
|
||||
expect(item3.count).toBe(2);
|
||||
expect(item3.avgRanking).toBe(3);
|
||||
});
|
||||
@@ -747,17 +742,23 @@ describe("getQuestionSummary", () => {
|
||||
expect(summary[0].responseCount).toBe(1);
|
||||
|
||||
// Item 1 is in position 2, so avg ranking should be 2
|
||||
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
|
||||
const item1 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
|
||||
);
|
||||
expect(item1.count).toBe(1);
|
||||
expect(item1.avgRanking).toBe(2);
|
||||
|
||||
// Item 2 is in position 1, so avg ranking should be 1
|
||||
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
|
||||
const item2 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
|
||||
);
|
||||
expect(item2.count).toBe(1);
|
||||
expect(item2.avgRanking).toBe(1);
|
||||
|
||||
// Item 3 is in position 3, so avg ranking should be 3
|
||||
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
|
||||
const item3 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
|
||||
);
|
||||
expect(item3.count).toBe(1);
|
||||
expect(item3.avgRanking).toBe(3);
|
||||
});
|
||||
@@ -830,10 +831,12 @@ describe("getQuestionSummary", () => {
|
||||
expect((summary[0] as any).choices).toHaveLength(3);
|
||||
|
||||
// All items should have count 0 and avgRanking 0
|
||||
(summary[0] as any).choices.forEach((choice) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.avgRanking).toBe(0);
|
||||
});
|
||||
(summary[0] as any).choices.forEach(
|
||||
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.avgRanking).toBe(0);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles ranking question with non-array answers", async () => {
|
||||
@@ -894,10 +897,12 @@ describe("getQuestionSummary", () => {
|
||||
expect((summary[0] as any).choices).toHaveLength(3);
|
||||
|
||||
// All items should have count 0 and avgRanking 0 since we had no valid ranking data
|
||||
(summary[0] as any).choices.forEach((choice) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.avgRanking).toBe(0);
|
||||
});
|
||||
(summary[0] as any).choices.forEach(
|
||||
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.avgRanking).toBe(0);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles ranking question with values not in choices", async () => {
|
||||
@@ -958,17 +963,23 @@ describe("getQuestionSummary", () => {
|
||||
expect((summary[0] as any).choices).toHaveLength(3);
|
||||
|
||||
// Item 1 is in position 1, so avg ranking should be 1
|
||||
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
|
||||
const item1 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
|
||||
);
|
||||
expect(item1.count).toBe(1);
|
||||
expect(item1.avgRanking).toBe(1);
|
||||
|
||||
// Item 2 was not ranked, so should have count 0 and avgRanking 0
|
||||
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
|
||||
const item2 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
|
||||
);
|
||||
expect(item2.count).toBe(0);
|
||||
expect(item2.avgRanking).toBe(0);
|
||||
|
||||
// Item 3 is in position 3, so avg ranking should be 3
|
||||
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
|
||||
const item3 = (summary[0] as any).choices.find(
|
||||
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
|
||||
);
|
||||
expect(item3.count).toBe(1);
|
||||
expect(item3.avgRanking).toBe(3);
|
||||
});
|
||||
@@ -986,7 +997,11 @@ describe("getSurveySummary", () => {
|
||||
// Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany
|
||||
// which is used by the actual implementation of getResponsesForSummary.
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue(
|
||||
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
|
||||
mockResponses.map((r: Record<string, unknown>) => ({
|
||||
...r,
|
||||
contactId: null,
|
||||
personAttributes: {},
|
||||
})) as any
|
||||
);
|
||||
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(10);
|
||||
|
||||
@@ -1020,8 +1035,8 @@ describe("getSurveySummary", () => {
|
||||
test("handles filterCriteria", async () => {
|
||||
const filterCriteria: TResponseFilterCriteria = { finished: true };
|
||||
const finishedResponses = mockResponses
|
||||
.filter((r) => r.finished)
|
||||
.map((r) => ({ ...r, contactId: null, personAttributes: {} }));
|
||||
.filter((r: Record<string, unknown>) => r.finished)
|
||||
.map((r: Record<string, unknown>) => ({ ...r, contactId: null, personAttributes: {} }));
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any);
|
||||
|
||||
await getSurveySummary(mockSurveyId, filterCriteria);
|
||||
@@ -1043,7 +1058,11 @@ describe("getResponsesForSummary", () => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue(
|
||||
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
|
||||
mockResponses.map((r: Record<string, unknown>) => ({
|
||||
...r,
|
||||
contactId: null,
|
||||
personAttributes: {},
|
||||
})) as any
|
||||
);
|
||||
// React cache is already mocked globally - no need to mock it again
|
||||
});
|
||||
@@ -1842,23 +1861,63 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].responseCount).toBe(2);
|
||||
|
||||
// Verify Speed row
|
||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
||||
const speedRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Speed"
|
||||
);
|
||||
expect(speedRow.totalResponsesForRow).toBe(2);
|
||||
expect(speedRow.columnPercentages).toHaveLength(4); // 4 columns
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
|
||||
expect(
|
||||
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||
.percentage
|
||||
).toBe(50);
|
||||
expect(
|
||||
speedRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Average"
|
||||
).percentage
|
||||
).toBe(50);
|
||||
|
||||
// Verify Quality row
|
||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
||||
const qualityRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Quality"
|
||||
);
|
||||
expect(qualityRow.totalResponsesForRow).toBe(2);
|
||||
expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(50);
|
||||
expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
|
||||
expect(
|
||||
qualityRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Excellent"
|
||||
).percentage
|
||||
).toBe(50);
|
||||
expect(
|
||||
qualityRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Good"
|
||||
).percentage
|
||||
).toBe(50);
|
||||
|
||||
// Verify Price row
|
||||
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
|
||||
const priceRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Price"
|
||||
);
|
||||
expect(priceRow.totalResponsesForRow).toBe(2);
|
||||
expect(priceRow.columnPercentages.find((col) => col.column === "Poor").percentage).toBe(50);
|
||||
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
|
||||
expect(
|
||||
priceRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Poor")
|
||||
.percentage
|
||||
).toBe(50);
|
||||
expect(
|
||||
priceRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Average"
|
||||
).percentage
|
||||
).toBe(50);
|
||||
});
|
||||
|
||||
test("getQuestionSummary correctly processes Matrix question with non-default language responses", async () => {
|
||||
@@ -1949,19 +2008,48 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].responseCount).toBe(1);
|
||||
|
||||
// Verify Speed row with localized values mapped to default language
|
||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
||||
const speedRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Speed"
|
||||
);
|
||||
expect(speedRow.totalResponsesForRow).toBe(1);
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
|
||||
expect(
|
||||
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||
.percentage
|
||||
).toBe(100);
|
||||
|
||||
// Verify Quality row
|
||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
||||
const qualityRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Quality"
|
||||
);
|
||||
expect(qualityRow.totalResponsesForRow).toBe(1);
|
||||
expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(100);
|
||||
expect(
|
||||
qualityRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Excellent"
|
||||
).percentage
|
||||
).toBe(100);
|
||||
|
||||
// Verify Price row
|
||||
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
|
||||
const priceRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Price"
|
||||
);
|
||||
expect(priceRow.totalResponsesForRow).toBe(1);
|
||||
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
|
||||
expect(
|
||||
priceRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Average"
|
||||
).percentage
|
||||
).toBe(100);
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles missing or invalid data for Matrix questions", async () => {
|
||||
@@ -2055,12 +2143,18 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].responseCount).toBe(3); // Count is 3 because responses 2, 3, and 4 have the "matrix-q1" property
|
||||
|
||||
// All rows should have zero responses for all columns
|
||||
summary[0].data.forEach((row) => {
|
||||
expect(row.totalResponsesForRow).toBe(0);
|
||||
row.columnPercentages.forEach((col) => {
|
||||
expect(col.percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
summary[0].data.forEach(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => {
|
||||
expect(row.totalResponsesForRow).toBe(0);
|
||||
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
|
||||
expect(col.percentage).toBe(0);
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles partial and incomplete matrix responses", async () => {
|
||||
@@ -2147,22 +2241,59 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].responseCount).toBe(2);
|
||||
|
||||
// Verify Speed row - both responses provided data
|
||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
||||
const speedRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Speed"
|
||||
);
|
||||
expect(speedRow.totalResponsesForRow).toBe(2);
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
|
||||
expect(
|
||||
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||
.percentage
|
||||
).toBe(50);
|
||||
expect(
|
||||
speedRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Average"
|
||||
).percentage
|
||||
).toBe(50);
|
||||
|
||||
// Verify Quality row - only one response provided data
|
||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
||||
const qualityRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Quality"
|
||||
);
|
||||
expect(qualityRow.totalResponsesForRow).toBe(1);
|
||||
expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
|
||||
expect(
|
||||
qualityRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Good"
|
||||
).percentage
|
||||
).toBe(100);
|
||||
|
||||
// Verify Price row - both responses provided data
|
||||
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
|
||||
const priceRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Price"
|
||||
);
|
||||
expect(priceRow.totalResponsesForRow).toBe(2);
|
||||
|
||||
// ExtraRow should not appear in the summary
|
||||
expect(summary[0].data.find((row) => row.rowLabel === "ExtraRow")).toBeUndefined();
|
||||
expect(
|
||||
summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "ExtraRow"
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles zero responses for Matrix question correctly", async () => {
|
||||
@@ -2221,12 +2352,18 @@ describe("Matrix question type tests", () => {
|
||||
// All rows should have proper structure but zero counts
|
||||
expect(summary[0].data).toHaveLength(2); // 2 rows
|
||||
|
||||
summary[0].data.forEach((row) => {
|
||||
expect(row.columnPercentages).toHaveLength(2); // 2 columns
|
||||
expect(row.totalResponsesForRow).toBe(0);
|
||||
expect(row.columnPercentages[0].percentage).toBe(0);
|
||||
expect(row.columnPercentages[1].percentage).toBe(0);
|
||||
});
|
||||
summary[0].data.forEach(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => {
|
||||
expect(row.columnPercentages).toHaveLength(2); // 2 columns
|
||||
expect(row.totalResponsesForRow).toBe(0);
|
||||
expect(row.columnPercentages[0].percentage).toBe(0);
|
||||
expect(row.columnPercentages[1].percentage).toBe(0);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => {
|
||||
@@ -2296,21 +2433,46 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].responseCount).toBe(1);
|
||||
|
||||
// Speed row should have a valid response
|
||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
||||
const speedRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Speed"
|
||||
);
|
||||
expect(speedRow.totalResponsesForRow).toBe(1);
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
|
||||
expect(
|
||||
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||
.percentage
|
||||
).toBe(100);
|
||||
|
||||
// Quality row should have no valid responses
|
||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
||||
const qualityRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Quality"
|
||||
);
|
||||
expect(qualityRow.totalResponsesForRow).toBe(0);
|
||||
qualityRow.columnPercentages.forEach((col) => {
|
||||
qualityRow.columnPercentages.forEach((col: { column: string; percentage: number }) => {
|
||||
expect(col.percentage).toBe(0);
|
||||
});
|
||||
|
||||
// Price row should have a valid response
|
||||
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
|
||||
const priceRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Price"
|
||||
);
|
||||
expect(priceRow.totalResponsesForRow).toBe(1);
|
||||
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
|
||||
expect(
|
||||
priceRow.columnPercentages.find(
|
||||
(col: { column: string; percentage: number }) => col.column === "Average"
|
||||
).percentage
|
||||
).toBe(100);
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles Matrix question with invalid row labels", async () => {
|
||||
@@ -2381,17 +2543,48 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].data).toHaveLength(2); // 2 rows
|
||||
|
||||
// Speed row should have a valid response
|
||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
||||
const speedRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Speed"
|
||||
);
|
||||
expect(speedRow.totalResponsesForRow).toBe(1);
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
|
||||
expect(
|
||||
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||
.percentage
|
||||
).toBe(100);
|
||||
|
||||
// Quality row should have no responses
|
||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
||||
const qualityRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Quality"
|
||||
);
|
||||
expect(qualityRow.totalResponsesForRow).toBe(0);
|
||||
|
||||
// Invalid rows should not appear in the summary
|
||||
expect(summary[0].data.find((row) => row.rowLabel === "InvalidRow")).toBeUndefined();
|
||||
expect(summary[0].data.find((row) => row.rowLabel === "AnotherInvalidRow")).toBeUndefined();
|
||||
expect(
|
||||
summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "InvalidRow"
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "AnotherInvalidRow"
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles Matrix question with mixed language responses", async () => {
|
||||
@@ -2493,12 +2686,27 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].responseCount).toBe(2);
|
||||
|
||||
// Speed row should have both responses
|
||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
||||
const speedRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Speed"
|
||||
);
|
||||
expect(speedRow.totalResponsesForRow).toBe(2);
|
||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
|
||||
expect(
|
||||
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||
.percentage
|
||||
).toBe(100);
|
||||
|
||||
// Quality row should have no responses
|
||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
||||
const qualityRow = summary[0].data.find(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => row.rowLabel === "Quality"
|
||||
);
|
||||
expect(qualityRow.totalResponsesForRow).toBe(0);
|
||||
});
|
||||
|
||||
@@ -2557,12 +2765,18 @@ describe("Matrix question type tests", () => {
|
||||
expect(summary[0].responseCount).toBe(0); // Counts as response even with null data
|
||||
|
||||
// Both rows should have zero responses
|
||||
summary[0].data.forEach((row) => {
|
||||
expect(row.totalResponsesForRow).toBe(0);
|
||||
row.columnPercentages.forEach((col) => {
|
||||
expect(col.percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
summary[0].data.forEach(
|
||||
(row: {
|
||||
rowLabel: string;
|
||||
totalResponsesForRow: number;
|
||||
columnPercentages: { column: string; percentage: number }[];
|
||||
}) => {
|
||||
expect(row.totalResponsesForRow).toBe(0);
|
||||
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
|
||||
expect(col.percentage).toBe(0);
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2994,23 +3208,33 @@ describe("Rating question type tests", () => {
|
||||
expect(summary[0].average).toBe(4.25);
|
||||
|
||||
// Verify each rating option count and percentage
|
||||
const rating5 = summary[0].choices.find((c) => c.rating === 5);
|
||||
const rating5 = summary[0].choices.find(
|
||||
(c: { rating: number; count: number; percentage: number }) => c.rating === 5
|
||||
);
|
||||
expect(rating5.count).toBe(2);
|
||||
expect(rating5.percentage).toBe(50); // 2/4 * 100
|
||||
|
||||
const rating4 = summary[0].choices.find((c) => c.rating === 4);
|
||||
const rating4 = summary[0].choices.find(
|
||||
(c: { rating: number; count: number; percentage: number }) => c.rating === 4
|
||||
);
|
||||
expect(rating4.count).toBe(1);
|
||||
expect(rating4.percentage).toBe(25); // 1/4 * 100
|
||||
|
||||
const rating3 = summary[0].choices.find((c) => c.rating === 3);
|
||||
const rating3 = summary[0].choices.find(
|
||||
(c: { rating: number; count: number; percentage: number }) => c.rating === 3
|
||||
);
|
||||
expect(rating3.count).toBe(1);
|
||||
expect(rating3.percentage).toBe(25); // 1/4 * 100
|
||||
|
||||
const rating2 = summary[0].choices.find((c) => c.rating === 2);
|
||||
const rating2 = summary[0].choices.find(
|
||||
(c: { rating: number; count: number; percentage: number }) => c.rating === 2
|
||||
);
|
||||
expect(rating2.count).toBe(0);
|
||||
expect(rating2.percentage).toBe(0);
|
||||
|
||||
const rating1 = summary[0].choices.find((c) => c.rating === 1);
|
||||
const rating1 = summary[0].choices.find(
|
||||
(c: { rating: number; count: number; percentage: number }) => c.rating === 1
|
||||
);
|
||||
expect(rating1.count).toBe(0);
|
||||
expect(rating1.percentage).toBe(0);
|
||||
|
||||
@@ -3154,10 +3378,12 @@ describe("Rating question type tests", () => {
|
||||
expect(summary[0].average).toBe(0);
|
||||
|
||||
// Verify all ratings have 0 count and percentage
|
||||
summary[0].choices.forEach((choice) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.percentage).toBe(0);
|
||||
});
|
||||
summary[0].choices.forEach(
|
||||
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.percentage).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
// Verify dismissed is 0
|
||||
expect(summary[0].dismissed.count).toBe(0);
|
||||
@@ -3232,15 +3458,21 @@ describe("PictureSelection question type tests", () => {
|
||||
expect(summary[0].selectionCount).toBe(3); // Total selections: img1, img2, img3
|
||||
|
||||
// Check individual choice counts
|
||||
const img1 = summary[0].choices.find((c) => c.id === "img1");
|
||||
const img1 = summary[0].choices.find(
|
||||
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
|
||||
);
|
||||
expect(img1.count).toBe(1);
|
||||
expect(img1.percentage).toBe(50);
|
||||
|
||||
const img2 = summary[0].choices.find((c) => c.id === "img2");
|
||||
const img2 = summary[0].choices.find(
|
||||
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
|
||||
);
|
||||
expect(img2.count).toBe(1);
|
||||
expect(img2.percentage).toBe(50);
|
||||
|
||||
const img3 = summary[0].choices.find((c) => c.id === "img3");
|
||||
const img3 = summary[0].choices.find(
|
||||
(c: { id: string; count: number; percentage: number }) => c.id === "img3"
|
||||
);
|
||||
expect(img3.count).toBe(1);
|
||||
expect(img3.percentage).toBe(50);
|
||||
});
|
||||
@@ -3311,10 +3543,12 @@ describe("PictureSelection question type tests", () => {
|
||||
expect(summary[0].selectionCount).toBe(0);
|
||||
|
||||
// All choices should have zero count
|
||||
summary[0].choices.forEach((choice) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.percentage).toBe(0);
|
||||
});
|
||||
summary[0].choices.forEach(
|
||||
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
|
||||
expect(choice.count).toBe(0);
|
||||
expect(choice.percentage).toBe(0);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => {
|
||||
@@ -3373,17 +3607,23 @@ describe("PictureSelection question type tests", () => {
|
||||
expect(summary[0].selectionCount).toBe(2); // Total selections including invalid one
|
||||
|
||||
// img1 should be counted
|
||||
const img1 = summary[0].choices.find((c) => c.id === "img1");
|
||||
const img1 = summary[0].choices.find(
|
||||
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
|
||||
);
|
||||
expect(img1.count).toBe(1);
|
||||
expect(img1.percentage).toBe(100);
|
||||
|
||||
// img2 should not be counted
|
||||
const img2 = summary[0].choices.find((c) => c.id === "img2");
|
||||
const img2 = summary[0].choices.find(
|
||||
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
|
||||
);
|
||||
expect(img2.count).toBe(0);
|
||||
expect(img2.percentage).toBe(0);
|
||||
|
||||
// Invalid ID should not appear in choices
|
||||
expect(summary[0].choices.find((c) => c.id === "invalid-id")).toBeUndefined();
|
||||
expect(
|
||||
summary[0].choices.find((c: { id: string; count: number; percentage: number }) => c.id === "invalid-id")
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,14 +11,9 @@ import {
|
||||
TResponseData,
|
||||
TResponseFilterCriteria,
|
||||
TResponseTtc,
|
||||
TResponseVariables,
|
||||
ZResponseFilterCriteria,
|
||||
} from "@formbricks/types/responses";
|
||||
import {
|
||||
TSurveyElement,
|
||||
TSurveyElementChoice,
|
||||
TSurveyElementTypeEnum,
|
||||
} from "@formbricks/types/surveys/elements";
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyElementSummaryAddress,
|
||||
@@ -41,8 +36,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { buildWhereClause } from "@/lib/response/utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { convertFloatTo2Decimal } from "./utils";
|
||||
|
||||
@@ -97,63 +91,13 @@ export const getSurveySummaryMeta = (
|
||||
};
|
||||
};
|
||||
|
||||
const evaluateLogicAndGetNextElementId = (
|
||||
localSurvey: TSurvey,
|
||||
elements: TSurveyElement[],
|
||||
data: TResponseData,
|
||||
localVariables: TResponseVariables,
|
||||
currentElementIndex: number,
|
||||
currElementTemp: TSurveyElement,
|
||||
selectedLanguage: string | null
|
||||
): {
|
||||
nextElementId: string | undefined;
|
||||
updatedSurvey: TSurvey;
|
||||
updatedVariables: TResponseVariables;
|
||||
} => {
|
||||
let updatedSurvey = { ...localSurvey };
|
||||
let updatedVariables = { ...localVariables };
|
||||
|
||||
let firstJumpTarget: string | undefined;
|
||||
|
||||
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
|
||||
|
||||
if (currentBlock?.logic && currentBlock.logic.length > 0) {
|
||||
for (const logic of currentBlock.logic) {
|
||||
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
|
||||
const { jumpTarget, requiredElementIds, calculations } = performActions(
|
||||
updatedSurvey,
|
||||
logic.actions,
|
||||
data,
|
||||
updatedVariables
|
||||
);
|
||||
|
||||
if (requiredElementIds.length > 0) {
|
||||
// Update blocks to mark elements as required
|
||||
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
|
||||
...block,
|
||||
elements: block.elements.map((e) =>
|
||||
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
|
||||
),
|
||||
}));
|
||||
}
|
||||
updatedVariables = { ...updatedVariables, ...calculations };
|
||||
|
||||
if (jumpTarget && !firstJumpTarget) {
|
||||
firstJumpTarget = jumpTarget;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no jump target was set, check for a fallback logic
|
||||
if (!firstJumpTarget && currentBlock?.logicFallback) {
|
||||
firstJumpTarget = currentBlock.logicFallback;
|
||||
}
|
||||
|
||||
// Return the first jump target if found, otherwise go to the next element
|
||||
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
|
||||
|
||||
return { nextElementId, updatedSurvey, updatedVariables };
|
||||
// Determine whether a response interacted with a given element.
|
||||
// An element was "seen" if the respondent has a ttc entry for it OR provided an answer.
|
||||
// This is more reliable than replaying survey logic, which can misattribute impressions
|
||||
// when branching logic skips elements or when partial response data is insufficient
|
||||
// to evaluate conditions correctly.
|
||||
const wasElementSeen = (response: TSurveySummaryResponse, elementId: string): boolean => {
|
||||
return (response.ttc != null && response.ttc[elementId] > 0) || response.data[elementId] !== undefined;
|
||||
};
|
||||
|
||||
export const getSurveySummaryDropOff = (
|
||||
@@ -174,16 +118,8 @@ export const getSurveySummaryDropOff = (
|
||||
let impressionsArr = new Array(elements.length).fill(0) as number[];
|
||||
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
||||
|
||||
const surveyVariablesData = survey.variables?.reduce(
|
||||
(acc, variable) => {
|
||||
acc[variable.id] = variable.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | number>
|
||||
);
|
||||
|
||||
responses.forEach((response) => {
|
||||
// Calculate total time-to-completion
|
||||
// Calculate total time-to-completion per element
|
||||
Object.keys(totalTtc).forEach((elementId) => {
|
||||
if (response.ttc && response.ttc[elementId]) {
|
||||
totalTtc[elementId] += response.ttc[elementId];
|
||||
@@ -191,51 +127,21 @@ export const getSurveySummaryDropOff = (
|
||||
}
|
||||
});
|
||||
|
||||
let localSurvey = structuredClone(survey);
|
||||
let localResponseData: TResponseData = { ...response.data };
|
||||
let localVariables: TResponseVariables = {
|
||||
...surveyVariablesData,
|
||||
};
|
||||
// Count impressions based on actual interaction data (ttc + response data)
|
||||
// instead of replaying survey logic which is unreliable with branching
|
||||
let lastSeenIdx = -1;
|
||||
|
||||
let currQuesIdx = 0;
|
||||
|
||||
while (currQuesIdx < elements.length) {
|
||||
const currQues = elements[currQuesIdx];
|
||||
if (!currQues) break;
|
||||
|
||||
// element is not answered and required
|
||||
if (response.data[currQues.id] === undefined && currQues.required) {
|
||||
dropOffArr[currQuesIdx]++;
|
||||
impressionsArr[currQuesIdx]++;
|
||||
break;
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const element = elements[i];
|
||||
if (wasElementSeen(response, element.id)) {
|
||||
impressionsArr[i]++;
|
||||
lastSeenIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
impressionsArr[currQuesIdx]++;
|
||||
|
||||
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
|
||||
localSurvey,
|
||||
elements,
|
||||
localResponseData,
|
||||
localVariables,
|
||||
currQuesIdx,
|
||||
currQues,
|
||||
response.language
|
||||
);
|
||||
|
||||
localSurvey = updatedSurvey;
|
||||
localVariables = updatedVariables;
|
||||
|
||||
if (nextElementId) {
|
||||
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
|
||||
if (!response.data[nextElementId] && !response.finished) {
|
||||
dropOffArr[nextQuesIdx]++;
|
||||
impressionsArr[nextQuesIdx]++;
|
||||
break;
|
||||
}
|
||||
currQuesIdx = nextQuesIdx;
|
||||
} else {
|
||||
currQuesIdx++;
|
||||
}
|
||||
// Attribute drop-off to the last element the respondent interacted with
|
||||
if (!response.finished && lastSeenIdx >= 0) {
|
||||
dropOffArr[lastSeenIdx]++;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -244,6 +150,8 @@ export const getSurveySummaryDropOff = (
|
||||
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
|
||||
});
|
||||
|
||||
// When the welcome card is disabled, the first element's impressions should equal displayCount
|
||||
// because every survey display is an impression of the first element
|
||||
if (!survey.welcomeCard.enabled) {
|
||||
dropOffArr[0] = displayCount - impressionsArr[0];
|
||||
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
|
||||
@@ -255,7 +163,7 @@ export const getSurveySummaryDropOff = (
|
||||
|
||||
impressionsArr[0] = displayCount;
|
||||
} else {
|
||||
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
|
||||
dropOffPercentageArr[0] = impressionsArr[0] > 0 ? (dropOffArr[0] / impressionsArr[0]) * 100 : 0;
|
||||
}
|
||||
|
||||
for (let i = 1; i < elements.length; i++) {
|
||||
@@ -293,7 +201,10 @@ const checkForI18n = (
|
||||
) => {
|
||||
const element = elements.find((element) => element.id === id);
|
||||
|
||||
if (element?.type === "multipleChoiceMulti" || element?.type === "ranking") {
|
||||
if (
|
||||
element?.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
|
||||
element?.type === TSurveyElementTypeEnum.Ranking
|
||||
) {
|
||||
// Initialize an array to hold the choice values
|
||||
let choiceValues = [] as string[];
|
||||
|
||||
@@ -318,13 +229,9 @@ const checkForI18n = (
|
||||
}
|
||||
|
||||
// Return the localized value of the choice fo multiSelect single element
|
||||
if (element && "choices" in element) {
|
||||
const choice = element.choices?.find(
|
||||
(choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id]
|
||||
);
|
||||
return choice && "label" in choice
|
||||
? getLocalizedValue(choice.label, "default") || responseData[id]
|
||||
: responseData[id];
|
||||
if (element?.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
|
||||
const choice = element.choices?.find((choice) => choice.label[languageCode] === responseData[id]);
|
||||
return choice ? getLocalizedValue(choice.label, "default") || responseData[id] : responseData[id];
|
||||
}
|
||||
|
||||
return responseData[id];
|
||||
@@ -832,13 +739,19 @@ export const getElementSummary = async (
|
||||
let totalResponseCount = 0;
|
||||
|
||||
// Initialize count object
|
||||
const countMap: Record<string, string> = rows.reduce((acc, row) => {
|
||||
acc[row] = columns.reduce((colAcc, col) => {
|
||||
colAcc[col] = 0;
|
||||
return colAcc;
|
||||
}, {});
|
||||
return acc;
|
||||
}, {});
|
||||
const countMap: Record<string, Record<string, number>> = rows.reduce(
|
||||
(acc: Record<string, Record<string, number>>, row) => {
|
||||
acc[row] = columns.reduce(
|
||||
(colAcc: Record<string, number>, col) => {
|
||||
colAcc[col] = 0;
|
||||
return colAcc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Record<string, number>>
|
||||
);
|
||||
|
||||
responses.forEach((response) => {
|
||||
const selectedResponses = response.data[element.id] as Record<string, string>;
|
||||
@@ -1095,7 +1008,7 @@ export const getResponsesForSummary = reactCache(
|
||||
[limit, ZOptionalNumber],
|
||||
[offset, ZOptionalNumber],
|
||||
[filterCriteria, ZResponseFilterCriteria.optional()],
|
||||
[cursor, z.string().cuid2().optional()]
|
||||
[cursor, z.cuid2().optional()]
|
||||
);
|
||||
|
||||
const queryLimit = limit ?? RESPONSES_PER_PAGE;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
@@ -32,26 +33,27 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
|
||||
if (!survey) {
|
||||
throw new Error(t("common.survey_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
if (!organizationId) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
}
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
throw new ResourceNotFoundError(t("common.organization"), organizationId);
|
||||
}
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
|
||||
|
||||
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
||||
const initialSurveySummary = await getSurveySummary(surveyId);
|
||||
|
||||
@@ -2,23 +2,16 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
import { TSurvey, ZSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
|
||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
|
||||
const ZGetResponsesDownloadUrlAction = z.object({
|
||||
@@ -28,7 +21,7 @@ const ZGetResponsesDownloadUrlAction = z.object({
|
||||
});
|
||||
|
||||
export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||
.schema(ZGetResponsesDownloadUrlAction)
|
||||
.inputSchema(ZGetResponsesDownloadUrlAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -39,9 +32,9 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
type: "workspaceTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -58,7 +51,7 @@ const ZGetSurveyFilterDataAction = z.object({
|
||||
});
|
||||
|
||||
export const getSurveyFilterDataAction = authenticatedActionClient
|
||||
.schema(ZGetSurveyFilterDataAction)
|
||||
.inputSchema(ZGetSurveyFilterDataAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
|
||||
@@ -77,9 +70,9 @@ export const getSurveyFilterDataAction = authenticatedActionClient
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
type: "workspaceTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -89,7 +82,7 @@ export const getSurveyFilterDataAction = authenticatedActionClient
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
|
||||
|
||||
const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([
|
||||
getTagsByEnvironmentId(survey.environmentId),
|
||||
@@ -99,76 +92,3 @@ export const getSurveyFilterDataAction = authenticatedActionClient
|
||||
|
||||
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
|
||||
});
|
||||
|
||||
/**
|
||||
* Checks if survey follow-ups are enabled for the given organization.
|
||||
*
|
||||
* @param {string} organizationId The ID of the organization to check.
|
||||
* @returns {Promise<void>} A promise that resolves if the permission is granted.
|
||||
* @throws {ResourceNotFoundError} If the organization is not found.
|
||||
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
|
||||
*/
|
||||
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization not found", organizationId);
|
||||
}
|
||||
|
||||
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
if (!isSurveyFollowUpsEnabled) {
|
||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: TSurvey }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user?.id ?? "",
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.id),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { followUps } = parsedInput;
|
||||
|
||||
const oldSurvey = await getSurvey(parsedInput.id);
|
||||
|
||||
if (parsedInput.recaptcha?.enabled) {
|
||||
await checkSpamProtectionPermission(organizationId);
|
||||
}
|
||||
|
||||
if (followUps?.length) {
|
||||
await checkSurveyFollowUpsPermission(organizationId);
|
||||
}
|
||||
|
||||
if (parsedInput.languages?.length) {
|
||||
await checkMultiLanguagePermission(organizationId);
|
||||
}
|
||||
|
||||
// Context for audit log
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.id;
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.oldObject = oldSurvey;
|
||||
|
||||
const newSurvey = await updateSurvey(parsedInput);
|
||||
|
||||
ctx.auditLoggingCtx.newObject = newSurvey;
|
||||
|
||||
return newSurvey;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -164,12 +164,12 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
|
||||
const datePickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const extractMetadataKeys = useCallback((obj, parentKey = "") => {
|
||||
const extractMetadataKeys = useCallback((obj: Record<string, unknown>, parentKey = "") => {
|
||||
let keys: string[] = [];
|
||||
|
||||
for (let key in obj) {
|
||||
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
keys = keys.concat(extractMetadataKeys(obj[key], parentKey + key + " - "));
|
||||
keys = keys.concat(extractMetadataKeys(obj[key] as Record<string, unknown>, parentKey + key + " - "));
|
||||
} else {
|
||||
keys.push(parentKey + key);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
@@ -54,6 +55,25 @@ export enum OptionsType {
|
||||
QUOTAS = "Quotas",
|
||||
}
|
||||
|
||||
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
|
||||
switch (type) {
|
||||
case OptionsType.ELEMENTS:
|
||||
return t("common.elements");
|
||||
case OptionsType.TAGS:
|
||||
return t("common.tags");
|
||||
case OptionsType.ATTRIBUTES:
|
||||
return t("common.attributes");
|
||||
case OptionsType.OTHERS:
|
||||
return t("common.other_filters");
|
||||
case OptionsType.META:
|
||||
return t("common.meta");
|
||||
case OptionsType.HIDDEN_FIELDS:
|
||||
return t("common.hidden_fields");
|
||||
case OptionsType.QUOTAS:
|
||||
return t("common.quotas");
|
||||
}
|
||||
};
|
||||
|
||||
export type ElementOption = {
|
||||
label: string;
|
||||
elementType?: TSurveyElementTypeEnum;
|
||||
@@ -113,7 +133,9 @@ const elementIcons = {
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const IconComponent = elementIcons[type];
|
||||
const IconComponent = (elementIcons as Record<string, (typeof elementIcons)[keyof typeof elementIcons]>)[
|
||||
type
|
||||
];
|
||||
return IconComponent ? <IconComponent className="h-5 w-5" strokeWidth={1.5} /> : null;
|
||||
};
|
||||
|
||||
@@ -216,7 +238,12 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
{options?.map((data) => (
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
|
||||
<CommandGroup
|
||||
heading={
|
||||
<p className="text-sm font-medium text-slate-600">
|
||||
{getOptionsTypeTranslationKey(data.header, t)}
|
||||
</p>
|
||||
}>
|
||||
{data?.option?.map((o) => (
|
||||
<CommandItem
|
||||
key={o.id}
|
||||
|
||||
@@ -198,7 +198,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
};
|
||||
setFilterValue({ ...filterValue });
|
||||
};
|
||||
const handleRemoveMultiSelect = (value: string[], index) => {
|
||||
const handleRemoveMultiSelect = (value: string[], index: number) => {
|
||||
filterValue.filter[index] = {
|
||||
...filterValue.filter[index],
|
||||
filterType: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
||||
import { updateSurveyAction } from "../actions";
|
||||
|
||||
interface SurveyStatusDropdownProps {
|
||||
environment: TEnvironment;
|
||||
@@ -34,23 +34,27 @@ export const SurveyStatusDropdown = ({
|
||||
const updateSurveyActionResponse = await updateSurveyAction({ ...survey, status });
|
||||
|
||||
if (updateSurveyActionResponse?.data) {
|
||||
toast.success(
|
||||
status === "inProgress"
|
||||
? t("common.survey_live")
|
||||
: status === "paused"
|
||||
? t("common.survey_paused")
|
||||
: status === "completed"
|
||||
? t("common.survey_completed")
|
||||
: ""
|
||||
);
|
||||
const resultingStatus = updateSurveyActionResponse.data.status;
|
||||
const statusToToastMessage: Partial<Record<TSurvey["status"], string>> = {
|
||||
inProgress: t("common.survey_live"),
|
||||
paused: t("common.survey_paused"),
|
||||
completed: t("common.survey_completed"),
|
||||
};
|
||||
|
||||
const toastMessage = statusToToastMessage[resultingStatus];
|
||||
if (toastMessage) {
|
||||
toast.success(toastMessage);
|
||||
}
|
||||
|
||||
if (updateLocalSurveyStatus) {
|
||||
updateLocalSurveyStatus(resultingStatus);
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
if (updateLocalSurveyStatus) updateLocalSurveyStatus(status);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { SurveyContextWrapper } from "./context/survey-context";
|
||||
|
||||
interface SurveyLayoutProps {
|
||||
@@ -10,9 +12,10 @@ const SurveyLayout = async ({ params, children }: SurveyLayoutProps) => {
|
||||
const resolvedParams = await params;
|
||||
|
||||
const survey = await getSurvey(resolvedParams.surveyId);
|
||||
const t = await getTranslate();
|
||||
|
||||
if (!survey) {
|
||||
throw new Error("Survey not found");
|
||||
throw new ResourceNotFoundError(t("common.survey"), resolvedParams.surveyId);
|
||||
}
|
||||
|
||||
return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const Page = async (props) => {
|
||||
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
|
||||
const params = await props.params;
|
||||
return redirect(`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, Sparkles } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
const FORMBRICKS_HOST = "https://app.formbricks.com";
|
||||
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
|
||||
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
|
||||
|
||||
interface WorkflowsPageProps {
|
||||
userEmail: string;
|
||||
organizationName: string;
|
||||
billingPlan: string;
|
||||
}
|
||||
|
||||
type Step = "prompt" | "followup" | "thankyou";
|
||||
|
||||
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<Step>("prompt");
|
||||
const [promptValue, setPromptValue] = useState("");
|
||||
const [detailsValue, setDetailsValue] = useState("");
|
||||
const [responseId, setResponseId] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleGenerateWorkflow = async () => {
|
||||
if (promptValue.trim().length < 100 || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
surveyId: SURVEY_ID,
|
||||
finished: false,
|
||||
data: {
|
||||
workflow: promptValue.trim(),
|
||||
useremail: userEmail,
|
||||
orgname: organizationName,
|
||||
billingplan: billingPlan,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setResponseId(json.data?.id ?? null);
|
||||
}
|
||||
|
||||
setStep("followup");
|
||||
} catch {
|
||||
setStep("followup");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitFeedback = async () => {
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
|
||||
if (responseId) {
|
||||
try {
|
||||
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
finished: true,
|
||||
data: {
|
||||
details: detailsValue.trim(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
setStep("thankyou");
|
||||
};
|
||||
|
||||
const handleSkipFeedback = async () => {
|
||||
if (!responseId) {
|
||||
setStep("thankyou");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
finished: true,
|
||||
data: {},
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
|
||||
setStep("thankyou");
|
||||
};
|
||||
|
||||
if (step === "prompt") {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
||||
<div className="w-full max-w-2xl space-y-8">
|
||||
<div className="space-y-3 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark shadow-md">
|
||||
<Sparkles className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
|
||||
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={promptValue}
|
||||
onChange={(e) => setPromptValue(e.target.value)}
|
||||
placeholder={t("workflows.placeholder")}
|
||||
rows={5}
|
||||
className="w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
handleGenerateWorkflow();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span
|
||||
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
|
||||
{promptValue.trim().length} / 100
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleGenerateWorkflow}
|
||||
disabled={promptValue.trim().length < 100 || isSubmitting}
|
||||
loading={isSubmitting}
|
||||
size="lg">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{t("workflows.generate_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "followup") {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
||||
<div className="w-full max-w-2xl space-y-8">
|
||||
<div className="space-y-3 text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
|
||||
<Sparkles className="h-6 w-6 text-brand-dark" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
|
||||
{t("workflows.coming_soon_title")}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-md text-base text-slate-500">
|
||||
{t("workflows.coming_soon_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<label className="text-md mb-2 block font-medium text-slate-700">
|
||||
{t("workflows.follow_up_label")}
|
||||
</label>
|
||||
<textarea
|
||||
value={detailsValue}
|
||||
onChange={(e) => setDetailsValue(e.target.value)}
|
||||
placeholder={t("workflows.follow_up_placeholder")}
|
||||
rows={4}
|
||||
className="w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
|
||||
/>
|
||||
<div className="mt-4 flex items-center justify-end gap-3">
|
||||
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
|
||||
{t("common.skip")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmitFeedback}
|
||||
disabled={!detailsValue.trim() || isSubmitting}
|
||||
loading={isSubmitting}>
|
||||
{t("workflows.submit_button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
||||
<div className="w-full max-w-md space-y-6 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
|
||||
<CheckCircle2 className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
|
||||
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { WorkflowsPage } from "./components/workflows-page";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Workflows",
|
||||
};
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
|
||||
if (!IS_FORMBRICKS_CLOUD) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
if (isBilling) {
|
||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
|
||||
|
||||
return (
|
||||
<WorkflowsPage
|
||||
userEmail={user.email}
|
||||
organizationName={organization.name}
|
||||
billingPlan={cloudBillingDisplayContext.currentCloudPlan}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user