Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Server-side Filtering to the Surveys Page #2277

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getSurveyCount } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import ContentWrapper from "@formbricks/ui/ContentWrapper";
import SurveysList from "@formbricks/ui/SurveysList";
import { SurveysList } from "@formbricks/ui/SurveysList";

export const metadata: Metadata = {
title: "Your Surveys",
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export const MAIL_FROM = env.MAIL_FROM;

export const NEXTAUTH_SECRET = env.NEXTAUTH_SECRET;
export const ITEMS_PER_PAGE = 50;
export const SURVEYS_PER_PAGE = 20;
export const SURVEYS_PER_PAGE = 12;
export const RESPONSES_PER_PAGE = 10;
export const TEXT_RESPONSES_PER_PAGE = 5;

Expand Down
2 changes: 1 addition & 1 deletion packages/lib/response/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function calculateTtcTotal(ttc: TResponseTtc) {
}

export const buildWhereClause = (filterCriteria?: TResponseFilterCriteria) => {
const whereClause: Record<string, any>[] = [];
const whereClause: Prisma.ResponseWhereInput["AND"] = [];

// For finished
if (filterCriteria?.finished !== undefined) {
Expand Down
21 changes: 12 additions & 9 deletions packages/lib/survey/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ import { ZId } from "@formbricks/types/environment";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TPerson } from "@formbricks/types/people";
import { TSegment, ZSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { TSurvey, TSurveyInput, ZSurveyWithRefinements } from "@formbricks/types/surveys";
import {
TSurvey,
TSurveyFilterCriteria,
TSurveyInput,
ZSurveyWithRefinements,
} from "@formbricks/types/surveys";

import { getActionsByPersonId } from "../action/service";
import { getActionClasses } from "../actionClass/service";
Expand All @@ -31,7 +36,7 @@ import { subscribeTeamMembersToSurveyResponses } from "../team/service";
import { diffInDays, formatDateFields } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
import { surveyCache } from "./cache";
import { anySurveyHasFilters, formatSurveyDateFields } from "./util";
import { anySurveyHasFilters, buildOrderByClause, buildWhereClause, formatSurveyDateFields } from "./util";

interface TriggerUpdate {
create?: Array<{ actionClassId: string }>;
Expand Down Expand Up @@ -283,7 +288,8 @@ export const getSurveysByActionClassId = async (actionClassId: string, page?: nu
export const getSurveys = async (
environmentId: string,
limit?: number,
offset?: number
offset?: number,
filterCriteria?: TSurveyFilterCriteria
): Promise<TSurvey[]> => {
const surveys = await unstable_cache(
async () => {
Expand All @@ -294,13 +300,10 @@ export const getSurveys = async (
surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
...buildWhereClause(filterCriteria),
},
select: selectSurvey,
orderBy: [
{
updatedAt: "desc",
},
],
orderBy: buildOrderByClause(filterCriteria?.sortBy),
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
Expand Down Expand Up @@ -335,7 +338,7 @@ export const getSurveys = async (
}
return surveys;
},
[`getSurveys-${environmentId}-${limit}-${offset}`],
[`getSurveys-${environmentId}-${limit}-${offset}-${JSON.stringify(filterCriteria)}`],
{
tags: [surveyCache.tag.byEnvironmentId(environmentId)],
revalidate: SERVICES_REVALIDATION_INTERVAL,
Expand Down
51 changes: 50 additions & 1 deletion packages/lib/survey/util.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import "server-only";

import { Prisma } from "@prisma/client";

import { TLegacySurvey } from "@formbricks/types/LegacySurvey";
import { TPerson } from "@formbricks/types/people";
import { TSurvey } from "@formbricks/types/surveys";
import { TSurvey, TSurveyFilterCriteria } from "@formbricks/types/surveys";

export const formatSurveyDateFields = (survey: TSurvey): TSurvey => {
if (typeof survey.createdAt === "string") {
Expand Down Expand Up @@ -42,6 +44,53 @@ export const formatSurveyDateFields = (survey: TSurvey): TSurvey => {
return survey;
};

export const buildWhereClause = (filterCriteria?: TSurveyFilterCriteria) => {
const whereClause: Prisma.SurveyWhereInput["AND"] = [];

// for name
if (filterCriteria?.name) {
whereClause.push({ name: { contains: filterCriteria.name, mode: "insensitive" } });
}

// for status
if (filterCriteria?.status && filterCriteria?.status?.length) {
whereClause.push({ status: { in: filterCriteria.status } });
}

// for type
if (filterCriteria?.type && filterCriteria?.type?.length) {
whereClause.push({ type: { in: filterCriteria.type } });
}

// for createdBy
if (filterCriteria?.createdBy?.value && filterCriteria?.createdBy?.value?.length) {
if (filterCriteria.createdBy.value.length === 1) {
if (filterCriteria.createdBy.value[0] === "you") {
whereClause.push({ createdBy: filterCriteria.createdBy.userId });
}
if (filterCriteria.createdBy.value[0] === "others") {
whereClause.push({ createdBy: { not: filterCriteria.createdBy.userId } });
}
}
}

return { AND: whereClause };
};

export const buildOrderByClause = (
sortBy?: TSurveyFilterCriteria["sortBy"]
): Prisma.SurveyOrderByWithRelationInput[] | undefined => {
if (!sortBy) {
return undefined;
}

if (sortBy === "name") {
return [{ name: "asc" }];
}

return [{ [sortBy]: "desc" }];
};

export const anySurveyHasFilters = (surveys: TSurvey[] | TLegacySurvey[]): boolean => {
return surveys.some((survey) => {
if ("segment" in survey && survey.segment) {
Expand Down
39 changes: 39 additions & 0 deletions packages/types/surveys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,4 +580,43 @@ export interface TSurveyQuestionSummary<T> {
}[];
}

export const ZSurveyFilterCriteria = z.object({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to eliminate one from TSurveyFilter and TSurveyFilterCriteria, looks quite similar 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, these two are very similar, but one is being used for client-side filtering types and the other is for server-side filter types. It would be a bit hacky to use one at both places. We can discuss if it needs to be fixed or if we can rename the types to make them more meaningful.

name: z.string().optional(),
status: z.array(ZSurveyStatus).optional(),
type: z.array(ZSurveyType).optional(),
createdBy: z
.object({
userId: z.string(),
value: z.array(z.enum(["you", "others"])),
})
.optional(),
sortBy: z.enum(["createdAt", "updatedAt", "name"]).optional(),
});

export type TSurveyFilterCriteria = z.infer<typeof ZSurveyFilterCriteria>;

const ZSurveyFilters = z.object({
name: z.string(),
createdBy: z.array(z.enum(["you", "others"])),
status: z.array(ZSurveyStatus),
type: z.array(ZSurveyType),
sortBy: z.enum(["createdAt", "updatedAt", "name"]),
});

export type TSurveyFilters = z.infer<typeof ZSurveyFilters>;

export type TSurveyEditorTabs = "questions" | "settings" | "styling";

const ZFilterOption = z.object({
label: z.string(),
value: z.string(),
});

export type TFilterOption = z.infer<typeof ZFilterOption>;

const ZSortOption = z.object({
label: z.string(),
value: z.enum(["createdAt", "updatedAt", "name"]),
});

export type TSortOption = z.infer<typeof ZSortOption>;
26 changes: 16 additions & 10 deletions packages/ui/SurveysList/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { surveyCache } from "@formbricks/lib/survey/cache";
import { deleteSurvey, duplicateSurvey, getSurvey, getSurveys } from "@formbricks/lib/survey/service";
import { generateSurveySingleUseId } from "@formbricks/lib/utils/singleUseSurveys";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSurveyFilterCriteria } from "@formbricks/types/surveys";

export const getSurveyAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);
Expand All @@ -22,7 +23,7 @@ export const getSurveyAction = async (surveyId: string) => {
return await getSurvey(surveyId);
};

export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
export const duplicateSurveyAction = async (environmentId: string, surveyId: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");

Expand All @@ -31,13 +32,13 @@ export async function duplicateSurveyAction(environmentId: string, surveyId: str

const duplicatedSurvey = await duplicateSurvey(environmentId, surveyId, session.user.id);
return duplicatedSurvey;
}
};

export async function copyToOtherEnvironmentAction(
export const copyToOtherEnvironmentAction = async (
environmentId: string,
surveyId: string,
targetEnvironmentId: string
) {
) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");

Expand Down Expand Up @@ -195,7 +196,7 @@ export async function copyToOtherEnvironmentAction(
environmentId: targetEnvironmentId,
});
return newSurvey;
}
};

export const deleteSurveyAction = async (surveyId: string) => {
const session = await getServerSession(authOptions);
Expand All @@ -212,7 +213,7 @@ export const deleteSurveyAction = async (surveyId: string) => {
await deleteSurvey(surveyId);
};

export async function generateSingleUseIdAction(surveyId: string, isEncrypted: boolean): Promise<string> {
export const generateSingleUseIdAction = async (surveyId: string, isEncrypted: boolean): Promise<string> => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");

Expand All @@ -221,14 +222,19 @@ export async function generateSingleUseIdAction(surveyId: string, isEncrypted: b
if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized");

return generateSurveySingleUseId(isEncrypted);
}
};

export async function getSurveysAction(environmentId: string, limit?: number, offset?: number) {
export const getSurveysAction = async (
environmentId: string,
limit?: number,
offset?: number,
filterCriteria?: TSurveyFilterCriteria
) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");

const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");

return await getSurveys(environmentId, limit, offset);
}
return await getSurveys(environmentId, limit, offset, filterCriteria);
};
24 changes: 24 additions & 0 deletions packages/ui/SurveysList/components/SortOption.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { TSortOption, TSurveyFilters } from "@formbricks/types/surveys";

import { DropdownMenuItem } from "../../DropdownMenu";

interface SortOptionProps {
option: TSortOption;
sortBy: TSurveyFilters["sortBy"];
handleSortChange: (option: TSortOption) => void;
}

export const SortOption = ({ option, sortBy, handleSortChange }: SortOptionProps) => (
<DropdownMenuItem
key={option.label}
className="m-0 p-0"
onClick={() => {
handleSortChange(option);
}}>
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
<span
className={`h-4 w-4 rounded-full border ${sortBy === option.value ? "bg-brand-dark outline-brand-dark border-slate-900 outline" : "border-white"}`}></span>
<p className="font-normal text-white">{option.label}</p>
</div>
</DropdownMenuItem>
);
16 changes: 9 additions & 7 deletions packages/ui/SurveysList/components/SurveyCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { TSurvey } from "@formbricks/types/surveys";

import { SurveyStatusIndicator } from "../../SurveyStatusIndicator";
import { generateSingleUseIdAction } from "../actions";
import SurveyDropDownMenu from "./SurveyDropdownMenu";
import { SurveyDropDownMenu } from "./SurveyDropdownMenu";

interface SurveyCardProps {
survey: TSurvey;
Expand All @@ -21,7 +21,7 @@ interface SurveyCardProps {
duplicateSurvey: (survey: TSurvey) => void;
deleteSurvey: (surveyId: string) => void;
}
export default function SurveyCard({
export const SurveyCard = ({
survey,
environment,
otherEnvironment,
Expand All @@ -30,7 +30,7 @@ export default function SurveyCard({
orientation,
deleteSurvey,
duplicateSurvey,
}: SurveyCardProps) {
}: SurveyCardProps) => {
const isSurveyCreationDeletionDisabled = isViewer;

const surveyStatusLabel = useMemo(() => {
Expand Down Expand Up @@ -119,7 +119,7 @@ export default function SurveyCard({
key={survey.id}
className="relative grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4
shadow-sm transition-all ease-in-out hover:scale-[101%]">
<div className="col-span-2 flex items-center justify-self-start overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-slate-900">
<div className="col-span-2 flex max-w-full items-center justify-self-start truncate whitespace-nowrap text-sm font-medium text-slate-900">
{survey.name}
</div>
<div
Expand Down Expand Up @@ -162,6 +162,8 @@ export default function SurveyCard({
</Link>
);
};
if (orientation === "grid") return renderGridContent();
else return renderListContent();
}

if (orientation === "grid") {
return renderGridContent();
} else return renderListContent();
};
6 changes: 3 additions & 3 deletions packages/ui/SurveysList/components/SurveyDropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ interface SurveyDropDownMenuProps {
deleteSurvey: (surveyId: string) => void;
}

export default function SurveyDropDownMenu({
export const SurveyDropDownMenu = ({
environmentId,
survey,
environment,
Expand All @@ -48,7 +48,7 @@ export default function SurveyDropDownMenu({
isSurveyCreationDeletionDisabled,
deleteSurvey,
duplicateSurvey,
}: SurveyDropDownMenuProps) {
}: SurveyDropDownMenuProps) => {
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
Expand Down Expand Up @@ -244,4 +244,4 @@ export default function SurveyDropDownMenu({
)}
</div>
);
}
};
Loading
Loading