mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-10 19:29:33 -05:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60f6ca9463 | ||
|
|
aa27d242bb | ||
|
|
d96304d86d | ||
|
|
1064f68435 | ||
|
|
3d16e859c6 | ||
|
|
af198c5632 | ||
|
|
a43ed2b25c | ||
|
|
87bcad2b20 | ||
|
|
b5eaa4c7fd | ||
|
|
995c03bc01 | ||
|
|
b4395a48c5 | ||
|
|
461e3893fe | ||
|
|
735a9f84ec | ||
|
|
8cb8d734cf | ||
|
|
44d5530b48 | ||
|
|
a314eb391e | ||
|
|
6c34c316d0 | ||
|
|
4f26278f16 | ||
|
|
b975e7fa2e | ||
|
|
6c3052f9e4 | ||
|
|
a771ae189a | ||
|
|
5bb8119ebf | ||
|
|
02411277d4 | ||
|
|
4cfb8c6d7b | ||
|
|
e74a51a5ff | ||
|
|
29cc6a10fe | ||
|
|
029e069af6 | ||
|
|
01f765e969 | ||
|
|
9366960f18 | ||
|
|
697dc9cc99 | ||
|
|
83bc272ed2 | ||
|
|
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 |
9
.codex/environments/environment.toml
Normal file
9
.codex/environments/environment.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "formbricks"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
pnpm install
|
||||
pnpm dev:setup
|
||||
'''
|
||||
50
.env.example
50
.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 #
|
||||
################
|
||||
@@ -94,6 +103,12 @@ EMAIL_VERIFICATION_DISABLED=1
|
||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||
PASSWORD_RESET_DISABLED=1
|
||||
|
||||
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
|
||||
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
|
||||
|
||||
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
|
||||
# DEBUG_SHOW_RESET_LINK=1
|
||||
|
||||
# Email login. Disable the ability for users to login with email.
|
||||
# EMAIL_AUTH_DISABLED=1
|
||||
|
||||
@@ -132,6 +147,31 @@ AZUREAD_CLIENT_ID=
|
||||
AZUREAD_CLIENT_SECRET=
|
||||
AZUREAD_TENANT_ID=
|
||||
|
||||
# Configure Formbricks AI at the instance level
|
||||
# Set the provider used for AI features on this instance.
|
||||
# Accepted values for AI_PROVIDER: aws, gcp, azure
|
||||
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
|
||||
# AI_PROVIDER=gcp
|
||||
# AI_MODEL=gemini-2.5-flash
|
||||
|
||||
# Google Vertex AI credentials
|
||||
# AI_GCP_PROJECT=
|
||||
# AI_GCP_LOCATION=
|
||||
# AI_GCP_CREDENTIALS_JSON=
|
||||
# AI_GCP_APPLICATION_CREDENTIALS=
|
||||
|
||||
# Amazon Bedrock credentials
|
||||
# AI_AWS_REGION=
|
||||
# AI_AWS_ACCESS_KEY_ID=
|
||||
# AI_AWS_SECRET_ACCESS_KEY=
|
||||
# AI_AWS_SESSION_TOKEN=
|
||||
|
||||
# Azure AI / Microsoft Foundry credentials
|
||||
# AI_AZURE_BASE_URL=
|
||||
# AI_AZURE_RESOURCE_NAME=
|
||||
# AI_AZURE_API_KEY=
|
||||
# AI_AZURE_API_VERSION=v1
|
||||
|
||||
# OpenID Connect (OIDC) configuration
|
||||
# OIDC_CLIENT_ID=
|
||||
# OIDC_CLIENT_SECRET=
|
||||
@@ -185,6 +225,14 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Ignore Rate Limiting across the Formbricks app
|
||||
# RATE_LIMITING_DISABLED=1
|
||||
|
||||
# Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
|
||||
# TELEMETRY_DISABLED=1
|
||||
|
||||
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
|
||||
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
|
||||
# that need to send webhooks to internal services.
|
||||
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
|
||||
|
||||
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
@@ -231,4 +279,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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -45,7 +45,7 @@ yarn-error.log*
|
||||
.direnv
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
**/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
@@ -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.
|
||||
|
||||
26
README.md
26
README.md
@@ -127,34 +127,10 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
|
||||
|
||||
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
|
||||
|
||||
If you opt for self-hosting Formbricks, here are a few options to consider:
|
||||
|
||||
#### Docker
|
||||
|
||||
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
|
||||
|
||||
#### Community-managed One Click Hosting
|
||||
|
||||
##### Railway
|
||||
|
||||
You can deploy Formbricks on [Railway](https://railway.app) using the button below.
|
||||
|
||||
[](https://railway.app/new/template/PPDzCd)
|
||||
|
||||
##### RepoCloud
|
||||
|
||||
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
|
||||
|
||||
[](https://repocloud.io/details/?app_id=254)
|
||||
|
||||
##### Zeabur
|
||||
|
||||
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
|
||||
|
||||
[](https://zeabur.com/templates/G4TUJL)
|
||||
|
||||
<a id="development"></a>
|
||||
|
||||
## 👨💻 Development
|
||||
|
||||
### Prerequisites
|
||||
@@ -247,4 +223,4 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
|
||||
|
||||
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
|
||||
|
||||
<p align="right"><a href="#top">🔼 Back to top</a></p>
|
||||
<a id="readme-de"></a>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import Link from "next/link";
|
||||
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 { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
|
||||
interface XMTemplatePageProps {
|
||||
params: Promise<{
|
||||
environmentId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const Page = async (props: XMTemplatePageProps) => {
|
||||
const params = await props.params;
|
||||
const session = await getServerSession(authOptions);
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
const t = await getTranslate();
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
}
|
||||
|
||||
const projects = await getUserProjects(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 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys`}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -44,7 +44,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
||||
className={cn(
|
||||
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
|
||||
)}>
|
||||
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
||||
<Image src={FBLogo} width={160} height={30} alt={t("workspace.formbricks_logo")} />
|
||||
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
@@ -105,7 +105,6 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
||||
organizationId: organization.id,
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
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: {
|
||||
@@ -24,16 +23,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);
|
||||
const prodEnvironment = environments.find((e) => e.type === "production");
|
||||
|
||||
if (prodEnvironment) {
|
||||
return redirect(`/environments/${prodEnvironment.id}/`);
|
||||
}
|
||||
if (workspaces.length !== 0) {
|
||||
const firstWorkspace = workspaces[0];
|
||||
return redirect(`/workspaces/${firstWorkspace.id}/`);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -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)/workspaces/[workspaceId]/components/workspace-and-org-switch";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
@@ -34,18 +34,17 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||
<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}
|
||||
isAccessControlAllowed={false}
|
||||
isMember={isMember}
|
||||
environments={[]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||
|
||||
@@ -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,7 @@ 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;
|
||||
}) => {
|
||||
@@ -25,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);
|
||||
@@ -36,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 (
|
||||
@@ -47,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,12 +1,13 @@
|
||||
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: {
|
||||
params: Promise<{ organizationId: string }>;
|
||||
@@ -28,15 +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, organizationProjectsCount] = await Promise.all([
|
||||
getOrganizationProjectsLimit(organization.id),
|
||||
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"
|
||||
|
||||
@@ -13,8 +13,8 @@ export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboard
|
||||
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")}
|
||||
title={t("workspace.settings.billing.select_plan_header_title")}
|
||||
subtitle={t("workspace.settings.billing.select_plan_header_subtitle")}
|
||||
/>
|
||||
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
|
||||
</div>
|
||||
|
||||
@@ -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)/workspaces/[workspaceId]/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,26 +81,21 @@ export const ProjectSettings = ({
|
||||
},
|
||||
});
|
||||
|
||||
if (createProjectResponse?.data) {
|
||||
// get production environment
|
||||
const productionEnvironment = createProjectResponse.data.environments.find(
|
||||
(environment) => environment.type === "production"
|
||||
);
|
||||
if (productionEnvironment) {
|
||||
if (globalThis.window !== undefined) {
|
||||
// Rmove filters when creating a new project
|
||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
}
|
||||
if (createWorkspaceResponse?.data) {
|
||||
if (globalThis.window !== undefined) {
|
||||
// Remove filters when creating a new workspace
|
||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
}
|
||||
const workspaceId = createWorkspaceResponse.data.id;
|
||||
if (channel === "app" || channel === "website") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/connect`);
|
||||
router.push(`/workspaces/${workspaceId}/connect`);
|
||||
} else if (channel === "link") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/surveys`);
|
||||
} else if (projectMode === "cx") {
|
||||
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
|
||||
router.push(`/workspaces/${workspaceId}/surveys`);
|
||||
} else if (workspaceMode === "cx") {
|
||||
router.push(`/workspaces/${workspaceId}/xm-templates`);
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createProjectResponse);
|
||||
const errorMessage = getFormattedErrorMessage(createWorkspaceResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -109,15 +104,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 +127,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 +179,7 @@ export const ProjectSettings = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAccessControlAllowed && userProjectsCount > 0 && (
|
||||
{isAccessControlAllowed && userWorkspacesCount > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="teamIds"
|
||||
@@ -242,7 +237,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.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"
|
||||
|
||||
@@ -4,21 +4,20 @@ import { ArrowRight } from "lucide-react";
|
||||
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";
|
||||
|
||||
interface ConnectWithFormbricksProps {
|
||||
environment: TEnvironment;
|
||||
workspaceId: string;
|
||||
publicDomain: string;
|
||||
appSetupCompleted: boolean;
|
||||
channel: TProjectConfigChannel;
|
||||
channel: TWorkspaceConfigChannel;
|
||||
}
|
||||
|
||||
export const ConnectWithFormbricks = ({
|
||||
environment,
|
||||
workspaceId,
|
||||
publicDomain,
|
||||
appSetupCompleted,
|
||||
channel,
|
||||
@@ -26,7 +25,7 @@ export const ConnectWithFormbricks = ({
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const handleFinishOnboarding = async () => {
|
||||
router.push(`/environments/${environment.id}/surveys`);
|
||||
router.push(`/workspaces/${workspaceId}/surveys`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -48,7 +47,7 @@ export const ConnectWithFormbricks = ({
|
||||
<div className="flex w-full space-x-10">
|
||||
<div className="flex w-1/2 flex-col space-y-4">
|
||||
<OnboardingSetupInstructions
|
||||
environmentId={environment.id}
|
||||
workspaceId={workspaceId}
|
||||
publicDomain={publicDomain}
|
||||
channel={channel}
|
||||
appSetupCompleted={appSetupCompleted}
|
||||
@@ -61,9 +60,9 @@ export const ConnectWithFormbricks = ({
|
||||
)}>
|
||||
{appSetupCompleted ? (
|
||||
<div>
|
||||
<p className="text-3xl">{t("environments.connect.congrats")}</p>
|
||||
<p className="text-3xl">{t("workspace.connect.congrats")}</p>
|
||||
<p className="pt-4 text-sm font-medium text-slate-600">
|
||||
{t("environments.connect.connection_successful_message")}
|
||||
{t("workspace.connect.connection_successful_message")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -73,7 +72,7 @@ export const ConnectWithFormbricks = ({
|
||||
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
|
||||
</span>
|
||||
<p className="pt-4 text-sm font-medium text-slate-600">
|
||||
{t("environments.connect.waiting_for_your_signal")}
|
||||
{t("workspace.connect.waiting_for_your_signal")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -83,9 +82,7 @@ export const ConnectWithFormbricks = ({
|
||||
id="finishOnboarding"
|
||||
variant={appSetupCompleted ? "default" : "ghost"}
|
||||
onClick={handleFinishOnboarding}>
|
||||
{appSetupCompleted
|
||||
? t("environments.connect.finish_onboarding")
|
||||
: t("environments.connect.do_it_later")}
|
||||
{appSetupCompleted ? t("workspace.connect.finish_onboarding") : t("workspace.connect.do_it_later")}
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -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";
|
||||
@@ -17,14 +17,14 @@ const tabs = [
|
||||
];
|
||||
|
||||
interface OnboardingSetupInstructionsProps {
|
||||
environmentId: string;
|
||||
workspaceId: string;
|
||||
publicDomain: string;
|
||||
channel: TProjectConfigChannel;
|
||||
channel: TWorkspaceConfigChannel;
|
||||
appSetupCompleted: boolean;
|
||||
}
|
||||
|
||||
export const OnboardingSetupInstructions = ({
|
||||
environmentId,
|
||||
workspaceId,
|
||||
publicDomain,
|
||||
channel,
|
||||
appSetupCompleted,
|
||||
@@ -35,8 +35,8 @@ export const OnboardingSetupInstructions = ({
|
||||
<script type="text/javascript">
|
||||
!function(){
|
||||
var appUrl = "${publicDomain}";
|
||||
var environmentId = "${environmentId}";
|
||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||
var workspaceId = "${workspaceId}";
|
||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||
</script>
|
||||
<!-- END Formbricks Surveys -->
|
||||
`;
|
||||
@@ -45,46 +45,46 @@ export const OnboardingSetupInstructions = ({
|
||||
<script type="text/javascript">
|
||||
!function(){
|
||||
var appUrl = "${publicDomain}";
|
||||
var environmentId = "${environmentId}";
|
||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||
var workspaceId = "${workspaceId}";
|
||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||
</script>
|
||||
<!-- END Formbricks Surveys -->
|
||||
`;
|
||||
|
||||
const npmSnippetForAppSurveys = `
|
||||
import formbricks from "@formbricks/js";
|
||||
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.setup({
|
||||
environmentId: "${environmentId}",
|
||||
workspaceId: "${workspaceId}",
|
||||
appUrl: "${publicDomain}",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function App() {
|
||||
// your own app
|
||||
}
|
||||
|
||||
|
||||
export default App;
|
||||
`;
|
||||
|
||||
const npmSnippetForWebsiteSurveys = `
|
||||
// other imports
|
||||
import formbricks from "@formbricks/js";
|
||||
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.setup({
|
||||
environmentId: "${environmentId}",
|
||||
workspaceId: "${workspaceId}",
|
||||
appUrl: "${publicDomain}",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function App() {
|
||||
// your own app
|
||||
}
|
||||
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
`;
|
||||
|
||||
return (
|
||||
@@ -109,7 +109,7 @@ export const OnboardingSetupInstructions = ({
|
||||
yarn add @formbricks/js
|
||||
</CodeBlock>
|
||||
<p className="text-sm text-slate-700">
|
||||
{t("environments.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
|
||||
{t("workspace.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
|
||||
</p>
|
||||
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
|
||||
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
|
||||
@@ -126,7 +126,7 @@ export const OnboardingSetupInstructions = ({
|
||||
) : activeTab === "html" ? (
|
||||
<div className="prose prose-slate">
|
||||
<p className="-mb-1 mt-6 text-sm text-slate-700">
|
||||
{t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")}
|
||||
{t("workspace.connect.insert_this_code_into_the_head_tag_of_your_website")}
|
||||
</p>
|
||||
<div>
|
||||
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
|
||||
@@ -1,55 +1,50 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/connect/components/ConnectWithFormbricks";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getWorkspace } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
|
||||
interface ConnectPageProps {
|
||||
params: Promise<{
|
||||
environmentId: string;
|
||||
workspaceId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const Page = async (props: ConnectPageProps) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
const workspace = await getWorkspace(params.workspaceId);
|
||||
if (!workspace) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
|
||||
}
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
}
|
||||
|
||||
const channel = project.config.channel || null;
|
||||
const channel = workspace.config.channel || null;
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center py-10">
|
||||
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
|
||||
<Header title={t("workspace.connect.headline")} subtitle={t("workspace.connect.subtitle")} />
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="text-4xl font-medium text-slate-800"></p>
|
||||
<p className="text-sm text-slate-500"></p>
|
||||
</div>
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
workspaceId={params.workspaceId}
|
||||
publicDomain={publicDomain}
|
||||
appSetupCompleted={environment.appSetupCompleted}
|
||||
appSetupCompleted={workspace.appSetupCompleted}
|
||||
channel={channel}
|
||||
/>
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}`}>
|
||||
<Link href={`/workspaces/${params.workspaceId}`}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const OnboardingLayout = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
params: Promise<{ workspaceId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
@@ -17,9 +17,9 @@ const OnboardingLayout = async (props: {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
const isAuthorized = await hasUserWorkspaceAccess(session.user.id, params.workspaceId);
|
||||
if (!isAuthorized) {
|
||||
throw new AuthorizationError("User is not authorized to access this environment");
|
||||
throw new AuthorizationError("User is not authorized to access this workspace");
|
||||
}
|
||||
|
||||
return <div className="flex-1 bg-slate-50">{children}</div>;
|
||||
@@ -5,23 +5,23 @@ 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 { 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 { TWorkspace } from "@formbricks/types/workspace";
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/utils";
|
||||
import { getXMTemplates } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/xm-templates";
|
||||
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;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
|
||||
export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListProps) => {
|
||||
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -33,12 +33,12 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
|
||||
createdBy: user.id,
|
||||
};
|
||||
const createSurveyResponse = await createSurveyAction({
|
||||
environmentId: environmentId,
|
||||
workspaceId: workspaceId,
|
||||
surveyBody: augmentedTemplate,
|
||||
});
|
||||
|
||||
if (createSurveyResponse?.data) {
|
||||
router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
|
||||
router.push(`/workspaces/${workspaceId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
|
||||
toast.error(errorMessage);
|
||||
@@ -48,49 +48,49 @@ 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);
|
||||
};
|
||||
|
||||
const XMTemplateOptions = [
|
||||
{
|
||||
title: t("environments.xm-templates.nps"),
|
||||
description: t("environments.xm-templates.nps_description"),
|
||||
title: t("workspace.xm-templates.nps"),
|
||||
description: t("workspace.xm-templates.nps_description"),
|
||||
icon: ShoppingCartIcon,
|
||||
onClick: () => handleTemplateClick(0),
|
||||
isLoading: activeTemplateId === 0,
|
||||
},
|
||||
{
|
||||
title: t("environments.xm-templates.five_star_rating"),
|
||||
description: t("environments.xm-templates.five_star_rating_description"),
|
||||
title: t("workspace.xm-templates.five_star_rating"),
|
||||
description: t("workspace.xm-templates.five_star_rating_description"),
|
||||
icon: StarIcon,
|
||||
onClick: () => handleTemplateClick(1),
|
||||
isLoading: activeTemplateId === 1,
|
||||
},
|
||||
{
|
||||
title: t("environments.xm-templates.csat"),
|
||||
description: t("environments.xm-templates.csat_description"),
|
||||
title: t("workspace.xm-templates.csat"),
|
||||
description: t("workspace.xm-templates.csat_description"),
|
||||
icon: ThumbsUpIcon,
|
||||
onClick: () => handleTemplateClick(2),
|
||||
isLoading: activeTemplateId === 2,
|
||||
},
|
||||
{
|
||||
title: t("environments.xm-templates.ces"),
|
||||
description: t("environments.xm-templates.ces_description"),
|
||||
title: t("workspace.xm-templates.ces"),
|
||||
description: t("workspace.xm-templates.ces_description"),
|
||||
icon: ActivityIcon,
|
||||
onClick: () => handleTemplateClick(3),
|
||||
isLoading: activeTemplateId === 3,
|
||||
},
|
||||
{
|
||||
title: t("environments.xm-templates.smileys"),
|
||||
description: t("environments.xm-templates.smileys_description"),
|
||||
title: t("workspace.xm-templates.smileys"),
|
||||
description: t("workspace.xm-templates.smileys_description"),
|
||||
icon: SmileIcon,
|
||||
onClick: () => handleTemplateClick(4),
|
||||
isLoading: activeTemplateId === 4,
|
||||
},
|
||||
{
|
||||
title: t("environments.xm-templates.enps"),
|
||||
description: t("environments.xm-templates.enps_description"),
|
||||
title: t("workspace.xm-templates.enps"),
|
||||
description: t("workspace.xm-templates.enps_description"),
|
||||
icon: UsersIcon,
|
||||
onClick: () => handleTemplateClick(5),
|
||||
isLoading: activeTemplateId === 5,
|
||||
@@ -1,17 +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,
|
||||
@@ -27,12 +27,12 @@ const mockProject: TProject = {
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
environments: [],
|
||||
appSetupCompleted: false,
|
||||
languages: [],
|
||||
logo: null,
|
||||
};
|
||||
const mockTemplate: TXMTemplate = {
|
||||
name: "$[projectName] Survey",
|
||||
name: "$[workspaceName] Survey",
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
@@ -42,7 +42,7 @@ const mockTemplate: TXMTemplate = {
|
||||
id: "q1",
|
||||
type: "openText" as TSurveyElementTypeEnum.OpenText,
|
||||
inputType: "text" as const,
|
||||
headline: { default: "$[projectName] Question" },
|
||||
headline: { default: "$[workspaceName] Question" },
|
||||
subheader: { default: "" },
|
||||
required: false,
|
||||
placeholder: { default: "" },
|
||||
@@ -70,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 };
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
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)/workspaces/[workspaceId]/xm-templates/components/XMTemplateList";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
|
||||
interface XMTemplatePageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const Page = async (props: XMTemplatePageProps) => {
|
||||
const params = await props.params;
|
||||
const session = await getServerSession(authOptions);
|
||||
const t = await getTranslate();
|
||||
|
||||
if (!session) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const workspace = await getWorkspace(params.workspaceId);
|
||||
if (!workspace) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
|
||||
}
|
||||
|
||||
const workspaces = await getUserWorkspaces(session.user.id, workspace.organizationId);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header title={t("workspace.xm-templates.headline")} />
|
||||
<XMTemplateList workspace={workspace} user={user} workspaceId={params.workspaceId} />
|
||||
{workspaces.length >= 2 && (
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/workspaces/${params.workspaceId}/surveys`}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,36 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
|
||||
const SurveyEditorEnvironmentLayout = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
|
||||
|
||||
if (!session) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SurveyEditorEnvironmentLayout;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getWorkspace } from "@/lib/workspace/service";
|
||||
import { workspaceIdLayoutChecks } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const SurveyEditorWorkspaceLayout = async (props: {
|
||||
params: Promise<{ workspaceId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const { t, session, user } = await workspaceIdLayoutChecks(params.workspaceId);
|
||||
|
||||
if (!session) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const workspace = await getWorkspace(params.workspaceId);
|
||||
|
||||
if (!workspace) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SurveyEditorWorkspaceLayout;
|
||||
@@ -6,12 +6,12 @@ import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Confetti } from "@/modules/ui/components/confetti";
|
||||
|
||||
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
|
||||
const BILLING_CONFIRMATION_WORKSPACE_ID_KEY = "billingConfirmationWorkspaceId";
|
||||
|
||||
export const ConfirmationPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
|
||||
const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setShowConfetti(true);
|
||||
@@ -20,11 +20,9 @@ export const ConfirmationPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
|
||||
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
|
||||
);
|
||||
if (storedEnvironmentId) {
|
||||
setResolvedEnvironmentId(storedEnvironmentId);
|
||||
const storedWorkspaceId = globalThis.window.sessionStorage.getItem(BILLING_CONFIRMATION_WORKSPACE_ID_KEY);
|
||||
if (storedWorkspaceId) {
|
||||
setResolvedWorkspaceId(storedWorkspaceId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -41,12 +39,7 @@ export const ConfirmationPage = () => {
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild className="w-full justify-center">
|
||||
<Link
|
||||
href={
|
||||
resolvedEnvironmentId
|
||||
? `/environments/${resolvedEnvironmentId}/settings/billing`
|
||||
: "/environments"
|
||||
}>
|
||||
<Link href={resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/billing` : "/"}>
|
||||
{t("billing_confirmation.back_to_billing_overview")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export const LoadingCard = ({
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
|
||||
interface EnvironmentStorageHandlerProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
const EnvironmentStorageHandler = ({ environmentId }: EnvironmentStorageHandlerProps) => {
|
||||
useEffect(() => {
|
||||
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, environmentId);
|
||||
}, [environmentId]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default EnvironmentStorageHandler;
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
|
||||
interface EnvironmentSwitchProps {
|
||||
environment: TEnvironment;
|
||||
environments: TEnvironment[];
|
||||
}
|
||||
|
||||
export const EnvironmentSwitch = ({ environment, environments }: EnvironmentSwitchProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isEnvSwitchChecked, setIsEnvSwitchChecked] = useState(environment?.type === "development");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleEnvironmentChange = (environmentType: "production" | "development") => {
|
||||
const newEnvironmentId = environments.find((e) => e.type === environmentType)?.id;
|
||||
if (newEnvironmentId) {
|
||||
router.push(`/environments/${newEnvironmentId}/`);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEnvSwitch = () => {
|
||||
const newEnvironmentType = isEnvSwitchChecked ? "production" : "development";
|
||||
setIsLoading(true);
|
||||
setIsEnvSwitchChecked(!isEnvSwitchChecked);
|
||||
handleEnvironmentChange(newEnvironmentType);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center space-x-2 rounded-lg p-2",
|
||||
isEnvSwitchChecked ? "bg-slate-100 text-orange-800" : "hover:bg-slate-100"
|
||||
)}>
|
||||
<Label
|
||||
htmlFor="development-mode"
|
||||
className={cn("hover:cursor-pointer", isEnvSwitchChecked && "text-orange-800")}>
|
||||
{t("common.dev_env")}
|
||||
</Label>
|
||||
<Switch
|
||||
className="focus:ring-orange-800 data-[state=checked]:bg-orange-800"
|
||||
id="development-mode"
|
||||
disabled={isLoading}
|
||||
checked={isEnvSwitchChecked}
|
||||
onCheckedChange={toggleEnvSwitch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,346 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
RocketIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||
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 { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
interface NavigationProps {
|
||||
environment: TEnvironment;
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
project: { id: string; name: string };
|
||||
isFormbricksCloud: boolean;
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
export const MainNavigation = ({
|
||||
environment,
|
||||
organization,
|
||||
user,
|
||||
project,
|
||||
membershipRole,
|
||||
isFormbricksCloud,
|
||||
isDevelopment,
|
||||
publicDomain,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslation();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isTextVisible, setIsTextVisible] = useState(true);
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
localStorage.setItem("isMainNavCollapsed", isCollapsed ? "false" : "true");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const isCollapsedValueFromLocalStorage = localStorage.getItem("isMainNavCollapsed") === "true";
|
||||
setIsCollapsed(isCollapsedValueFromLocalStorage);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const toggleTextOpacity = () => {
|
||||
setIsTextVisible(isCollapsed);
|
||||
};
|
||||
const timeoutId = setTimeout(toggleTextOpacity, 150);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [isCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
// Auto collapse project navbar on org and account settings
|
||||
if (pathname?.includes("/settings")) {
|
||||
setIsCollapsed(true);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const mainNavigation = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: t("common.surveys"),
|
||||
href: `/environments/${environment.id}/surveys`,
|
||||
icon: MessageCircle,
|
||||
isActive: pathname?.includes("/surveys"),
|
||||
isHidden: false,
|
||||
},
|
||||
{
|
||||
href: `/environments/${environment.id}/contacts`,
|
||||
name: t("common.contacts"),
|
||||
icon: UserIcon,
|
||||
isActive:
|
||||
pathname?.includes("/contacts") ||
|
||||
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("/workspace"),
|
||||
},
|
||||
],
|
||||
[t, environment.id, pathname, isFormbricksCloud]
|
||||
);
|
||||
|
||||
const dropdownNavigation = [
|
||||
{
|
||||
label: t("common.account"),
|
||||
href: `/environments/${environment.id}/settings/profile`,
|
||||
icon: UserCircleIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.documentation"),
|
||||
href: "https://formbricks.com/docs",
|
||||
target: "_blank",
|
||||
icon: ArrowUpRightIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.share_feedback"),
|
||||
href: "https://github.com/formbricks/formbricks/issues",
|
||||
target: "_blank",
|
||||
icon: ArrowUpRightIcon,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
async function loadReleases() {
|
||||
const res = await getLatestStableFbReleaseAction();
|
||||
if (res?.data) {
|
||||
const latestVersionTag = res.data;
|
||||
const currentVersionTag = `v${packageJson.version}`;
|
||||
|
||||
if (isNewerVersion(currentVersionTag, latestVersionTag)) {
|
||||
setLatestVersion(latestVersionTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 && (
|
||||
<aside
|
||||
className={cn(
|
||||
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
||||
isCollapsed ? "w-sidebar-expanded" : "w-sidebar-collapsed"
|
||||
)}>
|
||||
<div>
|
||||
{/* Logo and Toggle */}
|
||||
|
||||
<div className="flex items-center justify-between px-3 pb-4">
|
||||
{!isCollapsed && (
|
||||
<Link
|
||||
href={mainNavigationLink}
|
||||
className={cn(
|
||||
"flex items-center justify-center transition-opacity duration-100",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}>
|
||||
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
) : (
|
||||
<PanelLeftCloseIcon strokeWidth={1.5} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Main Nav Switch */}
|
||||
{!isBilling && (
|
||||
<ul>
|
||||
{mainNavigation.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* New Version Available */}
|
||||
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
|
||||
<Link
|
||||
href="https://github.com/formbricks/formbricks/releases"
|
||||
target="_blank"
|
||||
className="m-2 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
|
||||
<p className="flex items-center justify-center gap-x-2 text-xs">
|
||||
<RocketIcon strokeWidth={1.5} className="mx-1 h-6 w-6 text-slate-900" />
|
||||
{t("common.new_version_available", { version: latestVersion })}
|
||||
</p>
|
||||
</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>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="userDropdownTrigger"
|
||||
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-row items-center gap-3",
|
||||
isCollapsed ? "justify-center px-2" : "px-4"
|
||||
)}>
|
||||
<ProfileAvatar userId={user.id} />
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div
|
||||
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
|
||||
<p
|
||||
title={user?.email}
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
||||
)}>
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p className="text-sm text-slate-700">{t("common.account")}</p>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
id="userDropdownInnerContentWrapper"
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
alignOffset={5}
|
||||
align="end">
|
||||
{/* Dropdown Items */}
|
||||
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
className="flex w-full items-center"
|
||||
key={link.label}
|
||||
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
|
||||
<DropdownMenuItem>
|
||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
{link.label}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
))}
|
||||
{/* Logout */}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const loginUrl = `${publicDomain}/auth/login`;
|
||||
const route = await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: loginUrl,
|
||||
organizationId: organization.id,
|
||||
redirect: false,
|
||||
callbackUrl: loginUrl,
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDownIcon, CircleHelpIcon, Code2Icon, Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
export const EnvironmentBreadcrumb = ({
|
||||
environments,
|
||||
currentEnvironment,
|
||||
}: {
|
||||
environments: { id: string; type: string }[];
|
||||
currentEnvironment: { id: string; type: string };
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleEnvironmentChange = (environmentId: string) => {
|
||||
if (environmentId === currentEnvironment.id) return;
|
||||
setIsLoading(true);
|
||||
router.push(`/environments/${environmentId}/`);
|
||||
};
|
||||
|
||||
const developmentTooltip = () => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleHelpIcon className="h-3 w-3" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="mt-2 border-none bg-red-800 text-white">
|
||||
{t("common.development_environment_banner")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<BreadcrumbItem
|
||||
isActive={isEnvironmentDropdownOpen}
|
||||
isHighlighted={currentEnvironment.type === "development"}>
|
||||
<DropdownMenu onOpenChange={setIsEnvironmentDropdownOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className="flex cursor-pointer items-center gap-1 outline-none"
|
||||
id="environmentDropdownTrigger"
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<Code2Icon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span className="capitalize">{currentEnvironment.type}</span>
|
||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{currentEnvironment.type === "development" && developmentTooltip()}
|
||||
{isEnvironmentDropdownOpen && <ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="mt-2" align="start">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<Code2Icon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.choose_environment")}
|
||||
</div>
|
||||
<DropdownMenuGroup>
|
||||
{environments.map((env) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={env.id}
|
||||
checked={env.type === currentEnvironment.type}
|
||||
onClick={() => handleEnvironmentChange(env.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2 capitalize">
|
||||
<span>{env.type}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</BreadcrumbItem>
|
||||
);
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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 { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
||||
|
||||
interface ProjectAndOrgSwitchProps {
|
||||
currentOrganizationId: string;
|
||||
currentOrganizationName?: string; // Optional: for pages without context
|
||||
currentProjectId?: string;
|
||||
currentProjectName?: string; // Optional: for pages without context
|
||||
currentEnvironmentId?: string;
|
||||
environments: { id: string; type: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isMember: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
}
|
||||
|
||||
export const ProjectAndOrgSwitch = ({
|
||||
currentOrganizationId,
|
||||
currentOrganizationName,
|
||||
currentProjectId,
|
||||
currentProjectName,
|
||||
currentEnvironmentId,
|
||||
environments,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
isOwnerOrManager,
|
||||
isAccessControlAllowed,
|
||||
isMember,
|
||||
}: ProjectAndOrgSwitchProps) => {
|
||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList className="gap-0">
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
currentOrganizationName={currentOrganizationName}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isMember={isMember}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
/>
|
||||
{currentProjectId && currentEnvironmentId && (
|
||||
<ProjectBreadcrumb
|
||||
currentProjectId={currentProjectId}
|
||||
currentProjectName={currentProjectName}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
||||
/>
|
||||
)}
|
||||
{showEnvironmentBreadcrumb && (
|
||||
<EnvironmentBreadcrumb environments={environments} currentEnvironment={currentEnvironment} />
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organization: TOrganization;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
|
||||
|
||||
export const useEnvironment = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
throw new Error("useEnvironment must be used within an EnvironmentProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useProject = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
return { project: null };
|
||||
}
|
||||
return { project: context.project };
|
||||
};
|
||||
|
||||
export const useOrganization = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
return { organization: null };
|
||||
}
|
||||
return { organization: context.organization };
|
||||
};
|
||||
|
||||
// Client wrapper component to be used in server components
|
||||
interface EnvironmentContextWrapperProps {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organization: TOrganization;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentContextWrapper = ({
|
||||
environment,
|
||||
project,
|
||||
organization,
|
||||
children,
|
||||
}: EnvironmentContextWrapperProps) => {
|
||||
const environmentContextValue = useMemo(
|
||||
() => ({
|
||||
environment,
|
||||
project,
|
||||
organization,
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
[environment, project, organization]
|
||||
);
|
||||
|
||||
return (
|
||||
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||
|
||||
const EnvLayout = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
|
||||
// Check session first (required for userId)
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
// Single consolidated data fetch (replaces ~12 individual fetches)
|
||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||
<EnvironmentContextWrapper
|
||||
environment={layoutData.environment}
|
||||
project={layoutData.project}
|
||||
organization={layoutData.organization}>
|
||||
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
||||
</EnvironmentContextWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvLayout;
|
||||
@@ -1,37 +0,0 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
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([
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
getServerSession(authOptions),
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default AccountSettingsLayout;
|
||||
@@ -1,69 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
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 { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
const ZUpdateOrganizationNameAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
});
|
||||
|
||||
export const updateOrganizationNameAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateOrganizationNameAction)
|
||||
.action(
|
||||
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
|
||||
.inputSchema(ZDeleteOrganizationAction)
|
||||
.action(
|
||||
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
return await deleteOrganization(parsedInput.organizationId);
|
||||
})
|
||||
);
|
||||
@@ -1,34 +0,0 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
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([
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getProjectByEnvironmentId(params.environmentId),
|
||||
getServerSession(authOptions),
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error(t("common.organization_not_found"));
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
return redirect(`/environments/${params.environmentId}/settings/profile`);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,164 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
import { 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 { getTagsByEnvironmentId } from "@/lib/tag/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 { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
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({
|
||||
surveyId: ZId,
|
||||
format: z.union([z.literal("csv"), z.literal("xlsx")]),
|
||||
filterCriteria: ZResponseFilterCriteria,
|
||||
});
|
||||
|
||||
export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||
.inputSchema(ZGetResponsesDownloadUrlAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getResponseDownloadFile(
|
||||
parsedInput.surveyId,
|
||||
parsedInput.format,
|
||||
parsedInput.filterCriteria
|
||||
);
|
||||
});
|
||||
|
||||
const ZGetSurveyFilterDataAction = z.object({
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
export const getSurveyFilterDataAction = authenticatedActionClient
|
||||
.inputSchema(ZGetSurveyFilterDataAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||
if (!organizationBilling) {
|
||||
throw new ResourceNotFoundError("Organization", organizationId);
|
||||
}
|
||||
|
||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
|
||||
|
||||
const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([
|
||||
getTagsByEnvironmentId(survey.environmentId),
|
||||
getResponseFilteringValues(parsedInput.surveyId),
|
||||
isQuotasAllowed ? getQuotas(parsedInput.surveyId) : [],
|
||||
]);
|
||||
|
||||
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(organizationId);
|
||||
if (!isSurveyFollowUpsEnabled) {
|
||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
|
||||
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
})
|
||||
);
|
||||
@@ -1,8 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
|
||||
const params = await props.params;
|
||||
return redirect(`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,208 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
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;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { AppConnectionLoading } from "@/modules/projects/settings/(setup)/app-connection/loading";
|
||||
|
||||
export default AppConnectionLoading;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { AppConnectionPage } from "@/modules/projects/settings/(setup)/app-connection/page";
|
||||
|
||||
export default AppConnectionPage;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { GeneralSettingsLoading } from "@/modules/projects/settings/general/loading";
|
||||
|
||||
export default GeneralSettingsLoading;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { GeneralSettingsPage } from "@/modules/projects/settings/general/page";
|
||||
|
||||
export default GeneralSettingsPage;
|
||||
@@ -1,51 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
|
||||
|
||||
const t = await getTranslate();
|
||||
|
||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const [surveys, slackIntegration] = await Promise.all([
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrationByType(params.environmentId, "slack"),
|
||||
]);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
||||
<PageHeader pageTitle={t("environments.integrations.slack.slack_integration")} />
|
||||
<div className="h-[75vh] w-full">
|
||||
<SlackWrapper
|
||||
isEnabled={isEnabled}
|
||||
environment={environment}
|
||||
surveys={surveys}
|
||||
slackIntegration={slackIntegration as TIntegrationSlack}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { LanguagesLoading } from "@/modules/projects/settings/languages/loading";
|
||||
|
||||
export default LanguagesLoading;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { LanguagesPage } from "@/modules/projects/settings/languages/page";
|
||||
|
||||
export default LanguagesPage;
|
||||
@@ -1,4 +0,0 @@
|
||||
import { ProjectSettingsLayout, metadata } from "@/modules/projects/settings/layout";
|
||||
|
||||
export { metadata };
|
||||
export default ProjectSettingsLayout;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { ProjectLookSettingsLoading } from "@/modules/projects/settings/look/loading";
|
||||
|
||||
export default ProjectLookSettingsLoading;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { ProjectLookSettingsPage } from "@/modules/projects/settings/look/page";
|
||||
|
||||
export default ProjectLookSettingsPage;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { ProjectSettingsPage } from "@/modules/projects/settings/page";
|
||||
|
||||
export default ProjectSettingsPage;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { TagsLoading } from "@/modules/projects/settings/tags/loading";
|
||||
|
||||
export default TagsLoading;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { TagsPage } from "@/modules/projects/settings/tags/page";
|
||||
|
||||
export default TagsPage;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { ProjectTeams } from "@/modules/ee/teams/project-teams/page";
|
||||
|
||||
export default ProjectTeams;
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
CHATWOOT_WEBSITE_TOKEN,
|
||||
IS_CHATWOOT_CONFIGURED,
|
||||
POSTHOG_KEY,
|
||||
SESSION_MAX_AGE,
|
||||
} from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { NextAuthProvider } from "@/modules/auth/components/next-auth-provider";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||
@@ -23,7 +25,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextAuthProvider sessionMaxAge={SESSION_MAX_AGE}>
|
||||
<NoMobileOverlay />
|
||||
{POSTHOG_KEY && user && (
|
||||
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
|
||||
@@ -39,7 +41,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
)}
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</>
|
||||
</NextAuthProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { AppConnectionLoading } from "@/modules/workspaces/settings/(setup)/app-connection/loading";
|
||||
|
||||
export default AppConnectionLoading;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { AppConnectionPage } from "@/modules/workspaces/settings/(setup)/app-connection/page";
|
||||
|
||||
export default AppConnectionPage;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GeneralSettingsLoading } from "@/modules/workspaces/settings/general/loading";
|
||||
|
||||
export default GeneralSettingsLoading;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GeneralSettingsPage } from "@/modules/workspaces/settings/general/page";
|
||||
|
||||
export default GeneralSettingsPage;
|
||||
@@ -4,18 +4,18 @@ import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZIntegrationInput } from "@formbricks/types/integration";
|
||||
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import {
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromIntegrationId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
getProjectIdFromIntegrationId,
|
||||
getOrganizationIdFromWorkspaceId,
|
||||
getWorkspaceIdFromIntegrationId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
|
||||
const ZCreateOrUpdateIntegrationAction = z.object({
|
||||
environmentId: ZId,
|
||||
workspaceId: ZId,
|
||||
integrationData: ZIntegrationInput,
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateOrUpdateIntegrationAction)
|
||||
.action(
|
||||
withAuditLogging("createdUpdated", "integration", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
@@ -34,17 +34,23 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
type: "workspaceTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
|
||||
const result = await createOrUpdateIntegration(parsedInput.workspaceId, parsedInput.integrationData);
|
||||
ctx.auditLoggingCtx.integrationId = result.id;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
capturePostHogEvent(ctx.user.id, "integration_connected", {
|
||||
integration_type: parsedInput.integrationData.type,
|
||||
organization_id: organizationId,
|
||||
});
|
||||
|
||||
return result;
|
||||
})
|
||||
);
|
||||
@@ -66,8 +72,8 @@ export const deleteIntegrationAction = authenticatedActionClient.inputSchema(ZDe
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
|
||||
type: "workspaceTeam",
|
||||
workspaceId: await getWorkspaceIdFromIntegrationId(parsedInput.integrationId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
|
||||
import { BaseSelectDropdown } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/lib/airtable";
|
||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
@@ -93,7 +93,7 @@ type EditModeProps =
|
||||
type AddIntegrationModalProps = {
|
||||
open: boolean;
|
||||
setOpenWithStates: (v: boolean) => void;
|
||||
environmentId: string;
|
||||
workspaceId: string;
|
||||
airtableArray: TIntegrationItem[];
|
||||
surveys: TSurvey[];
|
||||
airtableIntegration: TIntegrationAirtable;
|
||||
@@ -103,8 +103,8 @@ const NoBaseFoundError = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Alert>
|
||||
<AlertTitle>{t("environments.integrations.airtable.no_bases_found")}</AlertTitle>
|
||||
<AlertDescription>{t("environments.integrations.airtable.please_create_a_base")}</AlertDescription>
|
||||
<AlertTitle>{t("workspace.integrations.airtable.no_bases_found")}</AlertTitle>
|
||||
<AlertDescription>{t("workspace.integrations.airtable.please_create_a_base")}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -172,7 +172,7 @@ const renderElementSelection = ({
|
||||
export const AddIntegrationModal = ({
|
||||
open,
|
||||
setOpenWithStates,
|
||||
environmentId,
|
||||
workspaceId,
|
||||
airtableArray,
|
||||
surveys,
|
||||
airtableIntegration,
|
||||
@@ -227,19 +227,19 @@ export const AddIntegrationModal = ({
|
||||
const submitHandler = async (data: IntegrationModalInputs) => {
|
||||
try {
|
||||
if (!data.base || data.base === "") {
|
||||
throw new Error(t("environments.integrations.airtable.please_select_a_base"));
|
||||
throw new Error(t("workspace.integrations.airtable.please_select_a_base"));
|
||||
}
|
||||
|
||||
if (!data.table || data.table === "") {
|
||||
throw new Error(t("environments.integrations.airtable.please_select_a_table"));
|
||||
throw new Error(t("workspace.integrations.airtable.please_select_a_table"));
|
||||
}
|
||||
|
||||
if (!selectedSurvey) {
|
||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||
throw new Error(t("workspace.integrations.please_select_a_survey_error"));
|
||||
}
|
||||
|
||||
if (data.elements.length === 0) {
|
||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||
throw new Error(t("workspace.integrations.select_at_least_one_question_error"));
|
||||
}
|
||||
|
||||
const currentTable = tables.find((item) => item.id === data.table);
|
||||
@@ -270,7 +270,7 @@ export const AddIntegrationModal = ({
|
||||
}
|
||||
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
workspaceId,
|
||||
integrationData: airtableIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
@@ -278,9 +278,9 @@ export const AddIntegrationModal = ({
|
||||
return;
|
||||
}
|
||||
if (isEditMode) {
|
||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||
toast.success(t("workspace.integrations.integration_updated_successfully"));
|
||||
} else {
|
||||
toast.success(t("environments.integrations.integration_added_successfully"));
|
||||
toast.success(t("workspace.integrations.integration_added_successfully"));
|
||||
}
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
@@ -289,7 +289,7 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
const handleTable = async (baseId: string) => {
|
||||
const data = await fetchTables(environmentId, baseId);
|
||||
const data = await fetchTables(workspaceId, baseId);
|
||||
|
||||
if (data.tables) {
|
||||
setTables(data.tables);
|
||||
@@ -312,7 +312,7 @@ export const AddIntegrationModal = ({
|
||||
const integrationData = structuredClone(airtableIntegrationData);
|
||||
integrationData.config.data.splice(index, 1);
|
||||
|
||||
const result = await createOrUpdateIntegrationAction({ environmentId, integrationData });
|
||||
const result = await createOrUpdateIntegrationAction({ workspaceId, integrationData });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
@@ -320,7 +320,7 @@ export const AddIntegrationModal = ({
|
||||
handleClose();
|
||||
router.refresh();
|
||||
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
toast.success(t("workspace.integrations.integration_removed_successfully"));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
|
||||
}
|
||||
@@ -336,13 +336,13 @@ export const AddIntegrationModal = ({
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={AirtableLogo}
|
||||
alt={t("environments.integrations.airtable.airtable_logo")}
|
||||
alt={t("workspace.integrations.airtable.airtable_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle>
|
||||
<DialogTitle>{t("workspace.integrations.airtable.link_airtable_table")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.airtable.sync_responses_with_airtable")}
|
||||
{t("workspace.integrations.airtable.sync_responses_with_airtable")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -364,7 +364,7 @@ export const AddIntegrationModal = ({
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
|
||||
<Label htmlFor="table">{t("workspace.integrations.airtable.table_name")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -427,7 +427,7 @@ export const AddIntegrationModal = ({
|
||||
</div>
|
||||
) : (
|
||||
<p className="m-1 text-xs text-slate-500">
|
||||
{t("environments.integrations.create_survey_warning")}
|
||||
{t("workspace.integrations.create_survey_warning")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -5,13 +5,13 @@ import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
||||
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/lib/airtable";
|
||||
import airtableLogo from "@/images/airtableLogo.svg";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
|
||||
interface AirtableWrapperProps {
|
||||
environmentId: string;
|
||||
workspaceId: string;
|
||||
airtableArray: TIntegrationItem[];
|
||||
airtableIntegration?: TIntegrationAirtable;
|
||||
surveys: TSurvey[];
|
||||
@@ -21,7 +21,7 @@ interface AirtableWrapperProps {
|
||||
}
|
||||
|
||||
export const AirtableWrapper = ({
|
||||
environmentId,
|
||||
workspaceId,
|
||||
airtableArray,
|
||||
airtableIntegration,
|
||||
surveys,
|
||||
@@ -34,7 +34,7 @@ export const AirtableWrapper = ({
|
||||
);
|
||||
|
||||
const handleAirtableAuthorization = async () => {
|
||||
authorize(environmentId, webAppUrl).then((url: string) => {
|
||||
authorize(workspaceId, webAppUrl).then((url: string) => {
|
||||
if (url) {
|
||||
window.location.replace(url);
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export const AirtableWrapper = ({
|
||||
return isConnected && airtableIntegration ? (
|
||||
<ManageIntegration
|
||||
airtableArray={airtableArray}
|
||||
environmentId={environmentId}
|
||||
workspaceId={workspaceId}
|
||||
airtableIntegration={airtableIntegration}
|
||||
setIsConnected={setIsConnected}
|
||||
surveys={surveys}
|
||||
@@ -33,7 +33,7 @@ export const BaseSelectDropdown = ({
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="base">{t("environments.integrations.airtable.airtable_base")}</Label>
|
||||
<Label htmlFor="base">{t("workspace.integrations.airtable.airtable_base")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -8,8 +8,8 @@ import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
|
||||
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/AddIntegrationModal";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -19,7 +19,7 @@ import { IntegrationModalInputs } from "../lib/types";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
airtableIntegration: TIntegrationAirtable;
|
||||
environmentId: string;
|
||||
workspaceId: string;
|
||||
setIsConnected: (data: boolean) => void;
|
||||
surveys: TSurvey[];
|
||||
airtableArray: TIntegrationItem[];
|
||||
@@ -27,12 +27,12 @@ interface ManageIntegrationProps {
|
||||
}
|
||||
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { airtableIntegration, workspaceId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tableHeaders = [
|
||||
t("common.survey"),
|
||||
t("environments.integrations.airtable.table_name"),
|
||||
t("workspace.integrations.airtable.table_name"),
|
||||
t("common.questions"),
|
||||
t("common.updated_at"),
|
||||
];
|
||||
@@ -53,7 +53,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
});
|
||||
|
||||
if (deleteIntegrationActionResult?.data) {
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
toast.success(t("workspace.integrations.integration_removed_successfully"));
|
||||
setIsConnected(false);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
|
||||
@@ -77,7 +77,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
<div className="flex items-center">
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="cursor-pointer text-slate-500">
|
||||
{t("environments.integrations.connected_with_email", {
|
||||
{t("workspace.integrations.connected_with_email", {
|
||||
email: airtableIntegration.config.email,
|
||||
})}
|
||||
</span>
|
||||
@@ -87,7 +87,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
setDefaultValues(null);
|
||||
handleModal(true);
|
||||
}}>
|
||||
{t("environments.integrations.airtable.link_new_table")}
|
||||
{t("workspace.integrations.airtable.link_new_table")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -130,21 +130,21 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptyState text={t("environments.integrations.airtable.no_integrations_yet")} />
|
||||
<EmptyState text={t("workspace.integrations.airtable.no_integrations_yet")} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
|
||||
<Trash2Icon />
|
||||
{t("environments.integrations.delete_integration")}
|
||||
{t("workspace.integrations.delete_integration")}
|
||||
</Button>
|
||||
|
||||
<DeleteDialog
|
||||
open={isDeleteIntegrationModalOpen}
|
||||
setOpen={setIsDeleteIntegrationModalOpen}
|
||||
deleteWhat={t("environments.integrations.airtable.airtable_integration")}
|
||||
deleteWhat={t("workspace.integrations.airtable.airtable_integration")}
|
||||
onDelete={handleDeleteIntegration}
|
||||
text={t("environments.integrations.delete_integration_confirmation")}
|
||||
text={t("workspace.integrations.delete_integration_confirmation")}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
|
||||
@@ -153,7 +153,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
airtableArray={airtableArray}
|
||||
open={isModalOpen}
|
||||
setOpenWithStates={handleModal}
|
||||
environmentId={environmentId}
|
||||
workspaceId={workspaceId}
|
||||
surveys={surveys}
|
||||
airtableIntegration={airtableIntegration}
|
||||
{...data}
|
||||
@@ -13,7 +13,7 @@ vi.mock("@formbricks/logger", () => ({
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const workspaceId = "test-env-id";
|
||||
const baseId = "test-base-id";
|
||||
const apiHost = "http://localhost:3000";
|
||||
|
||||
@@ -36,11 +36,11 @@ describe("Airtable Library", () => {
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
|
||||
|
||||
const tables = await fetchTables(environmentId, baseId);
|
||||
const tables = await fetchTables(workspaceId, baseId);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, {
|
||||
method: "GET",
|
||||
headers: { environmentId: environmentId },
|
||||
headers: { workspaceId: workspaceId },
|
||||
cache: "no-store",
|
||||
});
|
||||
expect(tables).toEqual(mockTables);
|
||||
@@ -56,11 +56,11 @@ describe("Airtable Library", () => {
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
|
||||
|
||||
const authUrl = await authorize(environmentId, apiHost);
|
||||
const authUrl = await authorize(workspaceId, apiHost);
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
|
||||
method: "GET",
|
||||
headers: { environmentId: environmentId },
|
||||
headers: { workspaceId: workspaceId },
|
||||
});
|
||||
expect(authUrl).toBe(mockAuthUrl);
|
||||
});
|
||||
@@ -73,11 +73,11 @@ describe("Airtable Library", () => {
|
||||
};
|
||||
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
|
||||
|
||||
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
|
||||
await expect(authorize(workspaceId, apiHost)).rejects.toThrow("Could not create response");
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
|
||||
method: "GET",
|
||||
headers: { environmentId: environmentId },
|
||||
headers: { workspaceId: workspaceId },
|
||||
});
|
||||
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch airtable config");
|
||||
});
|
||||
@@ -1,20 +1,20 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable";
|
||||
|
||||
export const fetchTables = async (environmentId: string, baseId: string) => {
|
||||
export const fetchTables = async (workspaceId: string, baseId: string) => {
|
||||
const res = await fetch(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, {
|
||||
method: "GET",
|
||||
headers: { environmentId: environmentId },
|
||||
headers: { workspaceId },
|
||||
cache: "no-store",
|
||||
});
|
||||
const resJson = await res.json();
|
||||
return resJson.data as Promise<TIntegrationAirtableTables>;
|
||||
};
|
||||
|
||||
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
|
||||
export const authorize = async (workspaceId: string, apiHost: string): Promise<string> => {
|
||||
const res = await fetch(`${apiHost}/api/v1/integrations/airtable`, {
|
||||
method: "GET",
|
||||
headers: { environmentId: environmentId },
|
||||
headers: { workspaceId },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -1,28 +1,29 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { AirtableWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/AirtableWrapper";
|
||||
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
|
||||
import { getAirtableTables } from "@/lib/airtable/service";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
const isEnabled = !!AIRTABLE_CLIENT_ID;
|
||||
|
||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||
const { isReadOnly, session, workspace } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
const [surveys, integrations] = await Promise.all([
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrations(params.environmentId),
|
||||
const [surveys, integrations, locale] = await Promise.all([
|
||||
getSurveys(workspace.id),
|
||||
getIntegrations(workspace.id),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
|
||||
@@ -31,28 +32,25 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
|
||||
let airtableArray: TIntegrationItem[] = [];
|
||||
if (airtableIntegration?.config.key) {
|
||||
airtableArray = await getAirtableTables(params.environmentId);
|
||||
airtableArray = await getAirtableTables(workspace.id);
|
||||
}
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
||||
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} />
|
||||
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/integrations`} />
|
||||
<PageHeader pageTitle={t("workspace.integrations.airtable.airtable_integration")} />
|
||||
<div className="h-[75vh] w-full">
|
||||
<AirtableWrapper
|
||||
isEnabled={isEnabled}
|
||||
airtableIntegration={airtableIntegration}
|
||||
airtableArray={airtableArray}
|
||||
environmentId={environment.id}
|
||||
workspaceId={workspace.id}
|
||||
surveys={surveys}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
@@ -10,10 +10,10 @@ import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/go
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
|
||||
const ZValidateGoogleSheetsConnectionAction = z.object({
|
||||
environmentId: ZId,
|
||||
workspaceId: ZId,
|
||||
});
|
||||
|
||||
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
|
||||
@@ -21,21 +21,21 @@ export const validateGoogleSheetsConnectionAction = authenticatedActionClient
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
type: "workspaceTeam",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets");
|
||||
const integration = await getIntegrationByType(parsedInput.workspaceId, "googleSheets");
|
||||
if (!integration) {
|
||||
return { data: false };
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export const validateGoogleSheetsConnectionAction = authenticatedActionClient
|
||||
|
||||
const ZGetSpreadsheetNameByIdAction = z.object({
|
||||
googleSheetIntegration: ZIntegrationGoogleSheets,
|
||||
environmentId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
spreadsheetId: z.string(),
|
||||
});
|
||||
|
||||
@@ -55,15 +55,15 @@ export const getSpreadsheetNameByIdAction = authenticatedActionClient
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
type: "workspaceTeam",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
|
||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/actions";
|
||||
import {
|
||||
constructGoogleSheetsUrl,
|
||||
extractSpreadsheetIdFromUrl,
|
||||
isValidGoogleSheetsUrl,
|
||||
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
|
||||
} from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/lib/util";
|
||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import {
|
||||
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
|
||||
@@ -44,7 +44,7 @@ import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface AddIntegrationModalProps {
|
||||
environmentId: string;
|
||||
workspaceId: string;
|
||||
open: boolean;
|
||||
surveys: TSurvey[];
|
||||
setOpen: (v: boolean) => void;
|
||||
@@ -53,7 +53,7 @@ interface AddIntegrationModalProps {
|
||||
}
|
||||
|
||||
export const AddIntegrationModal = ({
|
||||
environmentId,
|
||||
workspaceId,
|
||||
surveys,
|
||||
open,
|
||||
setOpen,
|
||||
@@ -125,9 +125,9 @@ export const AddIntegrationModal = ({
|
||||
const showErrorMessageToast = (response: Awaited<ReturnType<typeof getSpreadsheetNameByIdAction>>) => {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
if (errorMessage === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
|
||||
toast.error(t("environments.integrations.google_sheets.token_expired_error"));
|
||||
toast.error(t("workspace.integrations.google_sheets.token_expired_error"));
|
||||
} else if (errorMessage === GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION) {
|
||||
toast.error(t("environments.integrations.google_sheets.spreadsheet_permission_error"));
|
||||
toast.error(t("workspace.integrations.google_sheets.spreadsheet_permission_error"));
|
||||
} else {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
@@ -136,19 +136,19 @@ export const AddIntegrationModal = ({
|
||||
const linkSheet = async () => {
|
||||
try {
|
||||
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
|
||||
throw new Error(t("environments.integrations.google_sheets.enter_a_valid_spreadsheet_url_error"));
|
||||
throw new Error(t("workspace.integrations.google_sheets.enter_a_valid_spreadsheet_url_error"));
|
||||
}
|
||||
if (!selectedSurvey) {
|
||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||
throw new Error(t("workspace.integrations.please_select_a_survey_error"));
|
||||
}
|
||||
if (selectedElements.length === 0) {
|
||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||
throw new Error(t("workspace.integrations.select_at_least_one_question_error"));
|
||||
}
|
||||
setIsLinkingSheet(true);
|
||||
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
|
||||
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
|
||||
googleSheetIntegration,
|
||||
environmentId,
|
||||
workspaceId,
|
||||
spreadsheetId,
|
||||
});
|
||||
|
||||
@@ -180,7 +180,7 @@ export const AddIntegrationModal = ({
|
||||
googleSheetIntegrationData.config.data.push(integrationData);
|
||||
}
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
workspaceId,
|
||||
integrationData: googleSheetIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
@@ -188,9 +188,9 @@ export const AddIntegrationModal = ({
|
||||
return;
|
||||
}
|
||||
if (selectedIntegration) {
|
||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||
toast.success(t("workspace.integrations.integration_updated_successfully"));
|
||||
} else {
|
||||
toast.success(t("environments.integrations.integration_added_successfully"));
|
||||
toast.success(t("workspace.integrations.integration_added_successfully"));
|
||||
}
|
||||
resetForm();
|
||||
setOpen(false);
|
||||
@@ -227,14 +227,14 @@ export const AddIntegrationModal = ({
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
workspaceId,
|
||||
integrationData: googleSheetIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
toast.success(t("workspace.integrations.integration_removed_successfully"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error occurred");
|
||||
@@ -253,13 +253,13 @@ export const AddIntegrationModal = ({
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={GoogleSheetLogo}
|
||||
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
|
||||
alt={t("workspace.integrations.google_sheets.google_sheet_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle>
|
||||
<DialogTitle>{t("workspace.integrations.google_sheets.link_google_sheet")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
|
||||
{t("workspace.integrations.google_sheets.google_sheets_integration_description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,7 +269,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Label>{t("environments.integrations.google_sheets.spreadsheet_url")}</Label>
|
||||
<Label>{t("workspace.integrations.google_sheets.spreadsheet_url")}</Label>
|
||||
<Input
|
||||
value={spreadsheetUrl}
|
||||
onChange={(e) => setSpreadsheetUrl(e.target.value)}
|
||||
@@ -286,7 +286,7 @@ export const AddIntegrationModal = ({
|
||||
disabled={surveys.length === 0}
|
||||
/>
|
||||
<p className="m-1 text-xs text-slate-500">
|
||||
{surveys.length === 0 && t("environments.integrations.create_survey_warning")}
|
||||
{surveys.length === 0 && t("workspace.integrations.create_survey_warning")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -361,7 +361,7 @@ export const AddIntegrationModal = ({
|
||||
<Button type="submit" loading={isLinkingSheet}>
|
||||
{selectedIntegration
|
||||
? t("common.update")
|
||||
: t("environments.integrations.google_sheets.link_google_sheet")}
|
||||
: t("workspace.integrations.google_sheets.link_google_sheet")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
|
||||
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/actions";
|
||||
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/lib/google";
|
||||
import googleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
@@ -18,7 +17,7 @@ import { AddIntegrationModal } from "./AddIntegrationModal";
|
||||
|
||||
interface GoogleSheetWrapperProps {
|
||||
isEnabled: boolean;
|
||||
environment: TEnvironment;
|
||||
workspaceId: string;
|
||||
surveys: TSurvey[];
|
||||
googleSheetIntegration?: TIntegrationGoogleSheets;
|
||||
webAppUrl: string;
|
||||
@@ -27,7 +26,7 @@ interface GoogleSheetWrapperProps {
|
||||
|
||||
export const GoogleSheetWrapper = ({
|
||||
isEnabled,
|
||||
environment,
|
||||
workspaceId,
|
||||
surveys,
|
||||
googleSheetIntegration,
|
||||
webAppUrl,
|
||||
@@ -44,18 +43,18 @@ export const GoogleSheetWrapper = ({
|
||||
|
||||
const validateConnection = useCallback(async () => {
|
||||
if (!isConnected || !googleSheetIntegration) return;
|
||||
const response = await validateGoogleSheetsConnectionAction({ environmentId: environment.id });
|
||||
const response = await validateGoogleSheetsConnectionAction({ workspaceId });
|
||||
if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
|
||||
setShowReconnectButton(true);
|
||||
}
|
||||
}, [environment.id, isConnected, googleSheetIntegration]);
|
||||
}, [workspaceId, isConnected, googleSheetIntegration]);
|
||||
|
||||
useEffect(() => {
|
||||
validateConnection();
|
||||
}, [validateConnection]);
|
||||
|
||||
const handleGoogleAuthorization = async () => {
|
||||
authorize(environment.id, webAppUrl).then((url: string) => {
|
||||
authorize(workspaceId, webAppUrl).then((url: string) => {
|
||||
if (url) {
|
||||
window.location.replace(url);
|
||||
}
|
||||
@@ -67,7 +66,7 @@ export const GoogleSheetWrapper = ({
|
||||
{isConnected && googleSheetIntegration ? (
|
||||
<>
|
||||
<AddIntegrationModal
|
||||
environmentId={environment.id}
|
||||
workspaceId={workspaceId}
|
||||
surveys={surveys}
|
||||
open={isModalOpen}
|
||||
setOpen={setIsModalOpen}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
@@ -53,7 +53,7 @@ export const ManageIntegration = ({
|
||||
});
|
||||
|
||||
if (deleteIntegrationActionResult?.data) {
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
toast.success(t("workspace.integrations.integration_removed_successfully"));
|
||||
setIsConnected(false);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
|
||||
@@ -77,10 +77,10 @@ export const ManageIntegration = ({
|
||||
{showReconnectButton && (
|
||||
<Alert variant="warning" size="small" className="mb-4 w-full">
|
||||
<AlertDescription>
|
||||
{t("environments.integrations.google_sheets.reconnect_button_description")}
|
||||
{t("workspace.integrations.google_sheets.reconnect_button_description")}
|
||||
</AlertDescription>
|
||||
<AlertButton onClick={handleGoogleAuthorization}>
|
||||
{t("environments.integrations.google_sheets.reconnect_button")}
|
||||
{t("workspace.integrations.google_sheets.reconnect_button")}
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -88,7 +88,7 @@ export const ManageIntegration = ({
|
||||
<div className="mr-6 flex items-center">
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="text-slate-500">
|
||||
{t("environments.integrations.connected_with_email", {
|
||||
{t("workspace.integrations.connected_with_email", {
|
||||
email: googleSheetIntegration.config.email,
|
||||
})}
|
||||
</span>
|
||||
@@ -98,11 +98,11 @@ export const ManageIntegration = ({
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" onClick={handleGoogleAuthorization}>
|
||||
<RefreshCcwIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.integrations.google_sheets.reconnect_button")}
|
||||
{t("workspace.integrations.google_sheets.reconnect_button")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("environments.integrations.google_sheets.reconnect_button_tooltip")}
|
||||
{t("workspace.integrations.google_sheets.reconnect_button_tooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -111,12 +111,12 @@ export const ManageIntegration = ({
|
||||
setSelectedIntegration(null);
|
||||
setOpenAddIntegrationModal(true);
|
||||
}}>
|
||||
{t("environments.integrations.google_sheets.link_new_sheet")}
|
||||
{t("workspace.integrations.google_sheets.link_new_sheet")}
|
||||
</Button>
|
||||
</div>
|
||||
{!integrationArray || integrationArray.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptyState text={t("environments.integrations.google_sheets.no_integrations_yet")} />
|
||||
<EmptyState text={t("workspace.integrations.google_sheets.no_integrations_yet")} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
@@ -124,7 +124,7 @@ export const ManageIntegration = ({
|
||||
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.survey")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">
|
||||
{t("environments.integrations.google_sheets.google_sheet_name")}
|
||||
{t("workspace.integrations.google_sheets.google_sheet_name")}
|
||||
</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.questions")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
|
||||
@@ -149,15 +149,15 @@ export const ManageIntegration = ({
|
||||
)}
|
||||
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
|
||||
<Trash2Icon />
|
||||
{t("environments.integrations.delete_integration")}
|
||||
{t("workspace.integrations.delete_integration")}
|
||||
</Button>
|
||||
|
||||
<DeleteDialog
|
||||
open={isDeleteIntegrationModalOpen}
|
||||
setOpen={setIsDeleteIntegrationModalOpen}
|
||||
deleteWhat={t("environments.integrations.google_sheets.google_connection")}
|
||||
deleteWhat={t("workspace.integrations.google_sheets.google_connection")}
|
||||
onDelete={handleDeleteIntegration}
|
||||
text={t("environments.integrations.google_sheets.google_connection_deletion_description")}
|
||||
text={t("workspace.integrations.google_sheets.google_connection_deletion_description")}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
@@ -14,10 +14,10 @@ const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
describe("authorize", () => {
|
||||
const environmentId = "test-env-id";
|
||||
const workspaceId = "test-env-id";
|
||||
const apiHost = "http://test.com";
|
||||
const expectedUrl = `${apiHost}/api/google-sheet`;
|
||||
const expectedHeaders = { environmentId: environmentId };
|
||||
const expectedHeaders = { workspaceId: workspaceId };
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -30,7 +30,7 @@ describe("authorize", () => {
|
||||
json: async () => ({ data: { authUrl: mockAuthUrl } }),
|
||||
});
|
||||
|
||||
const authUrl = await authorize(environmentId, apiHost);
|
||||
const authUrl = await authorize(workspaceId, apiHost);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
|
||||
method: "GET",
|
||||
@@ -47,7 +47,7 @@ describe("authorize", () => {
|
||||
text: async () => errorText,
|
||||
});
|
||||
|
||||
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
|
||||
await expect(authorize(workspaceId, apiHost)).rejects.toThrow("Could not create response");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
|
||||
method: "GET",
|
||||
@@ -1,9 +1,9 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
|
||||
export const authorize = async (workspaceId: string, apiHost: string): Promise<string> => {
|
||||
const res = await fetch(`${apiHost}/api/google-sheet`, {
|
||||
method: "GET",
|
||||
headers: { environmentId: environmentId },
|
||||
headers: { workspaceId },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -11,7 +11,7 @@ const Loading = () => {
|
||||
<GoBackButton />
|
||||
<div className="mb-6 text-right">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
{t("environments.integrations.google_sheets.link_new_sheet")}
|
||||
{t("workspace.integrations.google_sheets.link_new_sheet")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ const Loading = () => {
|
||||
<div className="grid h-12 grid-cols-12 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-4 text-center">{t("common.survey")}</div>
|
||||
<div className="col-span-4 text-center">
|
||||
{t("environments.integrations.google_sheets.google_sheet_name")}
|
||||
{t("workspace.integrations.google_sheets.google_sheet_name")}
|
||||
</div>
|
||||
<div className="col-span-2 text-center">{t("common.questions")}</div>
|
||||
<div className="col-span-2 text-center">{t("common.updated_at")}</div>
|
||||
@@ -1,55 +1,54 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
GOOGLE_SHEETS_REDIRECT_URL,
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
|
||||
|
||||
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||
const { isReadOnly, session, workspace } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
const [surveys, integrations] = await Promise.all([
|
||||
getSurveys(params.environmentId),
|
||||
getIntegrations(params.environmentId),
|
||||
const [surveys, integrations, locale] = await Promise.all([
|
||||
getSurveys(workspace.id),
|
||||
getIntegrations(workspace.id),
|
||||
getUserLocale(session.user.id),
|
||||
]);
|
||||
|
||||
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
|
||||
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
|
||||
);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
||||
<PageHeader pageTitle={t("environments.integrations.google_sheets.google_sheets_integration")} />
|
||||
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/integrations`} />
|
||||
<PageHeader pageTitle={t("workspace.integrations.google_sheets.google_sheets_integration")} />
|
||||
<div className="h-[75vh] w-full">
|
||||
<GoogleSheetWrapper
|
||||
isEnabled={isEnabled}
|
||||
environment={environment}
|
||||
workspaceId={workspace.id}
|
||||
surveys={surveys}
|
||||
googleSheetIntegration={googleSheetIntegration}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
@@ -35,7 +35,7 @@ vi.mock("react", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const environmentId = "test-environment-id";
|
||||
const workspaceId = "test-environment-id";
|
||||
// Use 'as any' to bypass complex type matching for mock data
|
||||
const mockPrismaSurveys = [
|
||||
{ id: "survey1", name: "Survey 1", status: "inProgress", updatedAt: new Date() },
|
||||
@@ -58,7 +58,7 @@ const mockTransformedSurveys: TSurvey[] = [
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: false },
|
||||
type: "app", // Changed type to web to match original file
|
||||
environmentId: environmentId,
|
||||
workspaceId: workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: [],
|
||||
@@ -80,7 +80,7 @@ const mockTransformedSurveys: TSurvey[] = [
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: false },
|
||||
type: "app",
|
||||
environmentId: environmentId,
|
||||
workspaceId: workspaceId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: [],
|
||||
@@ -98,14 +98,14 @@ describe("getSurveys", () => {
|
||||
return { ...found } as TSurvey;
|
||||
});
|
||||
|
||||
const surveys = await getSurveys(environmentId);
|
||||
const surveys = await getSurveys(workspaceId);
|
||||
|
||||
expect(surveys).toEqual(mockTransformedSurveys);
|
||||
// Use expect.any(ZId) for the Zod schema validation check
|
||||
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); // Adjusted expectation
|
||||
expect(validateInputs).toHaveBeenCalledWith([workspaceId, expect.any(Object)]); // Adjusted expectation
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId,
|
||||
workspaceId,
|
||||
status: {
|
||||
not: "completed",
|
||||
},
|
||||
@@ -129,7 +129,7 @@ describe("getSurveys", () => {
|
||||
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(getSurveys(environmentId)).rejects.toThrow(DatabaseError);
|
||||
await expect(getSurveys(workspaceId)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: prismaError }, "getSurveys: Could not fetch surveys");
|
||||
// React cache is already mocked globally - no need to check it here
|
||||
});
|
||||
@@ -139,7 +139,7 @@ describe("getSurveys", () => {
|
||||
|
||||
vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(genericError);
|
||||
|
||||
await expect(getSurveys(environmentId)).rejects.toThrow(genericError);
|
||||
await expect(getSurveys(workspaceId)).rejects.toThrow(genericError);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
// React cache is already mocked globally - no need to check it here
|
||||
});
|
||||
@@ -10,13 +10,13 @@ import { selectSurvey } from "@/lib/survey/service";
|
||||
import { transformPrismaSurvey } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export const getSurveys = reactCache(async (environmentId: string): Promise<TSurvey[]> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
export const getSurveys = reactCache(async (workspaceId: string): Promise<TSurvey[]> => {
|
||||
validateInputs([workspaceId, ZId]);
|
||||
|
||||
try {
|
||||
const surveysPrisma = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
workspaceId,
|
||||
status: {
|
||||
not: "completed",
|
||||
},
|
||||
@@ -14,7 +14,7 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const environmentId = "test-environment-id";
|
||||
const workspaceId = "test-workspace-id";
|
||||
const sourceZapier = "zapier";
|
||||
|
||||
describe("getWebhookCountBySource", () => {
|
||||
@@ -26,16 +26,16 @@ describe("getWebhookCountBySource", () => {
|
||||
const mockCount = 5;
|
||||
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
|
||||
|
||||
const count = await getWebhookCountBySource(environmentId, sourceZapier);
|
||||
const count = await getWebhookCountBySource(workspaceId, sourceZapier);
|
||||
|
||||
expect(count).toBe(mockCount);
|
||||
expect(validateInputs).toHaveBeenCalledWith(
|
||||
[environmentId, expect.any(Object)],
|
||||
[workspaceId, expect.any(Object)],
|
||||
[sourceZapier, expect.any(Object)]
|
||||
);
|
||||
expect(prisma.webhook.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId,
|
||||
workspaceId: workspaceId,
|
||||
source: sourceZapier,
|
||||
},
|
||||
});
|
||||
@@ -45,16 +45,16 @@ describe("getWebhookCountBySource", () => {
|
||||
const mockCount = 10;
|
||||
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
|
||||
|
||||
const count = await getWebhookCountBySource(environmentId);
|
||||
const count = await getWebhookCountBySource(workspaceId);
|
||||
|
||||
expect(count).toBe(mockCount);
|
||||
expect(validateInputs).toHaveBeenCalledWith(
|
||||
[environmentId, expect.any(Object)],
|
||||
[workspaceId, expect.any(Object)],
|
||||
[undefined, expect.any(Object)]
|
||||
);
|
||||
expect(prisma.webhook.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId,
|
||||
workspaceId: workspaceId,
|
||||
source: undefined,
|
||||
},
|
||||
});
|
||||
@@ -67,7 +67,7 @@ describe("getWebhookCountBySource", () => {
|
||||
});
|
||||
vi.mocked(prisma.webhook.count).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getWebhookCountBySource(environmentId, sourceZapier)).rejects.toThrow(DatabaseError);
|
||||
await expect(getWebhookCountBySource(workspaceId, sourceZapier)).rejects.toThrow(DatabaseError);
|
||||
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -75,7 +75,7 @@ describe("getWebhookCountBySource", () => {
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(prisma.webhook.count).mockRejectedValue(genericError);
|
||||
|
||||
await expect(getWebhookCountBySource(environmentId)).rejects.toThrow(genericError);
|
||||
await expect(getWebhookCountBySource(workspaceId)).rejects.toThrow(genericError);
|
||||
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -6,15 +6,15 @@ import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export const getWebhookCountBySource = async (
|
||||
environmentId: string,
|
||||
workspaceId: string,
|
||||
source?: Webhook["source"]
|
||||
): Promise<number> => {
|
||||
validateInputs([environmentId, ZId], [source, z.string().optional()]);
|
||||
validateInputs([workspaceId, ZId], [source, z.string().optional()]);
|
||||
|
||||
try {
|
||||
const count = await prisma.webhook.count({
|
||||
where: {
|
||||
environmentId,
|
||||
workspaceId,
|
||||
source,
|
||||
},
|
||||
});
|
||||
@@ -15,12 +15,12 @@ import {
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
|
||||
import {
|
||||
MappingRow,
|
||||
TMapping,
|
||||
createEmptyMapping,
|
||||
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
|
||||
} from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/MappingRow";
|
||||
import NotionLogo from "@/images/notion.png";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
@@ -39,7 +39,7 @@ import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface AddIntegrationModalProps {
|
||||
environmentId: string;
|
||||
workspaceId: string;
|
||||
surveys: TSurvey[];
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -49,7 +49,7 @@ interface AddIntegrationModalProps {
|
||||
}
|
||||
|
||||
export const AddIntegrationModal = ({
|
||||
environmentId,
|
||||
workspaceId,
|
||||
surveys,
|
||||
open,
|
||||
setOpen,
|
||||
@@ -175,18 +175,18 @@ export const AddIntegrationModal = ({
|
||||
const linkDatabase = async () => {
|
||||
try {
|
||||
if (!selectedDatabase) {
|
||||
throw new Error(t("environments.integrations.notion.please_select_a_database"));
|
||||
throw new Error(t("workspace.integrations.notion.please_select_a_database"));
|
||||
}
|
||||
if (!selectedSurvey) {
|
||||
throw new Error(t("environments.integrations.please_select_a_survey_error"));
|
||||
throw new Error(t("workspace.integrations.please_select_a_survey_error"));
|
||||
}
|
||||
|
||||
if (mapping.length === 1 && (!mapping[0].element.id || !mapping[0].column.id)) {
|
||||
throw new Error(t("environments.integrations.notion.please_select_at_least_one_mapping"));
|
||||
throw new Error(t("workspace.integrations.notion.please_select_at_least_one_mapping"));
|
||||
}
|
||||
|
||||
if (mapping.filter((m) => m.error).length > 0) {
|
||||
throw new Error(t("environments.integrations.notion.please_resolve_mapping_errors"));
|
||||
throw new Error(t("workspace.integrations.notion.please_resolve_mapping_errors"));
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -194,7 +194,7 @@ export const AddIntegrationModal = ({
|
||||
mapping.filter((m) => m.element.id && !m.column.id).length >= 1
|
||||
) {
|
||||
throw new Error(
|
||||
t("environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
|
||||
t("workspace.integrations.notion.please_complete_mapping_fields_with_notion_property")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ export const AddIntegrationModal = ({
|
||||
}
|
||||
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
workspaceId,
|
||||
integrationData: notionIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
@@ -227,9 +227,9 @@ export const AddIntegrationModal = ({
|
||||
return;
|
||||
}
|
||||
if (selectedIntegration) {
|
||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||
toast.success(t("workspace.integrations.integration_updated_successfully"));
|
||||
} else {
|
||||
toast.success(t("environments.integrations.integration_added_successfully"));
|
||||
toast.success(t("workspace.integrations.integration_added_successfully"));
|
||||
}
|
||||
resetForm();
|
||||
setOpen(false);
|
||||
@@ -245,14 +245,14 @@ export const AddIntegrationModal = ({
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
workspaceId,
|
||||
integrationData: notionIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
toast.success(t("workspace.integrations.integration_removed_successfully"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Unknown error occurred");
|
||||
@@ -283,13 +283,13 @@ export const AddIntegrationModal = ({
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={NotionLogo}
|
||||
alt={t("environments.integrations.notion.notion_logo")}
|
||||
alt={t("workspace.integrations.notion.notion_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.notion.link_notion_database")}</DialogTitle>
|
||||
<DialogTitle>{t("workspace.integrations.notion.link_notion_database")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.notion.notion_integration_description")}
|
||||
{t("workspace.integrations.notion.notion_integration_description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -301,7 +301,7 @@ export const AddIntegrationModal = ({
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<DropdownSelector
|
||||
label={t("environments.integrations.notion.select_a_database")}
|
||||
label={t("workspace.integrations.notion.select_a_database")}
|
||||
items={databases.map((d) => ({
|
||||
id: d.id,
|
||||
name: (d as any).title?.[0]?.plain_text,
|
||||
@@ -314,13 +314,13 @@ export const AddIntegrationModal = ({
|
||||
{selectedDatabase && hasMatchingId && (
|
||||
<p className="text-xs text-amber-700">
|
||||
<strong>{t("common.warning")}:</strong>{" "}
|
||||
{t("environments.integrations.notion.duplicate_connection_warning")}
|
||||
{t("workspace.integrations.notion.duplicate_connection_warning")}
|
||||
</p>
|
||||
)}
|
||||
<p className="m-1 text-xs text-slate-500">
|
||||
{databases.length === 0 &&
|
||||
t(
|
||||
"environments.integrations.notion.create_at_least_one_database_to_setup_this_integration"
|
||||
"workspace.integrations.notion.create_at_least_one_database_to_setup_this_integration"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -333,13 +333,13 @@ export const AddIntegrationModal = ({
|
||||
disabled={surveys.length === 0}
|
||||
/>
|
||||
<p className="m-1 text-xs text-slate-500">
|
||||
{surveys.length === 0 && t("environments.integrations.create_survey_warning")}
|
||||
{surveys.length === 0 && t("workspace.integrations.create_survey_warning")}
|
||||
</p>
|
||||
</div>
|
||||
{selectedDatabase && selectedSurvey && (
|
||||
<div>
|
||||
<Label>
|
||||
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
|
||||
{t("workspace.integrations.notion.map_formbricks_fields_to_notion_property")}
|
||||
</Label>
|
||||
<div className="mt-1 space-y-2 overflow-y-auto">
|
||||
{mapping.map((m, idx) => (
|
||||
@@ -388,7 +388,7 @@ export const AddIntegrationModal = ({
|
||||
type="submit"
|
||||
loading={isLinkingDatabase}
|
||||
disabled={mapping.filter((m) => m.error).length > 0}>
|
||||
{selectedIntegration ? t("common.update") : t("environments.integrations.notion.link_database")}
|
||||
{selectedIntegration ? t("common.update") : t("workspace.integrations.notion.link_database")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -50,7 +50,7 @@ export const ManageIntegration = ({
|
||||
});
|
||||
|
||||
if (deleteIntegrationActionResult?.data) {
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
toast.success(t("workspace.integrations.integration_removed_successfully"));
|
||||
setIsConnected(false);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
|
||||
@@ -72,7 +72,7 @@ export const ManageIntegration = ({
|
||||
<div className="mr-6 flex items-center">
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="text-slate-500">
|
||||
{t("environments.integrations.notion.connected_with_workspace", {
|
||||
{t("workspace.integrations.notion.connected_with_workspace", {
|
||||
workspace: notionIntegration.config.key.workspace_name,
|
||||
})}
|
||||
</span>
|
||||
@@ -82,10 +82,10 @@ export const ManageIntegration = ({
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" onClick={handleNotionAuthorization}>
|
||||
<RefreshCcwIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.integrations.notion.update_connection")}
|
||||
{t("workspace.integrations.notion.update_connection")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("environments.integrations.notion.update_connection_tooltip")}</TooltipContent>
|
||||
<TooltipContent>{t("workspace.integrations.notion.update_connection_tooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
@@ -93,12 +93,12 @@ export const ManageIntegration = ({
|
||||
setSelectedIntegration(null);
|
||||
setOpenAddIntegrationModal(true);
|
||||
}}>
|
||||
{t("environments.integrations.notion.link_new_database")}
|
||||
{t("workspace.integrations.notion.link_new_database")}
|
||||
</Button>
|
||||
</div>
|
||||
{!integrationArray || integrationArray.length === 0 ? (
|
||||
<div className="mt-4 w-full">
|
||||
<EmptyState text={t("environments.integrations.notion.no_databases_found")} />
|
||||
<EmptyState text={t("workspace.integrations.notion.no_databases_found")} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex w-full flex-col items-center justify-center">
|
||||
@@ -106,7 +106,7 @@ export const ManageIntegration = ({
|
||||
<div className="grid h-12 grid-cols-6 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.survey")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">
|
||||
{t("environments.integrations.notion.database_name")}
|
||||
{t("workspace.integrations.notion.database_name")}
|
||||
</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
|
||||
</div>
|
||||
@@ -129,15 +129,15 @@ export const ManageIntegration = ({
|
||||
)}
|
||||
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
|
||||
<Trash2Icon />
|
||||
{t("environments.integrations.delete_integration")}
|
||||
{t("workspace.integrations.delete_integration")}
|
||||
</Button>
|
||||
|
||||
<DeleteDialog
|
||||
open={isDeleteIntegrationModalOpen}
|
||||
setOpen={setIsDeleteIntegrationModalOpen}
|
||||
deleteWhat={t("environments.integrations.notion.notion_integration")}
|
||||
deleteWhat={t("workspace.integrations.notion.notion_integration")}
|
||||
onDelete={handleDeleteIntegration}
|
||||
text={t("environments.integrations.delete_integration_confirmation")}
|
||||
text={t("workspace.integrations.delete_integration_confirmation")}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
ERRORS,
|
||||
TYPE_MAPPING,
|
||||
UNSUPPORTED_TYPES_BY_NOTION,
|
||||
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/constants";
|
||||
} from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/constants";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { getElementTypes } from "@/modules/survey/lib/elements";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -48,7 +48,7 @@ const MappingErrorMessage = ({
|
||||
return (
|
||||
<>
|
||||
-{" "}
|
||||
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
|
||||
{t("workspace.integrations.notion.col_name_of_type_is_not_supported", {
|
||||
col_name: col.name,
|
||||
type: col.type,
|
||||
})}
|
||||
@@ -60,7 +60,7 @@ const MappingErrorMessage = ({
|
||||
if (!element) return null;
|
||||
return (
|
||||
<>
|
||||
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
|
||||
{t("workspace.integrations.notion.que_name_of_type_cant_be_mapped_to", {
|
||||
que_name: elem.name,
|
||||
question_label: element.label,
|
||||
col_name: col.name,
|
||||
@@ -194,7 +194,7 @@ export const MappingRow = ({
|
||||
<div className="flex w-full items-center">
|
||||
<div className="max-w-full flex-1">
|
||||
<DropdownSelector
|
||||
placeholder={t("environments.integrations.notion.select_a_survey_question")}
|
||||
placeholder={t("workspace.integrations.notion.select_a_survey_question")}
|
||||
items={filteredElementItems}
|
||||
selectedItem={mapping?.[idx]?.element}
|
||||
setSelectedItem={handleElementSelect}
|
||||
@@ -204,7 +204,7 @@ export const MappingRow = ({
|
||||
<div className="h-px w-4 border-t border-t-slate-300" />
|
||||
<div className="max-w-full flex-1">
|
||||
<DropdownSelector
|
||||
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
|
||||
placeholder={t("workspace.integrations.notion.select_a_field_to_map")}
|
||||
items={getFilteredDbItems()}
|
||||
selectedItem={mapping?.[idx]?.column}
|
||||
setSelectedItem={handleColumnSelect}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TIntegrationNotion,
|
||||
TIntegrationNotionConfigData,
|
||||
@@ -9,8 +8,8 @@ import {
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/AddIntegrationModal";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/ManageIntegration";
|
||||
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/AddIntegrationModal";
|
||||
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/ManageIntegration";
|
||||
import notionLogo from "@/images/notion.png";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
import { authorize } from "../lib/notion";
|
||||
@@ -18,7 +17,7 @@ import { authorize } from "../lib/notion";
|
||||
interface NotionWrapperProps {
|
||||
notionIntegration: TIntegrationNotion | undefined;
|
||||
enabled: boolean;
|
||||
environment: TEnvironment;
|
||||
workspaceId: string;
|
||||
webAppUrl: string;
|
||||
surveys: TSurvey[];
|
||||
databasesArray: TIntegrationNotionDatabase[];
|
||||
@@ -28,7 +27,7 @@ interface NotionWrapperProps {
|
||||
export const NotionWrapper = ({
|
||||
notionIntegration,
|
||||
enabled,
|
||||
environment,
|
||||
workspaceId,
|
||||
webAppUrl,
|
||||
surveys,
|
||||
databasesArray,
|
||||
@@ -43,7 +42,7 @@ export const NotionWrapper = ({
|
||||
>(null);
|
||||
|
||||
const handleNotionAuthorization = async () => {
|
||||
authorize(environment.id, webAppUrl).then((url: string) => {
|
||||
authorize(workspaceId, webAppUrl).then((url: string) => {
|
||||
if (url) {
|
||||
window.location.replace(url);
|
||||
}
|
||||
@@ -55,7 +54,7 @@ export const NotionWrapper = ({
|
||||
{isConnected && notionIntegration ? (
|
||||
<>
|
||||
<AddIntegrationModal
|
||||
environmentId={environment.id}
|
||||
workspaceId={workspaceId}
|
||||
surveys={surveys}
|
||||
open={isModalOpen}
|
||||
setOpen={setIsModalOpen}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user