mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-18 06:52:01 -05:00
feat: add lazy response detail modal on OpenTextSummary row click
Co-authored-by: johannes <johannes@formbricks.com>
This commit is contained in:
+28
-6
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+113
@@ -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>
|
||||
);
|
||||
};
|
||||
+11
-2
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+3
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+1
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user