mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 11:20:56 -05:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c932a8583 | |||
| 99d794ad53 | |||
| b123965d93 | |||
| aed47b94a8 | |||
| ecaa2887b7 |
@@ -0,0 +1 @@
|
||||
export { WorkspaceSourcesPage as default } from "@/modules/workspaces/settings/sources/page";
|
||||
@@ -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.dashboards"),
|
||||
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"),
|
||||
@@ -537,23 +561,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} />;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { createFeedbackRecord, retrieveFeedbackRecord, updateFeedbackRecord } from "@/modules/hub/service";
|
||||
import type { FeedbackRecordCreateParams, FeedbackRecordUpdateParams } from "@/modules/hub/types";
|
||||
|
||||
const ZFeedbackRecordId = z.uuid();
|
||||
|
||||
const ZFeedbackRecordFieldType = z.enum([
|
||||
"text",
|
||||
"categorical",
|
||||
"nps",
|
||||
"csat",
|
||||
"ces",
|
||||
"rating",
|
||||
"number",
|
||||
"boolean",
|
||||
"date",
|
||||
]);
|
||||
|
||||
const ZFeedbackRecordMetadata = z.record(z.string(), z.unknown());
|
||||
|
||||
const ZFeedbackRecordCreateInput = z.object({
|
||||
submission_id: z.string().min(1),
|
||||
tenant_id: ZId,
|
||||
source_type: z.string().min(1),
|
||||
field_id: z.string().min(1),
|
||||
field_type: ZFeedbackRecordFieldType,
|
||||
collected_at: z.iso.datetime().optional(),
|
||||
source_id: z.string().optional().nullable(),
|
||||
source_name: z.string().optional().nullable(),
|
||||
field_label: z.string().optional().nullable(),
|
||||
field_group_id: z.string().optional(),
|
||||
field_group_label: z.string().optional().nullable(),
|
||||
value_text: z.string().optional().nullable(),
|
||||
value_number: z.number().optional(),
|
||||
value_boolean: z.boolean().optional(),
|
||||
value_date: z.iso.datetime().optional(),
|
||||
metadata: ZFeedbackRecordMetadata.optional(),
|
||||
language: z.string().optional(),
|
||||
user_identifier: z.string().optional(),
|
||||
});
|
||||
|
||||
const ZFeedbackRecordUpdateInput = z
|
||||
.object({
|
||||
value_text: z.string().optional().nullable(),
|
||||
value_number: z.number().optional().nullable(),
|
||||
value_boolean: z.boolean().optional().nullable(),
|
||||
value_date: z.iso.datetime().optional().nullable(),
|
||||
language: z.string().optional().nullable(),
|
||||
metadata: ZFeedbackRecordMetadata.optional(),
|
||||
user_identifier: z.string().optional().nullable(),
|
||||
})
|
||||
.refine(
|
||||
(value) => Object.values(value).some((entry) => entry !== undefined),
|
||||
"At least one field must be provided for update"
|
||||
);
|
||||
|
||||
const ZRetrieveFeedbackRecordAction = z.object({
|
||||
workspaceId: ZId,
|
||||
recordId: ZFeedbackRecordId,
|
||||
});
|
||||
|
||||
const ZCreateFeedbackRecordAction = z.object({
|
||||
workspaceId: ZId,
|
||||
recordInput: ZFeedbackRecordCreateInput,
|
||||
});
|
||||
|
||||
const ZUpdateFeedbackRecordAction = z.object({
|
||||
workspaceId: ZId,
|
||||
recordId: ZFeedbackRecordId,
|
||||
updateInput: ZFeedbackRecordUpdateInput,
|
||||
});
|
||||
|
||||
const ensureAccess = async (
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
minPermission: "read" | "readWrite"
|
||||
): Promise<void> => {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission,
|
||||
workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const getWorkspaceDirectoryIds = async (workspaceId: string): Promise<Set<string>> => {
|
||||
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
|
||||
return new Set(directories.map((directory) => directory.id));
|
||||
};
|
||||
|
||||
const assertWorkspaceDirectoryAccess = (directoryIds: Set<string>, tenantId: string): void => {
|
||||
if (!directoryIds.has(tenantId)) {
|
||||
throw new AuthorizationError("Invalid feedback record directory for this workspace");
|
||||
}
|
||||
};
|
||||
|
||||
export const retrieveFeedbackRecordAction = authenticatedActionClient
|
||||
.inputSchema(ZRetrieveFeedbackRecordAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZRetrieveFeedbackRecordAction>;
|
||||
}) => {
|
||||
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "read");
|
||||
|
||||
const recordResult = await retrieveFeedbackRecord(parsedInput.recordId);
|
||||
if (!recordResult.data || recordResult.error) {
|
||||
throw new Error(recordResult.error?.message || "Failed to retrieve feedback record");
|
||||
}
|
||||
|
||||
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
|
||||
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, recordResult.data.tenant_id);
|
||||
|
||||
return recordResult.data;
|
||||
}
|
||||
);
|
||||
|
||||
export const createFeedbackRecordAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateFeedbackRecordAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZCreateFeedbackRecordAction>;
|
||||
}) => {
|
||||
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
|
||||
|
||||
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
|
||||
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, parsedInput.recordInput.tenant_id);
|
||||
|
||||
const createResult = await createFeedbackRecord(
|
||||
parsedInput.recordInput as unknown as FeedbackRecordCreateParams
|
||||
);
|
||||
if (!createResult.data || createResult.error) {
|
||||
throw new Error(createResult.error?.message || "Failed to create feedback record");
|
||||
}
|
||||
|
||||
return createResult.data;
|
||||
}
|
||||
);
|
||||
|
||||
export const updateFeedbackRecordAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateFeedbackRecordAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateFeedbackRecordAction>;
|
||||
}) => {
|
||||
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
|
||||
|
||||
const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId);
|
||||
if (!currentRecordResult.data || currentRecordResult.error) {
|
||||
throw new Error(currentRecordResult.error?.message || "Failed to retrieve feedback record");
|
||||
}
|
||||
|
||||
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
|
||||
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, currentRecordResult.data.tenant_id);
|
||||
|
||||
const updatePayload = Object.fromEntries(
|
||||
Object.entries(parsedInput.updateInput).filter(([, value]) => value !== undefined)
|
||||
) as unknown as FeedbackRecordUpdateParams;
|
||||
|
||||
const updateResult = await updateFeedbackRecord(parsedInput.recordId, updatePayload);
|
||||
if (!updateResult.data || updateResult.error) {
|
||||
throw new Error(updateResult.error?.message || "Failed to update feedback record");
|
||||
}
|
||||
|
||||
return updateResult.data;
|
||||
}
|
||||
);
|
||||
+988
@@ -0,0 +1,988 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { z } from "zod";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/modules/ui/components/sheet";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import {
|
||||
createFeedbackRecordAction,
|
||||
retrieveFeedbackRecordAction,
|
||||
updateFeedbackRecordAction,
|
||||
} from "./actions";
|
||||
|
||||
type FeedbackRecordDrawerMode = "create" | "edit";
|
||||
|
||||
interface FeedbackRecordFormDrawerProps {
|
||||
mode: FeedbackRecordDrawerMode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
recordId?: string;
|
||||
onSuccess: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const FIELD_TYPE_OPTIONS = [
|
||||
"text",
|
||||
"categorical",
|
||||
"nps",
|
||||
"csat",
|
||||
"ces",
|
||||
"rating",
|
||||
"number",
|
||||
"boolean",
|
||||
"date",
|
||||
] as const;
|
||||
|
||||
const SOURCE_TYPE_PRESET_OPTIONS = [
|
||||
"survey",
|
||||
"review",
|
||||
"feedback_form",
|
||||
"support",
|
||||
"social",
|
||||
"interview",
|
||||
"usability_test",
|
||||
"nps_campaign",
|
||||
] as const;
|
||||
|
||||
const SOURCE_TYPE_CUSTOM_VALUE = "__custom__";
|
||||
|
||||
const ZMetadataEntry = z.object({
|
||||
key: z.string().trim().min(1),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const ZFeedbackRecordFormValues = z.object({
|
||||
id: z.string().optional(),
|
||||
tenant_id: z.string().min(1),
|
||||
submission_id: z.string().min(1),
|
||||
collected_at: z.string().min(1),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
source_type: z.string().min(1),
|
||||
source_id: z.string().optional(),
|
||||
source_name: z.string().optional(),
|
||||
field_id: z.string().min(1),
|
||||
field_label: z.string().optional(),
|
||||
field_type: z.enum(FIELD_TYPE_OPTIONS),
|
||||
field_group_id: z.string().optional(),
|
||||
field_group_label: z.string().optional(),
|
||||
value_text: z.string().optional(),
|
||||
value_number: z.string().optional(),
|
||||
value_boolean: z.boolean().optional(),
|
||||
value_date: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
user_identifier: z.string().optional(),
|
||||
metadataEntries: z.array(ZMetadataEntry),
|
||||
});
|
||||
|
||||
type TFeedbackRecordFormValues = z.infer<typeof ZFeedbackRecordFormValues>;
|
||||
|
||||
const getValueFieldByType = (
|
||||
fieldType: TFeedbackRecordFormValues["field_type"]
|
||||
): "value_text" | "value_number" | "value_boolean" | "value_date" => {
|
||||
switch (fieldType) {
|
||||
case "boolean":
|
||||
return "value_boolean";
|
||||
case "date":
|
||||
return "value_date";
|
||||
case "nps":
|
||||
case "csat":
|
||||
case "ces":
|
||||
case "rating":
|
||||
case "number":
|
||||
return "value_number";
|
||||
default:
|
||||
return "value_text";
|
||||
}
|
||||
};
|
||||
|
||||
const toLocalDateTimeInput = (isoDate: string): string => {
|
||||
const date = new Date(isoDate);
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const toISOOrUndefined = (dateTimeValue: string | undefined): string | undefined => {
|
||||
if (!dateTimeValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = new Date(dateTimeValue);
|
||||
if (!Number.isFinite(parsed.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parsed.toISOString();
|
||||
};
|
||||
|
||||
const getCreateDefaults = (directories: { id: string; name: string }[]): TFeedbackRecordFormValues => {
|
||||
const now = new Date();
|
||||
const defaultDirectoryId = directories[0]?.id ?? "";
|
||||
|
||||
return {
|
||||
id: "",
|
||||
tenant_id: defaultDirectoryId,
|
||||
submission_id: uuidv7(),
|
||||
collected_at: toLocalDateTimeInput(now.toISOString()),
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
source_type: "survey",
|
||||
source_id: "",
|
||||
source_name: "",
|
||||
field_id: "",
|
||||
field_label: "",
|
||||
field_type: "text",
|
||||
field_group_id: "",
|
||||
field_group_label: "",
|
||||
value_text: "",
|
||||
value_number: "",
|
||||
value_boolean: undefined,
|
||||
value_date: "",
|
||||
language: "",
|
||||
user_identifier: "",
|
||||
metadataEntries: [],
|
||||
};
|
||||
};
|
||||
|
||||
const mapRecordToValues = (record: FeedbackRecordData): TFeedbackRecordFormValues => {
|
||||
const metadataEntries = Object.entries(record.metadata ?? {})
|
||||
.filter(([, value]) => typeof value === "string")
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: value as string,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
tenant_id: record.tenant_id,
|
||||
submission_id: record.submission_id,
|
||||
collected_at: toLocalDateTimeInput(record.collected_at),
|
||||
created_at: record.created_at ? toLocalDateTimeInput(record.created_at) : "",
|
||||
updated_at: record.updated_at ? toLocalDateTimeInput(record.updated_at) : "",
|
||||
source_type: record.source_type,
|
||||
source_id: record.source_id ?? "",
|
||||
source_name: record.source_name ?? "",
|
||||
field_id: record.field_id,
|
||||
field_label: record.field_label ?? "",
|
||||
field_type: record.field_type,
|
||||
field_group_id: record.field_group_id ?? "",
|
||||
field_group_label: record.field_group_label ?? "",
|
||||
value_text: record.value_text ?? "",
|
||||
value_number: record.value_number == null ? "" : String(record.value_number),
|
||||
value_boolean: record.value_boolean,
|
||||
value_date: record.value_date ? toLocalDateTimeInput(record.value_date) : "",
|
||||
language: record.language ?? "",
|
||||
user_identifier: record.user_identifier ?? "",
|
||||
metadataEntries,
|
||||
};
|
||||
};
|
||||
|
||||
const getReadOnlyMetadataEntries = (record: FeedbackRecordData): { key: string; value: string }[] => {
|
||||
return Object.entries(record.metadata ?? {})
|
||||
.filter(([, value]) => typeof value !== "string")
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: JSON.stringify(value),
|
||||
}));
|
||||
};
|
||||
|
||||
const parseNumberValue = (value: string): number | null => {
|
||||
if (value.trim() === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const formatSourceType = (sourceType: string, t: (key: string) => string): string => {
|
||||
switch (sourceType) {
|
||||
case "formbricks":
|
||||
case "formbricks_survey":
|
||||
return t("workspace.unify.formbricks_surveys");
|
||||
case "csv":
|
||||
return t("workspace.unify.csv_import");
|
||||
default:
|
||||
return sourceType;
|
||||
}
|
||||
};
|
||||
|
||||
export const FeedbackRecordFormDrawer = ({
|
||||
mode,
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
directories,
|
||||
canWrite,
|
||||
recordId,
|
||||
onSuccess,
|
||||
}: Readonly<FeedbackRecordFormDrawerProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [record, setRecord] = useState<FeedbackRecordData | null>(null);
|
||||
const [isLoadingRecord, setIsLoadingRecord] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
|
||||
|
||||
const defaultValues = useMemo(() => getCreateDefaults(directories), [directories]);
|
||||
|
||||
const form = useForm<TFeedbackRecordFormValues>({
|
||||
resolver: zodResolver(ZFeedbackRecordFormValues),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "metadataEntries",
|
||||
});
|
||||
|
||||
const fieldType = form.watch("field_type");
|
||||
const selectedValueField = getValueFieldByType(fieldType);
|
||||
const isEditMode = mode === "edit";
|
||||
const isReadOnly = isEditMode && !canWrite;
|
||||
|
||||
const [sourceTypeMode, setSourceTypeMode] = useState<string>("survey");
|
||||
const [customSourceType, setCustomSourceType] = useState("");
|
||||
|
||||
const readOnlyMetadataEntries = useMemo(() => (record ? getReadOnlyMetadataEntries(record) : []), [record]);
|
||||
|
||||
const resetForCreate = useCallback(() => {
|
||||
const nextDefaults = getCreateDefaults(directories);
|
||||
form.reset(nextDefaults);
|
||||
setRecord(null);
|
||||
setSourceTypeMode(nextDefaults.source_type);
|
||||
setCustomSourceType("");
|
||||
}, [directories, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (mode === "create") {
|
||||
resetForCreate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recordId) return;
|
||||
|
||||
const loadRecord = async () => {
|
||||
setIsLoadingRecord(true);
|
||||
const result = await retrieveFeedbackRecordAction({ workspaceId, recordId });
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result) || t("workspace.unify.failed_to_load_feedback_records"));
|
||||
setIsLoadingRecord(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setRecord(result.data);
|
||||
form.reset(mapRecordToValues(result.data));
|
||||
setSourceTypeMode(
|
||||
SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never)
|
||||
? result.data.source_type
|
||||
: SOURCE_TYPE_CUSTOM_VALUE
|
||||
);
|
||||
setCustomSourceType(
|
||||
SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never) ? "" : result.data.source_type
|
||||
);
|
||||
setIsLoadingRecord(false);
|
||||
};
|
||||
|
||||
void loadRecord();
|
||||
}, [form, mode, open, recordId, resetForCreate, t, workspaceId]);
|
||||
|
||||
const requestClose = useCallback(() => {
|
||||
if (form.formState.isDirty && !isSubmitting) {
|
||||
setIsDiscardDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
}, [form.formState.isDirty, isSubmitting, onOpenChange]);
|
||||
|
||||
const handleDrawerOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
requestClose();
|
||||
},
|
||||
[onOpenChange, requestClose]
|
||||
);
|
||||
|
||||
const handleDiscardChanges = () => {
|
||||
setIsDiscardDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const setStrictValueValidationError = (message: string) => {
|
||||
form.setError(selectedValueField, { type: "manual", message });
|
||||
};
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
form.clearErrors();
|
||||
|
||||
if (mode === "create") {
|
||||
const requiredValueError = t("workspace.unify.feedback_record_value_required");
|
||||
if (selectedValueField === "value_text" && !values.value_text?.trim()) {
|
||||
setStrictValueValidationError(requiredValueError);
|
||||
return;
|
||||
}
|
||||
if (selectedValueField === "value_number" && parseNumberValue(values.value_number ?? "") == null) {
|
||||
setStrictValueValidationError(requiredValueError);
|
||||
return;
|
||||
}
|
||||
if (selectedValueField === "value_boolean" && values.value_boolean === undefined) {
|
||||
setStrictValueValidationError(requiredValueError);
|
||||
return;
|
||||
}
|
||||
if (selectedValueField === "value_date" && !toISOOrUndefined(values.value_date)) {
|
||||
setStrictValueValidationError(requiredValueError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = Object.fromEntries(
|
||||
values.metadataEntries
|
||||
.map((entry) => ({
|
||||
key: entry.key.trim(),
|
||||
value: entry.value,
|
||||
}))
|
||||
.filter((entry) => entry.key.length > 0)
|
||||
.map((entry) => [entry.key, entry.value])
|
||||
);
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const sourceTypeValue =
|
||||
sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE ? customSourceType.trim() : values.source_type;
|
||||
|
||||
const createResult = await createFeedbackRecordAction({
|
||||
workspaceId,
|
||||
recordInput: {
|
||||
submission_id: values.submission_id.trim(),
|
||||
tenant_id: values.tenant_id,
|
||||
source_type: sourceTypeValue,
|
||||
source_id: values.source_id?.trim() ? values.source_id.trim() : null,
|
||||
source_name: values.source_name?.trim() ? values.source_name.trim() : null,
|
||||
field_id: values.field_id.trim(),
|
||||
field_label: values.field_label?.trim() ? values.field_label.trim() : null,
|
||||
field_type: values.field_type,
|
||||
field_group_id: values.field_group_id?.trim() || undefined,
|
||||
field_group_label: values.field_group_label?.trim() ? values.field_group_label.trim() : null,
|
||||
collected_at: toISOOrUndefined(values.collected_at),
|
||||
value_text: selectedValueField === "value_text" ? (values.value_text ?? "") : null,
|
||||
value_number:
|
||||
selectedValueField === "value_number"
|
||||
? (parseNumberValue(values.value_number ?? "") ?? undefined)
|
||||
: undefined,
|
||||
value_boolean: selectedValueField === "value_boolean" ? values.value_boolean : undefined,
|
||||
value_date: selectedValueField === "value_date" ? toISOOrUndefined(values.value_date) : undefined,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
||||
language: values.language?.trim() || undefined,
|
||||
user_identifier: values.user_identifier?.trim() || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (!createResult?.data) {
|
||||
toast.error(getFormattedErrorMessage(createResult));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!recordId) {
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const preservedMetadata = Object.fromEntries(
|
||||
Object.entries(record?.metadata ?? {}).filter(([, value]) => typeof value !== "string")
|
||||
);
|
||||
|
||||
const updatePayload: Record<string, unknown> = {
|
||||
language: values.language?.trim() || null,
|
||||
user_identifier: values.user_identifier?.trim() || null,
|
||||
metadata: { ...preservedMetadata, ...metadata },
|
||||
};
|
||||
|
||||
if (selectedValueField === "value_text") {
|
||||
updatePayload.value_text = values.value_text?.trim() ?? "";
|
||||
} else if (selectedValueField === "value_number") {
|
||||
updatePayload.value_number = parseNumberValue(values.value_number ?? "");
|
||||
} else if (selectedValueField === "value_boolean") {
|
||||
updatePayload.value_boolean = values.value_boolean ?? null;
|
||||
} else if (selectedValueField === "value_date") {
|
||||
updatePayload.value_date = toISOOrUndefined(values.value_date) ?? null;
|
||||
}
|
||||
|
||||
const updateResult = await updateFeedbackRecordAction({
|
||||
workspaceId,
|
||||
recordId,
|
||||
updateInput: updatePayload as never,
|
||||
});
|
||||
|
||||
if (!updateResult?.data) {
|
||||
toast.error(getFormattedErrorMessage(updateResult));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(
|
||||
mode === "create"
|
||||
? t("workspace.unify.feedback_record_created_successfully")
|
||||
: t("workspace.unify.feedback_record_updated_successfully")
|
||||
);
|
||||
await onSuccess();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const drawerTitle =
|
||||
mode === "create"
|
||||
? t("workspace.unify.add_feedback_record")
|
||||
: t("workspace.unify.feedback_record_details");
|
||||
|
||||
const drawerDescription =
|
||||
mode === "create"
|
||||
? t("workspace.unify.add_feedback_record_description")
|
||||
: t("workspace.unify.feedback_record_details_description");
|
||||
|
||||
const valueBooleanStatus = form.watch("value_boolean");
|
||||
let valueBooleanLabel = t("common.not_set");
|
||||
if (valueBooleanStatus === true) {
|
||||
valueBooleanLabel = t("common.yes");
|
||||
} else if (valueBooleanStatus === false) {
|
||||
valueBooleanLabel = t("common.no");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={handleDrawerOpenChange}>
|
||||
<SheetContent className="w-full overflow-y-auto bg-white px-5 sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{drawerTitle}</SheetTitle>
|
||||
<SheetDescription>{drawerDescription}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{isLoadingRecord ? (
|
||||
<div className="py-8 text-sm text-slate-500">{t("common.loading")}</div>
|
||||
) : (
|
||||
<FormProvider {...form}>
|
||||
<form className="space-y-4 py-4" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.id")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tenant_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.feedback_record_directory")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange} disabled>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("workspace.unify.select_feedback_record_directory")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{directories.map((directory) => (
|
||||
<SelectItem key={directory.id} value={directory.id}>
|
||||
{directory.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="submission_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.submission_id")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="collected_at"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.collected_at")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="datetime-local" disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="created_at"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.created_at")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="updated_at"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.updated_at")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEditMode ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="source_type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_type")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={formatSourceType(field.value, t)} disabled />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("workspace.unify.source_type")}</FormLabel>
|
||||
<Select
|
||||
value={sourceTypeMode}
|
||||
onValueChange={(value) => {
|
||||
setSourceTypeMode(value);
|
||||
if (value !== SOURCE_TYPE_CUSTOM_VALUE) {
|
||||
form.setValue("source_type", value, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
disabled={!canWrite}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("workspace.unify.select_feedback_record_source_type")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SOURCE_TYPE_PRESET_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={SOURCE_TYPE_CUSTOM_VALUE}>
|
||||
{t("workspace.unify.custom_source_type")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE && (
|
||||
<Input
|
||||
value={customSourceType}
|
||||
onChange={(event) => {
|
||||
setCustomSourceType(event.target.value);
|
||||
form.setValue("source_type", event.target.value, { shouldDirty: true });
|
||||
}}
|
||||
placeholder={t("workspace.unify.custom_source_type_placeholder")}
|
||||
disabled={!canWrite}
|
||||
/>
|
||||
)}
|
||||
<FormError>{form.formState.errors.source_type?.message}</FormError>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="source_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_id")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="source_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.field_id")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field_label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.field_label")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field_type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.field_type")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value as TFeedbackRecordFormValues["field_type"])
|
||||
}
|
||||
disabled={isEditMode || !canWrite}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field_group_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.field_group_id")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field_group_label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.field_group_label")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value_text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.value_text")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
disabled={selectedValueField !== "value_text" || isReadOnly || !canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value_number"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.value_number")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
type="number"
|
||||
step="any"
|
||||
disabled={selectedValueField !== "value_number" || isReadOnly || !canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value_date"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.value_date")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
type="datetime-local"
|
||||
disabled={selectedValueField !== "value_date" || isReadOnly || !canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value_boolean"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.value_boolean")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-3 rounded-md border border-slate-200 px-3 py-2">
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
onCheckedChange={(checked) => field.onChange(checked)}
|
||||
disabled={selectedValueField !== "value_boolean" || isReadOnly || !canWrite}
|
||||
/>
|
||||
<span className="text-sm text-slate-600">{valueBooleanLabel}</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormError>{form.formState.errors[selectedValueField]?.message}</FormError>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.language")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={!canWrite || isReadOnly} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user_identifier"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.user_identifier")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={!canWrite || isReadOnly} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>{t("workspace.unify.metadata")}</FormLabel>
|
||||
{canWrite && !isReadOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => append({ key: "", value: "" })}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`metadataEntries.${index}.key`}
|
||||
render={({ field: entryField }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...entryField}
|
||||
placeholder={t("workspace.unify.metadata_key")}
|
||||
disabled={isReadOnly || !canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`metadataEntries.${index}.value`}
|
||||
render={({ field: entryField }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...entryField}
|
||||
placeholder={t("workspace.unify.metadata_value")}
|
||||
disabled={isReadOnly || !canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{canWrite && !isReadOnly && (
|
||||
<Button type="button" variant="outline" onClick={() => remove(index)}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{readOnlyMetadataEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("workspace.unify.metadata_read_only_entries")}
|
||||
</p>
|
||||
{readOnlyMetadataEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="grid grid-cols-2 gap-2 rounded-md bg-slate-50 p-2 text-xs">
|
||||
<span className="font-medium text-slate-700">{entry.key}</span>
|
||||
<span className="truncate text-slate-600" title={entry.value}>
|
||||
{entry.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)}
|
||||
|
||||
<SheetFooter className="mt-2">
|
||||
<Button variant="outline" onClick={requestClose} disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button onClick={handleSubmit} loading={isSubmitting} disabled={isLoadingRecord}>
|
||||
{mode === "create" ? t("workspace.unify.add_feedback_record") : t("common.save")}
|
||||
</Button>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog
|
||||
open={isDiscardDialogOpen}
|
||||
setOpen={setIsDiscardDialogOpen}
|
||||
headerText={t("workspace.unify.discard_feedback_record_changes_title")}
|
||||
mainText={t("workspace.unify.discard_feedback_record_changes_description")}
|
||||
confirmBtnLabel={t("common.discard")}
|
||||
declineBtnLabel={t("common.cancel")}
|
||||
declineBtnVariant="outline"
|
||||
onDecline={() => setIsDiscardDialogOpen(false)}
|
||||
onConfirm={handleDiscardChanges}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+13
-3
@@ -11,22 +11,32 @@ interface FeedbackRecordsPageClientProps {
|
||||
workspaceId: string;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export function FeedbackRecordsPageClient({
|
||||
workspaceId,
|
||||
initialRecords,
|
||||
frdMap,
|
||||
}: FeedbackRecordsPageClientProps) {
|
||||
csvSources,
|
||||
canWrite,
|
||||
}: Readonly<FeedbackRecordsPageClientProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("workspace.unify.unify_feedback")}>
|
||||
<PageHeader pageTitle={t("workspace.unify.feedback_records")}>
|
||||
<UnifyConfigNavigation workspaceId={workspaceId} activeId="feedback-records" />
|
||||
</PageHeader>
|
||||
|
||||
<FeedbackRecordsTable workspaceId={workspaceId} initialRecords={initialRecords} frdMap={frdMap} />
|
||||
<FeedbackRecordsTable
|
||||
workspaceId={workspaceId}
|
||||
initialRecords={initialRecords}
|
||||
frdMap={frdMap}
|
||||
csvSources={csvSources}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
+222
-68
@@ -3,13 +3,16 @@
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
HashIcon,
|
||||
MessageSquareTextIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ToggleLeftIcon,
|
||||
TypeIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
|
||||
@@ -18,7 +21,16 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { CsvImportModal } from "../sources/components/csv-import-modal";
|
||||
import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer";
|
||||
|
||||
const RECORDS_PER_PAGE = 50;
|
||||
|
||||
@@ -42,6 +54,18 @@ const formatValue = (record: FeedbackRecordData, t: TFunction, locale: string):
|
||||
return "—";
|
||||
};
|
||||
|
||||
const formatSourceType = (sourceType: string, t: TFunction): string => {
|
||||
switch (sourceType) {
|
||||
case "formbricks":
|
||||
case "formbricks_survey":
|
||||
return t("workspace.unify.formbricks_surveys");
|
||||
case "csv":
|
||||
return t("workspace.unify.csv_import");
|
||||
default:
|
||||
return sourceType;
|
||||
}
|
||||
};
|
||||
|
||||
function truncate(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + "…";
|
||||
@@ -51,13 +75,48 @@ interface FeedbackRecordsTableProps {
|
||||
workspaceId: string;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export const FeedbackRecordsTable = ({ workspaceId, initialRecords, frdMap }: FeedbackRecordsTableProps) => {
|
||||
export const FeedbackRecordsTable = ({
|
||||
workspaceId,
|
||||
initialRecords,
|
||||
frdMap,
|
||||
csvSources,
|
||||
canWrite,
|
||||
}: Readonly<FeedbackRecordsTableProps>) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [records, setRecords] = useState<FeedbackRecordData[]>(initialRecords);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [drawerMode, setDrawerMode] = useState<"create" | "edit">("edit");
|
||||
const [drawerRecordId, setDrawerRecordId] = useState<string | undefined>();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const directories = useMemo(
|
||||
() =>
|
||||
Object.entries(frdMap)
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[frdMap]
|
||||
);
|
||||
const feedbackDirectoryName = useMemo(() => {
|
||||
const directoryNames = Array.from(
|
||||
new Set(
|
||||
records
|
||||
.map((record) => frdMap[record.tenant_id])
|
||||
.filter((directoryName): directoryName is string => Boolean(directoryName))
|
||||
)
|
||||
);
|
||||
|
||||
if (directoryNames.length > 0) {
|
||||
return directoryNames.join(", ");
|
||||
}
|
||||
|
||||
return directories[0]?.name ?? "—";
|
||||
}, [directories, frdMap, records]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (isRefreshing) return;
|
||||
@@ -100,98 +159,193 @@ export const FeedbackRecordsTable = ({ workspaceId, initialRecords, frdMap }: Fe
|
||||
|
||||
const isEmpty = records.length === 0 && !isRefreshing;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{!isEmpty && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.unify.showing_count_loaded", { count: records.length })}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={t("workspace.unify.refresh_feedback_records")}>
|
||||
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
const openEditDrawer = (recordId: string) => {
|
||||
setDrawerMode("edit");
|
||||
setDrawerRecordId(recordId);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">
|
||||
{t("workspace.unify.feedback_record_directory")}
|
||||
</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{isEmpty ? (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={8}>
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
|
||||
</div>
|
||||
</td>
|
||||
const openCreateDrawer = () => {
|
||||
setDrawerMode("create");
|
||||
setDrawerRecordId(undefined);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const hasCsvSources = csvSources.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{isEmpty ? (
|
||||
<span />
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.unify.showing_count_loaded", {
|
||||
count: records.length,
|
||||
directoryName: feedbackDirectoryName,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{canWrite &&
|
||||
(hasCsvSources ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="secondary">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={openCreateDrawer}>
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{csvSources.map((source) => (
|
||||
<DropdownMenuItem
|
||||
key={source.id}
|
||||
onClick={() => {
|
||||
setCsvImportSource(source);
|
||||
}}>
|
||||
{t("workspace.unify.import_via_source_name", { sourceName: source.name })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" onClick={openCreateDrawer}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
</Button>
|
||||
))}
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/feedback-sources`}>
|
||||
{t("workspace.unify.manage_feedback_sources")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={t("workspace.unify.refresh_feedback_records")}>
|
||||
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
) : (
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{records.map((record) => (
|
||||
<FeedbackRecordRow
|
||||
key={record.id}
|
||||
record={record}
|
||||
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
|
||||
frdName={record.tenant_id ? (frdMap[record.tenant_id] ?? "—") : "—"}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
</thead>
|
||||
{isEmpty ? (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
) : (
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{records.map((record) => (
|
||||
<FeedbackRecordRow
|
||||
key={record.id}
|
||||
record={record}
|
||||
workspaceId={workspaceId}
|
||||
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
|
||||
t={t}
|
||||
onClick={() => openEditDrawer(record.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FeedbackRecordFormDrawer
|
||||
mode={drawerMode}
|
||||
open={isDrawerOpen}
|
||||
onOpenChange={setIsDrawerOpen}
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
canWrite={canWrite}
|
||||
recordId={drawerMode === "edit" ? drawerRecordId : undefined}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
|
||||
{csvImportSource && (
|
||||
<CsvImportModal
|
||||
open={csvImportSource !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCsvImportSource(null);
|
||||
}
|
||||
}}
|
||||
connectorId={csvImportSource.id}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FeedbackRecordRow = ({
|
||||
record,
|
||||
workspaceId,
|
||||
locale,
|
||||
frdName,
|
||||
t,
|
||||
onClick,
|
||||
}: {
|
||||
record: FeedbackRecordData;
|
||||
workspaceId: string;
|
||||
locale: string;
|
||||
frdName: string;
|
||||
t: TFunction;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const value = formatValue(record, t, locale);
|
||||
const isLongValue = value.length > 60;
|
||||
const isFormbricksSurveySource =
|
||||
(record.source_type === "formbricks" || record.source_type === "formbricks_survey") && !!record.source_id;
|
||||
const surveySummaryHref = `/workspaces/${workspaceId}/surveys/${record.source_id}/summary`;
|
||||
|
||||
return (
|
||||
<tr className="text-sm text-slate-700 transition-colors hover:bg-slate-50">
|
||||
<tr
|
||||
className="cursor-pointer text-sm text-slate-700 transition-colors hover:bg-slate-50"
|
||||
onClick={onClick}>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
|
||||
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate px-4 py-3 text-slate-600" title={frdName}>
|
||||
{frdName}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<Badge text={record.source_type} type="gray" size="tiny" />
|
||||
<Badge text={formatSourceType(record.source_type, t)} type="gray" size="tiny" />
|
||||
</td>
|
||||
<td className="max-w-[150px] truncate px-4 py-3" title={record.source_name ?? undefined}>
|
||||
{record.source_name ?? "—"}
|
||||
{isFormbricksSurveySource ? (
|
||||
<Link
|
||||
href={surveySummaryHref}
|
||||
className="text-slate-700 underline underline-offset-2 hover:text-slate-900"
|
||||
onClick={(event) => event.stopPropagation()}>
|
||||
{record.source_name ?? "—"}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{record.source_name ?? "—"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate px-4 py-3" title={record.field_label ?? undefined}>
|
||||
{record.field_label ?? record.field_id}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getConnectorsWithMappings } from "@/lib/connector/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { listFeedbackRecords } from "@/modules/hub/service";
|
||||
@@ -7,7 +8,9 @@ import { FeedbackRecordsPageClient } from "./feedback-records-page-client";
|
||||
|
||||
const INITIAL_PAGE_SIZE = 50;
|
||||
|
||||
export default async function UnifyFeedbackRecordsPage(props: { params: Promise<{ workspaceId: string }> }) {
|
||||
export default async function UnifyFeedbackRecordsPage(
|
||||
props: Readonly<{ params: Promise<{ workspaceId: string }> }>
|
||||
) {
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
@@ -19,11 +22,15 @@ export default async function UnifyFeedbackRecordsPage(props: { params: Promise<
|
||||
}
|
||||
|
||||
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
|
||||
const canWrite = isOwner || isManager || hasReadWriteAccess || hasManageAccess;
|
||||
if (!hasAccess) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const frds = await getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId);
|
||||
const [frds, connectors] = await Promise.all([
|
||||
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
|
||||
getConnectorsWithMappings(params.workspaceId),
|
||||
]);
|
||||
|
||||
const results = await Promise.all(
|
||||
frds.map((frd) => listFeedbackRecords({ tenant_id: frd.id, limit: INITIAL_PAGE_SIZE }))
|
||||
@@ -38,8 +45,17 @@ export default async function UnifyFeedbackRecordsPage(props: { params: Promise<
|
||||
.slice(0, INITIAL_PAGE_SIZE);
|
||||
|
||||
const frdMap = Object.fromEntries(frds.map((f) => [f.id, f.name]));
|
||||
const csvSources = connectors
|
||||
.filter((connector) => connector.type === "csv")
|
||||
.map((connector) => ({ id: connector.id, name: connector.name }));
|
||||
|
||||
return (
|
||||
<FeedbackRecordsPageClient workspaceId={params.workspaceId} initialRecords={merged} frdMap={frdMap} />
|
||||
<FeedbackRecordsPageClient
|
||||
workspaceId={params.workspaceId}
|
||||
initialRecords={merged}
|
||||
frdMap={frdMap}
|
||||
csvSources={csvSources}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { FileSpreadsheetIcon, FormIcon } from "lucide-react";
|
||||
import { TConnectorType } from "@formbricks/types/connector";
|
||||
|
||||
export const getConnectorIcon = (type: TConnectorType, className: string) => {
|
||||
switch (type) {
|
||||
case "formbricks_survey":
|
||||
return <FormIcon className={className} />;
|
||||
case "csv":
|
||||
return <FileSpreadsheetIcon className={className} />;
|
||||
default:
|
||||
return <FormIcon className={className} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const getConnectorTypeLabelKey = (type: TConnectorType): string => {
|
||||
switch (type) {
|
||||
case "formbricks_survey":
|
||||
return "workspace.unify.formbricks_surveys";
|
||||
case "csv":
|
||||
return "workspace.unify.csv_import";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { FEEDBACK_RECORD_FIELDS, TFieldMapping } from "../types";
|
||||
|
||||
export const isConnectorNameValid = (name: string): boolean => name.trim().length > 0;
|
||||
|
||||
export const areAllRequiredFieldsMapped = (mappings: TFieldMapping[]): boolean => {
|
||||
const requiredFieldIds = new Set(
|
||||
FEEDBACK_RECORD_FIELDS.filter((field) => field.required).map((field) => field.id)
|
||||
);
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (!requiredFieldIds.has(mapping.targetFieldId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mapping.sourceFieldId || mapping.staticValue) {
|
||||
requiredFieldIds.delete(mapping.targetFieldId);
|
||||
}
|
||||
}
|
||||
|
||||
return requiredFieldIds.size === 0;
|
||||
};
|
||||
+24
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
FileSpreadsheetIcon,
|
||||
MoreVertical,
|
||||
PauseIcon,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
SquarePenIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
@@ -39,12 +41,15 @@ export function ConnectorRowDropdown({
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: ConnectorRowDropdownProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const isActive = connector.status === "active";
|
||||
const linkedSurveyId =
|
||||
connector.type === "formbricks_survey" ? connector.formbricksMappings[0]?.surveyId : undefined;
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
@@ -89,6 +94,25 @@ export function ConnectorRowDropdown({
|
||||
</>
|
||||
)}
|
||||
|
||||
{linkedSurveyId && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
router.push(`/workspaces/${connector.workspaceId}/surveys/${linkedSurveyId}/summary`);
|
||||
}}>
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
{`${t("common.view")} ${t("common.survey")}`}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+44
-18
@@ -1,56 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorType } from "@formbricks/types/connector";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { getConnectorOptions } from "../utils";
|
||||
import { TConnectorOptionId, getConnectorOptions } from "../utils";
|
||||
|
||||
interface ConnectorTypeSelectorProps {
|
||||
selectedType: TConnectorType | null;
|
||||
onSelectType: (type: TConnectorType) => void;
|
||||
selectedType: TConnectorOptionId | null;
|
||||
onSelectType: (type: TConnectorOptionId) => void;
|
||||
}
|
||||
|
||||
export function ConnectorTypeSelector({ selectedType, onSelectType }: ConnectorTypeSelectorProps) {
|
||||
const getOptionClassName = (
|
||||
selectedType: TConnectorOptionId | null,
|
||||
optionId: TConnectorOptionId,
|
||||
disabled: boolean
|
||||
): string => {
|
||||
if (selectedType === optionId) {
|
||||
return "border-brand-dark bg-slate-50";
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60";
|
||||
}
|
||||
|
||||
return "border-slate-200 hover:border-slate-300 hover:bg-slate-50";
|
||||
};
|
||||
|
||||
export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<ConnectorTypeSelectorProps>) {
|
||||
const { t } = useTranslation();
|
||||
const connectorOptions = getConnectorOptions(t);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-600">{t("workspace.unify.select_source_type_prompt")}</p>
|
||||
<div className="space-y-2">
|
||||
{connectorOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={() => onSelectType(option.id as TConnectorType)}
|
||||
className={`flex w-full items-center justify-between rounded-lg border p-4 text-left transition-colors ${
|
||||
selectedType === option.id
|
||||
? "border-brand-dark bg-slate-50"
|
||||
: option.disabled
|
||||
? "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60"
|
||||
: "border-slate-200 hover:border-slate-300 hover:bg-slate-50"
|
||||
}`}>
|
||||
onClick={() => onSelectType(option.id)}
|
||||
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
|
||||
selectedType,
|
||||
option.id,
|
||||
option.disabled
|
||||
)}`}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{option.name}</span>
|
||||
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
|
||||
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">{option.description}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`ml-4 h-5 w-5 rounded-full border-2 ${
|
||||
className={`ml-3 h-4 w-4 rounded-full border-2 ${
|
||||
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
|
||||
}`}>
|
||||
{selectedType === option.id && (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-2 w-2 rounded-full bg-white" />
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Alert variant="outbound" size="small">
|
||||
<AlertTitle>{t("workspace.unify.missing_feedback_source_title")}</AlertTitle>
|
||||
<AlertButton asChild>
|
||||
<Link
|
||||
href="https://app.formbricks.com/s/cmob8tub9s2ndu5010ei4it0g"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-slate-900 hover:underline">
|
||||
{t("workspace.unify.request_feedback_source")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+34
-12
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorType, TConnectorWithMappings, THubTargetField } from "@formbricks/types/connector";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import {
|
||||
createConnectorWithMappingsAction,
|
||||
deleteConnectorAction,
|
||||
@@ -12,9 +14,10 @@ import {
|
||||
updateConnectorWithMappingsAction,
|
||||
} from "@/lib/connector/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
|
||||
import { WorkspaceConfigNavigation } from "@/modules/workspaces/settings/components/workspace-config-navigation";
|
||||
import { TFieldMapping, TUnifySurvey } from "../types";
|
||||
import { ConnectorsTable } from "./connectors-table";
|
||||
import { CreateConnectorModal } from "./create-connector-modal";
|
||||
@@ -33,12 +36,21 @@ export function ConnectorsSection({
|
||||
initialConnectors,
|
||||
initialSurveys,
|
||||
directories,
|
||||
}: ConnectorsSectionProps) {
|
||||
}: Readonly<ConnectorsSectionProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingConnector, setEditingConnector] = useState<TConnectorWithMappings | null>(null);
|
||||
const [csvImportConnector, setCsvImportConnector] = useState<TConnectorWithMappings | null>(null);
|
||||
const directoryNames = directories.map((directory) => directory.name).join(", ");
|
||||
const feedbackDirectoryAccessText =
|
||||
directories.length === 1
|
||||
? t("workspace.unify.feedback_sources_directory_access_single", {
|
||||
directoryNames,
|
||||
})
|
||||
: t("workspace.unify.feedback_sources_directory_access_multiple", {
|
||||
directoryNames,
|
||||
});
|
||||
|
||||
const handleCreateConnector = async (data: {
|
||||
name: string;
|
||||
@@ -55,9 +67,9 @@ export function ConnectorsSection({
|
||||
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
|
||||
},
|
||||
formbricksMappings:
|
||||
data.type === "formbricks" && data.surveyMappings?.length ? data.surveyMappings : undefined,
|
||||
data.type === "formbricks_survey" && data.surveyMappings?.length ? data.surveyMappings : undefined,
|
||||
fieldMappings:
|
||||
data.type !== "formbricks" && data.fieldMappings?.length
|
||||
data.type !== "formbricks_survey" && data.fieldMappings?.length
|
||||
? data.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId || "",
|
||||
targetFieldId: m.targetFieldId as THubTargetField,
|
||||
@@ -154,8 +166,13 @@ export function ConnectorsSection({
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader
|
||||
pageTitle={t("workspace.unify.unify_feedback")}
|
||||
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||
<WorkspaceConfigNavigation activeId="feedback-sources" />
|
||||
</PageHeader>
|
||||
|
||||
<SettingsCard
|
||||
title={t("workspace.unify.feedback_sources")}
|
||||
description={t("workspace.unify.feedback_sources_settings_description")}
|
||||
cta={
|
||||
<CreateConnectorModal
|
||||
open={isCreateModalOpen}
|
||||
@@ -166,10 +183,6 @@ export function ConnectorsSection({
|
||||
directories={directories}
|
||||
/>
|
||||
}>
|
||||
<UnifyConfigNavigation workspaceId={workspaceId} activeId="sources" />
|
||||
</PageHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<ConnectorsTable
|
||||
connectors={initialConnectors}
|
||||
onConnectorClick={setEditingConnector}
|
||||
@@ -179,7 +192,17 @@ export function ConnectorsSection({
|
||||
onDelete={handleDeleteConnector}
|
||||
isLoading={false}
|
||||
/>
|
||||
</div>
|
||||
{directories.length > 0 && (
|
||||
<Alert size="small" className="mt-4">
|
||||
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
|
||||
<AlertButton asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.unify.manage_directories")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
<EditConnectorModal
|
||||
connector={editingConnector}
|
||||
@@ -187,7 +210,6 @@ export function ConnectorsSection({
|
||||
onOpenChange={(open) => !open && setEditingConnector(null)}
|
||||
onUpdateConnector={handleUpdateConnector}
|
||||
surveys={initialSurveys}
|
||||
directories={directories}
|
||||
onOpenCsvImport={() => {
|
||||
if (editingConnector) {
|
||||
setCsvImportConnector(editingConnector);
|
||||
|
||||
+23
-35
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { FileSpreadsheetIcon, FormIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorStatus, TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
|
||||
import { ConnectorRowDropdown } from "./connector-row-dropdown";
|
||||
|
||||
const RELATIVE_TIME_DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
|
||||
@@ -39,17 +39,6 @@ interface ConnectorsTableDataRowProps {
|
||||
onDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
function getConnectorIcon(type: TConnectorType) {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return <FormIcon className="h-4 w-4 text-slate-500" />;
|
||||
case "csv":
|
||||
return <FileSpreadsheetIcon className="h-4 w-4 text-slate-500" />;
|
||||
default:
|
||||
return <FormIcon className="h-4 w-4 text-slate-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
|
||||
active: "success",
|
||||
paused: "warning",
|
||||
@@ -63,13 +52,24 @@ export function ConnectorsTableDataRow({
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: ConnectorsTableDataRowProps) {
|
||||
}: Readonly<ConnectorsTableDataRowProps>) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const handleRowClick = () => {
|
||||
if (connector.type === "csv" && onCsvImport) {
|
||||
onCsvImport();
|
||||
return;
|
||||
}
|
||||
|
||||
const getStatusLabel = (s: TConnectorStatus) => {
|
||||
onEdit();
|
||||
};
|
||||
|
||||
const getStatusLabel = (s: TConnectorStatus, connectorType: TConnectorType) => {
|
||||
switch (s) {
|
||||
case "active":
|
||||
return t("workspace.unify.status_active");
|
||||
if (connectorType === "csv") {
|
||||
return t("workspace.unify.status_ready");
|
||||
}
|
||||
return t("workspace.unify.status_live_sync");
|
||||
case "paused":
|
||||
return t("workspace.unify.status_paused");
|
||||
case "error":
|
||||
@@ -77,44 +77,32 @@ export function ConnectorsTableDataRow({
|
||||
}
|
||||
};
|
||||
|
||||
const getConnectorTypeLabel = (connectorType: TConnectorType) => {
|
||||
switch (connectorType) {
|
||||
case "formbricks":
|
||||
return t("workspace.unify.formbricks_surveys");
|
||||
case "csv":
|
||||
return t("workspace.unify.csv_import");
|
||||
default:
|
||||
return connectorType;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="grid h-12 min-h-12 cursor-pointer grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50"
|
||||
onClick={onEdit}
|
||||
onClick={handleRowClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
onEdit();
|
||||
handleRowClick();
|
||||
}
|
||||
}}>
|
||||
<div className="col-span-1 flex items-center gap-2 pl-4" title={getConnectorTypeLabel(connector.type)}>
|
||||
{getConnectorIcon(connector.type)}
|
||||
<div
|
||||
className="col-span-1 flex items-center gap-2 pl-4"
|
||||
title={t(getConnectorTypeLabelKey(connector.type))}>
|
||||
{getConnectorIcon(connector.type, "h-4 w-4 text-slate-500")}
|
||||
</div>
|
||||
<div className="col-span-3 flex items-center">
|
||||
<div className="col-span-5 flex items-center">
|
||||
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
|
||||
</div>
|
||||
<div className="col-span-1 hidden items-center justify-center sm:flex">
|
||||
<Badge
|
||||
text={getStatusLabel(connector.status)}
|
||||
text={getStatusLabel(connector.status, connector.type)}
|
||||
type={STATUS_BADGE_TYPE[connector.status]}
|
||||
size="tiny"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
|
||||
{getRelativeTime(connector.createdAt, i18n.language)}
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
|
||||
{getRelativeTime(connector.updatedAt, i18n.language)}
|
||||
</div>
|
||||
|
||||
+2
-3
@@ -23,16 +23,15 @@ export function ConnectorsTable({
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
isLoading = false,
|
||||
}: ConnectorsTableProps) {
|
||||
}: Readonly<ConnectorsTableProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1 pl-6">{t("common.type")}</div>
|
||||
<div className="col-span-3">{t("common.name")}</div>
|
||||
<div className="col-span-5">{t("common.name")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.status")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.created")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.updated_at")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.created_by")}</div>
|
||||
<div className="col-span-1" />
|
||||
|
||||
+297
-313
@@ -1,9 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2Icon, PlusIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TConnectorType, UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
|
||||
import {
|
||||
getResponseCountAction,
|
||||
@@ -21,8 +25,15 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -30,17 +41,18 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { TCreateConnectorStep, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
|
||||
import {
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
TCreateConnectorStep,
|
||||
TFieldMapping,
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
} from "../types";
|
||||
import { TEnumValidationError, parseCSVColumnsToFields, validateEnumMappings } from "../utils";
|
||||
TConnectorOptionId,
|
||||
TEnumValidationError,
|
||||
parseCSVColumnsToFields,
|
||||
validateEnumMappings,
|
||||
} from "../utils";
|
||||
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
|
||||
import { ConnectorTypeSelector } from "./connector-type-selector";
|
||||
import { CsvConnectorUI } from "./csv-connector-ui";
|
||||
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||
import { FormbricksQuestionList } from "./formbricks-question-list";
|
||||
|
||||
interface CreateConnectorModalProps {
|
||||
open: boolean;
|
||||
@@ -59,101 +71,47 @@ interface CreateConnectorModalProps {
|
||||
|
||||
const getDialogTitle = (
|
||||
step: TCreateConnectorStep,
|
||||
type: TConnectorType | null,
|
||||
type: TConnectorOptionId | null,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
if (step === "selectType") return t("workspace.unify.add_feedback_source");
|
||||
if (type === "formbricks") return t("workspace.unify.select_survey_and_questions");
|
||||
if (type === "formbricks_survey") return t("workspace.unify.select_survey_and_questions");
|
||||
if (type === "csv") return t("workspace.unify.import_csv_data");
|
||||
return t("workspace.unify.configure_mapping");
|
||||
};
|
||||
|
||||
const getDialogDescription = (
|
||||
step: TCreateConnectorStep,
|
||||
type: TConnectorType | null,
|
||||
type: TConnectorOptionId | null,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
if (step === "selectType") return t("workspace.unify.select_source_type_description");
|
||||
if (type === "formbricks") return t("workspace.unify.select_survey_questions_description");
|
||||
if (type === "formbricks_survey") return t("workspace.unify.select_survey_questions_description");
|
||||
if (type === "csv") return t("workspace.unify.upload_csv_data_description");
|
||||
return t("workspace.unify.configure_mapping");
|
||||
};
|
||||
|
||||
const getNextStepButtonLabel = (type: TConnectorType | null, t: (key: string) => string): string => {
|
||||
if (type === "formbricks") return t("workspace.unify.select_questions");
|
||||
const getNextStepButtonLabel = (type: TConnectorOptionId | null, t: (key: string) => string): string => {
|
||||
if (type === "formbricks_survey") return t("workspace.unify.select_questions");
|
||||
if (type === "csv") return t("workspace.unify.configure_import");
|
||||
if (type === "api_ingestion") return t("workspace.unify.api_ingestion_manage_api_keys");
|
||||
if (type === "feedback_record_mcp") return t("common.learn_more");
|
||||
return t("workspace.unify.create_mapping");
|
||||
};
|
||||
|
||||
const getCreateDisabled = (
|
||||
type: TConnectorType | null,
|
||||
isFormbricksValid: boolean,
|
||||
isCsvValid: boolean,
|
||||
allRequiredMapped: boolean
|
||||
): boolean => {
|
||||
if (type === "formbricks") return !isFormbricksValid;
|
||||
if (type === "csv") return !isCsvValid || !allRequiredMapped;
|
||||
return !allRequiredMapped;
|
||||
};
|
||||
const ZFormbricksConnectorForm = z.object({
|
||||
sourceName: z.string().trim().min(1),
|
||||
surveyId: z.string().min(1),
|
||||
selectedQuestionIds: z.array(z.string()).min(1),
|
||||
importHistorical: z.boolean(),
|
||||
});
|
||||
|
||||
interface AggregateImportSectionProps {
|
||||
surveyEntries: {
|
||||
surveyId: string;
|
||||
surveyName: string;
|
||||
responseCount: number;
|
||||
elementCount: number;
|
||||
importHistorical: boolean;
|
||||
}[];
|
||||
onImportHistoricalChange: (surveyId: string, checked: boolean) => void;
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}
|
||||
type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
|
||||
|
||||
const AggregateImportSection = ({
|
||||
surveyEntries,
|
||||
onImportHistoricalChange,
|
||||
t,
|
||||
}: AggregateImportSectionProps) => {
|
||||
const totalRecords = surveyEntries.reduce((sum, e) => sum + e.responseCount * e.elementCount, 0);
|
||||
const checkedCount = surveyEntries.filter((e) => e.importHistorical).length;
|
||||
|
||||
const checkedTotal = surveyEntries
|
||||
.filter((e) => e.importHistorical)
|
||||
.reduce((sum, e) => sum + e.responseCount * e.elementCount, 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||
<div className="space-y-2">
|
||||
{surveyEntries.map((entry) => (
|
||||
<label key={entry.surveyId} className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={entry.importHistorical}
|
||||
onChange={(e) => onImportHistoricalChange(entry.surveyId, e.target.checked)}
|
||||
className="h-4 w-4 rounded border-amber-300 text-amber-600 focus:ring-amber-500"
|
||||
/>
|
||||
<span className="text-xs text-amber-800">
|
||||
{t("workspace.unify.survey_import_line", {
|
||||
surveyName: entry.surveyName,
|
||||
responseCount: entry.responseCount,
|
||||
questionCount: entry.elementCount,
|
||||
total: entry.responseCount * entry.elementCount,
|
||||
})}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{surveyEntries.length > 1 && (
|
||||
<p className="mt-3 border-t border-amber-200 pt-2 text-xs font-medium text-amber-900">
|
||||
{t("workspace.unify.total_feedback_records", {
|
||||
checked: checkedTotal,
|
||||
total: totalRecords,
|
||||
surveyCount: checkedCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const getSelectableQuestionIds = (survey: TUnifySurvey): string[] =>
|
||||
survey.elements
|
||||
.filter((element) => !(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(element.type))
|
||||
.map((element) => element.id);
|
||||
|
||||
export const CreateConnectorModal = ({
|
||||
open,
|
||||
@@ -164,34 +122,53 @@ export const CreateConnectorModal = ({
|
||||
directories,
|
||||
}: CreateConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const defaultConnectorName = useMemo<Record<TConnectorType, string>>(
|
||||
() => ({
|
||||
formbricks_survey: t("workspace.unify.default_connector_name_formbricks"),
|
||||
csv: t("workspace.unify.default_connector_name_csv"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formbricksForm = useForm<TFormbricksConnectorForm>({
|
||||
resolver: zodResolver(ZFormbricksConnectorForm),
|
||||
defaultValues: {
|
||||
sourceName: defaultConnectorName.formbricks_survey,
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const defaultConnectorName: Record<TConnectorType, string> = {
|
||||
formbricks: t("workspace.unify.default_connector_name_formbricks"),
|
||||
csv: t("workspace.unify.default_connector_name_csv"),
|
||||
};
|
||||
const [currentStep, setCurrentStep] = useState<TCreateConnectorStep>("selectType");
|
||||
const [selectedType, setSelectedType] = useState<TConnectorType | null>(null);
|
||||
const [connectorName, setConnectorName] = useState("");
|
||||
const [selectedType, setSelectedType] = useState<TConnectorOptionId | null>(null);
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
|
||||
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
|
||||
const [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
|
||||
|
||||
const [csvParsedData, setCsvParsedData] = useState<Record<string, string>[]>([]);
|
||||
|
||||
const [enumValidationErrors, setEnumValidationErrors] = useState<TEnumValidationError[]>([]);
|
||||
|
||||
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
|
||||
|
||||
const [csvConnectorName, setCsvConnectorName] = useState("");
|
||||
const [responseCountBySurvey, setResponseCountBySurvey] = useState<Record<string, number | null>>({});
|
||||
const [importHistoricalBySurvey, setImportHistoricalBySurvey] = useState<Record<string, boolean>>({});
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(
|
||||
directories.length === 1 ? directories[0].id : null
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories[0]?.id ?? null);
|
||||
|
||||
const formbricksValues = formbricksForm.watch();
|
||||
const selectedSurveyId = formbricksValues.surveyId;
|
||||
const selectedQuestionIds = formbricksValues.selectedQuestionIds ?? [];
|
||||
|
||||
const selectedSurvey = useMemo(
|
||||
() => surveys.find((survey) => survey.id === selectedSurveyId) ?? null,
|
||||
[surveys, selectedSurveyId]
|
||||
);
|
||||
|
||||
const selectedSurveyResponseCount =
|
||||
selectedSurveyId && responseCountBySurvey[selectedSurveyId] !== undefined
|
||||
? responseCountBySurvey[selectedSurveyId]
|
||||
: null;
|
||||
|
||||
const fetchResponseCount = useCallback(
|
||||
async (surveyId: string) => {
|
||||
if (responseCountBySurvey[surveyId] !== undefined) return;
|
||||
@@ -204,30 +181,50 @@ export const CreateConnectorModal = ({
|
||||
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: null }));
|
||||
}
|
||||
},
|
||||
[workspaceId, responseCountBySurvey]
|
||||
[responseCountBySurvey, workspaceId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSurveyId && selectedType === "formbricks") {
|
||||
if (selectedSurveyId && currentStep === "mapping" && selectedType === "formbricks_survey") {
|
||||
fetchResponseCount(selectedSurveyId);
|
||||
}
|
||||
}, [selectedSurveyId, selectedType, fetchResponseCount]);
|
||||
}, [currentStep, fetchResponseCount, selectedSurveyId, selectedType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep !== "mapping" || selectedType !== "formbricks_survey" || !selectedSurveyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const survey = surveys.find((item) => item.id === selectedSurveyId);
|
||||
const supportedElementIds = survey ? getSelectableQuestionIds(survey) : [];
|
||||
|
||||
formbricksForm.setValue("selectedQuestionIds", supportedElementIds, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
formbricksForm.setValue("importHistorical", true, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}, [currentStep, formbricksForm, selectedSurveyId, selectedType, surveys]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCurrentStep("selectType");
|
||||
setSelectedType(null);
|
||||
setConnectorName("");
|
||||
formbricksForm.reset({
|
||||
sourceName: defaultConnectorName.formbricks_survey,
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setCsvParsedData([]);
|
||||
setEnumValidationErrors([]);
|
||||
setSelectedSurveyId(null);
|
||||
setElementIdsBySurvey({});
|
||||
setResponseCountBySurvey({});
|
||||
setImportHistoricalBySurvey({});
|
||||
setCsvConnectorName("");
|
||||
setIsImporting(false);
|
||||
setIsCreating(false);
|
||||
setSelectedDirectoryId(directories.length === 1 ? directories[0].id : null);
|
||||
setSelectedDirectoryId(directories[0]?.id ?? null);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
@@ -239,50 +236,31 @@ export const CreateConnectorModal = ({
|
||||
const handleNextStep = () => {
|
||||
if (currentStep !== "selectType" || !selectedType) return;
|
||||
|
||||
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
|
||||
setConnectorName(
|
||||
selectedType === "formbricks" && selectedSurvey
|
||||
? `${selectedSurvey.name} ${t("workspace.unify.connection")}`
|
||||
: defaultConnectorName[selectedType]
|
||||
);
|
||||
setCurrentStep("mapping");
|
||||
};
|
||||
|
||||
const handleSurveySelect = (surveyId: string | null) => {
|
||||
setSelectedSurveyId(surveyId);
|
||||
};
|
||||
|
||||
const handleElementToggle = (elementId: string) => {
|
||||
if (!selectedSurveyId) return;
|
||||
setElementIdsBySurvey((prev) => {
|
||||
const current = prev[selectedSurveyId] ?? [];
|
||||
return {
|
||||
...prev,
|
||||
[selectedSurveyId]: current.includes(elementId)
|
||||
? current.filter((id) => id !== elementId)
|
||||
: [...current, elementId],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAllElements = (surveyId: string) => {
|
||||
const survey = surveys.find((s) => s.id === surveyId);
|
||||
if (survey) {
|
||||
setElementIdsBySurvey((prev) => ({
|
||||
...prev,
|
||||
[surveyId]: survey.elements
|
||||
.filter((e) => !(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(e.type))
|
||||
.map((e) => e.id),
|
||||
}));
|
||||
if (selectedType === "api_ingestion") {
|
||||
handleOpenChange(false);
|
||||
router.push(`/workspaces/${workspaceId}/settings/api-keys`);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeselectAllElements = () => {
|
||||
if (!selectedSurveyId) return;
|
||||
setElementIdsBySurvey((prev) => ({
|
||||
...prev,
|
||||
[selectedSurveyId]: [],
|
||||
}));
|
||||
if (selectedType === "feedback_record_mcp") {
|
||||
window.open("https://formbricks.com/docs", "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedType === "formbricks_survey") {
|
||||
formbricksForm.reset({
|
||||
sourceName: defaultConnectorName.formbricks_survey,
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedType === "csv") {
|
||||
setCsvConnectorName(defaultConnectorName.csv);
|
||||
}
|
||||
|
||||
setCurrentStep("mapping");
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
@@ -290,52 +268,31 @@ export const CreateConnectorModal = ({
|
||||
setCurrentStep("selectType");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setEnumValidationErrors([]);
|
||||
}
|
||||
};
|
||||
|
||||
const getSurveyMappings = () =>
|
||||
Object.entries(elementIdsBySurvey)
|
||||
.filter(([, ids]) => ids.length > 0)
|
||||
.map(([surveyId, elementIds]) => ({ surveyId, elementIds }));
|
||||
|
||||
const handleHistoricalImports = async (connectorId: string) => {
|
||||
const surveysToImport = Object.entries(importHistoricalBySurvey)
|
||||
.filter(([surveyId, checked]) => checked && (elementIdsBySurvey[surveyId]?.length ?? 0) > 0)
|
||||
.map(([surveyId]) => surveyId);
|
||||
|
||||
if (surveysToImport.length === 0) return;
|
||||
|
||||
const handleHistoricalImport = async (connectorId: string, surveyId: string) => {
|
||||
const responseCount = responseCountBySurvey[surveyId] ?? 0;
|
||||
if (responseCount <= 0) return;
|
||||
setIsImporting(true);
|
||||
let totalSuccesses = 0;
|
||||
let totalFailures = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const surveyId of surveysToImport) {
|
||||
const importResult = await importHistoricalResponsesAction({
|
||||
connectorId,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
});
|
||||
|
||||
if (importResult?.data) {
|
||||
totalSuccesses += importResult.data.successes;
|
||||
totalFailures += importResult.data.failures;
|
||||
totalSkipped += importResult.data.skipped;
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(importResult));
|
||||
}
|
||||
}
|
||||
|
||||
const importResult = await importHistoricalResponsesAction({
|
||||
connectorId,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
});
|
||||
setIsImporting(false);
|
||||
|
||||
if (totalSuccesses > 0 || totalFailures > 0) {
|
||||
if (importResult?.data) {
|
||||
toast.success(
|
||||
t("workspace.unify.historical_import_complete", {
|
||||
successes: totalSuccesses,
|
||||
failures: totalFailures,
|
||||
skipped: totalSkipped,
|
||||
successes: importResult.data.successes,
|
||||
failures: importResult.data.failures,
|
||||
skipped: importResult.data.skipped,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(importResult));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -361,10 +318,41 @@ export const CreateConnectorModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!selectedType || !connectorName.trim() || !selectedDirectoryId) return;
|
||||
const handleFormbricksQuestionToggle = (questionId: string) => {
|
||||
const currentSelection = formbricksForm.getValues("selectedQuestionIds");
|
||||
const isSelected = currentSelection.includes(questionId);
|
||||
const nextSelection = isSelected
|
||||
? currentSelection.filter((id) => id !== questionId)
|
||||
: [...currentSelection, questionId];
|
||||
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
};
|
||||
|
||||
if (selectedType === "csv" && csvParsedData.length > 0) {
|
||||
const handleCreateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
|
||||
if (!selectedDirectoryId) return;
|
||||
setIsCreating(true);
|
||||
|
||||
const connectorId = await onCreateConnector({
|
||||
name: values.sourceName.trim(),
|
||||
type: "formbricks_survey",
|
||||
feedbackRecordDirectoryId: selectedDirectoryId,
|
||||
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
|
||||
});
|
||||
|
||||
if (connectorId && values.importHistorical) {
|
||||
await handleHistoricalImport(connectorId, values.surveyId);
|
||||
}
|
||||
|
||||
setIsCreating(false);
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCreateCsvConnector = async () => {
|
||||
if (!selectedDirectoryId || !isConnectorNameValid(csvConnectorName)) return;
|
||||
if (csvParsedData.length > 0) {
|
||||
const errors = validateEnumMappings(mappings, csvParsedData);
|
||||
if (errors.length > 0) {
|
||||
setEnumValidationErrors(errors);
|
||||
@@ -375,21 +363,14 @@ export const CreateConnectorModal = ({
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
const surveyMappings = getSurveyMappings();
|
||||
|
||||
const connectorId = await onCreateConnector({
|
||||
name: connectorName.trim(),
|
||||
type: selectedType,
|
||||
name: csvConnectorName.trim(),
|
||||
type: "csv",
|
||||
feedbackRecordDirectoryId: selectedDirectoryId,
|
||||
surveyMappings: selectedType === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
|
||||
fieldMappings: selectedType !== "formbricks" && mappings.length > 0 ? mappings : undefined,
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
|
||||
if (connectorId && selectedType === "formbricks") {
|
||||
await handleHistoricalImports(connectorId);
|
||||
}
|
||||
|
||||
if (connectorId && selectedType === "csv" && csvParsedData.length > 0) {
|
||||
if (connectorId && csvParsedData.length > 0) {
|
||||
await handleCsvImport(connectorId);
|
||||
}
|
||||
|
||||
@@ -398,14 +379,8 @@ export const CreateConnectorModal = ({
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||
const allRequiredMapped = requiredFields.every((field) =>
|
||||
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
|
||||
);
|
||||
|
||||
const hasAnyElementSelections = Object.values(elementIdsBySurvey).some((ids) => ids.length > 0);
|
||||
const isFormbricksValid = selectedType === "formbricks" && hasAnyElementSelections;
|
||||
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
|
||||
const areCsvRequiredFieldsMapped = areAllRequiredFieldsMapped(mappings);
|
||||
|
||||
const handleLoadSourceFields = () => {
|
||||
if (selectedType === "csv") {
|
||||
@@ -444,86 +419,118 @@ export const CreateConnectorModal = ({
|
||||
<ConnectorTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
|
||||
)}
|
||||
|
||||
{currentStep === "mapping" && selectedType === "formbricks" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="connectorName">{t("workspace.unify.source_name")}</Label>
|
||||
<Input
|
||||
id="connectorName"
|
||||
value={connectorName}
|
||||
onChange={(e) => setConnectorName(e.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FrdPicker
|
||||
directories={directories}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
onChange={setSelectedDirectoryId}
|
||||
workspaceId={workspaceId}
|
||||
t={t}
|
||||
/>
|
||||
{directories.length === 0 && (
|
||||
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<FormbricksSurveySelector
|
||||
surveys={surveys}
|
||||
selectedSurveyId={selectedSurveyId}
|
||||
selectedElementIds={selectedElementIds}
|
||||
onSurveySelect={handleSurveySelect}
|
||||
onElementToggle={handleElementToggle}
|
||||
onSelectAllElements={handleSelectAllElements}
|
||||
onDeselectAllElements={handleDeselectAllElements}
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="surveyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("workspace.unify.select_survey")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{surveys.map((survey) => (
|
||||
<SelectItem key={survey.id} value={survey.id}>
|
||||
{survey.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
const entries = Object.entries(elementIdsBySurvey)
|
||||
.filter(([, ids]) => ids.length > 0)
|
||||
.map(([surveyId, ids]) => ({
|
||||
surveyId,
|
||||
surveyName: surveys.find((s) => s.id === surveyId)?.name ?? surveyId,
|
||||
responseCount: responseCountBySurvey[surveyId] ?? 0,
|
||||
elementCount: ids.length,
|
||||
importHistorical: importHistoricalBySurvey[surveyId] ?? false,
|
||||
}))
|
||||
.filter((e) => e.responseCount > 0);
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="selectedQuestionIds"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<FormbricksQuestionList
|
||||
survey={selectedSurvey}
|
||||
selectedQuestionIds={selectedQuestionIds}
|
||||
onQuestionToggle={handleFormbricksQuestionToggle}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<AggregateImportSection
|
||||
surveyEntries={entries}
|
||||
onImportHistoricalChange={(surveyId, checked) => {
|
||||
setImportHistoricalBySurvey((prev) => ({ ...prev, [surveyId]: checked }));
|
||||
}}
|
||||
t={t}
|
||||
{selectedSurveyResponseCount !== null && selectedSurveyResponseCount > 0 && (
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="importHistorical"
|
||||
render={({ field }) => (
|
||||
<FormItem className="rounded-md border border-slate-200 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>{t("workspace.unify.import_historical_responses")}</FormLabel>
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.unify.import_historical_responses_description")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
)}
|
||||
|
||||
{currentStep === "mapping" && selectedType === "csv" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="connectorName">{t("workspace.unify.source_name")}</Label>
|
||||
<label htmlFor="connectorName" className="text-sm font-medium text-slate-700">
|
||||
{t("workspace.unify.source_name")}
|
||||
</label>
|
||||
<Input
|
||||
id="connectorName"
|
||||
value={connectorName}
|
||||
onChange={(e) => setConnectorName(e.target.value)}
|
||||
value={csvConnectorName}
|
||||
onChange={(event) => setCsvConnectorName(event.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FrdPicker
|
||||
directories={directories}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
onChange={setSelectedDirectoryId}
|
||||
workspaceId={workspaceId}
|
||||
t={t}
|
||||
/>
|
||||
{directories.length === 0 && (
|
||||
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
|
||||
)}
|
||||
|
||||
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
|
||||
<CsvConnectorUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
@@ -582,13 +589,20 @@ export const CreateConnectorModal = ({
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
onClick={
|
||||
selectedType === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleCreateFormbricksConnector)()
|
||||
: handleCreateCsvConnector
|
||||
}
|
||||
disabled={
|
||||
isCreating ||
|
||||
isImporting ||
|
||||
!connectorName.trim() ||
|
||||
!selectedDirectoryId ||
|
||||
getCreateDisabled(selectedType, !!isFormbricksValid, isCsvValid, allRequiredMapped)
|
||||
(selectedType === "formbricks_survey"
|
||||
? !isConnectorNameValid(formbricksValues.sourceName ?? "") ||
|
||||
!formbricksValues.surveyId ||
|
||||
!formbricksValues.selectedQuestionIds?.length
|
||||
: !isConnectorNameValid(csvConnectorName) || !isCsvValid || !areCsvRequiredFieldsMapped)
|
||||
}>
|
||||
{isCreating && <Loader2Icon className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("workspace.unify.setup_connection")}
|
||||
@@ -601,52 +615,22 @@ export const CreateConnectorModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface FrdPickerProps {
|
||||
directories: { id: string; name: string }[];
|
||||
selectedDirectoryId: string | null;
|
||||
onChange: (id: string) => void;
|
||||
interface NoFeedbackRecordDirectoryAlertProps {
|
||||
workspaceId: string;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const FrdPicker = ({ directories, selectedDirectoryId, onChange, workspaceId, t }: FrdPickerProps) => {
|
||||
if (directories.length === 0) {
|
||||
return (
|
||||
<Alert variant="error" size="small">
|
||||
<div>
|
||||
<p>{t("workspace.unify.no_feedback_record_directory_available")}</p>
|
||||
<a
|
||||
className="mt-1 inline-block font-medium underline"
|
||||
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.unify.go_to_feedback_record_directories")}
|
||||
</a>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (directories.length === 1) {
|
||||
return (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||
{t("workspace.unify.records_will_go_to")}{" "}
|
||||
<span className="font-medium text-slate-900">{directories[0].name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const NoFeedbackRecordDirectoryAlert = ({ workspaceId, t }: NoFeedbackRecordDirectoryAlertProps) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="feedbackRecordDirectory">{t("workspace.unify.feedback_record_directory")}</Label>
|
||||
<Select value={selectedDirectoryId ?? ""} onValueChange={onChange}>
|
||||
<SelectTrigger id="feedbackRecordDirectory">
|
||||
<SelectValue placeholder={t("workspace.unify.select_feedback_record_directory")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{directories.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Alert variant="error" size="small">
|
||||
<div>
|
||||
<p>{t("workspace.unify.no_feedback_record_directory_available")}</p>
|
||||
<a
|
||||
className="mt-1 inline-block font-medium underline"
|
||||
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.unify.go_to_feedback_record_directories")}
|
||||
</a>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
+236
-166
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { FileSpreadsheetIcon, GlobeIcon } from "lucide-react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { z } from "zod";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -13,17 +15,27 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import {
|
||||
FEEDBACK_RECORD_FIELDS,
|
||||
SAMPLE_CSV_COLUMNS,
|
||||
TFieldMapping,
|
||||
TSourceField,
|
||||
TUnifySurvey,
|
||||
} from "../types";
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { SAMPLE_CSV_COLUMNS, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
|
||||
import { parseCSVColumnsToFields } from "../utils";
|
||||
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
|
||||
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
|
||||
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
|
||||
import { FormbricksQuestionList } from "./formbricks-question-list";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
|
||||
interface EditConnectorModalProps {
|
||||
@@ -38,42 +50,17 @@ interface EditConnectorModalProps {
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => Promise<void>;
|
||||
surveys: TUnifySurvey[];
|
||||
directories: { id: string; name: string }[];
|
||||
onOpenCsvImport?: () => void;
|
||||
}
|
||||
|
||||
const getConnectorIcon = (type: TConnectorType) => {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
|
||||
case "csv":
|
||||
return <FileSpreadsheetIcon className="h-5 w-5 text-slate-500" />;
|
||||
default:
|
||||
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
|
||||
}
|
||||
};
|
||||
const ZFormbricksEditConnectorForm = z.object({
|
||||
sourceName: z.string().trim().min(1),
|
||||
surveyId: z.string().min(1),
|
||||
selectedQuestionIds: z.array(z.string()).min(1),
|
||||
importHistorical: z.boolean(),
|
||||
});
|
||||
|
||||
const getConnectorTypeLabelKey = (type: TConnectorType): string => {
|
||||
switch (type) {
|
||||
case "formbricks":
|
||||
return "workspace.unify.formbricks_surveys";
|
||||
case "csv":
|
||||
return "workspace.unify.csv_import";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const groupMappingsBySurvey = (
|
||||
mappings: { surveyId: string; elementId: string }[]
|
||||
): Record<string, string[]> => {
|
||||
const grouped: Record<string, string[]> = {};
|
||||
for (const m of mappings) {
|
||||
if (!grouped[m.surveyId]) grouped[m.surveyId] = [];
|
||||
grouped[m.surveyId].push(m.elementId);
|
||||
}
|
||||
return grouped;
|
||||
};
|
||||
type TFormbricksEditConnectorForm = z.infer<typeof ZFormbricksEditConnectorForm>;
|
||||
|
||||
export const EditConnectorModal = ({
|
||||
connector,
|
||||
@@ -81,35 +68,52 @@ export const EditConnectorModal = ({
|
||||
onOpenChange,
|
||||
onUpdateConnector,
|
||||
surveys,
|
||||
directories,
|
||||
onOpenCsvImport,
|
||||
}: EditConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [connectorName, setConnectorName] = useState("");
|
||||
const [csvConnectorName, setCsvConnectorName] = useState("");
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
|
||||
const [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
|
||||
const formbricksForm = useForm<TFormbricksEditConnectorForm>({
|
||||
resolver: zodResolver(ZFormbricksEditConnectorForm),
|
||||
defaultValues: {
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
|
||||
|
||||
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||
const allRequiredMapped = requiredFields.every((field) =>
|
||||
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
|
||||
const formbricksValues = formbricksForm.watch();
|
||||
const selectedSurveyId = formbricksValues.surveyId;
|
||||
const selectedQuestionIds = formbricksValues.selectedQuestionIds ?? [];
|
||||
const selectedSurvey = useMemo(
|
||||
() => surveys.find((survey) => survey.id === selectedSurveyId) ?? null,
|
||||
[surveys, selectedSurveyId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (connector) {
|
||||
setConnectorName(connector.name);
|
||||
if (connector.type === "formbricks_survey") {
|
||||
const mappedSurveyId = connector.formbricksMappings[0]?.surveyId ?? "";
|
||||
const mappedQuestionIds = connector.formbricksMappings
|
||||
.filter((mapping) => mapping.surveyId === mappedSurveyId)
|
||||
.map((mapping) => mapping.elementId);
|
||||
|
||||
if (connector.type === "formbricks") {
|
||||
const fbMappings = connector.formbricksMappings;
|
||||
setSelectedSurveyId(fbMappings.length > 0 ? fbMappings[0].surveyId : null);
|
||||
setElementIdsBySurvey(groupMappingsBySurvey(fbMappings));
|
||||
formbricksForm.reset({
|
||||
sourceName: connector.name,
|
||||
surveyId: mappedSurveyId,
|
||||
selectedQuestionIds: mappedQuestionIds,
|
||||
importHistorical: true,
|
||||
});
|
||||
setCsvConnectorName("");
|
||||
setSourceFields([]);
|
||||
setMappings([]);
|
||||
} else if (connector.type === "csv") {
|
||||
setCsvConnectorName(connector.name);
|
||||
const columnsFromMappings = [
|
||||
...new Set(connector.fieldMappings.map((m) => m.sourceFieldId).filter(Boolean)),
|
||||
];
|
||||
@@ -125,23 +129,37 @@ export const EditConnectorModal = ({
|
||||
staticValue: m.staticValue ?? undefined,
|
||||
}))
|
||||
);
|
||||
setSelectedSurveyId(null);
|
||||
setElementIdsBySurvey({});
|
||||
formbricksForm.reset({
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
} else {
|
||||
setCsvConnectorName("");
|
||||
setSourceFields([]);
|
||||
setMappings([]);
|
||||
setSelectedSurveyId(null);
|
||||
setElementIdsBySurvey({});
|
||||
formbricksForm.reset({
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [connector]);
|
||||
}, [connector, formbricksForm]);
|
||||
|
||||
const resetForm = () => {
|
||||
setConnectorName("");
|
||||
setCsvConnectorName("");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setSelectedSurveyId(null);
|
||||
setElementIdsBySurvey({});
|
||||
formbricksForm.reset({
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
@@ -151,76 +169,64 @@ export const EditConnectorModal = ({
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const handleSurveySelect = (surveyId: string | null) => {
|
||||
setSelectedSurveyId(surveyId);
|
||||
};
|
||||
|
||||
const handleElementToggle = (elementId: string) => {
|
||||
if (!selectedSurveyId) return;
|
||||
setElementIdsBySurvey((prev) => {
|
||||
const current = prev[selectedSurveyId] ?? [];
|
||||
return {
|
||||
...prev,
|
||||
[selectedSurveyId]: current.includes(elementId)
|
||||
? current.filter((id) => id !== elementId)
|
||||
: [...current, elementId],
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAllElements = (surveyId: string) => {
|
||||
const survey = surveys.find((s) => s.id === surveyId);
|
||||
if (survey) {
|
||||
setElementIdsBySurvey((prev) => ({
|
||||
...prev,
|
||||
[surveyId]: survey.elements.map((e) => e.id),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeselectAllElements = () => {
|
||||
if (!selectedSurveyId) return;
|
||||
setElementIdsBySurvey((prev) => ({
|
||||
...prev,
|
||||
[selectedSurveyId]: [],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!connector || !connectorName.trim()) return;
|
||||
|
||||
const surveyMappings = Object.entries(elementIdsBySurvey)
|
||||
.filter(([, ids]) => ids.length > 0)
|
||||
.map(([surveyId, elementIds]) => ({ surveyId, elementIds }));
|
||||
|
||||
const handleUpdateFormbricksConnector = async (values: TFormbricksEditConnectorForm) => {
|
||||
if (connector?.type !== "formbricks_survey") return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: connectorName.trim(),
|
||||
surveyMappings:
|
||||
connector.type === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
|
||||
fieldMappings: connector.type !== "formbricks" && mappings.length > 0 ? mappings : undefined,
|
||||
name: values.sourceName.trim(),
|
||||
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
|
||||
fieldMappings: undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
const assignedDirectoryName =
|
||||
directories.find((d) => d.id === connector?.feedbackRecordDirectoryId)?.name ??
|
||||
connector?.feedbackRecordDirectoryId ??
|
||||
"—";
|
||||
const handleUpdateCsvConnector = async () => {
|
||||
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: csvConnectorName.trim(),
|
||||
surveyMappings: undefined,
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
const saveChangesDisbaled = useMemo(() => {
|
||||
const handleFormbricksQuestionToggle = (questionId: string) => {
|
||||
const currentSelection = formbricksForm.getValues("selectedQuestionIds");
|
||||
const isSelected = currentSelection.includes(questionId);
|
||||
const nextSelection = isSelected
|
||||
? currentSelection.filter((id) => id !== questionId)
|
||||
: [...currentSelection, questionId];
|
||||
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
};
|
||||
|
||||
const saveChangesDisabled = useMemo(() => {
|
||||
if (!connector) return true;
|
||||
if (!connectorName.trim()) return true;
|
||||
if (isUpdating) return true;
|
||||
|
||||
if (connector.type === "formbricks") {
|
||||
return !Object.values(elementIdsBySurvey).some((ids) => ids.length > 0);
|
||||
if (connector.type === "formbricks_survey") {
|
||||
return (
|
||||
!isConnectorNameValid(formbricksValues.sourceName ?? "") ||
|
||||
!formbricksValues.surveyId ||
|
||||
!formbricksValues.selectedQuestionIds?.length
|
||||
);
|
||||
}
|
||||
|
||||
if (connector.type === "csv") {
|
||||
return !allRequiredMapped;
|
||||
return !isConnectorNameValid(csvConnectorName) || !areAllRequiredFieldsMapped(mappings);
|
||||
}
|
||||
}, [allRequiredMapped, connector, connectorName, elementIdsBySurvey]);
|
||||
|
||||
return true;
|
||||
}, [connector, csvConnectorName, formbricksValues, isUpdating, mappings]);
|
||||
|
||||
if (!connector) return null;
|
||||
|
||||
@@ -233,53 +239,111 @@ export const EditConnectorModal = ({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
{getConnectorIcon(connector.type)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{t(getConnectorTypeLabelKey(connector.type))}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{t("workspace.unify.source_type_cannot_be_changed")}</p>
|
||||
</div>
|
||||
</div>
|
||||
{connector.type === "formbricks_survey" ? (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="editConnectorName">{t("workspace.unify.source_name")}</Label>
|
||||
<Input
|
||||
id="editConnectorName"
|
||||
value={connectorName}
|
||||
onChange={(e) => setConnectorName(e.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="surveyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange} disabled>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("workspace.unify.select_survey")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectedSurvey && (
|
||||
<SelectItem key={selectedSurvey.id} value={selectedSurvey.id}>
|
||||
{selectedSurvey.name}
|
||||
</SelectItem>
|
||||
)}
|
||||
{!selectedSurvey && field.value && (
|
||||
<SelectItem value={field.value}>{field.value}</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||
{t("workspace.unify.records_will_go_to")}{" "}
|
||||
<span className="font-medium text-slate-900">{assignedDirectoryName}</span>
|
||||
<p className="mt-1 text-xs text-slate-400">{t("workspace.unify.frd_cannot_be_changed")}</p>
|
||||
</div>
|
||||
|
||||
{connector.type === "formbricks" ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<FormbricksSurveySelector
|
||||
surveys={surveys}
|
||||
selectedSurveyId={selectedSurveyId}
|
||||
selectedElementIds={selectedElementIds}
|
||||
onSurveySelect={handleSurveySelect}
|
||||
onElementToggle={handleElementToggle}
|
||||
onSelectAllElements={handleSelectAllElements}
|
||||
onDeselectAllElements={handleDeselectAllElements}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="selectedQuestionIds"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<FormbricksQuestionList
|
||||
survey={selectedSurvey}
|
||||
selectedQuestionIds={selectedQuestionIds}
|
||||
onQuestionToggle={handleFormbricksQuestionToggle}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
) : (
|
||||
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
connectorType={connector.type}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
{getConnectorIcon(connector.type, "h-5 w-5 text-slate-500")}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{t(getConnectorTypeLabelKey(connector.type))}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("workspace.unify.source_type_cannot_be_changed")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="editConnectorName" className="text-sm font-medium text-slate-700">
|
||||
{t("workspace.unify.source_name")}
|
||||
</label>
|
||||
<Input
|
||||
id="editConnectorName"
|
||||
value={csvConnectorName}
|
||||
onChange={(event) => setCsvConnectorName(event.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
connectorType={connector.type}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -294,7 +358,13 @@ export const EditConnectorModal = ({
|
||||
{t("workspace.unify.import_feedback")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleUpdate} disabled={saveChangesDisbaled}>
|
||||
<Button
|
||||
onClick={
|
||||
connector.type === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
|
||||
: handleUpdateCsvConnector
|
||||
}
|
||||
disabled={saveChangesDisabled}>
|
||||
{t("workspace.unify.save_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
|
||||
import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { TUnifySurvey } from "../types";
|
||||
|
||||
interface FormbricksQuestionListProps {
|
||||
survey: TUnifySurvey | null;
|
||||
selectedQuestionIds: string[];
|
||||
onQuestionToggle: (questionId: string) => void;
|
||||
}
|
||||
|
||||
const isUnsupportedElementType = (type: string): boolean =>
|
||||
(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(type);
|
||||
|
||||
export const FormbricksQuestionList = ({
|
||||
survey,
|
||||
selectedQuestionIds,
|
||||
onQuestionToggle,
|
||||
}: Readonly<FormbricksQuestionListProps>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!survey) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-slate-300 p-3">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.select_a_survey_to_see_questions")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (survey.elements.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-slate-300 p-3">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.survey_has_no_questions")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-64 space-y-2 overflow-y-auto rounded-md border border-slate-200 p-3">
|
||||
{survey.elements.map((element) => {
|
||||
const unsupported = isUnsupportedElementType(element.type);
|
||||
const isChecked = selectedQuestionIds.includes(element.id);
|
||||
const elementTypeLabel = getTSurveyElementTypeEnumName(element.type, t) ?? element.type;
|
||||
const inputId = `connector-question-${element.id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className={`flex items-start gap-3 rounded-md border border-slate-100 p-2 ${
|
||||
unsupported ? "opacity-60" : ""
|
||||
}`}>
|
||||
<Checkbox
|
||||
id={inputId}
|
||||
checked={!unsupported && isChecked}
|
||||
disabled={unsupported}
|
||||
onCheckedChange={() => {
|
||||
if (!unsupported) {
|
||||
onQuestionToggle(element.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={inputId} className={unsupported ? "cursor-not-allowed" : "cursor-pointer"}>
|
||||
{element.headline}
|
||||
</Label>
|
||||
<p className="text-xs text-slate-500">{elementTypeLabel}</p>
|
||||
{unsupported && (
|
||||
<p className="text-xs text-slate-500">{t("workspace.unify.question_type_not_supported")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-233
@@ -1,233 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, ChevronRightIcon, FileTextIcon, MessageSquareTextIcon, StarIcon } from "lucide-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||
import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { TUnifySurvey } from "../types";
|
||||
|
||||
interface FormbricksSurveySelectorProps {
|
||||
surveys: TUnifySurvey[];
|
||||
selectedSurveyId: string | null;
|
||||
selectedElementIds: string[];
|
||||
onSurveySelect: (surveyId: string | null) => void;
|
||||
onElementToggle: (elementId: string) => void;
|
||||
onSelectAllElements: (surveyId: string) => void;
|
||||
onDeselectAllElements: () => void;
|
||||
}
|
||||
|
||||
const getElementIcon = (type: TSurveyElementTypeEnum) => {
|
||||
switch (type) {
|
||||
case "openText":
|
||||
return <MessageSquareTextIcon className="h-4 w-4 text-slate-500" />;
|
||||
case "rating":
|
||||
case "nps":
|
||||
return <StarIcon className="h-4 w-4 text-amber-500" />;
|
||||
default:
|
||||
return <FileTextIcon className="h-4 w-4 text-slate-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const isUnsupportedType = (type: TSurveyElementTypeEnum): boolean => {
|
||||
return UNSUPPORTED_CONNECTOR_ELEMENT_TYPES.includes(type);
|
||||
};
|
||||
|
||||
export const FormbricksSurveySelector = ({
|
||||
surveys,
|
||||
selectedSurveyId,
|
||||
selectedElementIds,
|
||||
onSurveySelect,
|
||||
onElementToggle,
|
||||
onSelectAllElements,
|
||||
onDeselectAllElements,
|
||||
}: FormbricksSurveySelectorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
|
||||
const supportedElements = selectedSurvey?.elements.filter((e) => !isUnsupportedType(e.type)) ?? [];
|
||||
const allSupportedSelected =
|
||||
supportedElements.length > 0 && supportedElements.every((e) => selectedElementIds.includes(e.id));
|
||||
|
||||
const handleSurveyClick = (survey: TUnifySurvey) => {
|
||||
if (selectedSurveyId !== survey.id) {
|
||||
onSurveySelect(survey.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAllSupported = (surveyId: string) => {
|
||||
onSelectAllElements(surveyId);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: TUnifySurvey["status"]) => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return <Badge text={t("workspace.unify.status_active")} type="success" size="tiny" />;
|
||||
case "paused":
|
||||
return <Badge text={t("workspace.unify.status_paused")} type="warning" size="tiny" />;
|
||||
case "draft":
|
||||
return <Badge text={t("workspace.unify.status_draft")} type="gray" size="tiny" />;
|
||||
case "completed":
|
||||
return <Badge text={t("workspace.unify.status_completed")} type="gray" size="tiny" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getSupportedElementCount = (survey: TUnifySurvey) =>
|
||||
survey.elements.filter((e) => !isUnsupportedType(e.type)).length;
|
||||
|
||||
const getElementButtonClassName = (unsupported: boolean, isSelected: boolean): string => {
|
||||
if (unsupported) return "cursor-not-allowed border-slate-100 bg-slate-50 opacity-50";
|
||||
if (isSelected) return "border-green-300 bg-green-50";
|
||||
return "border-slate-200 bg-white hover:border-slate-300";
|
||||
};
|
||||
|
||||
const getCheckboxClassName = (unsupported: boolean, isSelected: boolean): string => {
|
||||
if (unsupported) return "border border-slate-200 bg-slate-100";
|
||||
if (isSelected) return "bg-green-500 text-white";
|
||||
return "border border-slate-300 bg-white";
|
||||
};
|
||||
|
||||
const renderElementPanel = () => {
|
||||
if (!selectedSurvey) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.select_a_survey_to_see_questions")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedSurvey.elements.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.survey_has_no_questions")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 overflow-y-auto pr-1">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
{selectedSurvey.elements.map((element) => {
|
||||
const isSelected = selectedElementIds.includes(element.id);
|
||||
const unsupported = isUnsupportedType(element.type);
|
||||
|
||||
const button = (
|
||||
<button
|
||||
key={element.id}
|
||||
type="button"
|
||||
disabled={unsupported}
|
||||
onClick={() => onElementToggle(element.id)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${getElementButtonClassName(unsupported, isSelected)}`}>
|
||||
<div
|
||||
className={`flex h-5 w-5 items-center justify-center rounded ${getCheckboxClassName(unsupported, isSelected)}`}>
|
||||
{isSelected && !unsupported && <CheckIcon className="h-3 w-3" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">{getElementIcon(element.type)}</div>
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm ${unsupported ? "text-slate-400" : "text-slate-900"}`}>
|
||||
{element.headline}
|
||||
</p>
|
||||
<span className={`text-xs ${unsupported ? "text-slate-300" : "text-slate-500"}`}>
|
||||
{getTSurveyElementTypeEnumName(element.type, t) ?? element.type}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (unsupported) {
|
||||
return (
|
||||
<Tooltip key={element.id}>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>{t("workspace.unify.question_type_not_supported")}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
})}
|
||||
</TooltipProvider>
|
||||
|
||||
{selectedElementIds.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
<Trans
|
||||
i18nKey={
|
||||
selectedElementIds.length === 1
|
||||
? "workspace.unify.question_selected"
|
||||
: "workspace.unify.questions_selected"
|
||||
}
|
||||
values={{ count: selectedElementIds.length }}
|
||||
components={{ strong: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid h-[50vh] grid-cols-2 gap-6">
|
||||
{/* Left: Survey List */}
|
||||
<div className="flex flex-col gap-3 overflow-hidden">
|
||||
<h4 className="shrink-0 text-sm font-medium text-slate-700">{t("workspace.unify.select_survey")}</h4>
|
||||
<div className="space-y-2 overflow-y-auto pr-1">
|
||||
{surveys.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_surveys_found")}</p>
|
||||
</div>
|
||||
) : (
|
||||
surveys.map((survey) => {
|
||||
const isSelected = selectedSurveyId === survey.id;
|
||||
|
||||
return (
|
||||
<div key={survey.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSurveyClick(survey)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border bg-white p-3 text-left transition-colors ${
|
||||
isSelected ? "border-brand-dark bg-slate-50" : "border-slate-200 hover:border-slate-300"
|
||||
}`}>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div>{getStatusBadge(survey.status)}</div>
|
||||
<span className="block truncate text-sm font-medium text-slate-900">{survey.name}</span>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("workspace.unify.n_supported_questions", {
|
||||
count: getSupportedElementCount(survey),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && <ChevronRightIcon className="h-5 w-5 shrink-0 text-brand-dark" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Element Selection */}
|
||||
<div className="flex flex-col gap-3 overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-slate-700">{t("workspace.unify.select_questions")}</h4>
|
||||
{selectedSurvey && supportedElements.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
allSupportedSelected ? onDeselectAllElements() : handleSelectAllSupported(selectedSurvey.id)
|
||||
}
|
||||
className="text-xs text-slate-500 hover:text-slate-700">
|
||||
{allSupportedSelected ? t("workspace.unify.deselect_all") : t("workspace.unify.select_all")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderElementPanel()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,42 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getConnectorsWithMappings } from "@/lib/connector/service";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { ConnectorsSection } from "./components/connectors-page-client";
|
||||
import { transformToUnifySurvey } from "./lib";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function UnifySourcesPage(props: { params: Promise<{ workspaceId: string }> }) {
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session } =
|
||||
await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
|
||||
if (!hasAccess) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const [connectors, surveys, directories] = await Promise.all([
|
||||
getConnectorsWithMappings(params.workspaceId),
|
||||
getSurveys(params.workspaceId),
|
||||
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
|
||||
]);
|
||||
|
||||
const unifySurveys = surveys.map(transformToUnifySurvey);
|
||||
|
||||
return (
|
||||
<ConnectorsSection
|
||||
workspaceId={params.workspaceId}
|
||||
initialConnectors={connectors}
|
||||
initialSurveys={unifySurveys}
|
||||
directories={directories}
|
||||
/>
|
||||
);
|
||||
redirect(`/workspaces/${params.workspaceId}/feedback-sources`);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { getConnectorOptions, parseCSVColumnsToFields, validateCsvFile } from ".
|
||||
const mockT = (key: string) => key;
|
||||
|
||||
describe("getConnectorOptions", () => {
|
||||
test("returns formbricks and csv options", () => {
|
||||
test("returns formbricks, csv, api ingestion, and mcp options", () => {
|
||||
const options = getConnectorOptions(mockT as never);
|
||||
expect(options).toHaveLength(2);
|
||||
expect(options[0].id).toBe("formbricks");
|
||||
expect(options).toHaveLength(4);
|
||||
expect(options[0].id).toBe("formbricks_survey");
|
||||
expect(options[1].id).toBe("csv");
|
||||
expect(options[2].id).toBe("api_ingestion");
|
||||
expect(options[3].id).toBe("feedback_record_mcp");
|
||||
});
|
||||
|
||||
test("both options are enabled by default", () => {
|
||||
@@ -23,6 +25,10 @@ describe("getConnectorOptions", () => {
|
||||
expect(options[0].description).toBe("workspace.unify.source_connect_formbricks_description");
|
||||
expect(options[1].name).toBe("workspace.unify.csv_import");
|
||||
expect(options[1].description).toBe("workspace.unify.source_connect_csv_description");
|
||||
expect(options[2].name).toBe("workspace.unify.api_ingestion");
|
||||
expect(options[2].description).toBe("workspace.unify.api_ingestion_settings_description");
|
||||
expect(options[3].name).toBe("workspace.unify.feedback_record_mcp");
|
||||
expect(options[3].description).toBe("workspace.unify.source_connect_feedback_record_mcp_description");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { THubFieldType } from "@formbricks/types/connector";
|
||||
import { TConnectorType, THubFieldType } from "@formbricks/types/connector";
|
||||
import { FEEDBACK_RECORD_FIELDS, MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
|
||||
|
||||
export type TConnectorOptionId = TConnectorType | "api_ingestion" | "feedback_record_mcp";
|
||||
|
||||
export interface TConnectorOption {
|
||||
id: string;
|
||||
id: TConnectorOptionId;
|
||||
name: string;
|
||||
description: string;
|
||||
disabled: boolean;
|
||||
@@ -12,7 +14,7 @@ export interface TConnectorOption {
|
||||
|
||||
export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [
|
||||
{
|
||||
id: "formbricks",
|
||||
id: "formbricks_survey",
|
||||
name: t("workspace.unify.formbricks_surveys"),
|
||||
description: t("workspace.unify.source_connect_formbricks_description"),
|
||||
disabled: false,
|
||||
@@ -23,6 +25,18 @@ export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [
|
||||
description: t("workspace.unify.source_connect_csv_description"),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "api_ingestion",
|
||||
name: t("workspace.unify.api_ingestion"),
|
||||
description: t("workspace.unify.api_ingestion_settings_description"),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "feedback_record_mcp",
|
||||
name: t("workspace.unify.feedback_record_mcp"),
|
||||
description: t("workspace.unify.source_connect_feedback_record_mcp_description"),
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const parseCSVColumnsToFields = (columns: string): TSourceField[] => {
|
||||
|
||||
@@ -108,7 +108,7 @@ const resolveFormbricksMappingsInput = async (
|
||||
const allMappings = await Promise.all(
|
||||
entries.map(({ surveyId, elementIds }) => resolveSurveyMappings(surveyId, elementIds))
|
||||
);
|
||||
return { type: "formbricks", mappings: allMappings.flat() };
|
||||
return { type: "formbricks_survey", mappings: allMappings.flat() };
|
||||
};
|
||||
|
||||
const ZFormbricksSurveyMapping = z.object({
|
||||
@@ -124,7 +124,7 @@ const ZCreateConnectorWithMappingsAction = z
|
||||
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.connectorInput.type === "formbricks") {
|
||||
if (data.connectorInput.type === "formbricks_survey") {
|
||||
if (!data.formbricksMappings?.length) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
@@ -298,9 +298,9 @@ export const duplicateConnectorAction = authenticatedActionClient
|
||||
|
||||
let mappingsInput: TMappingsInput | undefined;
|
||||
|
||||
if (source.type === "formbricks" && source.formbricksMappings.length > 0) {
|
||||
if (source.type === "formbricks_survey" && source.formbricksMappings.length > 0) {
|
||||
mappingsInput = {
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
mappings: source.formbricksMappings.map((m) => ({
|
||||
surveyId: m.surveyId,
|
||||
elementId: m.elementId,
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("importCsvData", () => {
|
||||
});
|
||||
|
||||
test("throws InvalidInputError for non-csv connector", async () => {
|
||||
const connector = makeConnector({ type: "formbricks" });
|
||||
const connector = makeConnector({ type: "formbricks_survey" });
|
||||
await expect(importCsvData(connector, [])).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const mockConnector: TConnectorWithMappings = {
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
name: "Test Connector",
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
status: "active",
|
||||
workspaceId: ENV_ID,
|
||||
lastSyncAt: null,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const importHistoricalResponses = async (
|
||||
connector: TConnectorWithMappings,
|
||||
survey: TSurvey
|
||||
): Promise<TImportResult> => {
|
||||
if (connector.type !== "formbricks") {
|
||||
if (connector.type !== "formbricks_survey") {
|
||||
throw new InvalidInputError("Historical import is only supported for Formbricks connectors");
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ function createConnector(
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Connector",
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
status: "active",
|
||||
workspaceId: "env-1",
|
||||
feedbackRecordDirectoryId: "frd-1",
|
||||
@@ -79,7 +79,7 @@ const oneFeedbackRecord = [
|
||||
{
|
||||
field_id: "el-1",
|
||||
field_type: "rating" as const,
|
||||
source_type: "formbricks",
|
||||
source_type: "formbricks_survey",
|
||||
source_id: "survey-1",
|
||||
source_name: "Test Survey",
|
||||
field_label: "Question?",
|
||||
|
||||
@@ -47,7 +47,7 @@ const mockConnector = {
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
name: "Test Connector",
|
||||
type: "formbricks" as const,
|
||||
type: "formbricks_survey" as const,
|
||||
status: "active" as const,
|
||||
workspaceId: ENV_ID,
|
||||
lastSyncAt: null,
|
||||
@@ -144,7 +144,7 @@ describe("getConnectorsBySurveyId", () => {
|
||||
expect(prisma.connector.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
status: "active",
|
||||
formbricksMappings: { some: { surveyId: SURVEY_ID } },
|
||||
},
|
||||
@@ -303,13 +303,18 @@ describe("createConnectorWithMappings", () => {
|
||||
|
||||
const result = await createConnectorWithMappings(ENV_ID, {
|
||||
name: "New",
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
});
|
||||
|
||||
expect(tx.connector.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { name: "New", type: "formbricks", workspaceId: ENV_ID, feedbackRecordDirectoryId: FRD_ID },
|
||||
data: {
|
||||
name: "New",
|
||||
type: "formbricks_survey",
|
||||
workspaceId: ENV_ID,
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(tx.connectorFormbricksMapping.create).not.toHaveBeenCalled();
|
||||
@@ -325,9 +330,9 @@ describe("createConnectorWithMappings", () => {
|
||||
|
||||
await createConnectorWithMappings(
|
||||
ENV_ID,
|
||||
{ name: "FB", type: "formbricks", feedbackRecordDirectoryId: FRD_ID },
|
||||
{ name: "FB", type: "formbricks_survey", feedbackRecordDirectoryId: FRD_ID },
|
||||
{
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
mappings: [
|
||||
{ surveyId: SURVEY_ID, elementId: "el-1", hubFieldType: "text" },
|
||||
{ surveyId: SURVEY_ID, elementId: "el-2", hubFieldType: "nps" },
|
||||
@@ -392,7 +397,7 @@ describe("createConnectorWithMappings", () => {
|
||||
await expect(
|
||||
createConnectorWithMappings(ENV_ID, {
|
||||
name: "Dup",
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
feedbackRecordDirectoryId: FRD_ID,
|
||||
})
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
@@ -470,7 +475,7 @@ describe("updateConnectorWithMappings", () => {
|
||||
ENV_ID,
|
||||
{ name: "Updated" },
|
||||
{
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
mappings: [{ surveyId: SURVEY_ID, elementId: "el-new", hubFieldType: "nps" }],
|
||||
}
|
||||
);
|
||||
|
||||
@@ -132,7 +132,7 @@ export const getConnectorsBySurveyId = reactCache(
|
||||
try {
|
||||
const connectors = await prisma.connector.findMany({
|
||||
where: {
|
||||
type: "formbricks",
|
||||
type: "formbricks_survey",
|
||||
status: "active",
|
||||
formbricksMappings: {
|
||||
some: {
|
||||
@@ -213,7 +213,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
|
||||
// -- Composite functions --
|
||||
|
||||
export type TFormbricksMappingsInput = {
|
||||
type: "formbricks";
|
||||
type: "formbricks_survey";
|
||||
mappings: TConnectorFormbricksMappingCreateInput[];
|
||||
};
|
||||
|
||||
@@ -243,7 +243,7 @@ export const createConnectorWithMappings = async (
|
||||
},
|
||||
});
|
||||
|
||||
if (mappingsInput?.type === "formbricks") {
|
||||
if (mappingsInput?.type === "formbricks_survey") {
|
||||
await Promise.all(
|
||||
mappingsInput.mappings.map((mapping) =>
|
||||
tx.connectorFormbricksMapping.create({
|
||||
@@ -311,7 +311,7 @@ export const updateConnectorWithMappings = async (
|
||||
},
|
||||
});
|
||||
|
||||
if (mappingsInput?.type === "formbricks") {
|
||||
if (mappingsInput?.type === "formbricks_survey") {
|
||||
await tx.connectorFormbricksMapping.deleteMany({
|
||||
where: { connectorId, workspaceId },
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("transformResponseToFeedbackRecords", () => {
|
||||
const result = transformResponseToFeedbackRecords(mockResponse, mockSurvey, mappings);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
source_type: "formbricks",
|
||||
source_type: "formbricks_survey",
|
||||
field_id: "el-text",
|
||||
field_type: "text",
|
||||
field_label: "How can we improve?",
|
||||
|
||||
@@ -117,7 +117,7 @@ export function transformResponseToFeedbackRecords(
|
||||
|
||||
const feedbackRecord = {
|
||||
collected_at: getCollectedAt(response),
|
||||
source_type: "formbricks",
|
||||
source_type: "formbricks_survey",
|
||||
submission_id: response.id,
|
||||
tenant_id: tenantId,
|
||||
field_id: mapping.elementId,
|
||||
|
||||
@@ -31,6 +31,7 @@ interface AddToDashboardDialogProps {
|
||||
onDashboardSelect: (id: string) => void;
|
||||
onConfirm: () => void;
|
||||
isSaving: boolean;
|
||||
showChartNameField?: boolean;
|
||||
}
|
||||
|
||||
export function AddToDashboardDialog({
|
||||
@@ -43,6 +44,7 @@ export function AddToDashboardDialog({
|
||||
onDashboardSelect,
|
||||
onConfirm,
|
||||
isSaving,
|
||||
showChartNameField = true,
|
||||
}: Readonly<AddToDashboardDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -57,17 +59,19 @@ export function AddToDashboardDialog({
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
|
||||
<Input
|
||||
id="chart-name"
|
||||
className="mt-2"
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
value={chartName}
|
||||
onChange={(e) => onChartNameChange(e.target.value)}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
{showChartNameField && (
|
||||
<div>
|
||||
<Label htmlFor="chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
|
||||
<Input
|
||||
id="chart-name"
|
||||
className="mt-2"
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
value={chartName}
|
||||
onChange={(e) => onChartNameChange(e.target.value)}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label htmlFor="dashboard-select">{t("workspace.analysis.charts.dashboard")}</Label>
|
||||
<Select value={selectedDashboardId} onValueChange={onDashboardSelect}>
|
||||
@@ -103,7 +107,10 @@ export function AddToDashboardDialog({
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSaving}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} loading={isSaving} disabled={!selectedDashboardId || !chartName.trim()}>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
loading={isSaving}
|
||||
disabled={!selectedDashboardId || (showChartNameField && !chartName.trim())}>
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -31,6 +31,7 @@ interface AdvancedChartBuilderProps {
|
||||
hidePreview?: boolean;
|
||||
onChartGenerated?: (data: AnalyticsResponse) => void;
|
||||
feedbackRecordDirectoryId: string | null;
|
||||
runQueryCtaLabel?: string;
|
||||
}
|
||||
|
||||
const ACTION = {
|
||||
@@ -84,6 +85,7 @@ export function AdvancedChartBuilder({
|
||||
hidePreview = false,
|
||||
onChartGenerated,
|
||||
feedbackRecordDirectoryId,
|
||||
runQueryCtaLabel,
|
||||
}: Readonly<AdvancedChartBuilderProps>) {
|
||||
const { t } = useTranslation();
|
||||
const parsedInitial = initialQuery ? parseQueryToState(initialQuery) : null;
|
||||
@@ -151,11 +153,7 @@ export function AdvancedChartBuilder({
|
||||
return (
|
||||
<div className={hidePreview ? "space-y-2" : "grid gap-4 lg:grid-cols-2"}>
|
||||
<div className="mx-1 space-y-2">
|
||||
{!hidePreview && (
|
||||
<>
|
||||
<ChartTypeSelector selectedChartType={chartType} onChartTypeSelect={() => {}} />
|
||||
</>
|
||||
)}
|
||||
{!hidePreview && <ChartTypeSelector selectedChartType={chartType} onChartTypeSelect={() => {}} />}
|
||||
|
||||
<div className="mt-4 flex w-full flex-col gap-3 overflow-hidden rounded-lg border bg-slate-50 p-4">
|
||||
<MeasuresPanel
|
||||
@@ -249,7 +247,11 @@ export function AdvancedChartBuilder({
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleRunQuery} disabled={isLoading || !hasConfigChanged}>
|
||||
{isLoading ? <LoadingSpinner /> : t("workspace.analysis.charts.create_chart")}
|
||||
{isLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
(runQueryCtaLabel ?? t("workspace.analysis.charts.create_chart"))
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,25 +7,31 @@ import { DialogFooter } from "@/modules/ui/components/dialog";
|
||||
|
||||
interface ChartDialogFooterProps {
|
||||
onSaveClick: () => void;
|
||||
onAddToDashboardClick: () => void;
|
||||
onAddToDashboardClick?: () => void;
|
||||
isSaving: boolean;
|
||||
saveLabel?: string;
|
||||
showAddToDashboard?: boolean;
|
||||
}
|
||||
|
||||
export function ChartDialogFooter({
|
||||
onSaveClick,
|
||||
onAddToDashboardClick,
|
||||
isSaving,
|
||||
saveLabel,
|
||||
showAddToDashboard = true,
|
||||
}: Readonly<ChartDialogFooterProps>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
{showAddToDashboard && onAddToDashboardClick && (
|
||||
<Button variant="outline" onClick={onAddToDashboardClick} disabled={isSaving}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSaveClick} disabled={isSaving}>
|
||||
<SaveIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.charts.save_chart")}
|
||||
{saveLabel ?? t("workspace.analysis.charts.save_chart")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { CopyIcon, MoreVertical, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { CopyIcon, MoreVertical, PlusIcon, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteChartAction, duplicateChartAction } from "@/modules/ee/analysis/charts/actions";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { addChartToDashboardAction, getDashboardsAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
@@ -31,6 +33,36 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
|
||||
const [isAddingToDashboard, setIsAddingToDashboard] = useState(false);
|
||||
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [selectedDashboardId, setSelectedDashboardId] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!isAddToDashboardDialogOpen) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
void getDashboardsAction({ workspaceId }).then((result) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.data) {
|
||||
setDashboards(result.data.map((dashboard) => ({ id: dashboard.id, name: dashboard.name })));
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAddToDashboardDialogOpen, workspaceId]);
|
||||
|
||||
const handleDeleteChart = async () => {
|
||||
setIsDeleting(true);
|
||||
@@ -70,6 +102,37 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddChartToDashboard = async () => {
|
||||
if (!selectedDashboardId) {
|
||||
toast.error(t("workspace.analysis.charts.please_select_dashboard"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAddingToDashboard(true);
|
||||
|
||||
try {
|
||||
const result = await addChartToDashboardAction({
|
||||
workspaceId,
|
||||
chartId: chart.id,
|
||||
dashboardId: selectedDashboardId,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(
|
||||
getFormattedErrorMessage(result) || t("workspace.analysis.charts.failed_to_add_chart_to_dashboard")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.analysis.charts.chart_added_to_dashboard"));
|
||||
setIsAddToDashboardDialogOpen(false);
|
||||
setSelectedDashboardId(undefined);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setIsAddingToDashboard(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={`chart-${chart.id}-actions`} data-testid="chart-dropdown-menu">
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
@@ -102,6 +165,15 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
|
||||
{t("common.duplicate")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
icon={<PlusIcon className="size-4" />}
|
||||
onClick={() => {
|
||||
setIsDropDownOpen(false);
|
||||
setIsAddToDashboardDialogOpen(true);
|
||||
}}>
|
||||
{t("workspace.analysis.charts.add_to_dashboard")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
icon={<TrashIcon className="size-4" />}
|
||||
onClick={() => {
|
||||
@@ -123,6 +195,23 @@ export function ChartDropdownMenu({ workspaceId, chart, onEdit }: Readonly<Chart
|
||||
text={t("workspace.analysis.charts.delete_chart_confirmation")}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
<AddToDashboardDialog
|
||||
isOpen={isAddToDashboardDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsAddToDashboardDialogOpen(open);
|
||||
if (!open) {
|
||||
setSelectedDashboardId(undefined);
|
||||
}
|
||||
}}
|
||||
chartName={chart.name}
|
||||
onChartNameChange={() => {}}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={setSelectedDashboardId}
|
||||
onConfirm={handleAddChartToDashboard}
|
||||
isSaving={isAddingToDashboard}
|
||||
showChartNameField={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ChartsList } from "@/modules/ee/analysis/charts/components/charts-list"
|
||||
import { CreateChartButton } from "@/modules/ee/analysis/charts/components/create-chart-button";
|
||||
import { getChartsWithCreator } from "@/modules/ee/analysis/charts/lib/charts";
|
||||
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
|
||||
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
|
||||
import { hasFeedbackRecordsInDirectories } from "@/modules/ee/analysis/lib/feedback-records";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
@@ -35,22 +37,35 @@ interface ChartsListPageProps {
|
||||
export async function ChartsListPage({ workspaceId }: Readonly<ChartsListPageProps>) {
|
||||
const t = await getTranslate();
|
||||
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
|
||||
const chartsPromise = getChartsWithCreator(workspaceId);
|
||||
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
|
||||
const hasFeedbackRecords = await hasFeedbackRecordsInDirectories(
|
||||
directories.map((directory) => directory.id)
|
||||
);
|
||||
const chartsPromise = hasFeedbackRecords ? getChartsWithCreator(workspaceId) : null;
|
||||
|
||||
return (
|
||||
<AnalysisPageLayout
|
||||
pageTitle={t("common.dashboards")}
|
||||
workspaceId={workspaceId}
|
||||
cta={
|
||||
isReadOnly ? undefined : <CreateChartButton workspaceId={workspaceId} directories={directories} />
|
||||
isReadOnly ? undefined : (
|
||||
<CreateChartButton
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
buttonProps={{ disabled: !hasFeedbackRecords }}
|
||||
/>
|
||||
)
|
||||
}>
|
||||
<ChartsListContent
|
||||
chartsPromise={chartsPromise}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
directories={directories}
|
||||
/>
|
||||
{hasFeedbackRecords && chartsPromise ? (
|
||||
<ChartsListContent
|
||||
chartsPromise={chartsPromise}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
directories={directories}
|
||||
/>
|
||||
) : (
|
||||
<NoFeedbackRecordsState workspaceId={workspaceId} />
|
||||
)}
|
||||
</AnalysisPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Button, type ButtonProps } from "@/modules/ui/components/button";
|
||||
interface CreateChartButtonProps {
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
autoAddToDashboardId?: string;
|
||||
label?: string;
|
||||
onSuccess?: () => void;
|
||||
showIcon?: boolean;
|
||||
@@ -18,6 +19,7 @@ interface CreateChartButtonProps {
|
||||
export function CreateChartButton({
|
||||
workspaceId,
|
||||
directories,
|
||||
autoAddToDashboardId,
|
||||
label,
|
||||
onSuccess,
|
||||
showIcon = true,
|
||||
@@ -36,6 +38,7 @@ export function CreateChartButton({
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
workspaceId={workspaceId}
|
||||
autoAddToDashboardId={autoAddToDashboardId}
|
||||
directories={directories}
|
||||
onSuccess={onSuccess}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CreateChartView } from "@/modules/ee/analysis/charts/components/create-chart-view";
|
||||
import { EditChartView } from "@/modules/ee/analysis/charts/components/edit-chart-view";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
|
||||
export interface CreateChartDialogProps {
|
||||
@@ -9,6 +8,7 @@ export interface CreateChartDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
chartId?: string;
|
||||
autoAddToDashboardId?: string;
|
||||
initialChart?: TChartWithCreator;
|
||||
onSuccess?: () => void;
|
||||
directories: { id: string; name: string }[];
|
||||
@@ -19,29 +19,19 @@ export function CreateChartDialog({
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
autoAddToDashboardId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
directories,
|
||||
}: Readonly<CreateChartDialogProps>) {
|
||||
if (chartId) {
|
||||
return (
|
||||
<EditChartView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
workspaceId={workspaceId}
|
||||
chartId={chartId}
|
||||
initialChart={initialChart}
|
||||
onSuccess={onSuccess}
|
||||
directories={directories}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateChartView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
workspaceId={workspaceId}
|
||||
chartId={chartId}
|
||||
initialChart={initialChart}
|
||||
autoAddToDashboardId={autoAddToDashboardId}
|
||||
onSuccess={onSuccess}
|
||||
directories={directories}
|
||||
/>
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
|
||||
import { AIQuerySection } from "@/modules/ee/analysis/charts/components/ai-query-section";
|
||||
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
|
||||
import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/chart-dialog-loading-view";
|
||||
import { ChartPreview } from "@/modules/ee/analysis/charts/components/chart-preview";
|
||||
import { ManualChartBuilder } from "@/modules/ee/analysis/charts/components/manual-chart-builder";
|
||||
import { SaveChartDialog } from "@/modules/ee/analysis/charts/components/save-chart-dialog";
|
||||
import { useChartDialog } from "@/modules/ee/analysis/charts/hooks/use-chart-dialog";
|
||||
import { FrdPicker } from "@/modules/ee/feedback-record-directory/components/frd-picker";
|
||||
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";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
@@ -19,11 +21,16 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface CreateChartViewProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
chartId?: string;
|
||||
initialChart?: TChartWithCreator;
|
||||
autoAddToDashboardId?: string;
|
||||
onSuccess?: () => void;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
@@ -32,32 +39,39 @@ export function CreateChartView({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
initialChart,
|
||||
autoAddToDashboardId,
|
||||
onSuccess,
|
||||
directories,
|
||||
}: Readonly<CreateChartViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
const isEditing = !!chartId;
|
||||
|
||||
const {
|
||||
chartData,
|
||||
initialQuery,
|
||||
isLoadingChart,
|
||||
chartLoadError,
|
||||
chartName,
|
||||
setChartName,
|
||||
selectedChartType,
|
||||
handleChartTypeChange,
|
||||
handleChartGenerated,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
setSelectedDashboardId,
|
||||
handleAddToDashboard,
|
||||
handleSaveChart,
|
||||
isSaving,
|
||||
isSaveDialogOpen,
|
||||
setIsSaveDialogOpen,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
selectedDirectoryId,
|
||||
setSelectedDirectoryId,
|
||||
handleClose,
|
||||
} = useChartDialog({ open, onOpenChange, workspaceId, onSuccess, directories });
|
||||
} = useChartDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
initialChart,
|
||||
autoAddToDashboardId,
|
||||
onSuccess,
|
||||
directories,
|
||||
});
|
||||
|
||||
const chartPreviewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -67,96 +81,139 @@ export function CreateChartView({
|
||||
}
|
||||
}, [chartData]);
|
||||
|
||||
if (isLoadingChart && isEditing && !initialChart) {
|
||||
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
|
||||
}
|
||||
|
||||
if (isEditing && !isLoadingChart && !chartData && !initialChart && chartLoadError) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent width="wide">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("common.error")}</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<p className="text-sm text-red-600">{chartLoadError}</p>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const chartType = selectedChartType ?? (isEditing ? DEFAULT_CHART_TYPE : undefined);
|
||||
const hasSelectedDirectory = !!selectedDirectoryId;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide" disableCloseOnOutsideClick>
|
||||
<DialogContent
|
||||
className="max-h-[90vh] overflow-y-auto"
|
||||
width="wide"
|
||||
disableCloseOnOutsideClick={!isEditing}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.analysis.charts.create_chart")}</DialogTitle>
|
||||
<DialogDescription>{t("workspace.analysis.charts.create_chart_description")}</DialogDescription>
|
||||
<DialogTitle>
|
||||
{isEditing
|
||||
? t("workspace.analysis.charts.edit_chart_title")
|
||||
: t("workspace.analysis.charts.create_chart")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing
|
||||
? t("workspace.analysis.charts.edit_chart_description")
|
||||
: t("workspace.analysis.charts.create_chart_description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="grid gap-4">
|
||||
<FrdPicker
|
||||
directories={directories}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
onChange={setSelectedDirectoryId}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
|
||||
{hasSelectedDirectory && (
|
||||
{hasSelectedDirectory ? (
|
||||
<>
|
||||
<AIQuerySection
|
||||
workspaceId={workspaceId}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
feedbackRecordDirectoryId={selectedDirectoryId}
|
||||
/>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-white px-2 text-sm text-gray-500">
|
||||
{t("workspace.analysis.charts.OR")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-chart-name">{t("workspace.analysis.charts.chart_name")}</Label>
|
||||
<Input
|
||||
id="create-chart-name"
|
||||
value={chartName}
|
||||
onChange={(event) => setChartName(event.target.value)}
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
maxLength={255}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ManualChartBuilder
|
||||
selectedChartType={selectedChartType}
|
||||
onChartTypeSelect={handleChartTypeChange}
|
||||
/>
|
||||
{!isEditing && (
|
||||
<>
|
||||
<AIQuerySection
|
||||
workspaceId={workspaceId}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
feedbackRecordDirectoryId={selectedDirectoryId}
|
||||
/>
|
||||
|
||||
{selectedChartType && (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-200" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-white px-2 text-sm text-gray-500">
|
||||
{t("workspace.analysis.charts.OR")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
|
||||
|
||||
{chartType && (
|
||||
<AdvancedChartBuilder
|
||||
workspaceId={workspaceId}
|
||||
chartType={selectedChartType}
|
||||
initialQuery={chartData?.query}
|
||||
chartType={chartType}
|
||||
initialQuery={chartData?.query ?? initialQuery}
|
||||
hidePreview={true}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
feedbackRecordDirectoryId={selectedDirectoryId}
|
||||
runQueryCtaLabel={
|
||||
chartData
|
||||
? t("workspace.analysis.charts.update_chart")
|
||||
: t("workspace.analysis.charts.preview_chart")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{chartData && (
|
||||
{(isEditing || chartData) && (
|
||||
<div ref={chartPreviewRef}>
|
||||
<ChartPreview chartData={chartData} />
|
||||
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Alert variant="error" size="small">
|
||||
<div>
|
||||
<p>{t("workspace.analysis.charts.no_data_source_available")}</p>
|
||||
<a
|
||||
className="mt-1 inline-block font-medium underline"
|
||||
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.analysis.charts.go_to_feedback_record_directories")}
|
||||
</a>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
{chartData && (
|
||||
<>
|
||||
<ChartDialogFooter
|
||||
onSaveClick={() => setIsSaveDialogOpen(true)}
|
||||
onAddToDashboardClick={() => setIsAddToDashboardDialogOpen(true)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<SaveChartDialog
|
||||
open={isSaveDialogOpen}
|
||||
onOpenChange={setIsSaveDialogOpen}
|
||||
chartName={chartName}
|
||||
onChartNameChange={setChartName}
|
||||
onSave={handleSaveChart}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<AddToDashboardDialog
|
||||
isOpen={isAddToDashboardDialogOpen}
|
||||
onOpenChange={setIsAddToDashboardDialogOpen}
|
||||
chartName={chartName}
|
||||
onChartNameChange={setChartName}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={setSelectedDashboardId}
|
||||
onConfirm={handleAddToDashboard}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</>
|
||||
<ChartDialogFooter
|
||||
onSaveClick={handleSaveChart}
|
||||
isSaving={isSaving}
|
||||
showAddToDashboard={false}
|
||||
saveLabel={
|
||||
autoAddToDashboardId
|
||||
? t("workspace.analysis.charts.save_and_add_to_dashboard")
|
||||
: t("workspace.analysis.charts.save_chart")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddToDashboardDialog } from "@/modules/ee/analysis/charts/components/add-to-dashboard-dialog";
|
||||
import { AdvancedChartBuilder } from "@/modules/ee/analysis/charts/components/advanced-chart-builder";
|
||||
import { ChartDialogFooter } from "@/modules/ee/analysis/charts/components/chart-dialog-footer";
|
||||
import { ChartDialogLoadingView } from "@/modules/ee/analysis/charts/components/chart-dialog-loading-view";
|
||||
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 { DEFAULT_CHART_TYPE } from "@/modules/ee/analysis/charts/lib/chart-types";
|
||||
import type { TChartWithCreator } from "@/modules/ee/analysis/types/analysis";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface EditChartViewProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
chartId: string;
|
||||
initialChart?: TChartWithCreator;
|
||||
onSuccess?: () => void;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function EditChartView({
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
directories,
|
||||
}: Readonly<EditChartViewProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
chartData,
|
||||
initialQuery,
|
||||
isLoadingChart,
|
||||
chartLoadError,
|
||||
chartName,
|
||||
setChartName,
|
||||
selectedChartType,
|
||||
handleChartTypeChange,
|
||||
handleChartGenerated,
|
||||
dashboards,
|
||||
selectedDashboardId,
|
||||
setSelectedDashboardId,
|
||||
handleAddToDashboard,
|
||||
handleSaveChart,
|
||||
isSaving,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
selectedDirectoryId,
|
||||
handleClose,
|
||||
} = useChartDialog({ open, onOpenChange, workspaceId, chartId, initialChart, onSuccess, directories });
|
||||
|
||||
if (isLoadingChart && !initialChart) {
|
||||
return <ChartDialogLoadingView open={open} onClose={handleClose} />;
|
||||
}
|
||||
|
||||
if (!isLoadingChart && !chartData && !initialChart && chartLoadError) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent width="wide">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("common.error")}</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<p className="text-sm text-red-600">{chartLoadError}</p>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const chartType = selectedChartType ?? DEFAULT_CHART_TYPE;
|
||||
const directoryName = directories.find((d) => d.id === selectedDirectoryId)?.name;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && handleClose()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto" width="wide">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.analysis.charts.edit_chart_title")}</DialogTitle>
|
||||
<DialogDescription>{t("workspace.analysis.charts.edit_chart_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<div className="grid gap-4 px-1">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="edit-chart-name" className="text-sm">
|
||||
{t("workspace.analysis.charts.chart_name")}
|
||||
</label>
|
||||
<Input
|
||||
id="edit-chart-name"
|
||||
value={chartName}
|
||||
onChange={(e) => setChartName(e.target.value)}
|
||||
placeholder={t("workspace.analysis.charts.chart_name_placeholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
{directoryName && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("workspace.analysis.charts.data_source")}</Label>
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600">
|
||||
{directoryName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<ManualChartBuilder selectedChartType={chartType} onChartTypeSelect={handleChartTypeChange} />
|
||||
</div>
|
||||
<AdvancedChartBuilder
|
||||
workspaceId={workspaceId}
|
||||
chartType={chartType}
|
||||
initialQuery={chartData?.query ?? initialQuery}
|
||||
hidePreview={true}
|
||||
onChartGenerated={handleChartGenerated}
|
||||
feedbackRecordDirectoryId={selectedDirectoryId}
|
||||
/>
|
||||
<ChartPreview chartData={chartData} isLoading={isLoadingChart} error={chartLoadError} />
|
||||
</div>
|
||||
</DialogBody>
|
||||
<ChartDialogFooter
|
||||
onSaveClick={handleSaveChart}
|
||||
onAddToDashboardClick={() => setIsAddToDashboardDialogOpen(true)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<AddToDashboardDialog
|
||||
isOpen={isAddToDashboardDialogOpen}
|
||||
onOpenChange={setIsAddToDashboardDialogOpen}
|
||||
chartName={chartName}
|
||||
onChartNameChange={setChartName}
|
||||
dashboards={dashboards}
|
||||
selectedDashboardId={selectedDashboardId}
|
||||
onDashboardSelect={setSelectedDashboardId}
|
||||
onConfirm={handleAddToDashboard}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export interface UseChartDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
chartId?: string;
|
||||
autoAddToDashboardId?: string;
|
||||
/** Pre-loaded chart metadata; when provided for edit, skips getChartAction */
|
||||
initialChart?: TChartWithCreator;
|
||||
onSuccess?: () => void;
|
||||
@@ -37,6 +38,7 @@ export function useChartDialog({
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
chartId,
|
||||
autoAddToDashboardId,
|
||||
initialChart,
|
||||
onSuccess,
|
||||
directories,
|
||||
@@ -45,7 +47,6 @@ export function useChartDialog({
|
||||
const router = useRouter();
|
||||
const [selectedChartType, setSelectedChartType] = useState<TChartType | undefined>();
|
||||
const [chartData, setChartData] = useState<AnalyticsResponse | null>(null);
|
||||
const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false);
|
||||
const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false);
|
||||
const [chartName, setChartName] = useState("");
|
||||
const [dashboards, setDashboards] = useState<Array<{ id: string; name: string }>>([]);
|
||||
@@ -54,9 +55,7 @@ export function useChartDialog({
|
||||
const [isLoadingChart, setIsLoadingChart] = useState(false);
|
||||
const [chartLoadError, setChartLoadError] = useState<string | null>(null);
|
||||
const [currentChartId, setCurrentChartId] = useState<string | undefined>(chartId);
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(
|
||||
directories?.length === 1 ? directories[0].id : null
|
||||
);
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories?.[0]?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -85,7 +84,7 @@ export function useChartDialog({
|
||||
setChartName("");
|
||||
setSelectedChartType(undefined);
|
||||
setCurrentChartId(undefined);
|
||||
setSelectedDirectoryId(directories?.length === 1 ? directories[0].id : null);
|
||||
setSelectedDirectoryId(directories?.[0]?.id ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,11 +158,6 @@ export function useChartDialog({
|
||||
|
||||
const handleChartGenerated = (data: AnalyticsResponse) => {
|
||||
setChartData(data);
|
||||
if (!currentChartId) {
|
||||
setChartName(
|
||||
data.chartType ? `${t("workspace.analysis.charts.chart")} ${new Date().toLocaleString()}` : ""
|
||||
);
|
||||
}
|
||||
setSelectedChartType(data.chartType);
|
||||
};
|
||||
|
||||
@@ -180,6 +174,8 @@ export function useChartDialog({
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let savedChartId = currentChartId;
|
||||
|
||||
if (currentChartId) {
|
||||
const result = await updateChartAction({
|
||||
workspaceId,
|
||||
@@ -218,11 +214,32 @@ export function useChartDialog({
|
||||
}
|
||||
|
||||
setCurrentChartId(result.data.id);
|
||||
savedChartId = result.data.id;
|
||||
toast.success(t("workspace.analysis.charts.chart_saved_successfully"));
|
||||
}
|
||||
|
||||
setIsSaveDialogOpen(false);
|
||||
if (autoAddToDashboardId && savedChartId) {
|
||||
const addResult = await addChartToDashboardAction({
|
||||
workspaceId,
|
||||
chartId: savedChartId,
|
||||
dashboardId: autoAddToDashboardId,
|
||||
});
|
||||
|
||||
if (!addResult?.data) {
|
||||
toast.error(
|
||||
getFormattedErrorMessage(addResult) ||
|
||||
t("workspace.analysis.charts.failed_to_add_chart_to_dashboard")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.analysis.charts.chart_added_to_dashboard"));
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
if (autoAddToDashboardId) {
|
||||
router.push(`/workspaces/${workspaceId}/dashboards/${autoAddToDashboardId}`);
|
||||
}
|
||||
router.refresh();
|
||||
onSuccess?.();
|
||||
} catch (error: unknown) {
|
||||
@@ -328,7 +345,7 @@ export function useChartDialog({
|
||||
setSelectedChartType(undefined);
|
||||
setCurrentChartId(undefined);
|
||||
setChartLoadError(null);
|
||||
setSelectedDirectoryId(directories?.length === 1 ? directories[0].id : null);
|
||||
setSelectedDirectoryId(directories?.[0]?.id ?? null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
@@ -349,8 +366,6 @@ export function useChartDialog({
|
||||
setSelectedChartType,
|
||||
currentChartId,
|
||||
setCurrentChartId,
|
||||
isSaveDialogOpen,
|
||||
setIsSaveDialogOpen,
|
||||
isAddToDashboardDialogOpen,
|
||||
setIsAddToDashboardDialogOpen,
|
||||
dashboards,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MessageSquareDashedIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface NoFeedbackRecordsStateProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export const NoFeedbackRecordsState = async ({ workspaceId }: Readonly<NoFeedbackRecordsStateProps>) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<div className="mx-auto flex max-w-xl flex-col items-center gap-4 text-center">
|
||||
<MessageSquareDashedIcon className="h-8 w-8 text-slate-400" />
|
||||
<p className="text-balance text-sm text-slate-600">
|
||||
{t("workspace.analysis.no_feedback_records_message")}
|
||||
</p>
|
||||
<Button asChild size="sm">
|
||||
<Link href={`/workspaces/${workspaceId}/feedback-sources`}>
|
||||
{t("workspace.analysis.setup_feedback_source")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -292,6 +292,9 @@ export const addChartToDashboardAction = authenticatedActionClient
|
||||
layout: parsedInput.layout,
|
||||
});
|
||||
|
||||
revalidatePath(`/workspaces/${workspaceId}/dashboards`);
|
||||
revalidatePath(`/workspaces/${workspaceId}/dashboards/${parsedInput.dashboardId}`);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspaceId;
|
||||
ctx.auditLoggingCtx.dashboardWidgetId = widget.id;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -8,7 +9,6 @@ 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 { addChartToDashboardAction } from "@/modules/ee/analysis/dashboards/actions";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -47,6 +47,7 @@ export function AddExistingChartsDialog({
|
||||
onSuccess,
|
||||
}: Readonly<AddExistingChartsDialogProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [chartOptions, setChartOptions] = useState<ChartOption[]>([]);
|
||||
const [selectedChartIds, setSelectedChartIds] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -130,35 +131,30 @@ export function AddExistingChartsDialog({
|
||||
<Loader2Icon className="h-5 w-5 animate-spin text-slate-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{chartOptions.length === 0 && (
|
||||
<Alert variant="info" className="mb-4">
|
||||
<AlertTitle>{t("workspace.analysis.dashboards.no_charts_to_add_message")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("workspace.analysis.dashboards.no_charts_available_description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("common.add_chart")}</Label>
|
||||
<MultiSelect
|
||||
options={chartOptions}
|
||||
value={selectedChartIds}
|
||||
onChange={setSelectedChartIds}
|
||||
placeholder={t("common.search_charts")}
|
||||
disabled={chartOptions.length === 0}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("common.add_chart")}</Label>
|
||||
<MultiSelect
|
||||
options={chartOptions}
|
||||
value={selectedChartIds}
|
||||
onChange={setSelectedChartIds}
|
||||
placeholder={t("common.search_charts")}
|
||||
disabled={chartOptions.length === 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<CreateChartButton
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
autoAddToDashboardId={dashboardId}
|
||||
label={t("workspace.analysis.dashboards.create_new_chart")}
|
||||
onSuccess={loadCharts}
|
||||
buttonProps={{ variant: "outline", size: "default", disabled: isAdding }}
|
||||
onSuccess={() => {
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
onSuccess();
|
||||
}}
|
||||
buttonProps={{ variant: "secondary", size: "default", disabled: isAdding }}
|
||||
/>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isAdding}>
|
||||
|
||||
@@ -12,9 +12,13 @@ import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface CreateDashboardButtonProps {
|
||||
workspaceId: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const CreateDashboardButton = ({ workspaceId }: Readonly<CreateDashboardButtonProps>) => {
|
||||
export const CreateDashboardButton = ({
|
||||
workspaceId,
|
||||
disabled = false,
|
||||
}: Readonly<CreateDashboardButtonProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
@@ -59,7 +63,7 @@ export const CreateDashboardButton = ({ workspaceId }: Readonly<CreateDashboardB
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" onClick={() => handleOpenChange(true)}>
|
||||
<Button size="sm" onClick={() => handleOpenChange(true)} disabled={disabled}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.analysis.dashboards.create_dashboard")}
|
||||
</Button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next";
|
||||
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 { 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";
|
||||
@@ -115,17 +116,26 @@ const MemoizedWidgetItem = memo(function WidgetItem({
|
||||
widget,
|
||||
isEditing,
|
||||
dataPromise,
|
||||
onEdit,
|
||||
onResize,
|
||||
onRemove,
|
||||
}: Readonly<{
|
||||
widget: TDashboardWidget;
|
||||
isEditing: boolean;
|
||||
dataPromise?: Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: string }>;
|
||||
onEdit?: () => void;
|
||||
onResize?: () => void;
|
||||
onRemove?: () => void;
|
||||
}>) {
|
||||
const title = widget.chart.name;
|
||||
|
||||
return (
|
||||
<DashboardWidget title={title} isEditing={isEditing} onRemove={onRemove}>
|
||||
<DashboardWidget
|
||||
title={title}
|
||||
isEditing={isEditing}
|
||||
onEdit={onEdit}
|
||||
onResize={onResize}
|
||||
onRemove={onRemove}>
|
||||
<MemoizedWidgetContent widget={widget} dataPromise={dataPromise} />
|
||||
</DashboardWidget>
|
||||
);
|
||||
@@ -144,6 +154,7 @@ export function DashboardDetailClient({
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [editingChartId, setEditingChartId] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
const [name, setName] = useState(dashboard.name);
|
||||
@@ -173,6 +184,32 @@ export function DashboardDetailClient({
|
||||
[dashboard.widgets]
|
||||
);
|
||||
|
||||
const handleEnterEditMode = useCallback(() => {
|
||||
if (isEditing) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDraftWidgets((current) => current ?? dashboard.widgets);
|
||||
setIsEditing(true);
|
||||
}, [dashboard.widgets, isEditing]);
|
||||
|
||||
const handleEditChart = useCallback((chartId: string) => {
|
||||
setEditingChartId(chartId);
|
||||
}, []);
|
||||
|
||||
const handleRemoveWidgetFromMenu = useCallback(
|
||||
(widgetId: string) => {
|
||||
if (!isEditing) {
|
||||
setDraftWidgets((current) => (current ?? dashboard.widgets).filter((w) => w.id !== widgetId));
|
||||
setIsEditing(true);
|
||||
return;
|
||||
}
|
||||
|
||||
handleRemoveWidget(widgetId);
|
||||
},
|
||||
[dashboard.widgets, handleRemoveWidget, isEditing]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setName(dashboard.name);
|
||||
setDraftWidgets(null);
|
||||
@@ -299,7 +336,9 @@ export function DashboardDetailClient({
|
||||
widget={widget}
|
||||
isEditing={isEditing}
|
||||
dataPromise={widgetDataPromises.get(widget.id)}
|
||||
onRemove={isEditing ? () => handleRemoveWidget(widget.id) : undefined}
|
||||
onEdit={isReadOnly ? undefined : () => handleEditChart(widget.chartId)}
|
||||
onResize={isReadOnly ? undefined : handleEnterEditMode}
|
||||
onRemove={isReadOnly ? undefined : () => handleRemoveWidgetFromMenu(widget.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -308,6 +347,23 @@ export function DashboardDetailClient({
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
{!isReadOnly && (
|
||||
<CreateChartDialog
|
||||
open={editingChartId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditingChartId(null);
|
||||
}
|
||||
}}
|
||||
workspaceId={workspaceId}
|
||||
chartId={editingChartId ?? undefined}
|
||||
onSuccess={() => {
|
||||
setEditingChartId(null);
|
||||
router.refresh();
|
||||
}}
|
||||
directories={directories}
|
||||
/>
|
||||
)}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MoreVerticalIcon, TrashIcon } from "lucide-react";
|
||||
import { Maximize2Icon, MoreVerticalIcon, SquarePenIcon, TrashIcon } from "lucide-react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -15,18 +15,28 @@ interface DashboardWidgetProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
isEditing?: boolean;
|
||||
onEdit?: () => void;
|
||||
onResize?: () => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export function DashboardWidget({ title, children, isEditing, onRemove }: Readonly<DashboardWidgetProps>) {
|
||||
export function DashboardWidget({
|
||||
title,
|
||||
children,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onResize,
|
||||
onRemove,
|
||||
}: Readonly<DashboardWidgetProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const hasMenuActions = Boolean(onEdit || onResize || onRemove);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col rounded-lg border border-gray-200 bg-white shadow-sm ring-2 ring-transparent",
|
||||
isEditing && "ring-brand-dark/20 hover:ring-brand-dark/40 transition-shadow"
|
||||
isEditing && "ring-brand-dark/20 transition-shadow hover:ring-brand-dark/40"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -34,7 +44,7 @@ export function DashboardWidget({ title, children, isEditing, onRemove }: Readon
|
||||
isEditing && "rgl-drag-handle cursor-grab active:cursor-grabbing"
|
||||
)}>
|
||||
<h3 className="flex-1 truncate text-sm font-semibold text-gray-800">{title}</h3>
|
||||
{onRemove && (
|
||||
{hasMenuActions && (
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
@@ -47,15 +57,37 @@ export function DashboardWidget({ title, children, isEditing, onRemove }: Readon
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setMenuOpen(false);
|
||||
onRemove();
|
||||
}}
|
||||
className="text-red-600 focus:text-red-600">
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.remove")}
|
||||
</DropdownMenuItem>
|
||||
{onEdit && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setMenuOpen(false);
|
||||
onEdit();
|
||||
}}>
|
||||
<SquarePenIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onResize && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setMenuOpen(false);
|
||||
onResize();
|
||||
}}>
|
||||
<Maximize2Icon className="mr-2 h-4 w-4" />
|
||||
{t("common.resize")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onRemove && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setMenuOpen(false);
|
||||
onRemove();
|
||||
}}
|
||||
className="text-red-600 focus:text-red-600">
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.remove")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { use } from "react";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { AnalysisPageLayout } from "@/modules/ee/analysis/components/analysis-page-layout";
|
||||
import { NoFeedbackRecordsState } from "@/modules/ee/analysis/components/no-feedback-records-state";
|
||||
import { hasWorkspaceFeedbackRecords } from "@/modules/ee/analysis/lib/feedback-records";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { TDashboardWithCount } from "../../types/analysis";
|
||||
import { CreateDashboardButton } from "../components/create-dashboard-button";
|
||||
@@ -31,18 +33,27 @@ export const DashboardsListPage = async ({ workspaceId }: Readonly<DashboardsLis
|
||||
const t = await getTranslate();
|
||||
const { isReadOnly } = await getWorkspaceAuth(workspaceId);
|
||||
|
||||
const dashboardsPromise = getDashboards(workspaceId);
|
||||
const hasFeedbackRecords = await hasWorkspaceFeedbackRecords(workspaceId);
|
||||
const dashboardsPromise = hasFeedbackRecords ? getDashboards(workspaceId) : null;
|
||||
|
||||
return (
|
||||
<AnalysisPageLayout
|
||||
pageTitle={t("common.dashboards")}
|
||||
workspaceId={workspaceId}
|
||||
cta={isReadOnly ? undefined : <CreateDashboardButton workspaceId={workspaceId} />}>
|
||||
<DashboardsListContent
|
||||
dashboardsPromise={dashboardsPromise}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
cta={
|
||||
isReadOnly ? undefined : (
|
||||
<CreateDashboardButton workspaceId={workspaceId} disabled={!hasFeedbackRecords} />
|
||||
)
|
||||
}>
|
||||
{hasFeedbackRecords && dashboardsPromise ? (
|
||||
<DashboardsListContent
|
||||
dashboardsPromise={dashboardsPromise}
|
||||
workspaceId={workspaceId}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
) : (
|
||||
<NoFeedbackRecordsState workspaceId={workspaceId} />
|
||||
)}
|
||||
</AnalysisPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"server-only";
|
||||
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { listFeedbackRecords } from "@/modules/hub/service";
|
||||
|
||||
export const hasFeedbackRecordsInDirectories = async (directoryIds: string[]): Promise<boolean> => {
|
||||
if (directoryIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
directoryIds.map((directoryId) => listFeedbackRecords({ tenant_id: directoryId, limit: 1 }))
|
||||
);
|
||||
|
||||
const hasRecords = results.some((result) => (result.data?.data?.length ?? 0) > 0);
|
||||
if (hasRecords) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasErrors = results.some((result) => Boolean(result.error));
|
||||
|
||||
// Do not lock creation flows when record availability is unknown.
|
||||
return hasErrors;
|
||||
};
|
||||
|
||||
export const hasWorkspaceFeedbackRecords = async (workspaceId: string): Promise<boolean> => {
|
||||
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
|
||||
|
||||
return hasFeedbackRecordsInDirectories(directories.map((directory) => directory.id));
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -3,7 +3,10 @@ export {
|
||||
createFeedbackRecord,
|
||||
createFeedbackRecordsBatch,
|
||||
listFeedbackRecords,
|
||||
retrieveFeedbackRecord,
|
||||
updateFeedbackRecord,
|
||||
type CreateFeedbackRecordResult,
|
||||
type HubFeedbackRecordResult,
|
||||
type ListFeedbackRecordsResult,
|
||||
} from "./service";
|
||||
export type {
|
||||
@@ -11,4 +14,5 @@ export type {
|
||||
FeedbackRecordData,
|
||||
FeedbackRecordListParams,
|
||||
FeedbackRecordListResponse,
|
||||
FeedbackRecordUpdateParams,
|
||||
} from "./types";
|
||||
|
||||
@@ -15,7 +15,7 @@ const { getHubClient } = await import("./hub-client");
|
||||
const sampleInput: FeedbackRecordCreateParams = {
|
||||
field_id: "el-1",
|
||||
field_type: "rating",
|
||||
source_type: "formbricks",
|
||||
source_type: "formbricks_survey",
|
||||
source_id: "survey-1",
|
||||
source_name: "Test Survey",
|
||||
field_label: "Question?",
|
||||
|
||||
@@ -7,12 +7,16 @@ import type {
|
||||
FeedbackRecordData,
|
||||
FeedbackRecordListParams,
|
||||
FeedbackRecordListResponse,
|
||||
FeedbackRecordUpdateParams,
|
||||
} from "./types";
|
||||
|
||||
export type CreateFeedbackRecordResult = {
|
||||
type HubError = { status: number; message: string; detail: string };
|
||||
|
||||
export type HubFeedbackRecordResult = {
|
||||
data: FeedbackRecordData | null;
|
||||
error: { status: number; message: string; detail: string } | null;
|
||||
error: HubError | null;
|
||||
};
|
||||
export type CreateFeedbackRecordResult = HubFeedbackRecordResult;
|
||||
|
||||
const NO_CONFIG_ERROR = {
|
||||
status: 0,
|
||||
@@ -20,7 +24,7 @@ const NO_CONFIG_ERROR = {
|
||||
detail: "HUB_API_KEY is not set; Hub integration is disabled.",
|
||||
} as const;
|
||||
|
||||
const createResultFromError = (err: unknown): CreateFeedbackRecordResult => {
|
||||
const createResultFromError = (err: unknown): HubFeedbackRecordResult => {
|
||||
const status = err instanceof FormbricksHub.APIError ? err.status : 0;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { data: null, error: { status, message, detail: message } };
|
||||
@@ -32,7 +36,7 @@ const createResultFromError = (err: unknown): CreateFeedbackRecordResult => {
|
||||
*/
|
||||
export const createFeedbackRecord = async (
|
||||
input: FeedbackRecordCreateParams
|
||||
): Promise<CreateFeedbackRecordResult> => {
|
||||
): Promise<HubFeedbackRecordResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
@@ -46,9 +50,48 @@ export const createFeedbackRecord = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve a single feedback record from the Hub by id.
|
||||
*/
|
||||
export const retrieveFeedbackRecord = async (id: string): Promise<HubFeedbackRecordResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await client.feedbackRecords.retrieve(id);
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err, id }, "Hub: retrieveFeedbackRecord failed");
|
||||
return createResultFromError(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a single feedback record in the Hub by id.
|
||||
*/
|
||||
export const updateFeedbackRecord = async (
|
||||
id: string,
|
||||
input: FeedbackRecordUpdateParams
|
||||
): Promise<HubFeedbackRecordResult> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return { data: null, error: { ...NO_CONFIG_ERROR } };
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await client.feedbackRecords.update(id, input);
|
||||
return { data, error: null };
|
||||
} catch (err) {
|
||||
logger.warn({ err, id }, "Hub: updateFeedbackRecord failed");
|
||||
return createResultFromError(err);
|
||||
}
|
||||
};
|
||||
|
||||
export type ListFeedbackRecordsResult = {
|
||||
data: FeedbackRecordListResponse | null;
|
||||
error: { status: number; message: string; detail: string } | null;
|
||||
error: HubError | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -78,7 +121,7 @@ export const listFeedbackRecords = async (
|
||||
*/
|
||||
export const createFeedbackRecordsBatch = async (
|
||||
inputs: FeedbackRecordCreateParams[]
|
||||
): Promise<{ results: CreateFeedbackRecordResult[] }> => {
|
||||
): Promise<{ results: HubFeedbackRecordResult[] }> => {
|
||||
const client = getHubClient();
|
||||
if (!client) {
|
||||
return {
|
||||
@@ -90,7 +133,7 @@ export const createFeedbackRecordsBatch = async (
|
||||
inputs.map(async (input) => {
|
||||
try {
|
||||
const data = await client.feedbackRecords.create(input);
|
||||
return { data, error: null as CreateFeedbackRecordResult["error"] };
|
||||
return { data, error: null as HubFeedbackRecordResult["error"] };
|
||||
} catch (err) {
|
||||
logger.warn({ err, fieldId: input.field_id }, "Hub: createFeedbackRecord failed");
|
||||
return createResultFromError(err);
|
||||
|
||||
@@ -4,3 +4,4 @@ export type FeedbackRecordCreateParams = FormbricksHub.FeedbackRecordCreateParam
|
||||
export type FeedbackRecordData = FormbricksHub.FeedbackRecordData;
|
||||
export type FeedbackRecordListParams = FormbricksHub.FeedbackRecordListParams;
|
||||
export type FeedbackRecordListResponse = FormbricksHub.FeedbackRecordListResponse;
|
||||
export type FeedbackRecordUpdateParams = FormbricksHub.FeedbackRecordUpdateParams;
|
||||
|
||||
@@ -44,7 +44,7 @@ const alertVariants = cva("relative w-full rounded-lg border [&>svg]:size-4 bg-w
|
||||
default:
|
||||
"py-3 px-4 text-sm grid grid-cols-[2fr_auto] grid-rows-[auto_auto] gap-y-0.5 gap-x-3 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7",
|
||||
small:
|
||||
"px-4 py-2 text-xs flex items-center gap-2 [&>svg]:flex-shrink-0 [&_button]:bg-transparent [&_button:hover]:bg-transparent [&>svg~*]:pl-0",
|
||||
"px-4 py-2 text-xs flex items-center gap-2 [&>svg]:flex-shrink-0 [&_button]:bg-transparent [&_button:hover]:bg-transparent [&_a]:bg-transparent [&_a:hover]:bg-transparent [&>svg~*]:pl-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -94,8 +94,8 @@ const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<H
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"col-start-1 row-start-1 font-medium tracking-tight",
|
||||
size === "small" ? "flex-shrink truncate" : "col-start-1 row-start-1",
|
||||
"col-start-1 row-start-1 tracking-tight",
|
||||
size === "small" ? "flex-shrink truncate font-normal" : "col-start-1 row-start-1 font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
@@ -133,6 +133,7 @@ const AlertButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
// Determine button styling based on alert context
|
||||
const buttonVariant = variant ?? (alertSize === "small" ? "link" : "secondary");
|
||||
const buttonSize = size ?? (alertSize === "small" ? "sm" : "default");
|
||||
const isSmallLinkButton = alertSize === "small" && buttonVariant === "link";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -142,7 +143,16 @@ const AlertButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
? "-my-2 -mr-4 ml-auto flex-shrink-0"
|
||||
: "col-start-2 row-span-2 row-start-1 flex items-center justify-center"
|
||||
)}>
|
||||
<Button ref={ref} variant={buttonVariant} size={buttonSize} className={className} {...props}>
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={buttonVariant}
|
||||
size={buttonSize}
|
||||
className={cn(
|
||||
isSmallLinkButton &&
|
||||
"bg-transparent font-normal underline-offset-4 hover:bg-transparent hover:underline",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { ConnectorsSection } from "@/app/(app)/workspaces/[workspaceId]/unify/sources/components/connectors-page-client";
|
||||
import { transformToUnifySurvey } from "@/app/(app)/workspaces/[workspaceId]/unify/sources/lib";
|
||||
import { getConnectorsWithMappings } from "@/lib/connector/service";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
export const WorkspaceSourcesPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session } =
|
||||
await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
|
||||
if (!hasAccess) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const [connectors, surveys, directories] = await Promise.all([
|
||||
getConnectorsWithMappings(params.workspaceId),
|
||||
getSurveys(params.workspaceId),
|
||||
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
|
||||
]);
|
||||
|
||||
const unifySurveys = surveys.map(transformToUnifySurvey);
|
||||
|
||||
return (
|
||||
<ConnectorsSection
|
||||
workspaceId={params.workspaceId}
|
||||
initialConnectors={connectors}
|
||||
initialSurveys={unifySurveys}
|
||||
directories={directories}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."ConnectorType" RENAME VALUE 'formbricks' TO 'formbricks_survey';
|
||||
@@ -1144,7 +1144,7 @@ model DashboardWidget {
|
||||
}
|
||||
|
||||
enum ConnectorType {
|
||||
formbricks
|
||||
formbricks_survey
|
||||
csv
|
||||
}
|
||||
|
||||
@@ -1171,7 +1171,7 @@ enum HubFieldType {
|
||||
///
|
||||
/// @property id - Unique identifier for the connector
|
||||
/// @property name - Display name for the connector
|
||||
/// @property type - Type of connector (formbricks, webhook, csv, email, slack)
|
||||
/// @property type - Type of connector (formbricks_survey, webhook, csv, email, slack)
|
||||
/// @property status - Current state of the connector (active, paused)
|
||||
/// @property environment - The environment this connector belongs to
|
||||
/// @property config - Type-specific configuration (e.g., webhook secret, S3 config)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
import { TSurveyElementTypeEnum } from "./surveys/constants";
|
||||
|
||||
// Connector type enum
|
||||
export const ZConnectorType = z.enum(["formbricks", "csv"]);
|
||||
export const ZConnectorType = z.enum(["formbricks_survey", "csv"]);
|
||||
export type TConnectorType = z.infer<typeof ZConnectorType>;
|
||||
|
||||
// Connector status enum
|
||||
|
||||
Reference in New Issue
Block a user