Compare commits

..

3 Commits

Author SHA1 Message Date
Johannes 7c932a8583 feat: refresh analysis charts and dashboard feedback gating
Unify chart create and edit flows, update dashboard chart interactions, and add feedback-record availability checks with dedicated empty-state handling across analysis entry points.

Made-with: Cursor
2026-04-24 10:40:46 +02:00
Johannes 99d794ad53 feat: integrate hub feedback records into unify workspace
Add hub-backed feedback record actions and UI flows under Unify so workspaces can list and manage feedback records from a dedicated drawer and table experience.

Made-with: Cursor
2026-04-24 10:39:38 +02:00
Johannes b123965d93 feat: wire workspace settings to feedback record directories
Integrate feedback record directory selection into workspace settings and creation flows while updating workspace navigation components to expose the new workspace-level destinations.

Made-with: Cursor
2026-04-24 10:38:32 +02:00
47 changed files with 2831 additions and 591 deletions
@@ -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>
@@ -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;
}
);
@@ -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}
/>
</>
);
};
@@ -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>
);
}
@@ -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`);
}
@@ -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);
@@ -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>
);
};
@@ -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}
/>
)}
@@ -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>
@@ -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(),
+4
View File
@@ -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";
+1 -1
View File
@@ -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?",
+50 -7
View File
@@ -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);
+1
View File
@@ -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;
@@ -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: {