mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-09 11:10:36 -05:00
feat: wire workspace settings to feedback record directories
Integrate feedback record directory selection into workspace settings and creation flows while updating workspace navigation components to expose the new workspace-level destinations. Made-with: Cursor
This commit is contained in:
@@ -23,9 +23,13 @@ import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
|
||||
import { getOrganizationsByUserId } from "./lib/organization";
|
||||
import { getWorkspacesByUserId } from "./lib/workspace";
|
||||
|
||||
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.extend({
|
||||
feedbackRecordDirectoryId: ZId.optional(),
|
||||
});
|
||||
|
||||
const ZCreateWorkspaceAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZWorkspaceUpdateInput,
|
||||
data: ZCreateWorkspaceInput,
|
||||
});
|
||||
|
||||
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
|
||||
@@ -40,7 +44,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZWorkspaceUpdateInput,
|
||||
schema: ZCreateWorkspaceInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
Loader2,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
MessageSquareTextIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlusIcon,
|
||||
RocketIcon,
|
||||
SettingsIcon,
|
||||
Shapes,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
@@ -146,58 +146,77 @@ export const MainNavigation = ({
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const mainNavigation = useMemo(
|
||||
const mainNavigationSections = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: t("common.surveys"),
|
||||
href: `/workspaces/${workspace.id}/surveys`,
|
||||
icon: MessageCircle,
|
||||
isActive: pathname?.includes("/surveys"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
href: `/workspaces/${workspace.id}/contacts`,
|
||||
name: t("common.contacts"),
|
||||
icon: UserIcon,
|
||||
isActive:
|
||||
pathname?.includes("/contacts") ||
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
name: t("common.analysis"),
|
||||
href: `/workspaces/${workspace.id}/dashboards`,
|
||||
icon: BarChart3Icon,
|
||||
isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
id: "ask",
|
||||
name: "Ask",
|
||||
items: [
|
||||
{
|
||||
name: t("common.surveys"),
|
||||
href: `/workspaces/${workspace.id}/surveys`,
|
||||
icon: MessageCircle,
|
||||
isActive: pathname?.includes("/surveys"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
href: `/workspaces/${workspace.id}/contacts`,
|
||||
name: t("common.contacts"),
|
||||
icon: UserIcon,
|
||||
isActive:
|
||||
pathname?.includes("/contacts") ||
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "unify-feedback",
|
||||
name: t("workspace.unify.unify_feedback"),
|
||||
href: `/workspaces/${workspace.id}/unify/sources`,
|
||||
icon: Shapes,
|
||||
isActive: pathname?.includes("/unify"),
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/workspaces/${workspace.id}/general`,
|
||||
icon: Cog,
|
||||
isActive:
|
||||
pathname?.includes("/general") ||
|
||||
pathname?.includes("/look") ||
|
||||
pathname?.includes("/app-connection") ||
|
||||
pathname?.includes("/integrations") ||
|
||||
pathname?.includes("/teams") ||
|
||||
pathname?.includes("/languages") ||
|
||||
pathname?.includes("/tags"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
items: [
|
||||
{
|
||||
name: t("workspace.unify.feedback_records"),
|
||||
href: `/workspaces/${workspace.id}/unify/feedback-records`,
|
||||
icon: MessageSquareTextIcon,
|
||||
isActive: pathname?.includes("/unify/feedback-records"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
name: t("common.dashboards"),
|
||||
href: `/workspaces/${workspace.id}/dashboards`,
|
||||
icon: BarChart3Icon,
|
||||
isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[t, workspace.id, pathname, isMembershipPending, isBilling]
|
||||
);
|
||||
|
||||
const configurationNavigationItem = useMemo(
|
||||
() => ({
|
||||
name: t("common.configuration"),
|
||||
href: `/workspaces/${workspace.id}/general`,
|
||||
icon: Cog,
|
||||
isActive:
|
||||
pathname?.includes("/general") ||
|
||||
pathname?.includes("/look") ||
|
||||
pathname?.includes("/app-connection") ||
|
||||
pathname?.includes("/feedback-sources") ||
|
||||
pathname?.includes("/integrations") ||
|
||||
pathname?.includes("/teams") ||
|
||||
pathname?.includes("/languages") ||
|
||||
pathname?.includes("/tags"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
}),
|
||||
[t, workspace.id, pathname, isMembershipPending, isBilling]
|
||||
);
|
||||
|
||||
const dropdownNavigation = [
|
||||
{
|
||||
label: t("common.account"),
|
||||
@@ -256,6 +275,11 @@ export const MainNavigation = ({
|
||||
label: t("common.website_and_app_connection"),
|
||||
href: `/workspaces/${workspace.id}/app-connection`,
|
||||
},
|
||||
{
|
||||
id: "feedback-sources",
|
||||
label: t("workspace.unify.feedback_sources"),
|
||||
href: `/workspaces/${workspace.id}/feedback-sources`,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
@@ -552,23 +576,50 @@ export const MainNavigation = ({
|
||||
</div>
|
||||
|
||||
{/* Main Nav Switch */}
|
||||
<ul>
|
||||
{mainNavigation.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={item.disabled}
|
||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{mainNavigationSections.map((section) => (
|
||||
<li key={section.id}>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<p className="px-4 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
{section.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul>
|
||||
{section.items.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={item.disabled}
|
||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<li className={cn("mt-2 border-t border-slate-100 pt-2", isCollapsed && "border-t-0 pt-0")}>
|
||||
<NavigationLink
|
||||
href={configurationNavigationItem.href}
|
||||
isActive={configurationNavigationItem.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={configurationNavigationItem.disabled}
|
||||
disabledMessage={
|
||||
configurationNavigationItem.disabled ? disabledNavigationMessage : undefined
|
||||
}
|
||||
linkText={configurationNavigationItem.name}>
|
||||
<configurationNavigationItem.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -118,6 +118,11 @@ export const WorkspaceBreadcrumb = ({
|
||||
label: t("common.website_and_app_connection"),
|
||||
href: `${workspaceBasePath}/app-connection`,
|
||||
},
|
||||
{
|
||||
id: "feedback-sources",
|
||||
label: t("workspace.unify.feedback_sources"),
|
||||
href: `${workspaceBasePath}/feedback-sources`,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
|
||||
@@ -21,6 +21,7 @@ export const SettingsCard = ({
|
||||
beta,
|
||||
className,
|
||||
buttonInfo,
|
||||
cta,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -30,6 +31,7 @@ export const SettingsCard = ({
|
||||
beta?: boolean;
|
||||
className?: string;
|
||||
buttonInfo?: ButtonInfo;
|
||||
cta?: React.ReactNode;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -52,11 +54,12 @@ export const SettingsCard = ({
|
||||
{description}
|
||||
</Small>
|
||||
</div>
|
||||
{buttonInfo && (
|
||||
<Button type="button" onClick={buttonInfo?.onClick} variant={buttonInfo?.variant ?? "default"}>
|
||||
{buttonInfo?.text}
|
||||
</Button>
|
||||
)}
|
||||
{cta ??
|
||||
(buttonInfo && (
|
||||
<Button type="button" onClick={buttonInfo?.onClick} variant={buttonInfo?.variant ?? "default"}>
|
||||
{buttonInfo?.text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className={cn(noPadding ? "" : "px-4 pt-4")}>{children}</div>
|
||||
</div>
|
||||
|
||||
+12
-2
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface UnifyConfigNavigationProps {
|
||||
@@ -17,15 +18,24 @@ export const UnifyConfigNavigation = ({
|
||||
const { t } = useTranslation();
|
||||
const baseHref = `/workspaces/${workspaceId}/unify`;
|
||||
|
||||
const activeId = activeIdProp ?? "sources";
|
||||
const activeId = activeIdProp ?? "feedback-records";
|
||||
|
||||
const navigation = [
|
||||
{ id: "sources", label: t("workspace.unify.sources"), href: `${baseHref}/sources` },
|
||||
{
|
||||
id: "feedback-records",
|
||||
label: t("workspace.unify.feedback_records"),
|
||||
href: `${baseHref}/feedback-records`,
|
||||
},
|
||||
{
|
||||
id: "topics-subtopics",
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{t("workspace.unify.topics_and_subtopics")}
|
||||
<Badge text={t("common.soon")} type="gray" size="tiny" />
|
||||
</span>
|
||||
),
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
|
||||
@@ -2,5 +2,5 @@ import { redirect } from "next/navigation";
|
||||
|
||||
export default async function UnifyPage(props: { params: Promise<{ workspaceId: string }> }) {
|
||||
const params = await props.params;
|
||||
redirect(`/workspaces/${params.workspaceId}/unify/sources`);
|
||||
redirect(`/workspaces/${params.workspaceId}/unify/feedback-records`);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ export const getFeedbackRecordDirectoryDetailsAction = authenticatedActionClient
|
||||
const ZUpdateFeedbackRecordDirectoryAction = z.object({
|
||||
directoryId: ZId,
|
||||
data: ZFeedbackRecordDirectoryUpdateInput,
|
||||
pauseConnectorsInRemovedWorkspaces: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const updateFeedbackRecordDirectoryAction = authenticatedActionClient
|
||||
@@ -99,7 +100,10 @@ export const updateFeedbackRecordDirectoryAction = authenticatedActionClient
|
||||
const result = await updateFeedbackRecordDirectory(
|
||||
parsedInput.directoryId,
|
||||
organizationId,
|
||||
parsedInput.data
|
||||
parsedInput.data,
|
||||
{
|
||||
pauseConnectorsInRemovedWorkspaces: parsedInput.pauseConnectorsInRemovedWorkspaces,
|
||||
}
|
||||
);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = await getFeedbackRecordDirectoryDetails(parsedInput.directoryId);
|
||||
|
||||
+147
-27
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CircleAlert } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -17,6 +18,7 @@ import { ArchiveFeedbackRecordDirectory } from "@/modules/ee/feedback-record-dir
|
||||
import {
|
||||
TFeedbackRecordDirectoryDetails,
|
||||
TFeedbackRecordDirectoryUpdateInput,
|
||||
TWorkspaceFeedbackRecordDirectoryAccess,
|
||||
ZFeedbackRecordDirectoryUpdateInput,
|
||||
getTranslatedFeedbackRecordDirectoryError,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
@@ -43,6 +45,7 @@ interface FeedbackRecordDirectorySettingsModalProps {
|
||||
directory?: TFeedbackRecordDirectoryDetails;
|
||||
organizationId: string;
|
||||
orgWorkspaces: TOrganizationWorkspace[];
|
||||
workspaceAccessByWorkspace: TWorkspaceFeedbackRecordDirectoryAccess[];
|
||||
membershipRole: TOrganizationRole;
|
||||
}
|
||||
|
||||
@@ -52,24 +55,47 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
directory,
|
||||
organizationId,
|
||||
orgWorkspaces,
|
||||
workspaceAccessByWorkspace,
|
||||
membershipRole,
|
||||
}: FeedbackRecordDirectorySettingsModalProps) => {
|
||||
}: Readonly<FeedbackRecordDirectorySettingsModalProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
const router = useRouter();
|
||||
const isEdit = !!directory;
|
||||
|
||||
const [confirmPauseDialogOpen, setConfirmPauseDialogOpen] = useState(false);
|
||||
const [pendingSubmitData, setPendingSubmitData] = useState<TFeedbackRecordDirectoryUpdateInput | null>(
|
||||
null
|
||||
);
|
||||
const [connectorsToPauseCount, setConnectorsToPauseCount] = useState(0);
|
||||
|
||||
const workspaceAccessMap = useMemo(
|
||||
() => new Map(workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment])),
|
||||
[workspaceAccessByWorkspace]
|
||||
);
|
||||
|
||||
const workspaceOptions = useMemo(
|
||||
() =>
|
||||
orgWorkspaces
|
||||
.map((p) => ({ value: p.id, label: p.name }))
|
||||
.map((workspace) => {
|
||||
const assignment = workspaceAccessMap.get(workspace.id);
|
||||
const isAssignedToDifferentDirectory = Boolean(
|
||||
assignment && assignment.feedbackRecordDirectoryId !== directory?.id
|
||||
);
|
||||
|
||||
return {
|
||||
value: workspace.id,
|
||||
label: workspace.name,
|
||||
disabled: isAssignedToDifferentDirectory,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: "base" })),
|
||||
[orgWorkspaces]
|
||||
[orgWorkspaces, workspaceAccessMap, directory?.id]
|
||||
);
|
||||
|
||||
const initialWorkspaceIds = useMemo(
|
||||
() => directory?.workspaces.map((p) => p.workspaceId) ?? [],
|
||||
() => directory?.workspaces.map((workspace) => workspace.workspaceId) ?? [],
|
||||
[directory?.workspaces]
|
||||
);
|
||||
|
||||
@@ -91,21 +117,29 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
} = form;
|
||||
|
||||
const closeModal = () => {
|
||||
setConfirmPauseDialogOpen(false);
|
||||
setPendingSubmitData(null);
|
||||
setConnectorsToPauseCount(0);
|
||||
reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleSubmitForm: SubmitHandler<TFeedbackRecordDirectoryUpdateInput> = async (data) => {
|
||||
const response = isEdit
|
||||
? await updateFeedbackRecordDirectoryAction({
|
||||
directoryId: directory.id,
|
||||
data: { name: data.name, workspaceIds: data.workspaceIds },
|
||||
})
|
||||
: await createFeedbackRecordDirectoryAction({
|
||||
organizationId,
|
||||
name: data.name ?? "",
|
||||
workspaceIds: data.workspaceIds,
|
||||
});
|
||||
const submitDirectory = async (
|
||||
data: TFeedbackRecordDirectoryUpdateInput,
|
||||
pauseConnectorsInRemovedWorkspaces: boolean
|
||||
) => {
|
||||
const response =
|
||||
isEdit && directory
|
||||
? await updateFeedbackRecordDirectoryAction({
|
||||
directoryId: directory.id,
|
||||
data: { name: data.name, workspaceIds: data.workspaceIds },
|
||||
pauseConnectorsInRemovedWorkspaces,
|
||||
})
|
||||
: await createFeedbackRecordDirectoryAction({
|
||||
organizationId,
|
||||
name: data.name ?? "",
|
||||
workspaceIds: data.workspaceIds,
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
toast.success(
|
||||
@@ -115,12 +149,54 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
);
|
||||
closeModal();
|
||||
router.refresh();
|
||||
return true;
|
||||
} else {
|
||||
const errorCode = getFormattedErrorMessage(response);
|
||||
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmPauseAndSubmit = async () => {
|
||||
if (!pendingSubmitData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasSuccessful = await submitDirectory(pendingSubmitData, true);
|
||||
if (wasSuccessful) {
|
||||
setConfirmPauseDialogOpen(false);
|
||||
setPendingSubmitData(null);
|
||||
setConnectorsToPauseCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitForm: SubmitHandler<TFeedbackRecordDirectoryUpdateInput> = async (data) => {
|
||||
if (!isEdit || !directory) {
|
||||
await submitDirectory(data, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedWorkspaceIds = data.workspaceIds ?? [];
|
||||
const removedWorkspaceIds = initialWorkspaceIds.filter(
|
||||
(workspaceId) => !updatedWorkspaceIds.includes(workspaceId)
|
||||
);
|
||||
|
||||
if (removedWorkspaceIds.length > 0) {
|
||||
const affectedConnectors = directory.connectors.filter((connector) =>
|
||||
removedWorkspaceIds.includes(connector.workspaceId)
|
||||
);
|
||||
|
||||
if (affectedConnectors.length > 0) {
|
||||
setPendingSubmitData(data);
|
||||
setConnectorsToPauseCount(affectedConnectors.length);
|
||||
setConfirmPauseDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await submitDirectory(data, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(newOpen) => (newOpen ? setOpen(true) : closeModal())}>
|
||||
<DialogContent>
|
||||
@@ -157,21 +233,17 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
disabled={!isOwnerOrManager}
|
||||
/>
|
||||
</FormControl>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
{error?.message && (
|
||||
<FormError className="text-left">
|
||||
{getTranslatedFeedbackRecordDirectoryError(error.message, t)}
|
||||
</FormError>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isEdit && (
|
||||
<IdBadge
|
||||
id={directory.id}
|
||||
label={t("workspace.settings.feedback_record_directories.directory_id")}
|
||||
variant="column"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("common.workspaces")}</FormLabel>
|
||||
<FormLabel>{t("workspace.settings.feedback_record_directories.workspace_access")}</FormLabel>
|
||||
<Muted className="block text-slate-500">
|
||||
{t("workspace.settings.feedback_record_directories.assign_workspaces_description")}
|
||||
</Muted>
|
||||
@@ -213,7 +285,7 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
</div>
|
||||
<a
|
||||
className="text-xs font-medium text-slate-700 hover:text-slate-900 hover:underline"
|
||||
href={`/workspaces/${c.workspaceId}/unify/sources`}>
|
||||
href={`/workspaces/${c.workspaceId}/feedback-sources`}>
|
||||
{t("common.view")}
|
||||
</a>
|
||||
</li>
|
||||
@@ -222,6 +294,14 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEdit && (
|
||||
<IdBadge
|
||||
id={directory.id}
|
||||
label={t("workspace.settings.feedback_record_directories.directory_id")}
|
||||
variant="column"
|
||||
/>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{isEdit && (
|
||||
@@ -243,6 +323,46 @@ export const FeedbackRecordDirectorySettingsModal = ({
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DialogContent>
|
||||
|
||||
{confirmPauseDialogOpen && (
|
||||
<Dialog open={confirmPauseDialogOpen} onOpenChange={setConfirmPauseDialogOpen}>
|
||||
<DialogContent width="narrow" hideCloseButton={true} disableCloseOnOutsideClick={true}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleAlert className="h-4 w-4" />
|
||||
<DialogTitle>
|
||||
{t("workspace.settings.feedback_record_directories.pause_connectors_confirmation_title")}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<p>
|
||||
{t(
|
||||
"workspace.settings.feedback_record_directories.pause_connectors_confirmation_description",
|
||||
{
|
||||
count: connectorsToPauseCount,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setConfirmPauseDialogOpen(false);
|
||||
setPendingSubmitData(null);
|
||||
setConnectorsToPauseCount(0);
|
||||
}}
|
||||
disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleConfirmPauseAndSubmit} loading={isSubmitting}>
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
+27
-1
@@ -15,6 +15,7 @@ import { FeedbackRecordDirectorySettingsModal } from "@/modules/ee/feedback-reco
|
||||
import {
|
||||
TFeedbackRecordDirectory,
|
||||
TFeedbackRecordDirectoryDetails,
|
||||
TWorkspaceFeedbackRecordDirectoryAccess,
|
||||
getTranslatedFeedbackRecordDirectoryError,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
import { TOrganizationWorkspace } from "@/modules/ee/teams/team-list/types/workspace";
|
||||
@@ -27,6 +28,7 @@ interface FeedbackRecordDirectoryTableProps {
|
||||
directories: TFeedbackRecordDirectory[];
|
||||
organizationId: string;
|
||||
orgWorkspaces: TOrganizationWorkspace[];
|
||||
workspaceAccessByWorkspace: TWorkspaceFeedbackRecordDirectoryAccess[];
|
||||
membershipRole: TOrganizationRole;
|
||||
}
|
||||
|
||||
@@ -34,8 +36,9 @@ export const FeedbackRecordDirectoryTable = ({
|
||||
directories,
|
||||
organizationId,
|
||||
orgWorkspaces,
|
||||
workspaceAccessByWorkspace,
|
||||
membershipRole,
|
||||
}: FeedbackRecordDirectoryTableProps) => {
|
||||
}: Readonly<FeedbackRecordDirectoryTableProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openSettingsModal, setOpenSettingsModal] = useState(false);
|
||||
@@ -67,6 +70,27 @@ export const FeedbackRecordDirectoryTable = ({
|
||||
const handleUnarchiveDirectory = async (directoryId: string) => {
|
||||
setLoadingDirectoryId(directoryId);
|
||||
try {
|
||||
const directoryDetailsResponse = await getFeedbackRecordDirectoryDetailsAction({ directoryId });
|
||||
if (!directoryDetailsResponse?.data) {
|
||||
const errorCode = getFormattedErrorMessage(directoryDetailsResponse);
|
||||
toast.error(getTranslatedFeedbackRecordDirectoryError(errorCode, t));
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceAccessMap = new Map(
|
||||
workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment])
|
||||
);
|
||||
|
||||
const hasConflicts = directoryDetailsResponse.data.workspaces.some((workspace) => {
|
||||
const assignment = workspaceAccessMap.get(workspace.workspaceId);
|
||||
return assignment && assignment.feedbackRecordDirectoryId !== directoryId;
|
||||
});
|
||||
|
||||
if (hasConflicts) {
|
||||
toast.error(t("workspace.settings.feedback_record_directories.unarchive_workspace_conflict"));
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await updateFeedbackRecordDirectoryAction({
|
||||
directoryId,
|
||||
data: { isArchived: false },
|
||||
@@ -166,6 +190,7 @@ export const FeedbackRecordDirectoryTable = ({
|
||||
setOpen={setOpenCreateModal}
|
||||
organizationId={organizationId}
|
||||
orgWorkspaces={orgWorkspaces}
|
||||
workspaceAccessByWorkspace={workspaceAccessByWorkspace}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
)}
|
||||
@@ -177,6 +202,7 @@ export const FeedbackRecordDirectoryTable = ({
|
||||
directory={selectedDirectory}
|
||||
organizationId={organizationId}
|
||||
orgWorkspaces={orgWorkspaces}
|
||||
workspaceAccessByWorkspace={workspaceAccessByWorkspace}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
)}
|
||||
|
||||
+7
-2
@@ -2,7 +2,10 @@ import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { FeedbackRecordDirectoryTable } from "@/modules/ee/feedback-record-directory/components/feedback-record-directory-table";
|
||||
import { getFeedbackRecordDirectories } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import {
|
||||
getFeedbackRecordDirectories,
|
||||
getWorkspaceFeedbackRecordDirectoryAccess,
|
||||
} from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspacesByOrganizationId } from "@/modules/ee/teams/team-list/lib/workspace";
|
||||
|
||||
interface FeedbackRecordDirectoryViewProps {
|
||||
@@ -16,9 +19,10 @@ export const FeedbackRecordDirectoryView = async ({
|
||||
}: FeedbackRecordDirectoryViewProps) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
const [directories, orgWorkspaces] = await Promise.all([
|
||||
const [directories, orgWorkspaces, workspaceAccessByWorkspace] = await Promise.all([
|
||||
getFeedbackRecordDirectories(organizationId),
|
||||
getWorkspacesByOrganizationId(organizationId),
|
||||
getWorkspaceFeedbackRecordDirectoryAccess(organizationId),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -29,6 +33,7 @@ export const FeedbackRecordDirectoryView = async ({
|
||||
directories={directories}
|
||||
organizationId={organizationId}
|
||||
orgWorkspaces={orgWorkspaces}
|
||||
workspaceAccessByWorkspace={workspaceAccessByWorkspace}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
+111
-2
@@ -8,6 +8,7 @@ import {
|
||||
getFeedbackRecordDirectoriesByWorkspaceId,
|
||||
getFeedbackRecordDirectoryDetails,
|
||||
getOrganizationIdFromDirectoryId,
|
||||
getWorkspaceFeedbackRecordDirectoryAccess,
|
||||
updateFeedbackRecordDirectory,
|
||||
} from "./feedback-record-directory";
|
||||
|
||||
@@ -33,6 +34,7 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
connector: {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -147,7 +149,7 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
{
|
||||
id: "conn-1",
|
||||
name: "My Connector",
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
workspaceId: mockWorkspaceId1,
|
||||
workspace: { name: "Workspace A" },
|
||||
},
|
||||
@@ -161,7 +163,7 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
{
|
||||
id: "conn-1",
|
||||
name: "My Connector",
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
workspaceId: mockWorkspaceId1,
|
||||
workspaceName: "Workspace A",
|
||||
},
|
||||
@@ -345,6 +347,34 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("pauses connectors in removed workspaces when requested", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce(
|
||||
mockDirectoryDetailsDbRow as any
|
||||
);
|
||||
vi.mocked(prisma.workspace.count).mockResolvedValueOnce(1);
|
||||
vi.mocked(prisma.feedbackRecordDirectory.update).mockResolvedValueOnce({} as any);
|
||||
|
||||
const result = await updateFeedbackRecordDirectory(
|
||||
mockDirectoryId,
|
||||
mockOrganizationId,
|
||||
{
|
||||
workspaceIds: [mockWorkspaceId1],
|
||||
},
|
||||
{ pauseConnectorsInRemovedWorkspaces: true }
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.connector.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
feedbackRecordDirectoryId: mockDirectoryId,
|
||||
workspaceId: { in: [mockWorkspaceId2] },
|
||||
},
|
||||
data: {
|
||||
status: "paused",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when directory does not exist (P2025)", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
@@ -446,6 +476,85 @@ describe("FeedbackRecordDirectory Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWorkspaceFeedbackRecordDirectoryAccess", () => {
|
||||
test("returns one active assignment per workspace with directory details", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockResolvedValueOnce([
|
||||
{
|
||||
workspaceId: mockWorkspaceId1,
|
||||
feedbackRecordDirectory: { id: mockDirectoryId, name: "Directory A" },
|
||||
},
|
||||
{
|
||||
workspaceId: mockWorkspaceId1,
|
||||
feedbackRecordDirectory: { id: "clj28r6va000409j3ep7h8xy2", name: "Directory B" },
|
||||
},
|
||||
{
|
||||
workspaceId: mockWorkspaceId2,
|
||||
feedbackRecordDirectory: { id: "clj28r6va000409j3ep7h8xy3", name: "Directory C" },
|
||||
},
|
||||
] as any);
|
||||
|
||||
const result = await getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
workspaceId: mockWorkspaceId1,
|
||||
feedbackRecordDirectoryId: mockDirectoryId,
|
||||
feedbackRecordDirectoryName: "Directory A",
|
||||
},
|
||||
{
|
||||
workspaceId: mockWorkspaceId2,
|
||||
feedbackRecordDirectoryId: "clj28r6va000409j3ep7h8xy3",
|
||||
feedbackRecordDirectoryName: "Directory C",
|
||||
},
|
||||
]);
|
||||
expect(prisma.feedbackRecordDirectoryWorkspace.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
feedbackRecordDirectory: {
|
||||
organizationId: mockOrganizationId,
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
workspaceId: true,
|
||||
feedbackRecordDirectory: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ workspaceId: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no active access assignments exist", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockResolvedValueOnce([]);
|
||||
|
||||
const result = await getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P2010",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockRejectedValueOnce(prismaError);
|
||||
|
||||
await expect(getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
|
||||
test("re-throws unexpected errors", async () => {
|
||||
const error = new Error("Unexpected");
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.findMany).mockRejectedValueOnce(error);
|
||||
|
||||
await expect(getWorkspaceFeedbackRecordDirectoryAccess(mockOrganizationId)).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrganizationIdFromDirectoryId", () => {
|
||||
test("returns organization ID for a valid directory", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findUnique).mockResolvedValueOnce({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TFeedbackRecordDirectory,
|
||||
TFeedbackRecordDirectoryDetails,
|
||||
TFeedbackRecordDirectoryUpdateInput,
|
||||
TWorkspaceFeedbackRecordDirectoryAccess,
|
||||
ZFeedbackRecordDirectoryUpdateInput,
|
||||
} from "@/modules/ee/feedback-record-directory/types/feedback-record-directory";
|
||||
|
||||
@@ -99,6 +100,55 @@ export const getFeedbackRecordDirectoriesByWorkspaceId = reactCache(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Lists active feedback directory access assignments by workspace for an organization.
|
||||
* Each workspace appears once with the first active directory assignment found.
|
||||
*/
|
||||
export const getWorkspaceFeedbackRecordDirectoryAccess = reactCache(
|
||||
async (organizationId: string): Promise<TWorkspaceFeedbackRecordDirectoryAccess[]> => {
|
||||
validateInputs([organizationId, ZId]);
|
||||
try {
|
||||
const rows = await prisma.feedbackRecordDirectoryWorkspace.findMany({
|
||||
where: {
|
||||
feedbackRecordDirectory: {
|
||||
organizationId,
|
||||
isArchived: false,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
workspaceId: true,
|
||||
feedbackRecordDirectory: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ workspaceId: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
|
||||
const accessByWorkspaceId = new Map<string, TWorkspaceFeedbackRecordDirectoryAccess>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (!accessByWorkspaceId.has(row.workspaceId)) {
|
||||
accessByWorkspaceId.set(row.workspaceId, {
|
||||
workspaceId: row.workspaceId,
|
||||
feedbackRecordDirectoryId: row.feedbackRecordDirectory.id,
|
||||
feedbackRecordDirectoryName: row.feedbackRecordDirectory.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(accessByWorkspaceId.values());
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getFeedbackRecordDirectoryDetails = reactCache(
|
||||
async (directoryId: string): Promise<TFeedbackRecordDirectoryDetails | null> => {
|
||||
validateInputs([directoryId, ZId]);
|
||||
@@ -238,7 +288,10 @@ const buildWorkspaceAssignmentPayload = async (
|
||||
workspaceIds: string[],
|
||||
organizationId: string,
|
||||
currentWorkspaceIds: string[]
|
||||
): Promise<Prisma.FeedbackRecordDirectoryWorkspaceUpdateManyWithoutFeedbackRecordDirectoryNestedInput> => {
|
||||
): Promise<{
|
||||
payload: Prisma.FeedbackRecordDirectoryWorkspaceUpdateManyWithoutFeedbackRecordDirectoryNestedInput;
|
||||
deletedWorkspaceIds: string[];
|
||||
}> => {
|
||||
if (workspaceIds.length > 0) {
|
||||
const orgWorkspacesCount = await prismaClient.workspace.count({
|
||||
where: {
|
||||
@@ -254,22 +307,94 @@ const buildWorkspaceAssignmentPayload = async (
|
||||
const deletedWorkspaceIds = currentWorkspaceIds.filter((id) => !workspaceIds.includes(id));
|
||||
|
||||
return {
|
||||
deleteMany: {
|
||||
workspaceId: { in: deletedWorkspaceIds },
|
||||
},
|
||||
upsert: workspaceIds.map((workspaceId) => ({
|
||||
where: {
|
||||
feedbackRecordDirectoryId_workspaceId: {
|
||||
feedbackRecordDirectoryId: directoryId,
|
||||
workspaceId,
|
||||
},
|
||||
payload: {
|
||||
deleteMany: {
|
||||
workspaceId: { in: deletedWorkspaceIds },
|
||||
},
|
||||
update: {},
|
||||
create: { workspaceId },
|
||||
})),
|
||||
upsert: workspaceIds.map((workspaceId) => ({
|
||||
where: {
|
||||
feedbackRecordDirectoryId_workspaceId: {
|
||||
feedbackRecordDirectoryId: directoryId,
|
||||
workspaceId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: { workspaceId },
|
||||
})),
|
||||
},
|
||||
deletedWorkspaceIds,
|
||||
};
|
||||
};
|
||||
|
||||
interface UpdateFeedbackRecordDirectoryOptions {
|
||||
pauseConnectorsInRemovedWorkspaces?: boolean;
|
||||
}
|
||||
|
||||
const getArchiveUpdate = async (
|
||||
directoryId: string,
|
||||
isArchived: boolean | undefined
|
||||
): Promise<Pick<Prisma.FeedbackRecordDirectoryUpdateInput, "isArchived">> => {
|
||||
if (isArchived === true) {
|
||||
const connectorCount = await prisma.connector.count({
|
||||
where: { feedbackRecordDirectoryId: directoryId },
|
||||
});
|
||||
if (connectorCount > 0) {
|
||||
throw new InvalidInputError("DIRECTORY_HAS_CONNECTORS");
|
||||
}
|
||||
return { isArchived: true };
|
||||
}
|
||||
|
||||
if (isArchived === false) {
|
||||
return { isArchived: false };
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const getWorkspaceAssignmentUpdate = async (
|
||||
directoryId: string,
|
||||
organizationId: string,
|
||||
workspaceIds: string[] | undefined
|
||||
): Promise<{
|
||||
workspaces?: Prisma.FeedbackRecordDirectoryWorkspaceUpdateManyWithoutFeedbackRecordDirectoryNestedInput;
|
||||
removedWorkspaceIds: string[];
|
||||
}> => {
|
||||
if (workspaceIds === undefined) {
|
||||
return { removedWorkspaceIds: [] };
|
||||
}
|
||||
|
||||
const currentDetails = await getFeedbackRecordDirectoryDetails(directoryId);
|
||||
const currentWorkspaceIds = currentDetails?.workspaces.map((workspace) => workspace.workspaceId) ?? [];
|
||||
const assignmentPayload = await buildWorkspaceAssignmentPayload(
|
||||
prisma,
|
||||
directoryId,
|
||||
workspaceIds,
|
||||
organizationId,
|
||||
currentWorkspaceIds
|
||||
);
|
||||
|
||||
return {
|
||||
workspaces: assignmentPayload.payload,
|
||||
removedWorkspaceIds: assignmentPayload.deletedWorkspaceIds,
|
||||
};
|
||||
};
|
||||
|
||||
const pauseConnectorsInWorkspaces = async (directoryId: string, workspaceIds: string[]): Promise<void> => {
|
||||
if (workspaceIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.connector.updateMany({
|
||||
where: {
|
||||
feedbackRecordDirectoryId: directoryId,
|
||||
workspaceId: { in: workspaceIds },
|
||||
},
|
||||
data: {
|
||||
status: "paused",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a feedback record directory. Supports partial updates for name, workspace
|
||||
* assignments, and archive status.
|
||||
@@ -291,49 +416,36 @@ const buildWorkspaceAssignmentPayload = async (
|
||||
export const updateFeedbackRecordDirectory = async (
|
||||
directoryId: string,
|
||||
organizationId: string,
|
||||
data: TFeedbackRecordDirectoryUpdateInput
|
||||
data: TFeedbackRecordDirectoryUpdateInput,
|
||||
options?: UpdateFeedbackRecordDirectoryOptions
|
||||
): Promise<boolean> => {
|
||||
validateInputs([directoryId, ZId], [organizationId, ZId], [data, ZFeedbackRecordDirectoryUpdateInput]);
|
||||
|
||||
try {
|
||||
const { name, workspaceIds, isArchived } = data;
|
||||
|
||||
const payload: Prisma.FeedbackRecordDirectoryUpdateInput = {};
|
||||
const archiveUpdate = await getArchiveUpdate(directoryId, isArchived);
|
||||
const workspaceAssignmentUpdate = await getWorkspaceAssignmentUpdate(
|
||||
directoryId,
|
||||
organizationId,
|
||||
workspaceIds
|
||||
);
|
||||
|
||||
if (name !== undefined) {
|
||||
payload.name = name;
|
||||
}
|
||||
|
||||
if (isArchived === true) {
|
||||
const connectorCount = await prisma.connector.count({
|
||||
where: { feedbackRecordDirectoryId: directoryId },
|
||||
});
|
||||
if (connectorCount > 0) {
|
||||
throw new InvalidInputError("DIRECTORY_HAS_CONNECTORS");
|
||||
}
|
||||
payload.isArchived = true;
|
||||
} else if (isArchived === false) {
|
||||
payload.isArchived = false;
|
||||
}
|
||||
|
||||
if (workspaceIds !== undefined) {
|
||||
const currentDetails = await getFeedbackRecordDirectoryDetails(directoryId);
|
||||
const currentWorkspaceIds = currentDetails?.workspaces.map((p) => p.workspaceId) ?? [];
|
||||
|
||||
payload.workspaces = await buildWorkspaceAssignmentPayload(
|
||||
prisma,
|
||||
directoryId,
|
||||
workspaceIds,
|
||||
organizationId,
|
||||
currentWorkspaceIds
|
||||
);
|
||||
}
|
||||
const payload: Prisma.FeedbackRecordDirectoryUpdateInput = {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...archiveUpdate,
|
||||
...(workspaceAssignmentUpdate.workspaces ? { workspaces: workspaceAssignmentUpdate.workspaces } : {}),
|
||||
};
|
||||
|
||||
await prisma.feedbackRecordDirectory.update({
|
||||
where: { id: directoryId },
|
||||
data: payload,
|
||||
});
|
||||
|
||||
if (options?.pauseConnectorsInRemovedWorkspaces) {
|
||||
await pauseConnectorsInWorkspaces(directoryId, workspaceAssignmentUpdate.removedWorkspaceIds);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
|
||||
@@ -35,6 +35,12 @@ export const ZFeedbackRecordDirectoryDetails = z.object({
|
||||
|
||||
export type TFeedbackRecordDirectoryDetails = z.infer<typeof ZFeedbackRecordDirectoryDetails>;
|
||||
|
||||
export interface TWorkspaceFeedbackRecordDirectoryAccess {
|
||||
workspaceId: string;
|
||||
feedbackRecordDirectoryId: string;
|
||||
feedbackRecordDirectoryName: string;
|
||||
}
|
||||
|
||||
export const ZFeedbackRecordDirectoryCreateInput = z.object({
|
||||
name: z.string().trim().min(1, "DIRECTORY_NAME_REQUIRED"),
|
||||
workspaceIds: z.array(ZId).optional(),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { cn } from "@/modules/ui/lib/utils";
|
||||
interface TOption<T> {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface MultiSelectProps<T extends string, K extends TOption<T>["value"][]> {
|
||||
@@ -225,17 +226,18 @@ export function MultiSelect<T extends string, K extends TOption<T>["value"][]>(
|
||||
{selectableOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
disabled={option.disabled}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (disabled) return;
|
||||
if (disabled || option.disabled) return;
|
||||
isUserInitiatedRef.current = true; // Mark as user-initiated
|
||||
setSelected((prev) => [...prev, option]);
|
||||
setInputValue("");
|
||||
}}
|
||||
className="cursor-pointer">
|
||||
className={option.disabled ? "cursor-not-allowed" : "cursor-pointer"}>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components
|
||||
|
||||
interface TSecondaryNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
label: React.ReactNode;
|
||||
href?: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
hidden?: boolean;
|
||||
|
||||
@@ -31,11 +31,22 @@ import {
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||
import { getTeamsByOrganizationIdAction } from "@/modules/workspaces/settings/actions";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import {
|
||||
getFeedbackRecordDirectoriesByOrganizationIdAction,
|
||||
getTeamsByOrganizationIdAction,
|
||||
} from "@/modules/workspaces/settings/actions";
|
||||
|
||||
const ZCreateWorkspaceForm = z.object({
|
||||
name: ZWorkspace.shape.name,
|
||||
teamIds: z.array(z.string()).optional(),
|
||||
feedbackRecordDirectoryId: z.string().optional(),
|
||||
});
|
||||
|
||||
type TCreateWorkspaceForm = z.infer<typeof ZCreateWorkspaceForm>;
|
||||
@@ -57,27 +68,51 @@ export const CreateWorkspaceModal = ({
|
||||
const router = useRouter();
|
||||
|
||||
const [organizationTeams, setOrganizationTeams] = useState<TOrganizationTeam[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchOrganizationTeams = async () => {
|
||||
const response = await getTeamsByOrganizationIdAction({ organizationId });
|
||||
if (response?.data) {
|
||||
setOrganizationTeams(response.data);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
fetchOrganizationTeams();
|
||||
}, [organizationId]);
|
||||
const [feedbackDirectories, setFeedbackDirectories] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
const form = useForm<TCreateWorkspaceForm>({
|
||||
resolver: zodResolver(ZCreateWorkspaceForm),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
teamIds: [],
|
||||
feedbackRecordDirectoryId: undefined,
|
||||
},
|
||||
});
|
||||
const { getValues, setValue } = form;
|
||||
|
||||
useEffect(() => {
|
||||
const fetchModalData = async () => {
|
||||
const [teamsResponse, directoriesResponse] = await Promise.all([
|
||||
getTeamsByOrganizationIdAction({ organizationId }),
|
||||
getFeedbackRecordDirectoriesByOrganizationIdAction({ organizationId }),
|
||||
]);
|
||||
|
||||
if (teamsResponse?.data) {
|
||||
setOrganizationTeams(teamsResponse.data);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(teamsResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
|
||||
if (directoriesResponse?.data) {
|
||||
setFeedbackDirectories(directoriesResponse.data);
|
||||
const selectedFeedbackDirectory = getValues("feedbackRecordDirectoryId");
|
||||
const isSelectedDirectoryAvailable = directoriesResponse.data.some(
|
||||
(directory) => directory.id === selectedFeedbackDirectory
|
||||
);
|
||||
|
||||
if (directoriesResponse.data.length === 0) {
|
||||
setValue("feedbackRecordDirectoryId", undefined);
|
||||
} else if (!selectedFeedbackDirectory || !isSelectedDirectoryAvailable) {
|
||||
setValue("feedbackRecordDirectoryId", directoriesResponse.data[0].id);
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(directoriesResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
fetchModalData();
|
||||
}, [organizationId, getValues, setValue]);
|
||||
|
||||
const { isSubmitting } = form.formState;
|
||||
|
||||
@@ -92,6 +127,7 @@ export const CreateWorkspaceModal = ({
|
||||
data: {
|
||||
name: data.name,
|
||||
teamIds: data.teamIds || [],
|
||||
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -138,6 +174,40 @@ export const CreateWorkspaceModal = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="feedbackRecordDirectoryId"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.feedback_record_directory")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value ?? ""}
|
||||
onValueChange={field.onChange}
|
||||
disabled={feedbackDirectories.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
feedbackDirectories.length > 0
|
||||
? t("workspace.unify.select_feedback_record_directory")
|
||||
: t("workspace.unify.no_feedback_record_directory_available")
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{feedbackDirectories.map((directory) => (
|
||||
<SelectItem key={directory.id} value={directory.id}>
|
||||
{directory.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isAccessControlAllowed && organizationTeams.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { getWorkspace } from "@/lib/workspace/service";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getFeedbackRecordDirectories } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { updateWorkspace } from "@/modules/workspaces/settings/lib/workspace";
|
||||
|
||||
@@ -96,3 +97,25 @@ export const getTeamsByOrganizationIdAction = authenticatedActionClient
|
||||
const teams = await getTeamsByOrganizationId(parsedInput.organizationId);
|
||||
return teams;
|
||||
});
|
||||
|
||||
const ZGetFeedbackRecordDirectoriesByOrganizationIdAction = z.object({
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const getFeedbackRecordDirectoriesByOrganizationIdAction = authenticatedActionClient
|
||||
.inputSchema(ZGetFeedbackRecordDirectoriesByOrganizationIdAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const directories = await getFeedbackRecordDirectories(parsedInput.organizationId);
|
||||
return directories.filter((directory) => !directory.isArchived).map(({ id, name }) => ({ id, name }));
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { BlocksIcon, BrushIcon, LanguagesIcon, ListChecksIcon, TagIcon, UsersIcon } from "lucide-react";
|
||||
import {
|
||||
BlocksIcon,
|
||||
BrushIcon,
|
||||
LanguagesIcon,
|
||||
ListChecksIcon,
|
||||
ShapesIcon,
|
||||
TagIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
@@ -39,6 +47,13 @@ export const WorkspaceConfigNavigation = ({ activeId, loading }: WorkspaceConfig
|
||||
href: `${workspaceBasePath}/app-connection`,
|
||||
current: pathname?.includes("/app-connection"),
|
||||
},
|
||||
{
|
||||
id: "feedback-sources",
|
||||
label: t("workspace.unify.feedback_sources"),
|
||||
icon: <ShapesIcon className="h-5 w-5" />,
|
||||
href: `${workspaceBasePath}/feedback-sources`,
|
||||
current: pathname?.includes("/feedback-sources"),
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
feedbackRecordDirectory: {
|
||||
upsert: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
feedbackRecordDirectoryWorkspace: {
|
||||
count: vi.fn(),
|
||||
@@ -136,6 +137,34 @@ describe("workspace lib", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("creates workspace and links selected feedback directory when provided", async () => {
|
||||
const createdWorkspace = { ...baseWorkspace, id: "p-selected" };
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findFirst).mockResolvedValueOnce({
|
||||
id: "frd-selected",
|
||||
} as any);
|
||||
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
|
||||
vi.mocked(prisma.feedbackRecordDirectoryWorkspace.create).mockResolvedValueOnce({} as any);
|
||||
|
||||
const result = await createWorkspace("org1", {
|
||||
name: "Workspace with Selected Directory",
|
||||
feedbackRecordDirectoryId: "frd-selected",
|
||||
});
|
||||
|
||||
expect(result).toEqual(createdWorkspace);
|
||||
expect(prisma.feedbackRecordDirectory.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "frd-selected",
|
||||
organizationId: "org1",
|
||||
isArchived: false,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.feedbackRecordDirectoryWorkspace.create).toHaveBeenCalledWith({
|
||||
data: { feedbackRecordDirectoryId: "frd-selected", workspaceId: "p-selected" },
|
||||
});
|
||||
expect(prisma.feedbackRecordDirectory.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips FRD link when default FRD already has links", async () => {
|
||||
const createdWorkspace = { ...baseWorkspace, id: "p4" };
|
||||
vi.mocked(prisma.workspace.create).mockResolvedValueOnce(createdWorkspace as any);
|
||||
@@ -147,6 +176,19 @@ describe("workspace lib", () => {
|
||||
expect(prisma.feedbackRecordDirectoryWorkspace.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when selected feedback directory is invalid", async () => {
|
||||
vi.mocked(prisma.feedbackRecordDirectory.findFirst).mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
createWorkspace("org1", {
|
||||
name: "Workspace with Invalid Directory",
|
||||
feedbackRecordDirectoryId: "frd-missing",
|
||||
})
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
|
||||
expect(prisma.workspace.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws ValidationError if name is missing", async () => {
|
||||
await expect(createWorkspace("org1", {})).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
@@ -29,6 +29,14 @@ const selectWorkspace = {
|
||||
customHeadScripts: true,
|
||||
};
|
||||
|
||||
type TCreateWorkspaceInput = Partial<TWorkspaceUpdateInput> & {
|
||||
feedbackRecordDirectoryId?: string;
|
||||
};
|
||||
|
||||
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.partial().extend({
|
||||
feedbackRecordDirectoryId: ZId.optional(),
|
||||
});
|
||||
|
||||
export const updateWorkspace = async (
|
||||
workspaceId: string,
|
||||
inputWorkspace: TWorkspaceUpdateInput
|
||||
@@ -56,17 +64,32 @@ export const updateWorkspace = async (
|
||||
|
||||
export const createWorkspace = async (
|
||||
organizationId: string,
|
||||
workspaceInput: Partial<TWorkspaceUpdateInput>
|
||||
workspaceInput: TCreateWorkspaceInput
|
||||
): Promise<TWorkspace> => {
|
||||
validateInputs([organizationId, ZString], [workspaceInput, ZWorkspaceUpdateInput.partial()]);
|
||||
validateInputs([organizationId, ZString], [workspaceInput, ZCreateWorkspaceInput]);
|
||||
|
||||
if (!workspaceInput.name) {
|
||||
throw new ValidationError("Workspace Name is required");
|
||||
}
|
||||
|
||||
const { teamIds, ...data } = workspaceInput;
|
||||
const { teamIds, feedbackRecordDirectoryId, ...data } = workspaceInput;
|
||||
|
||||
try {
|
||||
if (feedbackRecordDirectoryId) {
|
||||
const feedbackDirectory = await prisma.feedbackRecordDirectory.findFirst({
|
||||
where: {
|
||||
id: feedbackRecordDirectoryId,
|
||||
organizationId,
|
||||
isArchived: false,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!feedbackDirectory) {
|
||||
throw new InvalidInputError("FEEDBACK_RECORD_DIRECTORY_NOT_FOUND");
|
||||
}
|
||||
}
|
||||
|
||||
const workspace = await prisma.workspace.create({
|
||||
data: {
|
||||
config: {
|
||||
@@ -89,6 +112,17 @@ export const createWorkspace = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (feedbackRecordDirectoryId) {
|
||||
await prisma.feedbackRecordDirectoryWorkspace.create({
|
||||
data: {
|
||||
feedbackRecordDirectoryId,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
// Ensure default FRD exists + link to first workspace atomically
|
||||
const defaultFrd = await prisma.feedbackRecordDirectory.upsert({
|
||||
where: {
|
||||
|
||||
Reference in New Issue
Block a user