fixes after CR

This commit is contained in:
Javi Aguilar
2026-05-18 12:13:08 +02:00
parent 2f72d0ef33
commit e434d29e9f
17 changed files with 443 additions and 142 deletions
@@ -7,17 +7,25 @@ import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { generateAIChartAction } from "@/modules/ee/analysis/charts/actions";
import {
type TAIUnavailableActionLabelKey,
type TAIUnavailableReason,
getAIUnavailableAction,
getAIUnavailableMessageKey,
} from "@/modules/ee/analysis/charts/lib/ai-availability";
import type { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
type TAIUnavailableMessageKey = ReturnType<typeof getAIUnavailableMessageKey>;
interface AIQuerySectionProps {
workspaceId: string;
onChartGenerated: (data: AnalyticsResponse) => void;
feedbackDirectoryId: string;
isAIAvailable?: boolean;
aiUnavailableReason?: string;
aiUnavailableReason?: TAIUnavailableReason;
}
export function AIQuerySection({
@@ -31,12 +39,30 @@ export function AIQuerySection({
const [isGenerating, setIsGenerating] = useState(false);
const { t } = useTranslation();
const aiUnavailableMessage =
{
not_in_plan: t("workspace.analysis.charts.ai_not_in_plan"),
not_enabled: t("workspace.analysis.charts.ai_not_enabled"),
instance_not_configured: t("workspace.analysis.charts.ai_instance_not_configured"),
}[aiUnavailableReason ?? ""] ?? t("workspace.analysis.charts.ai_not_available");
const translateAIUnavailableMessage = (messageKey: TAIUnavailableMessageKey): string => {
switch (messageKey) {
case "workspace.analysis.charts.ai_not_in_plan":
return t("workspace.analysis.charts.ai_not_in_plan");
case "workspace.analysis.charts.ai_not_enabled":
return t("workspace.analysis.charts.ai_not_enabled");
case "workspace.analysis.charts.ai_instance_not_configured":
return t("workspace.analysis.charts.ai_instance_not_configured");
case "workspace.analysis.charts.ai_not_available":
return t("workspace.analysis.charts.ai_not_available");
}
};
const translateAIUnavailableAction = (labelKey: TAIUnavailableActionLabelKey): string => {
switch (labelKey) {
case "workspace.analysis.charts.ai_enable_in_settings":
return t("workspace.analysis.charts.ai_enable_in_settings");
case "workspace.analysis.charts.ai_upgrade_plan":
return t("workspace.analysis.charts.ai_upgrade_plan");
}
};
const aiUnavailableMessage = translateAIUnavailableMessage(getAIUnavailableMessageKey(aiUnavailableReason));
const aiUnavailableAction = getAIUnavailableAction(aiUnavailableReason, workspaceId);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -103,18 +129,9 @@ export function AIQuerySection({
<Alert variant="info" size="small">
<AlertDescription className="overflow-visible whitespace-normal">
<span>{aiUnavailableMessage}</span>
{aiUnavailableReason === "not_enabled" && (
<Link
href={`/workspaces/${workspaceId}/settings/organization/general`}
className="ml-2 inline-flex shrink-0 underline">
{t("workspace.analysis.charts.ai_enable_in_settings")}
</Link>
)}
{aiUnavailableReason === "not_in_plan" && (
<Link
href={`/workspaces/${workspaceId}/settings/organization/billing`}
className="ml-2 inline-flex shrink-0 underline">
{t("workspace.analysis.charts.ai_upgrade_plan")}
{aiUnavailableAction && (
<Link href={aiUnavailableAction.href} className="ml-2 inline-flex shrink-0 underline">
{translateAIUnavailableAction(aiUnavailableAction.labelKey)}
</Link>
)}
</AlertDescription>
@@ -4,6 +4,7 @@ import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { Button, type ButtonProps } from "@/modules/ui/components/button";
interface CreateChartButtonProps {
@@ -15,7 +16,7 @@ interface CreateChartButtonProps {
showIcon?: boolean;
buttonProps?: Omit<ButtonProps, "onClick" | "children">;
isAIAvailable?: boolean;
aiUnavailableReason?: string;
aiUnavailableReason?: TAIUnavailableReason;
}
export function CreateChartButton({
@@ -1,6 +1,7 @@
"use client";
import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
export interface CreateChartDialogProps {
@@ -13,7 +14,7 @@ export interface CreateChartDialogProps {
onSuccess?: () => void;
directories: { id: string; name: string }[];
isAIAvailable?: boolean;
aiUnavailableReason?: string;
aiUnavailableReason?: TAIUnavailableReason;
}
export function CreateChartDialog({
@@ -11,6 +11,7 @@ import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types";
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
import { Alert } from "@/modules/ui/components/alert";
@@ -36,7 +37,7 @@ interface CreateChartViewProps {
onSuccess?: () => void;
directories: { id: string; name: string }[];
isAIAvailable?: boolean;
aiUnavailableReason?: string;
aiUnavailableReason?: TAIUnavailableReason;
}
export function CreateChartView({
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { getAIUnavailableAction, getAIUnavailableMessageKey } from "./ai-availability";
describe("ai availability helpers", () => {
test("returns the not enabled message key and organization settings action", () => {
expect(getAIUnavailableMessageKey("not_enabled")).toBe("workspace.analysis.charts.ai_not_enabled");
expect(getAIUnavailableAction("not_enabled", "workspace-1")).toEqual({
href: "/workspaces/workspace-1/settings/organization/general",
labelKey: "workspace.analysis.charts.ai_enable_in_settings",
});
});
test("returns the not in plan message key and billing action", () => {
expect(getAIUnavailableMessageKey("not_in_plan")).toBe("workspace.analysis.charts.ai_not_in_plan");
expect(getAIUnavailableAction("not_in_plan", "workspace-1")).toEqual({
href: "/workspaces/workspace-1/settings/organization/billing",
labelKey: "workspace.analysis.charts.ai_upgrade_plan",
});
});
test("returns the instance not configured message key without an action", () => {
expect(getAIUnavailableMessageKey("instance_not_configured")).toBe(
"workspace.analysis.charts.ai_instance_not_configured"
);
expect(getAIUnavailableAction("instance_not_configured", "workspace-1")).toBeUndefined();
});
test("returns the generic fallback message key without an action", () => {
expect(getAIUnavailableMessageKey()).toBe("workspace.analysis.charts.ai_not_available");
expect(getAIUnavailableAction(undefined, "workspace-1")).toBeUndefined();
});
});
@@ -0,0 +1,52 @@
export type TAIUnavailableReason = "not_in_plan" | "not_enabled" | "instance_not_configured";
export type TAIUnavailableMessageKey =
| "workspace.analysis.charts.ai_not_in_plan"
| "workspace.analysis.charts.ai_not_enabled"
| "workspace.analysis.charts.ai_instance_not_configured"
| "workspace.analysis.charts.ai_not_available";
export type TAIUnavailableActionLabelKey =
| "workspace.analysis.charts.ai_enable_in_settings"
| "workspace.analysis.charts.ai_upgrade_plan";
interface AIUnavailableAction {
href: string;
labelKey: TAIUnavailableActionLabelKey;
}
export const AI_UNAVAILABLE_MESSAGE_KEYS: Record<
TAIUnavailableReason,
Exclude<TAIUnavailableMessageKey, "workspace.analysis.charts.ai_not_available">
> = {
not_in_plan: "workspace.analysis.charts.ai_not_in_plan",
not_enabled: "workspace.analysis.charts.ai_not_enabled",
instance_not_configured: "workspace.analysis.charts.ai_instance_not_configured",
};
export const getAIUnavailableMessageKey = (reason?: TAIUnavailableReason): TAIUnavailableMessageKey => {
if (!reason) {
return "workspace.analysis.charts.ai_not_available";
}
return AI_UNAVAILABLE_MESSAGE_KEYS[reason];
};
export const getAIUnavailableAction = (
reason: TAIUnavailableReason | undefined,
workspaceId: string
): AIUnavailableAction | undefined => {
if (reason === "not_enabled") {
return {
href: `/workspaces/${workspaceId}/settings/organization/general`,
labelKey: "workspace.analysis.charts.ai_enable_in_settings",
};
}
if (reason === "not_in_plan") {
return {
href: `/workspaces/${workspaceId}/settings/organization/billing`,
labelKey: "workspace.analysis.charts.ai_upgrade_plan",
};
}
return undefined;
};
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { getChartsAction } from "@/modules/ee/analysis/charts/actions";
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { addChartToDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
import { Button } from "@/modules/ui/components/button";
import {
@@ -31,7 +32,7 @@ interface AddExistingChartsDialogProps {
existingChartIds: string[];
onSuccess: () => void;
isAIAvailable?: boolean;
aiUnavailableReason?: string;
aiUnavailableReason?: TAIUnavailableReason;
}
interface ChartOption {
@@ -6,6 +6,7 @@ import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { deleteDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
import { AddExistingChartsDialog } from "@/modules/ee/analysis/dashboards/components/add-existing-charts-dialog";
import { Button } from "@/modules/ui/components/button";
@@ -22,7 +23,7 @@ interface DashboardControlBarProps {
hasChanges: boolean;
isReadOnly: boolean;
isAIAvailable?: boolean;
aiUnavailableReason?: string;
aiUnavailableReason?: TAIUnavailableReason;
onRefresh: () => void;
onEditToggle: () => void;
onSave: () => void;
@@ -11,6 +11,7 @@ import "react-resizable/css/styles.css";
import type { TChartQuery } from "@formbricks/types/analysis";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateChartDialog } from "@/modules/ee/analysis/charts/components/create-chart-dialog";
import type { TAIUnavailableReason } from "@/modules/ee/analysis/charts/lib/ai-availability";
import { DashboardControlBar } from "@/modules/ee/analysis/dashboards/components/dashboard-control-bar";
import { DashboardPageHeader } from "@/modules/ee/analysis/dashboards/components/dashboard-page-header";
import { DashboardWidget } from "@/modules/ee/analysis/dashboards/components/dashboard-widget";
@@ -40,7 +41,7 @@ interface DashboardDetailClientProps {
directories: { id: string; name: string }[];
isReadOnly: boolean;
isAIAvailable: boolean;
aiUnavailableReason?: string;
aiUnavailableReason?: TAIUnavailableReason;
}
const widgetsToLayout = (widgets: TDashboardWidget[]): LayoutItem[] => {
@@ -15,6 +15,10 @@ import {
updateFeedbackDirectoryAction,
} from "@/modules/ee/feedback-directory/actions";
import { ArchiveFeedbackDirectory } from "@/modules/ee/feedback-directory/components/feedback-directory-settings/archive-feedback-directory";
import {
getWorkspaceConflictDetails,
shouldShowWorkspaceAccessBlockedExplanation,
} from "@/modules/ee/feedback-directory/lib/workspace-access-conflicts";
import {
TFeedbackDirectoryDetails,
TFeedbackDirectoryUpdateInput,
@@ -97,32 +101,18 @@ export const FeedbackDirectorySettingsModal = ({
[orgWorkspaces, workspaceAccessMap, directory?.id]
);
const workspaceConflictDetails = useMemo(
() =>
orgWorkspaces
.map((workspace) => {
const assignment = workspaceAccessMap.get(workspace.id);
if (!assignment || assignment.feedbackDirectoryId === directory?.id) {
return null;
}
const workspaceConflictInput = useMemo(
() => ({
orgWorkspaces,
workspaceAccessByWorkspace,
currentDirectoryId: directory?.id,
}),
[orgWorkspaces, workspaceAccessByWorkspace, directory?.id]
);
return {
workspaceId: workspace.id,
workspaceName: workspace.name,
feedbackDirectoryName: assignment.feedbackDirectoryName,
};
})
.filter(
(
conflict
): conflict is {
workspaceId: string;
workspaceName: string;
feedbackDirectoryName: string;
} => conflict !== null
)
.sort((a, b) => a.workspaceName.localeCompare(b.workspaceName, undefined, { sensitivity: "base" })),
[orgWorkspaces, workspaceAccessMap, directory?.id]
const workspaceConflictDetails = useMemo(
() => getWorkspaceConflictDetails(workspaceConflictInput),
[workspaceConflictInput]
);
const initialWorkspaceIds = useMemo(
@@ -147,9 +137,8 @@ export const FeedbackDirectorySettingsModal = ({
reset,
} = form;
const selectedWorkspaceIds = form.watch("workspaceIds") ?? [];
const hasUnassignedWorkspace = workspaceOptions.some((option) => !option.disabled);
const showWorkspaceAccessBlockedExplanation =
workspaceConflictDetails.length > 0 && !hasUnassignedWorkspace;
shouldShowWorkspaceAccessBlockedExplanation(workspaceConflictInput);
const workspaceNameById = useMemo(() => {
const map = new Map(orgWorkspaces.map((workspace) => [workspace.id, workspace.name]));
@@ -339,9 +328,7 @@ export const FeedbackDirectorySettingsModal = ({
</AlertTitle>
<AlertDescription className="overflow-visible whitespace-normal">
<p>
{t(
"workspace.settings.feedback_directories.no_unassigned_workspaces_description"
)}
{t("workspace.settings.feedback_directories.no_unassigned_workspaces_description")}
</p>
<ul className="mt-1 list-disc space-y-0.5 pl-4">
{workspaceConflictDetails.map((conflict) => (
@@ -424,6 +424,19 @@ describe("FeedbackDirectory Service", () => {
});
});
test("throws ResourceNotFoundError when unarchiving and directory cannot be loaded", async () => {
vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValueOnce(null);
await expect(
updateFeedbackDirectory(mockDirectoryId, mockOrganizationId, {
isArchived: false,
})
).rejects.toThrow(ResourceNotFoundError);
expect(prisma.feedbackDirectoryWorkspace.findFirst).not.toHaveBeenCalled();
expect(prisma.feedbackDirectory.update).not.toHaveBeenCalled();
});
test("throws InvalidInputError when unarchiving would assign a workspace to two active directories", async () => {
vi.mocked(prisma.feedbackDirectory.findUnique).mockResolvedValueOnce(mockDirectoryDetailsDbRow as any);
vi.mocked(prisma.feedbackDirectoryWorkspace.findFirst).mockResolvedValueOnce({
@@ -1,5 +1,5 @@
import "server-only";
import { Prisma, PrismaClient } from "@prisma/client";
import { Prisma, type PrismaClient } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
@@ -15,6 +15,11 @@ import {
ZFeedbackDirectoryUpdateInput,
} from "@/modules/ee/feedback-directory/types/feedback-directory";
type FeedbackDirectoryPrismaClient = Pick<
PrismaClient,
"connector" | "feedbackDirectory" | "feedbackDirectoryWorkspace" | "workspace"
>;
/**
* Retrieves all feedback directories for a given organization.
*
@@ -186,63 +191,70 @@ export const getWorkspaceFeedbackDirectoryAccess = reactCache(
}
);
const getFeedbackDirectoryDetailsWithClient = async (
prismaClient: FeedbackDirectoryPrismaClient,
directoryId: string
): Promise<TFeedbackDirectoryDetails | null> => {
const directory = await prismaClient.feedbackDirectory.findUnique({
where: {
id: directoryId,
},
select: {
id: true,
name: true,
isArchived: true,
organizationId: true,
workspaces: {
select: {
workspaceId: true,
workspace: {
select: {
name: true,
},
},
},
},
connectors: {
select: {
id: true,
name: true,
type: true,
workspaceId: true,
workspace: { select: { name: true } },
},
orderBy: { createdAt: "desc" },
},
},
});
if (!directory) {
return null;
}
return {
id: directory.id,
name: directory.name,
isArchived: directory.isArchived,
organizationId: directory.organizationId,
workspaces: directory.workspaces.map((dp) => ({
workspaceId: dp.workspaceId,
workspaceName: dp.workspace.name,
})),
connectors: directory.connectors.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
workspaceId: c.workspaceId,
workspaceName: c.workspace.name,
})),
};
};
export const getFeedbackDirectoryDetails = reactCache(
async (directoryId: string): Promise<TFeedbackDirectoryDetails | null> => {
validateInputs([directoryId, ZId]);
try {
const directory = await prisma.feedbackDirectory.findUnique({
where: {
id: directoryId,
},
select: {
id: true,
name: true,
isArchived: true,
organizationId: true,
workspaces: {
select: {
workspaceId: true,
workspace: {
select: {
name: true,
},
},
},
},
connectors: {
select: {
id: true,
name: true,
type: true,
workspaceId: true,
workspace: { select: { name: true } },
},
orderBy: { createdAt: "desc" },
},
},
});
if (!directory) {
return null;
}
return {
id: directory.id,
name: directory.name,
isArchived: directory.isArchived,
organizationId: directory.organizationId,
workspaces: directory.workspaces.map((dp) => ({
workspaceId: dp.workspaceId,
workspaceName: dp.workspace.name,
})),
connectors: directory.connectors.map((c) => ({
id: c.id,
name: c.name,
type: c.type,
workspaceId: c.workspaceId,
workspaceName: c.workspace.name,
})),
};
return await getFeedbackDirectoryDetailsWithClient(prisma, directoryId);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -279,7 +291,7 @@ export const createFeedbackDirectory = async (
if (count !== workspaceIds.length) {
throw new InvalidInputError("DIRECTORY_WORKSPACES_INVALID_ORG");
}
await assertWorkspacesNotAssignedElsewhere(undefined, workspaceIds);
await assertWorkspacesNotAssignedElsewhere(prisma, undefined, workspaceIds);
}
const directory = await prisma.feedbackDirectory.create({
@@ -321,7 +333,7 @@ export const createFeedbackDirectory = async (
* @throws {InvalidInputError} If any workspace does not belong to the organization.
*/
const buildWorkspaceAssignmentPayload = async (
prismaClient: PrismaClient,
prismaClient: FeedbackDirectoryPrismaClient,
directoryId: string,
workspaceIds: string[],
organizationId: string,
@@ -369,11 +381,12 @@ interface UpdateFeedbackDirectoryOptions {
}
const getArchiveUpdate = async (
prismaClient: FeedbackDirectoryPrismaClient,
directoryId: string,
isArchived: boolean | undefined
): Promise<Pick<Prisma.FeedbackDirectoryUpdateInput, "isArchived">> => {
if (isArchived === true) {
const connectorCount = await prisma.connector.count({
const connectorCount = await prismaClient.connector.count({
where: { feedbackDirectoryId: directoryId },
});
if (connectorCount > 0) {
@@ -383,12 +396,13 @@ const getArchiveUpdate = async (
}
if (isArchived === false) {
const currentDetails = await getFeedbackDirectoryDetails(directoryId);
const currentDetails = await getFeedbackDirectoryDetailsWithClient(prismaClient, directoryId);
if (!currentDetails) {
throw new ResourceNotFoundError("FeedbackDirectory", directoryId);
}
await assertWorkspacesNotAssignedElsewhere(
prismaClient,
directoryId,
currentDetails.workspaces.map((workspace) => workspace.workspaceId)
);
@@ -400,6 +414,7 @@ const getArchiveUpdate = async (
};
const getWorkspaceAssignmentUpdate = async (
prismaClient: FeedbackDirectoryPrismaClient,
directoryId: string,
organizationId: string,
workspaceIds: string[] | undefined
@@ -411,10 +426,10 @@ const getWorkspaceAssignmentUpdate = async (
return { removedWorkspaceIds: [] };
}
const currentDetails = await getFeedbackDirectoryDetails(directoryId);
const currentDetails = await getFeedbackDirectoryDetailsWithClient(prismaClient, directoryId);
const currentWorkspaceIds = currentDetails?.workspaces.map((workspace) => workspace.workspaceId) ?? [];
const assignmentPayload = await buildWorkspaceAssignmentPayload(
prisma,
prismaClient,
directoryId,
workspaceIds,
organizationId,
@@ -456,12 +471,13 @@ const pauseConnectorsInWorkspaces = async (
* conflict check. Omit it on create — every active directory is a conflict.
*/
const assertWorkspacesNotAssignedElsewhere = async (
prismaClient: FeedbackDirectoryPrismaClient,
directoryId: string | undefined,
workspaceIds: string[]
): Promise<void> => {
if (workspaceIds.length === 0) return;
const conflicting = await prisma.feedbackDirectoryWorkspace.findFirst({
const conflicting = await prismaClient.feedbackDirectoryWorkspace.findFirst({
where: {
workspaceId: { in: workspaceIds },
...(directoryId === undefined ? {} : { feedbackDirectoryId: { not: directoryId } }),
@@ -504,33 +520,41 @@ export const updateFeedbackDirectory = async (
try {
const { name, workspaceIds, isArchived } = data;
if (workspaceIds !== undefined) {
await assertWorkspacesNotAssignedElsewhere(directoryId, workspaceIds);
}
await prisma.$transaction(
async (tx) => {
if (workspaceIds !== undefined) {
await assertWorkspacesNotAssignedElsewhere(tx, directoryId, workspaceIds);
}
const archiveUpdate = await getArchiveUpdate(directoryId, isArchived);
const workspaceAssignmentUpdate = await getWorkspaceAssignmentUpdate(
directoryId,
organizationId,
workspaceIds
);
const archiveUpdate = await getArchiveUpdate(tx, directoryId, isArchived);
const workspaceAssignmentUpdate = await getWorkspaceAssignmentUpdate(
tx,
directoryId,
organizationId,
workspaceIds
);
const payload: Prisma.FeedbackDirectoryUpdateInput = {
...(name !== undefined ? { name } : {}),
...archiveUpdate,
...(workspaceAssignmentUpdate.workspaces ? { workspaces: workspaceAssignmentUpdate.workspaces } : {}),
};
const payload: Prisma.FeedbackDirectoryUpdateInput = {
...(name !== undefined ? { name } : {}),
...archiveUpdate,
...(workspaceAssignmentUpdate.workspaces
? { workspaces: workspaceAssignmentUpdate.workspaces }
: {}),
};
await prisma.$transaction(async (tx) => {
await tx.feedbackDirectory.update({
where: { id: directoryId },
data: payload,
});
await tx.feedbackDirectory.update({
where: { id: directoryId },
data: payload,
});
if (options?.pauseConnectorsInRemovedWorkspaces) {
await pauseConnectorsInWorkspaces(tx, directoryId, workspaceAssignmentUpdate.removedWorkspaceIds);
if (options?.pauseConnectorsInRemovedWorkspaces) {
await pauseConnectorsInWorkspaces(tx, directoryId, workspaceAssignmentUpdate.removedWorkspaceIds);
}
},
{
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
}
});
);
return true;
} catch (error) {
@@ -0,0 +1,100 @@
import { describe, expect, test } from "vitest";
import {
getWorkspaceConflictDetails,
hasSelectableWorkspace,
shouldShowWorkspaceAccessBlockedExplanation,
} from "./workspace-access-conflicts";
const orgWorkspaces = [
{ id: "workspace-b", name: "Beta" },
{ id: "workspace-a", name: "Alpha" },
];
describe("workspace access conflict helpers", () => {
test("shows conflicts when every workspace is assigned to a different active directory", () => {
const input = {
orgWorkspaces,
workspaceAccessByWorkspace: [
{
workspaceId: "workspace-b",
feedbackDirectoryId: "directory-2",
feedbackDirectoryName: "Directory B",
},
{
workspaceId: "workspace-a",
feedbackDirectoryId: "directory-1",
feedbackDirectoryName: "Directory A",
},
],
currentDirectoryId: "directory-current",
};
expect(getWorkspaceConflictDetails(input)).toEqual([
{
workspaceId: "workspace-a",
workspaceName: "Alpha",
feedbackDirectoryName: "Directory A",
},
{
workspaceId: "workspace-b",
workspaceName: "Beta",
feedbackDirectoryName: "Directory B",
},
]);
expect(hasSelectableWorkspace(input)).toBe(false);
expect(shouldShowWorkspaceAccessBlockedExplanation(input)).toBe(true);
});
test("does not show the blocked explanation when some workspaces are still available", () => {
const input = {
orgWorkspaces,
workspaceAccessByWorkspace: [
{
workspaceId: "workspace-a",
feedbackDirectoryId: "directory-1",
feedbackDirectoryName: "Directory A",
},
],
currentDirectoryId: "directory-current",
};
expect(getWorkspaceConflictDetails(input)).toEqual([
{
workspaceId: "workspace-a",
workspaceName: "Alpha",
feedbackDirectoryName: "Directory A",
},
]);
expect(hasSelectableWorkspace(input)).toBe(true);
expect(shouldShowWorkspaceAccessBlockedExplanation(input)).toBe(false);
});
test("treats assignments to the current directory as selectable", () => {
const input = {
orgWorkspaces,
workspaceAccessByWorkspace: [
{
workspaceId: "workspace-a",
feedbackDirectoryId: "directory-current",
feedbackDirectoryName: "Current Directory",
},
{
workspaceId: "workspace-b",
feedbackDirectoryId: "directory-2",
feedbackDirectoryName: "Directory B",
},
],
currentDirectoryId: "directory-current",
};
expect(getWorkspaceConflictDetails(input)).toEqual([
{
workspaceId: "workspace-b",
workspaceName: "Beta",
feedbackDirectoryName: "Directory B",
},
]);
expect(hasSelectableWorkspace(input)).toBe(true);
expect(shouldShowWorkspaceAccessBlockedExplanation(input)).toBe(false);
});
});
@@ -0,0 +1,70 @@
interface WorkspaceAccessAssignment {
workspaceId: string;
feedbackDirectoryId: string;
feedbackDirectoryName: string;
}
interface WorkspaceOptionSource {
id: string;
name: string;
}
export interface WorkspaceConflictDetail {
workspaceId: string;
workspaceName: string;
feedbackDirectoryName: string;
}
interface WorkspaceConflictInput {
orgWorkspaces: WorkspaceOptionSource[];
workspaceAccessByWorkspace: WorkspaceAccessAssignment[];
currentDirectoryId?: string;
}
const sortByWorkspaceName = (a: WorkspaceConflictDetail, b: WorkspaceConflictDetail): number =>
a.workspaceName.localeCompare(b.workspaceName, undefined, { sensitivity: "base" });
export const getWorkspaceConflictDetails = ({
orgWorkspaces,
workspaceAccessByWorkspace,
currentDirectoryId,
}: WorkspaceConflictInput): WorkspaceConflictDetail[] => {
const workspaceAccessMap = new Map(
workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment])
);
return orgWorkspaces
.flatMap((workspace) => {
const assignment = workspaceAccessMap.get(workspace.id);
if (!assignment || assignment.feedbackDirectoryId === currentDirectoryId) {
return [];
}
return [
{
workspaceId: workspace.id,
workspaceName: workspace.name,
feedbackDirectoryName: assignment.feedbackDirectoryName,
},
];
})
.sort(sortByWorkspaceName);
};
export const hasSelectableWorkspace = ({
orgWorkspaces,
workspaceAccessByWorkspace,
currentDirectoryId,
}: WorkspaceConflictInput): boolean => {
const workspaceAccessMap = new Map(
workspaceAccessByWorkspace.map((assignment) => [assignment.workspaceId, assignment])
);
return orgWorkspaces.some((workspace) => {
const assignment = workspaceAccessMap.get(workspace.id);
return !assignment || assignment.feedbackDirectoryId === currentDirectoryId;
});
};
export const shouldShowWorkspaceAccessBlockedExplanation = (input: WorkspaceConflictInput): boolean =>
getWorkspaceConflictDetails(input).length > 0 && !hasSelectableWorkspace(input);
@@ -1,8 +1,8 @@
import { createId } from "@paralleldrive/cuid2";
import fs from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
import { fileURLToPath } from "node:url";
import { createId } from "@paralleldrive/cuid2";
import { logger } from "@formbricks/logger";
const __filename = fileURLToPath(import.meta.url);
@@ -1,9 +1,9 @@
import { type Prisma, PrismaClient } from "@prisma/client";
import { exec } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { type Prisma, PrismaClient } from "@prisma/client";
import { logger } from "@formbricks/logger";
const __filename = fileURLToPath(import.meta.url);
@@ -5,10 +5,10 @@ import { cn } from "@/lib/utils";
export type ButtonVariant = "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "custom";
export type ButtonSize = "default" | "custom" | "sm" | "lg" | "icon";
interface ButtonVariantProps {
type ButtonVariantProps = {
variant?: ButtonVariant | null;
size?: ButtonSize | null;
}
};
type ButtonVariantClassProps =
| (ButtonVariantProps & { class?: string; className?: never })
| (ButtonVariantProps & { class?: never; className?: string })