feat: Filter Responses by hidden field values (#2662)

This commit is contained in:
Piyush Gupta
2024-05-30 16:30:09 +05:30
committed by GitHub
parent a91c9db4e0
commit 9d33aa034a
11 changed files with 223 additions and 271 deletions

View File

@@ -3,11 +3,7 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import {
getResponseDownloadUrl,
getResponseMeta,
getResponsePersonAttributes,
} from "@formbricks/lib/response/service";
import { getResponseDownloadUrl, getResponseFilteringValues } from "@formbricks/lib/response/service";
import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth";
import { updateSurvey } from "@formbricks/lib/survey/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
@@ -36,13 +32,12 @@ export const getSurveyFilterDataAction = async (surveyId: string, environmentId:
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const [tags, attributes, meta] = await Promise.all([
const [tags, { personAttributes: attributes, meta, hiddenFields }] = await Promise.all([
getTagsByEnvironmentId(environmentId),
getResponsePersonAttributes(surveyId),
getResponseMeta(surveyId),
getResponseFilteringValues(surveyId),
]);
return { environmentTags: tags, attributes, meta };
return { environmentTags: tags, attributes, meta, hiddenFields };
};
export const updateSurveyAction = async (survey: TSurvey): Promise<TSurvey> => {

View File

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

View File

@@ -6,6 +6,7 @@ import {
CheckIcon,
ChevronDown,
ChevronUp,
EyeOff,
GlobeIcon,
GridIcon,
HashIcon,
@@ -41,6 +42,7 @@ export enum OptionsType {
ATTRIBUTES = "Attributes",
OTHERS = "Other Filters",
META = "Meta",
HIDDEN_FIELDS = "Hidden Fields",
}
export type QuestionOption = {
@@ -88,6 +90,9 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOpti
}
case OptionsType.ATTRIBUTES:
return <User width={18} height={18} className="text-white" />;
case OptionsType.HIDDEN_FIELDS:
return <EyeOff width={18} height={18} className="text-white" />;
case OptionsType.META:
switch (label) {
case "device":

View File

@@ -46,7 +46,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
// Fetch the initial data for the filter and load it into the state
const handleInitialData = async () => {
if (isOpen) {
const { attributes, meta, environmentTags } = isSharingPage
const { attributes, meta, environmentTags, hiddenFields } = isSharingPage
? await getSurveyFilterDataBySurveySharingKeyAction(sharingKey, survey.environmentId)
: await getSurveyFilterDataAction(survey.id, survey.environmentId);
@@ -54,7 +54,8 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
survey,
environmentTags,
attributes,
meta
meta,
hiddenFields
);
setSelectedOptions({ questionFilterOptions, questionOptions });
}

View File

@@ -12,6 +12,7 @@ import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/
import {
TResponseFilterCriteria,
TResponseHiddenFieldsFilter,
TSurveyMetaFieldFilter,
TSurveyPersonAttributes,
} from "@formbricks/types/responses";
@@ -49,7 +50,8 @@ export const generateQuestionAndFilterOptions = (
survey: TSurvey,
environmentTags: TTag[] | undefined,
attributes: TSurveyPersonAttributes,
meta: TSurveyMetaFieldFilter
meta: TSurveyMetaFieldFilter,
hiddenFields: TResponseHiddenFieldsFilter
): {
questionOptions: QuestionOptions[];
questionFilterOptions: QuestionFilterOptions[];
@@ -164,6 +166,26 @@ export const generateQuestionAndFilterOptions = (
});
}
if (hiddenFields) {
questionOptions = [
...questionOptions,
{
header: OptionsType.HIDDEN_FIELDS,
option: Object.keys(hiddenFields).map((hiddenField) => {
return { label: hiddenField, type: OptionsType.HIDDEN_FIELDS, id: hiddenField };
}),
},
];
Object.keys(hiddenFields).forEach((hiddenField) => {
questionFilterOptions.push({
type: "Hidden Fields",
filterOptions: ["Equals", "Not equals"],
filterComboBoxOptions: hiddenFields[hiddenField],
id: hiddenField,
});
});
}
let languageQuestion: QuestionOption[] = [];
//can be extended to include more properties
@@ -189,23 +211,29 @@ export const getFormattedFilters = (
dateRange: DateRange
): TResponseFilterCriteria => {
const filters: TResponseFilterCriteria = {};
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 === "Other Filters") {
result[3].push(filter);
} else if (filter.questionType?.type === "Meta") {
result[4].push(filter);
}
return result;
},
[[], [], [], [], []]
);
const questions: FilterValue[] = [];
const tags: FilterValue[] = [];
const attributes: FilterValue[] = [];
const others: FilterValue[] = [];
const meta: FilterValue[] = [];
const hiddenFields: FilterValue[] = [];
selectedFilter.filter.forEach((filter) => {
if (filter.questionType?.type === "Questions") {
questions.push(filter);
} else if (filter.questionType?.type === "Tags") {
tags.push(filter);
} else if (filter.questionType?.type === "Attributes") {
attributes.push(filter);
} else if (filter.questionType?.type === "Other Filters") {
others.push(filter);
} else if (filter.questionType?.type === "Meta") {
meta.push(filter);
} else if (filter.questionType?.type === "Hidden Fields") {
hiddenFields.push(filter);
}
});
// for completed responses
if (selectedFilter.onlyComplete) {
@@ -359,10 +387,30 @@ export const getFormattedFilters = (
});
}
// for hidden fields
if (hiddenFields.length) {
hiddenFields.forEach(({ filterType, questionType }) => {
if (!filters.data) filters.data = {};
if (!filterType.filterComboBoxValue) return;
if (filterType.filterValue === "Equals") {
filters.data[questionType.label ?? ""] = {
op: "equals",
value: filterType.filterComboBoxValue as string,
};
} else if (filterType.filterValue === "Not equals") {
filters.data[questionType.label ?? ""] = {
op: "notEquals",
value: filterType.filterComboBoxValue as string,
};
}
});
}
// for attributes
if (attributes.length) {
attributes.forEach(({ filterType, questionType }) => {
if (!filters.personAttributes) filters.personAttributes = {};
if (!filterType.filterComboBoxValue) return;
if (filterType.filterValue === "Equals") {
filters.personAttributes[questionType.label ?? ""] = {
op: "equals",
@@ -381,6 +429,7 @@ export const getFormattedFilters = (
if (others.length) {
others.forEach(({ filterType, questionType }) => {
if (!filters.others) filters.others = {};
if (!filterType.filterComboBoxValue) return;
if (filterType.filterValue === "Equals") {
filters.others[questionType.label ?? ""] = {
op: "equals",
@@ -399,6 +448,7 @@ export const getFormattedFilters = (
if (meta.length) {
meta.forEach(({ filterType, questionType }) => {
if (!filters.meta) filters.meta = {};
if (!filterType.filterComboBoxValue) return;
if (filterType.filterValue === "Equals") {
filters.meta[questionType.label ?? ""] = {
op: "equals",

View File

@@ -2,8 +2,7 @@
import {
getResponseCountBySurveyId,
getResponseMeta,
getResponsePersonAttributes,
getResponseFilteringValues,
getResponses,
getSurveySummary,
} from "@formbricks/lib/response/service";
@@ -53,11 +52,10 @@ export const getSurveyFilterDataBySurveySharingKeyAction = async (
const surveyId = await getSurveyIdByResultShareKey(sharingKey);
if (!surveyId) throw new AuthorizationError("Not authorized");
const [tags, attributes, meta] = await Promise.all([
const [tags, { personAttributes: attributes, meta, hiddenFields }] = await Promise.all([
getTagsByEnvironmentId(environmentId),
getResponsePersonAttributes(surveyId),
getResponseMeta(surveyId),
getResponseFilteringValues(surveyId),
]);
return { environmentTags: tags, attributes, meta };
return { environmentTags: tags, attributes, meta, hiddenFields };
};

View File

@@ -14,8 +14,6 @@ import {
TResponseInput,
TResponseLegacyInput,
TResponseUpdateInput,
TSurveyMetaFieldFilter,
TSurveyPersonAttributes,
ZResponseFilterCriteria,
ZResponseInput,
ZResponseLegacyInput,
@@ -43,6 +41,9 @@ import {
calculateTtcTotal,
extractSurveyDetails,
getQuestionWiseSummary,
getResponseHiddenFields,
getResponseMeta,
getResponsePersonAttributes,
getResponsesFileName,
getResponsesJson,
getSurveySummaryDropOff,
@@ -387,37 +388,33 @@ export const getResponse = (responseId: string): Promise<TResponse | null> =>
}
)();
export const getResponsePersonAttributes = (surveyId: string): Promise<TSurveyPersonAttributes> =>
export const getResponseFilteringValues = async (surveyId: string) =>
cache(
async () => {
validateInputs([surveyId, ZId]);
try {
let attributes: TSurveyPersonAttributes = {};
const responseAttributes = await prisma.response.findMany({
const survey = await getSurvey(surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", surveyId);
}
const responses = await prisma.response.findMany({
where: {
surveyId: surveyId,
surveyId,
},
select: {
data: true,
meta: true,
personAttributes: true,
},
});
responseAttributes.forEach((response) => {
Object.keys(response.personAttributes ?? {}).forEach((key) => {
if (response.personAttributes && attributes[key]) {
attributes[key].push(response.personAttributes[key].toString());
} else if (response.personAttributes) {
attributes[key] = [response.personAttributes[key].toString()];
}
});
});
const personAttributes = getResponsePersonAttributes(responses);
const meta = getResponseMeta(responses);
const hiddenFields = getResponseHiddenFields(survey, responses);
Object.keys(attributes).forEach((key) => {
attributes[key] = Array.from(new Set(attributes[key]));
});
return attributes;
return { personAttributes, meta, hiddenFields };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -426,67 +423,7 @@ export const getResponsePersonAttributes = (surveyId: string): Promise<TSurveyPe
throw error;
}
},
[`getResponsePersonAttributes-${surveyId}`],
{
tags: [responseCache.tag.bySurveyId(surveyId)],
}
)();
export const getResponseMeta = (surveyId: string): Promise<TSurveyMetaFieldFilter> =>
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}`],
[`getResponseFilteringValues-${surveyId}`],
{
tags: [responseCache.tag.bySurveyId(surveyId)],
}

View File

@@ -2,12 +2,7 @@ import { Prisma } from "@prisma/client";
import { isAfter, isBefore, isSameDay } from "date-fns";
import { TDisplay } from "@formbricks/types/displays";
import {
TResponse,
TResponseFilterCriteria,
TResponseUpdateInput,
TSurveyPersonAttributes,
} from "@formbricks/types/responses";
import { TResponse, TResponseFilterCriteria, TResponseUpdateInput } from "@formbricks/types/responses";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import { TTag } from "@formbricks/types/tags";
@@ -104,98 +99,6 @@ export const mockResponse: ResponseMock = {
ttc: {},
};
export const mockResponsePersonAttributes: ResponseMock[] = [
{
id: mockResponseId,
surveyId: mockSurveyId,
singleUseId: mockSingleUseId,
data: {},
createdAt: new Date(),
finished: constantsForTests.boolean,
meta: mockMeta,
notes: [mockResponseNote],
tags: mockTags,
personId: mockPersonId,
updatedAt: new Date(),
ttc: {},
person: null,
language: null,
personAttributes: { Plan: "Paid", "Init Attribute 1": "one", "Init Attribute 2": "two" },
},
{
id: mockResponseId,
surveyId: mockSurveyId,
singleUseId: mockSingleUseId,
data: {},
createdAt: new Date(),
finished: constantsForTests.boolean,
meta: mockMeta,
notes: [mockResponseNote],
tags: mockTags,
personId: mockPersonId,
updatedAt: new Date(),
ttc: {},
person: null,
language: null,
personAttributes: {
Plan: "Paid",
"Init Attribute 1": "three",
"Init Attribute 2": "four",
},
},
{
id: mockResponseId,
surveyId: mockSurveyId,
singleUseId: mockSingleUseId,
data: {},
createdAt: new Date(),
finished: constantsForTests.boolean,
meta: mockMeta,
notes: [mockResponseNote],
tags: mockTags,
personId: mockPersonId,
updatedAt: new Date(),
ttc: {},
person: null,
language: null,
personAttributes: { Plan: "Paid", "Init Attribute 1": "five", "Init Attribute 2": "six" },
},
{
id: mockResponseId,
surveyId: mockSurveyId,
singleUseId: mockSingleUseId,
data: {},
createdAt: new Date(),
finished: constantsForTests.boolean,
meta: mockMeta,
notes: [mockResponseNote],
tags: mockTags,
personId: mockPersonId,
updatedAt: new Date(),
ttc: {},
person: null,
language: null,
personAttributes: { Plan: "Paid", "Init Attribute 1": "five", "Init Attribute 2": "four" },
},
{
id: mockResponseId,
surveyId: mockSurveyId,
singleUseId: mockSingleUseId,
data: {},
createdAt: new Date(),
finished: constantsForTests.boolean,
meta: mockMeta,
notes: [mockResponseNote],
tags: mockTags,
personId: mockPersonId,
updatedAt: new Date(),
ttc: {},
person: null,
language: null,
personAttributes: { Plan: "Paid", "Init Attribute 1": "three", "Init Attribute 2": "two" },
},
];
const getMockTags = (tags: string[]): { tag: TTag }[] => {
return tags.map((tag) => ({
tag: {
@@ -445,12 +348,6 @@ export const mockResponseData: TResponseUpdateInput["data"] = {
key3: 20,
};
export const mockPersonAttributesData: TSurveyPersonAttributes = {
Plan: ["Paid"],
"Init Attribute 1": ["one", "three", "five"],
"Init Attribute 2": ["two", "four", "six"],
};
export const getMockUpdateResponseInput = (finished: boolean = false): TResponseUpdateInput => ({
data: mockResponseData,
finished,

View File

@@ -6,12 +6,10 @@ import {
mockEnvironmentId,
mockMeta,
mockPerson,
mockPersonAttributesData,
mockPersonId,
mockResponse,
mockResponseData,
mockResponseNote,
mockResponsePersonAttributes,
mockResponseWithMockPerson,
mockSingleUseId,
mockSurveyId,
@@ -43,7 +41,6 @@ import {
getResponseBySingleUseId,
getResponseCountBySurveyId,
getResponseDownloadUrl,
getResponsePersonAttributes,
getResponses,
getResponsesByEnvironmentId,
getResponsesByPersonId,
@@ -317,46 +314,6 @@ describe("Tests for getResponse service", () => {
});
});
describe("Tests for getAttributesFromResponses service", () => {
describe("Happy Path", () => {
it("Retrieves all attributes from responses for a given survey ID", async () => {
prisma.response.findMany.mockResolvedValue(mockResponsePersonAttributes);
const attributes = await getResponsePersonAttributes(mockSurveyId);
expect(attributes).toEqual(mockPersonAttributesData);
});
it("Returns an empty Object when no responses with attributes are found for the given survey ID", async () => {
prisma.response.findMany.mockResolvedValue([]);
const responses = await getResponsePersonAttributes(mockSurveyId);
expect(responses).toEqual({});
});
});
describe("Sad Path", () => {
testInputValidation(getResponsePersonAttributes, "123#");
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: "P2002",
clientVersion: "0.0.1",
});
prisma.response.findMany.mockRejectedValue(errToThrow);
await expect(getResponsePersonAttributes(mockSurveyId)).rejects.toThrow(DatabaseError);
});
it("Throws a generic Error for unexpected problems", async () => {
const mockErrorMessage = "Mock error message";
prisma.response.findMany.mockRejectedValue(new Error(mockErrorMessage));
await expect(getResponsePersonAttributes(mockSurveyId)).rejects.toThrow(Error);
});
});
});
describe("Tests for getResponses service", () => {
describe("Happy Path", () => {
it("Fetches first 10 responses for a given survey ID", async () => {

View File

@@ -2,7 +2,14 @@ import "server-only";
import { Prisma } from "@prisma/client";
import { TResponse, TResponseFilterCriteria, TResponseTtc } from "@formbricks/types/responses";
import {
TResponse,
TResponseFilterCriteria,
TResponseHiddenFieldsFilter,
TResponseTtc,
TSurveyMetaFieldFilter,
TSurveyPersonAttributes,
} from "@formbricks/types/responses";
import {
TSurvey,
TSurveyLanguage,
@@ -1210,3 +1217,110 @@ export const getQuestionWiseSummary = (
return summary;
};
export const getResponsePersonAttributes = (
responses: Pick<TResponse, "personAttributes" | "data" | "meta">[]
): TSurveyPersonAttributes => {
try {
let attributes: TSurveyPersonAttributes = {};
responses.forEach((response) => {
Object.keys(response.personAttributes ?? {}).forEach((key) => {
if (response.personAttributes && attributes[key]) {
attributes[key].push(response.personAttributes[key].toString());
} else if (response.personAttributes) {
attributes[key] = [response.personAttributes[key].toString()];
}
});
});
Object.keys(attributes).forEach((key) => {
attributes[key] = Array.from(new Set(attributes[key]));
});
return attributes;
} catch (error) {
throw error;
}
};
export const getResponseMeta = (
responses: Pick<TResponse, "personAttributes" | "data" | "meta">[]
): TSurveyMetaFieldFilter => {
try {
const meta: { [key: string]: Set<string> } = {};
responses.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) {
throw error;
}
};
export const getResponseHiddenFields = (
survey: TSurvey,
responses: Pick<TResponse, "personAttributes" | "data" | "meta">[]
): TResponseHiddenFieldsFilter => {
try {
const hiddenFields: { [key: string]: Set<string> } = {};
const surveyHiddenFields = survey?.hiddenFields.fieldIds;
const hasHiddenFields = surveyHiddenFields && surveyHiddenFields.length > 0;
if (hasHiddenFields) {
// adding hidden fields to meta
survey?.hiddenFields.fieldIds?.forEach((fieldId) => {
hiddenFields[fieldId] = new Set();
});
responses.forEach((response) => {
// Handling data fields(Hidden fields)
surveyHiddenFields?.forEach((fieldId) => {
const hiddenFieldValue = response.data[fieldId];
if (hiddenFieldValue) {
if (typeof hiddenFieldValue === "string") {
hiddenFields[fieldId].add(hiddenFieldValue);
}
}
});
});
}
// Convert Set to Array
const result = Object.fromEntries(
Object.entries(hiddenFields).map(([key, valueSet]) => [key, Array.from(valueSet)])
);
return result;
} catch (error) {
throw error;
}
};

View File

@@ -34,6 +34,10 @@ export const ZSurveyMetaFieldFilter = z.record(z.array(z.string()));
export type TSurveyMetaFieldFilter = z.infer<typeof ZSurveyMetaFieldFilter>;
export const ZResponseHiddenFieldsFilter = z.record(z.array(z.string()));
export type TResponseHiddenFieldsFilter = z.infer<typeof ZResponseHiddenFieldsFilter>;
const ZResponseFilterCriteriaDataLessThan = z.object({
op: z.literal(ZSurveyLogicCondition.Values.lessThan),
value: z.number(),