feat: Add response filtering for meta data (#2363)

Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Piyush Gupta
2024-04-09 16:07:58 +05:30
committed by GitHub
parent 27ce171dc3
commit ff60ff9e0b
17 changed files with 320 additions and 96 deletions

View File

@@ -21,7 +21,7 @@ import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TProduct } from "@formbricks/types/product";
import { TResponse, TSurveyPersonAttributes } from "@formbricks/types/responses";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import { TUser } from "@formbricks/types/user";
@@ -37,7 +37,6 @@ interface ResponsePageProps {
product: TProduct;
user?: TUser;
environmentTags: TTag[];
attributes: TSurveyPersonAttributes;
responsesPerPage: number;
membershipRole?: TMembershipRole;
totalResponseCount: number;
@@ -51,7 +50,6 @@ const ResponsePage = ({
product,
user,
environmentTags,
attributes,
responsesPerPage,
membershipRole,
totalResponseCount,
@@ -176,7 +174,7 @@ const ResponsePage = ({
membershipRole={membershipRole}
/>
<div className="flex gap-1.5">
<CustomFilter environmentTags={environmentTags} attributes={attributes} survey={survey} />
<CustomFilter survey={survey} />
{!isSharingPage && <ResultsShareButton survey={survey} webAppUrl={webAppUrl} user={user} />}
</div>
<SurveyResultsTabs

View File

@@ -6,7 +6,7 @@ import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getResponseCountBySurveyId, getResponsePersonAttributes } from "@formbricks/lib/response/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
@@ -39,7 +39,6 @@ export default async function Page({ params }) {
}
const tags = await getTagsByEnvironmentId(params.environmentId);
const team = await getTeamByEnvironmentId(params.environmentId);
const attributes = await getResponsePersonAttributes(params.surveyId);
if (!team) {
throw new Error("Team not found");
@@ -58,7 +57,6 @@ export default async function Page({ params }) {
webAppUrl={WEBAPP_URL}
product={product}
environmentTags={tags}
attributes={attributes}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}
membershipRole={currentUserMembership?.role}

View File

@@ -23,9 +23,8 @@ import { checkForRecallInHeadline } from "@formbricks/lib/utils/recall";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TProduct } from "@formbricks/types/product";
import { TSurveyPersonAttributes, TSurveySummary } from "@formbricks/types/responses";
import { TSurveySummary } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import { TUser } from "@formbricks/types/user";
import ContentWrapper from "@formbricks/ui/ContentWrapper";
@@ -53,8 +52,6 @@ interface SummaryPageProps {
webAppUrl: string;
product: TProduct;
user?: TUser;
environmentTags: TTag[];
attributes: TSurveyPersonAttributes;
membershipRole?: TMembershipRole;
totalResponseCount: number;
}
@@ -66,8 +63,6 @@ const SummaryPage = ({
product,
webAppUrl,
user,
environmentTags,
attributes,
membershipRole,
totalResponseCount,
}: SummaryPageProps) => {
@@ -142,7 +137,7 @@ const SummaryPage = ({
membershipRole={membershipRole}
/>
<div className="flex gap-1.5">
<CustomFilter environmentTags={environmentTags} attributes={attributes} survey={survey} />
<CustomFilter survey={survey} />
{!isSharingPage && <ResultsShareButton survey={survey} webAppUrl={webAppUrl} user={user} />}
</div>
<SurveyResultsTabs

View File

@@ -7,9 +7,8 @@ import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getResponseCountBySurveyId, getResponsePersonAttributes } from "@formbricks/lib/response/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { getUser } from "@formbricks/lib/user/service";
@@ -52,9 +51,6 @@ export default async function Page({ params }) {
throw new Error("Team not found");
}
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
const tags = await getTagsByEnvironmentId(params.environmentId);
const attributes = await getResponsePersonAttributes(params.surveyId);
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
return (
@@ -66,8 +62,6 @@ export default async function Page({ params }) {
webAppUrl={WEBAPP_URL}
product={product}
user={user}
environmentTags={tags}
attributes={attributes}
membershipRole={currentUserMembership?.role}
totalResponseCount={totalResponseCount}
/>

View File

@@ -3,8 +3,13 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { getResponseDownloadUrl } from "@formbricks/lib/response/service";
import {
getResponseDownloadUrl,
getResponseMeta,
getResponsePersonAttributes,
} from "@formbricks/lib/response/service";
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { TResponseFilterCriteria } from "@formbricks/types/responses";
@@ -21,3 +26,19 @@ export async function getResponsesDownloadUrlAction(
return getResponseDownloadUrl(surveyId, format, filterCritera);
}
export async function getSurveyFilterDataAction(surveyId: string, environmentId: string) {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const [tags, attributes, meta] = await Promise.all([
getTagsByEnvironmentId(environmentId),
getResponsePersonAttributes(surveyId),
getResponseMeta(surveyId),
]);
return { environmentTags: tags, attributes, meta };
}

View File

@@ -5,11 +5,7 @@ import {
useResponseFilter,
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import {
generateQuestionAndFilterOptions,
getFormattedFilters,
getTodayDate,
} from "@/app/lib/surveys/surveys";
import { getFormattedFilters, getTodayDate } from "@/app/lib/surveys/surveys";
import { differenceInDays, format, startOfDay, subDays } from "date-fns";
import { ChevronDown, ChevronUp, DownloadIcon } from "lucide-react";
import { useParams } from "next/navigation";
@@ -17,9 +13,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside";
import { TSurveyPersonAttributes } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
import { Calendar } from "@formbricks/ui/Calendar";
import {
DropdownMenu,
@@ -48,8 +42,6 @@ enum FilterDropDownLabels {
}
interface CustomFilterProps {
environmentTags: TTag[];
attributes: TSurveyPersonAttributes;
survey: TSurvey;
}
@@ -64,11 +56,11 @@ const getDifferenceOfDays = (from, to) => {
}
};
const CustomFilter = ({ environmentTags, attributes, survey }: CustomFilterProps) => {
const CustomFilter = ({ survey }: CustomFilterProps) => {
const params = useParams();
const isSharingPage = !!params.sharingKey;
const { selectedFilter, setSelectedOptions, dateRange, setDateRange, resetState } = useResponseFilter();
const { selectedFilter, dateRange, setDateRange, resetState } = useResponseFilter();
const [filterRange, setFilterRange] = useState<FilterDropDownLabels>(
dateRange.from && dateRange.to
? getDifferenceOfDays(dateRange.from, dateRange.to)
@@ -95,16 +87,6 @@ const CustomFilter = ({ environmentTags, attributes, survey }: CustomFilterProps
}
}, [survey?.id, resetState]);
// when the page loads we get total responses and iterate over the responses and questions, tags and attributes to create the filter options
useEffect(() => {
const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions(
survey,
environmentTags,
attributes
);
setSelectedOptions({ questionFilterOptions, questionOptions });
}, [survey, setSelectedOptions, environmentTags, attributes]);
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
[survey, selectedFilter, dateRange]
@@ -210,7 +192,7 @@ const CustomFilter = ({ environmentTags, attributes, survey }: CustomFilterProps
<>
<div className="relative mb-12 flex justify-between">
<div className="flex justify-stretch gap-x-1.5">
<ResponseFilter />
<ResponseFilter survey={survey} />
<DropdownMenu
onOpenChange={(value) => {
value && handleDatePickerClose();

View File

@@ -23,7 +23,13 @@ type QuestionFilterComboBoxProps = {
filterComboBoxValue: string | string[] | undefined;
onChangeFilterValue: (o: string) => void;
onChangeFilterComboBoxValue: (o: string | string[]) => void;
type: OptionsType.METADATA | TSurveyQuestionType | OptionsType.ATTRIBUTES | OptionsType.TAGS | undefined;
type:
| OptionsType.OTHERS
| TSurveyQuestionType
| OptionsType.ATTRIBUTES
| OptionsType.TAGS
| OptionsType.META
| undefined;
handleRemoveMultiSelect: (value: string[]) => void;
disabled?: boolean;
};

View File

@@ -2,19 +2,23 @@
import clsx from "clsx";
import {
AirplayIcon,
CheckIcon,
ChevronDown,
ChevronUp,
GlobeIcon,
GridIcon,
HashIcon,
HelpCircleIcon,
ImageIcon,
LanguagesIcon,
ListIcon,
MessageSquareTextIcon,
MousePointerClickIcon,
Rows3Icon,
SmartphoneIcon,
StarIcon,
User,
} from "lucide-react";
import * as React from "react";
@@ -35,7 +39,8 @@ export enum OptionsType {
QUESTIONS = "Questions",
TAGS = "Tags",
ATTRIBUTES = "Attributes",
METADATA = "Metadata",
OTHERS = "Other Filters",
META = "Meta",
}
export type QuestionOption = {
@@ -60,16 +65,18 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOpti
switch (type) {
case OptionsType.QUESTIONS:
switch (questionType) {
case TSurveyQuestionType.Rating:
return <StarIcon width={18} className="text-white" />;
case TSurveyQuestionType.CTA:
return <MousePointerClickIcon width={18} className="text-white" />;
case TSurveyQuestionType.OpenText:
return <HelpCircleIcon width={18} className="text-white" />;
return <MessageSquareTextIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionType.Rating:
return <StarIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionType.CTA:
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionType.OpenText:
return <HelpCircleIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionType.MultipleChoiceMulti:
return <ListIcon width={18} className="text-white" />;
return <ListIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionType.MultipleChoiceSingle:
return <Rows3Icon width={18} className="text-white" />;
return <Rows3Icon width={18} height={18} className="text-white" />;
case TSurveyQuestionType.NPS:
return <NetPromoterScoreIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionType.Consent:
@@ -80,13 +87,24 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOpti
return <GridIcon width={18} className="text-white" />;
}
case OptionsType.ATTRIBUTES:
return <HashIcon width={18} className="text-white" />;
case OptionsType.METADATA:
return <User width={18} height={18} className="text-white" />;
case OptionsType.META:
switch (label) {
case "device":
return <SmartphoneIcon width={18} height={18} className="text-white" />;
case "os":
return <AirplayIcon width={18} height={18} className="text-white" />;
case "browser":
return <GlobeIcon width={18} height={18} className="text-white" />;
case "source":
return <GlobeIcon width={18} height={18} className="text-white" />;
case "action":
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
}
case OptionsType.OTHERS:
switch (label) {
case "Language":
return <LanguagesIcon width={18} height={18} className="text-white" />;
case "Device Type":
return <SmartphoneIcon width={18} height={18} className="text-white" />;
}
case OptionsType.TAGS:
return <HashIcon width={18} className="text-white" />;

View File

@@ -4,14 +4,18 @@ import {
SelectedFilterValue,
useResponseFilter,
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import QuestionFilterComboBox from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/action";
import clsx from "clsx";
import { isEqual } from "lodash";
import { TrashIcon } from "lucide-react";
import { ChevronDown, ChevronUp, Plus } from "lucide-react";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { TSurveyTSurveyQuestionType } from "@formbricks/types/surveys";
import { TSurvey, TSurveyTSurveyQuestionType } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { Checkbox } from "@formbricks/ui/Checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui/Popover";
@@ -25,11 +29,40 @@ export type QuestionFilterOptions = {
id: string;
};
const ResponseFilter = () => {
interface ResponseFilterProps {
survey: TSurvey;
}
const ResponseFilter = ({ survey }: ResponseFilterProps) => {
const params = useParams();
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const { selectedFilter, setSelectedFilter, selectedOptions, setSelectedOptions } = useResponseFilter();
const [isOpen, setIsOpen] = useState<boolean>(false);
const { selectedFilter, setSelectedFilter, selectedOptions } = useResponseFilter();
const [filterValue, setFilterValue] = useState<SelectedFilterValue>(selectedFilter);
useEffect(() => {
// Fetch the initial data for the filter and load it into the state
const handleInitialData = async () => {
if (isOpen) {
const { attributes, meta, environmentTags } = isSharingPage
? await getSurveyFilterDataBySurveySharingKeyAction(sharingKey, survey.environmentId)
: await getSurveyFilterDataAction(survey.id, survey.environmentId);
const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions(
survey,
environmentTags,
attributes,
meta
);
setSelectedOptions({ questionFilterOptions, questionOptions });
}
};
handleInitialData();
}, [isOpen, isSharingPage, setSelectedOptions, sharingKey, survey]);
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
if (filterValue.filter[index].questionType) {
// Create a new array and copy existing values from SelectedFilter

View File

@@ -10,7 +10,11 @@ import {
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
import { TResponseFilterCriteria, TSurveyPersonAttributes } from "@formbricks/types/responses";
import {
TResponseFilterCriteria,
TSurveyMetaFieldFilter,
TSurveyPersonAttributes,
} from "@formbricks/types/responses";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import { TSurvey } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
@@ -42,7 +46,8 @@ const filterOptions = {
export const generateQuestionAndFilterOptions = (
survey: TSurvey,
environmentTags: TTag[] | undefined,
attributes: TSurveyPersonAttributes
attributes: TSurveyPersonAttributes,
meta: TSurveyMetaFieldFilter
): {
questionOptions: QuestionOptions[];
questionFilterOptions: QuestionFilterOptions[];
@@ -137,19 +142,40 @@ export const generateQuestionAndFilterOptions = (
});
}
let metadataOptions: QuestionOption[] = [];
if (meta) {
questionOptions = [
...questionOptions,
{
header: OptionsType.META,
option: Object.keys(meta).map((m) => {
return { label: m, type: OptionsType.META, id: m };
}),
},
];
Object.keys(meta).forEach((m) => {
questionFilterOptions.push({
type: "Meta",
filterOptions: ["Equals", "Not equals"],
filterComboBoxOptions: meta[m],
id: m,
});
});
}
let languageQuestion: QuestionOption[] = [];
//can be extended to include more properties
if (survey.languages?.length > 0) {
metadataOptions.push({ label: "Language", type: OptionsType.METADATA, id: "language" });
languageQuestion.push({ label: "Language", type: OptionsType.OTHERS, id: "language" });
const languageOptions = survey.languages.map((sl) => sl.language.code);
questionFilterOptions.push({
type: "Metadata",
type: OptionsType.OTHERS,
filterOptions: conditionOptions.languages,
filterComboBoxOptions: languageOptions,
id: "language",
});
}
questionOptions = [...questionOptions, { header: OptionsType.METADATA, option: metadataOptions }];
questionOptions = [...questionOptions, { header: OptionsType.OTHERS, option: languageQuestion }];
return { questionOptions: [...questionOptions], questionFilterOptions: [...questionFilterOptions] };
};
@@ -161,20 +187,22 @@ export const getFormattedFilters = (
dateRange: DateRange
): TResponseFilterCriteria => {
const filters: TResponseFilterCriteria = {};
const [questions, tags, attributes, metadata] = selectedFilter.filter.reduce(
(result: [FilterValue[], FilterValue[], FilterValue[], FilterValue[]], filter) => {
const [questions, tags, attributes, others, meta] = selectedFilter.filter.reduce(
(result: [FilterValue[], FilterValue[], FilterValue[], FilterValue[], FilterValue[]], filter) => {
if (filter.questionType?.type === "Questions") {
result[0].push(filter);
} else if (filter.questionType?.type === "Tags") {
result[1].push(filter);
} else if (filter.questionType?.type === "Attributes") {
result[2].push(filter);
} else if (filter.questionType?.type === "Metadata") {
} else if (filter.questionType?.type === "Other Filters") {
result[3].push(filter);
} else if (filter.questionType?.type === "Meta") {
result[4].push(filter);
}
return result;
},
[[], [], [], []]
[[], [], [], [], []]
);
// for completed responses
@@ -328,6 +356,7 @@ export const getFormattedFilters = (
});
}
// for attributes
if (attributes.length) {
attributes.forEach(({ filterType, questionType }) => {
if (!filters.personAttributes) filters.personAttributes = {};
@@ -345,24 +374,42 @@ export const getFormattedFilters = (
});
}
// for metadata
if (metadata.length) {
metadata.forEach(({ filterType, questionType }) => {
if (!filters.metadata) filters.metadata = {};
// for others
if (others.length) {
others.forEach(({ filterType, questionType }) => {
if (!filters.others) filters.others = {};
if (filterType.filterValue === "Equals") {
filters.metadata[questionType.label ?? ""] = {
filters.others[questionType.label ?? ""] = {
op: "equals",
value: filterType.filterComboBoxValue as string,
};
} else if (filterType.filterValue === "Not equals") {
filters.metadata[questionType.label ?? ""] = {
filters.others[questionType.label ?? ""] = {
op: "notEquals",
value: filterType.filterComboBoxValue as string,
};
}
});
}
// for meta
if (meta.length) {
meta.forEach(({ filterType, questionType }) => {
if (!filters.meta) filters.meta = {};
if (filterType.filterValue === "Equals") {
filters.meta[questionType.label ?? ""] = {
op: "equals",
value: filterType.filterComboBoxValue as string,
};
} else if (filterType.filterValue === "Not equals") {
filters.meta[questionType.label ?? ""] = {
op: "notEquals",
value: filterType.filterComboBoxValue as string,
};
}
});
}
return filters;
};

View File

@@ -4,7 +4,7 @@ import { notFound } from "next/navigation";
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getResponseCountBySurveyId, getResponsePersonAttributes } from "@formbricks/lib/response/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey, getSurveyIdByResultShareKey } from "@formbricks/lib/survey/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
@@ -32,7 +32,6 @@ export default async function Page({ params }) {
}
const tags = await getTagsByEnvironmentId(environment.id);
const attributes = await getResponsePersonAttributes(surveyId);
const totalResponseCount = await getResponseCountBySurveyId(surveyId);
return (
@@ -44,7 +43,6 @@ export default async function Page({ params }) {
webAppUrl={WEBAPP_URL}
product={product}
environmentTags={tags}
attributes={attributes}
responsesPerPage={RESPONSES_PER_PAGE}
totalResponseCount={totalResponseCount}
/>

View File

@@ -4,9 +4,8 @@ import { notFound } from "next/navigation";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getResponseCountBySurveyId, getResponsePersonAttributes } from "@formbricks/lib/response/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey, getSurveyIdByResultShareKey } from "@formbricks/lib/survey/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
export default async function Page({ params }) {
const surveyId = await getSurveyIdByResultShareKey(params.sharingKey);
@@ -31,8 +30,6 @@ export default async function Page({ params }) {
throw new Error("Product not found");
}
const tags = await getTagsByEnvironmentId(environment.id);
const attributes = await getResponsePersonAttributes(surveyId);
const totalResponseCount = await getResponseCountBySurveyId(surveyId);
return (
@@ -43,8 +40,6 @@ export default async function Page({ params }) {
surveyId={survey.id}
webAppUrl={WEBAPP_URL}
product={product}
environmentTags={tags}
attributes={attributes}
totalResponseCount={totalResponseCount}
/>
</>

View File

@@ -1,7 +1,14 @@
"use server";
import { getResponseCountBySurveyId, getResponses, getSurveySummary } from "@formbricks/lib/response/service";
import {
getResponseCountBySurveyId,
getResponseMeta,
getResponsePersonAttributes,
getResponses,
getSurveySummary,
} from "@formbricks/lib/response/service";
import { getSurveyIdByResultShareKey } from "@formbricks/lib/survey/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { TResponse, TResponseFilterCriteria, TSurveySummary } from "@formbricks/types/responses";
@@ -38,3 +45,19 @@ export const getResponseCountBySurveySharingKeyAction = async (
return await getResponseCountBySurveyId(surveyId, filterCriteria);
};
export const getSurveyFilterDataBySurveySharingKeyAction = async (
sharingKey: string,
environmentId: string
) => {
const surveyId = await getSurveyIdByResultShareKey(sharingKey);
if (!surveyId) throw new AuthorizationError("Not authorized");
const [tags, attributes, meta] = await Promise.all([
getTagsByEnvironmentId(environmentId),
getResponsePersonAttributes(surveyId),
getResponseMeta(surveyId),
]);
return { environmentTags: tags, attributes, meta };
};

View File

@@ -14,6 +14,7 @@ import {
TResponseInput,
TResponseLegacyInput,
TResponseUpdateInput,
TSurveyMetaFieldFilter,
TSurveyPersonAttributes,
TSurveySummary,
ZResponse,
@@ -457,6 +458,70 @@ export const getResponsePersonAttributes = async (surveyId: string): Promise<TSu
return responses;
};
export const getResponseMeta = async (surveyId: string): Promise<TSurveyMetaFieldFilter> => {
const meta = await unstable_cache(
async () => {
validateInputs([surveyId, ZId]);
try {
const responseMeta = await prisma.response.findMany({
where: {
surveyId: surveyId,
},
select: {
meta: true,
},
});
const meta: { [key: string]: Set<string> } = {};
responseMeta.forEach((response) => {
Object.entries(response.meta).forEach(([key, value]) => {
// skip url
if (key === "url") return;
// Handling nested objects (like userAgent)
if (typeof value === "object" && value !== null) {
Object.entries(value).forEach(([nestedKey, nestedValue]) => {
if (typeof nestedValue === "string" && nestedValue) {
if (!meta[nestedKey]) {
meta[nestedKey] = new Set();
}
meta[nestedKey].add(nestedValue);
}
});
} else if (typeof value === "string" && value) {
if (!meta[key]) {
meta[key] = new Set();
}
meta[key].add(value);
}
});
});
// Convert Set to Array
const result = Object.fromEntries(
Object.entries(meta).map(([key, valueSet]) => [key, Array.from(valueSet)])
);
return result;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getResponseMeta-${surveyId}`],
{
tags: [responseCache.tag.bySurveyId(surveyId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
}
)();
return meta;
};
export const getResponses = async (
surveyId: string,
page?: number,

View File

@@ -38,6 +38,7 @@ export function calculateTtcTotal(ttc: TResponseTtc) {
export const buildWhereClause = (filterCriteria?: TResponseFilterCriteria) => {
const whereClause: Record<string, any>[] = [];
// For finished
if (filterCriteria?.finished !== undefined) {
whereClause.push({
@@ -128,19 +129,56 @@ export const buildWhereClause = (filterCriteria?: TResponseFilterCriteria) => {
});
}
// For Metadata
if (filterCriteria?.metadata) {
const metadata: Prisma.ResponseWhereInput[] = [];
// for meta
if (filterCriteria?.meta) {
const meta: Prisma.ResponseWhereInput[] = [];
Object.entries(filterCriteria.meta).forEach(([key, val]) => {
let updatedKey: string[] = [];
if (["browser", "os", "device"].includes(key)) {
updatedKey = ["userAgent", key];
} else {
updatedKey = [key];
}
Object.entries(filterCriteria.metadata).forEach(([key, val]) => {
switch (val.op) {
case "equals":
metadata.push({
meta.push({
meta: {
path: updatedKey,
equals: val.value,
},
});
break;
case "notEquals":
meta.push({
meta: {
path: updatedKey,
not: val.value,
},
});
break;
}
});
whereClause.push({
AND: meta,
});
}
// For Language
if (filterCriteria?.others) {
const others: Prisma.ResponseWhereInput[] = [];
Object.entries(filterCriteria.others).forEach(([key, val]) => {
switch (val.op) {
case "equals":
others.push({
[key.toLocaleLowerCase()]: val.value,
});
break;
case "notEquals":
metadata.push({
others.push({
[key.toLocaleLowerCase()]: {
not: val.value,
},
@@ -149,7 +187,7 @@ export const buildWhereClause = (filterCriteria?: TResponseFilterCriteria) => {
}
});
whereClause.push({
AND: metadata,
AND: others,
});
}

View File

@@ -37,6 +37,10 @@ export const ZSurveyPersonAttributes = z.record(z.array(z.string()));
export type TSurveyPersonAttributes = z.infer<typeof ZSurveyPersonAttributes>;
export const ZSurveyMetaFieldFilter = z.record(z.array(z.string()));
export type TSurveyMetaFieldFilter = z.infer<typeof ZSurveyMetaFieldFilter>;
const ZResponseFilterCriteriaDataLessThan = z.object({
op: z.literal(ZSurveyLogicCondition.Values.lessThan),
value: z.number(),
@@ -158,7 +162,16 @@ export const ZResponseFilterCriteria = z.object({
})
.optional(),
metadata: z
others: z
.record(
z.object({
op: z.enum(["equals", "notEquals"]),
value: z.union([z.string(), z.number()]),
})
)
.optional(),
meta: z
.record(
z.object({
op: z.enum(["equals", "notEquals"]),

View File

@@ -61,7 +61,7 @@ const CommandList = React.forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
className={cn("max-h-full overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));