feat: add lazy response detail modal on OpenTextSummary row click

Co-authored-by: johannes <johannes@formbricks.com>
This commit is contained in:
Cursor Agent
2026-05-15 11:43:06 +00:00
parent 0a7482da0f
commit 208d83eb08
6 changed files with 183 additions and 9 deletions
@@ -3,6 +3,7 @@
import Link from "next/link";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveyElementSummaryOpenText } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
@@ -14,20 +15,28 @@ import { EmptyState } from "@/modules/ui/components/empty-state";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { ElementSummaryHeader } from "./ElementSummaryHeader";
import { ResponseSampleModal } from "./ResponseSampleModal";
interface OpenTextSummaryProps {
elementSummary: TSurveyElementSummaryOpenText;
environmentId: string;
environment: TEnvironment;
survey: TSurvey;
locale: TUserLocale;
isReadOnly: boolean;
}
export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale }: OpenTextSummaryProps) => {
export const OpenTextSummary = ({
elementSummary,
environment,
survey,
locale,
isReadOnly,
}: OpenTextSummaryProps) => {
const { t } = useTranslation();
const [visibleResponses, setVisibleResponses] = useState(10);
const [selectedResponseId, setSelectedResponseId] = useState<string | null>(null);
const handleLoadMore = () => {
// Increase the number of visible responses by 10, not exceeding the total number of responses
setVisibleResponses((prevVisibleResponses) =>
Math.min(prevVisibleResponses + 10, elementSummary.samples.length)
);
@@ -54,12 +63,16 @@ export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale
</TableHeader>
<TableBody>
{elementSummary.samples.slice(0, visibleResponses).map((response) => (
<TableRow key={response.id}>
<TableRow
key={response.id}
className="cursor-pointer hover:bg-slate-50"
onClick={() => setSelectedResponseId(response.id)}>
<TableCell className="w-1/4">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
href={`/environments/${environment.id}/contacts/${response.contact.id}`}
onClick={(e) => e.stopPropagation()}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
@@ -84,7 +97,7 @@ export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale
<TableCell className="w-1/6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</TableCell>
<TableCell className="w-1/6">
<TableCell className="w-1/6" onClick={(e) => e.stopPropagation()}>
<IdBadge id={response.id} />
</TableCell>
</TableRow>
@@ -100,6 +113,15 @@ export const OpenTextSummary = ({ elementSummary, environmentId, survey, locale
)}
</div>
)}
<ResponseSampleModal
responseId={selectedResponseId}
onClose={() => setSelectedResponseId(null)}
survey={survey}
environment={environment}
isReadOnly={isReadOnly}
locale={locale}
/>
</div>
);
};
@@ -0,0 +1,113 @@
"use client";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { useEffect, useRef, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUserLocale } from "@formbricks/types/user";
import {
getResponseAction,
getTagsByEnvironmentIdAction,
} from "@/modules/analysis/components/SingleResponseCard/actions";
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
interface ResponseSampleModalProps {
responseId: string | null;
onClose: () => void;
survey: TSurvey;
environment: TEnvironment;
isReadOnly: boolean;
locale: TUserLocale;
}
export const ResponseSampleModal = ({
responseId,
onClose,
survey,
environment,
isReadOnly,
locale,
}: ResponseSampleModalProps) => {
const [response, setResponse] = useState<TResponseWithQuotas | null>(null);
const [tags, setTags] = useState<TTag[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Cache fetched data per response ID to avoid re-fetching on re-open
const cache = useRef<Map<string, { response: TResponseWithQuotas; tags: TTag[] }>>(new Map());
useEffect(() => {
if (!responseId) return;
const cached = cache.current.get(responseId);
if (cached) {
setResponse(cached.response);
setTags(cached.tags);
return;
}
setIsLoading(true);
setResponse(null);
Promise.all([
getResponseAction({ responseId }),
getTagsByEnvironmentIdAction({ environmentId: environment.id }),
])
.then(([responseResult, tagsResult]) => {
const fetchedResponse = responseResult?.data ?? null;
const fetchedTags = tagsResult?.data ?? [];
if (fetchedResponse) {
const entry = { response: fetchedResponse as TResponseWithQuotas, tags: fetchedTags };
cache.current.set(responseId, entry);
setResponse(entry.response);
setTags(entry.tags);
}
})
.finally(() => {
setIsLoading(false);
});
}, [responseId, environment.id]);
const handleOpenChange = (open: boolean) => {
if (!open) onClose();
};
return (
<Dialog open={!!responseId} onOpenChange={handleOpenChange}>
<DialogContent width="wide">
<VisuallyHidden asChild>
<DialogTitle>Survey Response Details</DialogTitle>
</VisuallyHidden>
<VisuallyHidden asChild>
<DialogDescription>Full response details</DialogDescription>
</VisuallyHidden>
<DialogBody>
{isLoading || !response ? (
<div className="py-12">
<LoadingSpinner />
</div>
) : (
<SingleResponseCard
survey={survey}
response={response}
environment={environment}
environmentTags={tags}
isReadOnly={isReadOnly}
locale={locale}
/>
)}
</DialogBody>
</DialogContent>
</Dialog>
);
};
@@ -41,9 +41,17 @@ interface SummaryListProps {
environment: TEnvironment;
survey: TSurvey;
locale: TUserLocale;
isReadOnly: boolean;
}
export const SummaryList = ({ summary, environment, responseCount, survey, locale }: SummaryListProps) => {
export const SummaryList = ({
summary,
environment,
responseCount,
survey,
locale,
isReadOnly,
}: SummaryListProps) => {
const { setSelectedFilter, selectedFilter } = useResponseFilter();
const { t } = useTranslation();
const setFilter = (
@@ -113,9 +121,10 @@ export const SummaryList = ({ summary, environment, responseCount, survey, local
<OpenTextSummary
key={elementSummary.element.id}
elementSummary={elementSummary}
environmentId={environment.id}
environment={environment}
survey={survey}
locale={locale}
isReadOnly={isReadOnly}
/>
);
}
@@ -51,6 +51,7 @@ interface SummaryPageProps {
locale: TUserLocale;
initialSurveySummary?: TSurveySummary;
isQuotasAllowed: boolean;
isReadOnly: boolean;
}
export const SummaryPage = ({
@@ -60,6 +61,7 @@ export const SummaryPage = ({
locale,
initialSurveySummary,
isQuotasAllowed,
isReadOnly,
}: SummaryPageProps) => {
const { t } = useTranslation();
const searchParams = useSearchParams();
@@ -230,6 +232,7 @@ export const SummaryPage = ({
survey={surveyMemoized}
environment={environment}
locale={locale}
isReadOnly={isReadOnly}
/>
</>
);
@@ -91,6 +91,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
locale={user.locale ?? DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}
isQuotasAllowed={isQuotasAllowed}
isReadOnly={isReadOnly}
/>
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />
@@ -4,7 +4,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { createTag } from "@/lib/tag/service";
import { createTag, getTagsByEnvironmentId } from "@/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@/lib/tagOnResponse/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -175,6 +175,32 @@ export const deleteResponseAction = authenticatedActionClient.inputSchema(ZDelet
})
);
const ZGetTagsByEnvironmentIdAction = z.object({
environmentId: ZId,
});
export const getTagsByEnvironmentIdAction = authenticatedActionClient
.inputSchema(ZGetTagsByEnvironmentIdAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
return await getTagsByEnvironmentId(parsedInput.environmentId);
});
const ZGetResponseAction = z.object({
responseId: ZId,
});