diff --git a/apps/api/modules/answers/answers.params.ts b/apps/api/modules/answers/answers.params.ts new file mode 100644 index 00000000..402c5f98 --- /dev/null +++ b/apps/api/modules/answers/answers.params.ts @@ -0,0 +1,22 @@ +import { Prisma } from "@prisma/client"; +import { kv } from "../../utils.js"; +import { GetAnswersQuery } from "./answers.schemas"; + +export const getAnswersPrismaParams = ({ limit, offset, order, orderBy }: GetAnswersQuery) => { + return { + take: limit, + skip: offset, + ...(order && + orderBy && { + orderBy: { + ...(orderBy === "votesCount" + ? { + QuestionAnswerVote: { + _count: order, + }, + } + : kv(orderBy, order)), + }, + }), + } satisfies Prisma.QuestionAnswerFindManyArgs; +}; diff --git a/apps/api/modules/answers/answers.routes.ts b/apps/api/modules/answers/answers.routes.ts index 25510a65..68bfdbe1 100644 --- a/apps/api/modules/answers/answers.routes.ts +++ b/apps/api/modules/answers/answers.routes.ts @@ -4,12 +4,14 @@ import { FastifyPluginAsync, preHandlerAsyncHookHandler, preHandlerHookHandler } import { PrismaErrorCode } from "../db/prismaErrors.js"; import { isPrismaError } from "../db/prismaErrors.util.js"; import { dbAnswerToDto } from "./answers.mapper.js"; +import { getAnswersPrismaParams } from "./answers.params.js"; import { - getAnswersSchema, + getAnswersRelatedToPostSchema, createAnswerSchema, deleteAnswerSchema, updateAnswerSchema, upvoteAnswerSchema, + getAnswersSchema, } from "./answers.schemas.js"; export const answerSelect = (userId: number) => { @@ -60,9 +62,59 @@ const answersPlugin: FastifyPluginAsync = async (fastify) => { }; fastify.withTypeProvider().route({ - url: "/questions/:id/answers", + url: "/answers", method: "GET", schema: getAnswersSchema, + async handler(request) { + const params = getAnswersPrismaParams(request.query); + const [total, answers] = await Promise.all([ + fastify.db.questionAnswer.count(), + fastify.db.questionAnswer.findMany({ + ...params, + select: { + id: true, + content: true, + sources: true, + createdAt: true, + updatedAt: true, + CreatedBy: { + select: { id: true, firstName: true, lastName: true, socialLogin: true }, + }, + _count: { + select: { + QuestionAnswerVote: true, + }, + }, + }, + }), + ]); + + return { + data: answers.map((a) => { + return { + id: a.id, + content: a.content, + sources: a.sources, + createdAt: a.createdAt.toISOString(), + updatedAt: a.createdAt.toISOString(), + createdBy: { + id: a.CreatedBy.id, + firstName: a.CreatedBy.firstName, + lastName: a.CreatedBy.lastName, + socialLogin: a.CreatedBy.socialLogin as Record, + }, + votesCount: a._count.QuestionAnswerVote, + }; + }), + meta: { total }, + }; + }, + }); + + fastify.withTypeProvider().route({ + url: "/questions/:id/answers", + method: "GET", + schema: getAnswersRelatedToPostSchema, async handler(request) { const { params: { id }, diff --git a/apps/api/modules/answers/answers.schemas.ts b/apps/api/modules/answers/answers.schemas.ts index 0ef87fea..b8bcb8d6 100644 --- a/apps/api/modules/answers/answers.schemas.ts +++ b/apps/api/modules/answers/answers.schemas.ts @@ -1,4 +1,4 @@ -import { Type } from "@sinclair/typebox"; +import { Static, Type } from "@sinclair/typebox"; const answerSchema = Type.Object({ id: Type.Number(), @@ -15,7 +15,7 @@ const answerSchema = Type.Object({ }), }); -export const getAnswersSchema = { +export const getAnswersRelatedToPostSchema = { params: Type.Object({ id: Type.Integer(), }), @@ -89,3 +89,45 @@ export const downvoteAnswerSchema = { 204: Type.Never(), }, }; + +const generateGetAnswersQuerySchema = Type.Partial( + Type.Object({ + limit: Type.Integer(), + offset: Type.Integer(), + orderBy: Type.Union([ + Type.Literal("createdAt"), + Type.Literal("updatedAt"), + Type.Literal("votesCount"), + ]), + order: Type.Union([Type.Literal("asc"), Type.Literal("desc")]), + }), +); + +export const getAnswersSchema = { + querystring: generateGetAnswersQuerySchema, + response: { + 200: Type.Object({ + data: Type.Array( + Type.Object({ + id: Type.Number(), + content: Type.String(), + sources: Type.Array(Type.String()), + createdAt: Type.String({ format: "date-time" }), + updatedAt: Type.String({ format: "date-time" }), + createdBy: Type.Object({ + id: Type.Integer(), + firstName: Type.Union([Type.String(), Type.Null()]), + lastName: Type.Union([Type.String(), Type.Null()]), + socialLogin: Type.Record(Type.String(), Type.Union([Type.String(), Type.Number()])), + }), + votesCount: Type.Integer(), + }), + ), + meta: Type.Object({ + total: Type.Integer(), + }), + }), + }, +}; + +export type GetAnswersQuery = Static; diff --git a/apps/api/modules/questions/questions.params.ts b/apps/api/modules/questions/questions.params.ts index fed3e525..8a1e6866 100644 --- a/apps/api/modules/questions/questions.params.ts +++ b/apps/api/modules/questions/questions.params.ts @@ -1,6 +1,6 @@ import { Prisma } from "@prisma/client"; import { kv } from "../../utils.js"; -import { GetQuestionsQuery } from "./questions.schemas.js"; +import { GetQuestionsQuery } from "./questions.schemas"; export const getQuestionsPrismaParams = ( { diff --git a/apps/api/modules/questions/questions.routes.ts b/apps/api/modules/questions/questions.routes.ts index 1afc0180..fc6bc035 100644 --- a/apps/api/modules/questions/questions.routes.ts +++ b/apps/api/modules/questions/questions.routes.ts @@ -49,6 +49,7 @@ const questionsPlugin: FastifyPluginAsync = async (fastify) => { levelId: true, statusId: true, acceptedAt: true, + updatedAt: true, _count: { select: { QuestionVote: true, @@ -66,6 +67,7 @@ const questionsPlugin: FastifyPluginAsync = async (fastify) => { _levelId: q.levelId, _statusId: q.statusId, acceptedAt: q.acceptedAt?.toISOString(), + updatedAt: q.updatedAt?.toISOString(), votesCount: q._count.QuestionVote, }; }); diff --git a/apps/api/modules/questions/questions.schemas.ts b/apps/api/modules/questions/questions.schemas.ts index 369e7454..89ffe882 100644 --- a/apps/api/modules/questions/questions.schemas.ts +++ b/apps/api/modules/questions/questions.schemas.ts @@ -26,6 +26,7 @@ const generateGetQuestionsQuerySchema = < Type.Literal("acceptedAt"), Type.Literal("level"), Type.Literal("votesCount"), + Type.Literal("updatedAt"), ]), order: Type.Union([Type.Literal("asc"), Type.Literal("desc")]), userId: Type.Integer(), @@ -51,6 +52,7 @@ const generateQuestionShape = < _levelId: Type.Union(args.levels.map((val) => Type.Literal(val))), _statusId: Type.Union(args.statuses.map((val) => Type.Literal(val))), acceptedAt: Type.Optional(Type.String({ format: "date-time" })), + updatedAt: Type.Optional(Type.String({ format: "date-time" })), } as const; }; diff --git a/apps/app/next.config.js b/apps/app/next.config.js index 322fba8c..0a1ec49b 100644 --- a/apps/app/next.config.js +++ b/apps/app/next.config.js @@ -81,6 +81,11 @@ const nextConfig = { destination: "/user/questions/1", permanent: false, }, + { + source: "/answers", + destination: "/answers/1", + permanent: false, + }, ]; }, }; diff --git a/apps/app/src/app/(main-layout)/admin/[status]/[page]/page.tsx b/apps/app/src/app/(main-layout)/admin/[status]/[page]/page.tsx index fe9584c1..c71aa4d2 100644 --- a/apps/app/src/app/(main-layout)/admin/[status]/[page]/page.tsx +++ b/apps/app/src/app/(main-layout)/admin/[status]/[page]/page.tsx @@ -5,6 +5,7 @@ import { parseQueryLevels } from "../../../../../lib/level"; import { statuses } from "../../../../../lib/question"; import { parseTechnologyQuery } from "../../../../../lib/technologies"; import { Params, SearchParams } from "../../../../../types"; +import { DEFAULT_SORT_BY_QUERY, parseQuerySortBy } from "../../../../../lib/order"; const AdminPanel = dynamic( () => @@ -19,11 +20,12 @@ export default function AdminPage({ searchParams, }: { params: Params<"status" | "page">; - searchParams?: SearchParams<"technology" | "level">; + searchParams?: SearchParams<"technology" | "level" | "sortBy">; }) { const page = Number.parseInt(params.page); const technology = parseTechnologyQuery(searchParams?.technology); const levels = parseQueryLevels(searchParams?.level); + const sortBy = parseQuerySortBy(searchParams?.sortBy || DEFAULT_SORT_BY_QUERY); if (Number.isNaN(page) || !statuses.includes(params.status)) { return redirect("/admin"); @@ -31,7 +33,14 @@ export default function AdminPage({ return ( - + ); } diff --git a/apps/app/src/app/(main-layout)/answers/[page]/page.tsx b/apps/app/src/app/(main-layout)/answers/[page]/page.tsx new file mode 100644 index 00000000..344a8e8c --- /dev/null +++ b/apps/app/src/app/(main-layout)/answers/[page]/page.tsx @@ -0,0 +1,26 @@ +import { redirect } from "next/navigation"; +import { AnswersDashboard } from "../../../../components/AnswersDashboard/AnswersDashboard"; +import { PrivateRoute } from "../../../../components/PrivateRoute"; +import { DEFAULT_ANSWERS_SORT_BY_QUERY, parseSortByQuery } from "../../../../lib/order"; +import { Params, SearchParams } from "../../../../types"; + +export default function ManageQuestionsAnswers({ + params, + searchParams, +}: { + params: Params<"page">; + searchParams?: SearchParams<"sortBy">; +}) { + const page = Number.parseInt(params.page); + const sortBy = parseSortByQuery(searchParams?.sortBy || DEFAULT_ANSWERS_SORT_BY_QUERY); + + if (Number.isNaN(page)) { + return redirect("/answers/1"); + } + + return ( + + + + ); +} diff --git a/apps/app/src/app/(main-layout)/answers/head.tsx b/apps/app/src/app/(main-layout)/answers/head.tsx new file mode 100644 index 00000000..0e83a998 --- /dev/null +++ b/apps/app/src/app/(main-layout)/answers/head.tsx @@ -0,0 +1,5 @@ +import { HeadTags } from "../../../components/HeadTags"; + +export default function Head() { + return ; +} diff --git a/apps/app/src/app/(main-layout)/answers/layout.tsx b/apps/app/src/app/(main-layout)/answers/layout.tsx new file mode 100644 index 00000000..7bfcf05f --- /dev/null +++ b/apps/app/src/app/(main-layout)/answers/layout.tsx @@ -0,0 +1,6 @@ +import { ReactNode } from "react"; +import { Container } from "../../../components/Container"; + +export default function UserPageLayout({ children }: { readonly children: ReactNode }) { + return {children}; +} diff --git a/apps/app/src/app/(main-layout)/user/questions/[page]/page.tsx b/apps/app/src/app/(main-layout)/user/questions/[page]/page.tsx index 90e2d674..9fb363b8 100644 --- a/apps/app/src/app/(main-layout)/user/questions/[page]/page.tsx +++ b/apps/app/src/app/(main-layout)/user/questions/[page]/page.tsx @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { PrivateRoute } from "../../../../../components/PrivateRoute"; import { UserQuestions } from "../../../../../components/UserQuestions/UserQuestions"; import { parseQueryLevels } from "../../../../../lib/level"; +import { DEFAULT_SORT_BY_QUERY, parseQuerySortBy } from "../../../../../lib/order"; import { parseTechnologyQuery } from "../../../../../lib/technologies"; import { Params, SearchParams } from "../../../../../types"; @@ -10,11 +11,12 @@ export default function UserQuestionsPage({ searchParams, }: { params: Params<"page">; - searchParams?: SearchParams<"technology" | "level">; + searchParams?: SearchParams<"technology" | "level" | "sortBy">; }) { const page = Number.parseInt(params.page); const technology = parseTechnologyQuery(searchParams?.technology); const levels = parseQueryLevels(searchParams?.level); + const sortBy = parseQuerySortBy(searchParams?.sortBy || DEFAULT_SORT_BY_QUERY); if (Number.isNaN(page)) { return redirect("/user/questions"); @@ -22,7 +24,13 @@ export default function UserQuestionsPage({ return ( - + ); } diff --git a/apps/app/src/components/AdminPanel/AdminPanel.tsx b/apps/app/src/components/AdminPanel/AdminPanel.tsx index 4e7bd54a..b314c315 100644 --- a/apps/app/src/components/AdminPanel/AdminPanel.tsx +++ b/apps/app/src/components/AdminPanel/AdminPanel.tsx @@ -3,6 +3,7 @@ import { Suspense, useCallback } from "react"; import { useGetAllQuestions } from "../../hooks/useGetAllQuestions"; import { Level } from "../../lib/level"; +import { Order, OrderBy } from "../../lib/order"; import { QuestionStatus } from "../../lib/question"; import { Technology } from "../../lib/technologies"; import { FilterableQuestionsList } from "../FilterableQuestionsList/FilterableQuestionsList"; @@ -14,14 +15,25 @@ type AdminPanelProps = Readonly<{ technology: Technology | null; levels: Level[] | null; status: QuestionStatus; + order?: Order; + orderBy?: OrderBy; }>; -export const AdminPanel = ({ page, technology, levels, status }: AdminPanelProps) => { +export const AdminPanel = ({ + page, + technology, + levels, + status, + order, + orderBy, +}: AdminPanelProps) => { const { isSuccess, data, refetch } = useGetAllQuestions({ page, status, technology, levels, + order, + orderBy, }); const refetchQuestions = useCallback(() => { @@ -33,14 +45,14 @@ export const AdminPanel = ({ page, technology, levels, status }: AdminPanelProps page={page} total={data?.data.meta.total || 0} getHref={(page) => `/admin/${status}/${page}`} - data={{ status, technology, levels }} + data={{ status, technology, levels, order, orderBy }} > {isSuccess && data.data.data.length > 0 ? ( }> ) : ( -

+

{status === "accepted" ? "Nie znaleziono żadnego pytania" : "Brak pytań do zaakceptowania"} diff --git a/apps/app/src/components/AnswersDashboard/AnswersDashboard.tsx b/apps/app/src/components/AnswersDashboard/AnswersDashboard.tsx new file mode 100644 index 00000000..4b7df1ac --- /dev/null +++ b/apps/app/src/components/AnswersDashboard/AnswersDashboard.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { Suspense, useCallback } from "react"; +import { useGetAllAnswers } from "../../hooks/useGetAllAnswers"; +import { Order, AnswersOrderBy } from "../../lib/order"; +import { Loading } from "../Loading"; +import { AnswersList } from "./AnswersList"; +import { FilterableAnswersList } from "./FilterableAnswersList"; + +type AnswersDashboardType = { + page: number; + orderBy?: AnswersOrderBy; + order?: Order; +}; + +export const AnswersDashboard = ({ page, orderBy, order }: AnswersDashboardType) => { + const { isSuccess, data, refetch } = useGetAllAnswers({ + page, + orderBy, + order, + }); + + const refetchAnswers = useCallback(() => { + void refetch(); + }, [refetch]); + + return ( + `/answers/${page}`} + data={{ orderBy, order }} + > + {isSuccess && data.data.data.length > 0 ? ( + }> + + + ) : ( +

+ Nie znaleziono żadnej odpowiedzi. +

+ )} + + ); +}; diff --git a/apps/app/src/components/AnswersDashboard/AnswersList.tsx b/apps/app/src/components/AnswersDashboard/AnswersList.tsx new file mode 100644 index 00000000..0b557180 --- /dev/null +++ b/apps/app/src/components/AnswersDashboard/AnswersList.tsx @@ -0,0 +1,28 @@ +import { use } from "react"; +import { serializeAnswerToMarkdown } from "../../lib/answer"; +import { APIAnswers } from "../../types"; +import { Answer } from "../QuestionAnswers/Answer"; + +type AnswersListProps = Readonly<{ + answers: APIAnswers; + refetchAnswers: () => void; +}>; + +export const AnswersList = ({ answers, refetchAnswers }: AnswersListProps) => { + const serializedAnswers = answers.map((answer) => + use( + (async () => { + const { mdxContent } = await serializeAnswerToMarkdown(answer); + return { ...answer, mdxContent }; + })(), + ), + ); + + return ( +
+ {serializedAnswers.map((answer) => ( + + ))} +
+ ); +}; diff --git a/apps/app/src/components/AnswersDashboard/FilterableAnswersList.tsx b/apps/app/src/components/AnswersDashboard/FilterableAnswersList.tsx new file mode 100644 index 00000000..5e29e804 --- /dev/null +++ b/apps/app/src/components/AnswersDashboard/FilterableAnswersList.tsx @@ -0,0 +1,29 @@ +import { ComponentProps, ReactNode } from "react"; +import { AnswersOrderBy, Order } from "../../lib/order"; +import { QuestionsPagination } from "../QuestionsPagination/QuestionsPagination"; +import { FilterableAnswersListHeader } from "./FilterableAnswersListHeader"; + +type FilterableAnswersListProps = { + page: number; + children: ReactNode; + data: { + orderBy?: AnswersOrderBy; + order?: Order; + }; +} & Omit, "current">; + +export const FilterableAnswersList = ({ + page, + total, + getHref, + children, + data, +}: FilterableAnswersListProps) => { + return ( +
+ + {children} + +
+ ); +}; diff --git a/apps/app/src/components/AnswersDashboard/FilterableAnswersListHeader.tsx b/apps/app/src/components/AnswersDashboard/FilterableAnswersListHeader.tsx new file mode 100644 index 00000000..e895771e --- /dev/null +++ b/apps/app/src/components/AnswersDashboard/FilterableAnswersListHeader.tsx @@ -0,0 +1,34 @@ +import { ChangeEvent } from "react"; +import { useDevFAQRouter } from "../../hooks/useDevFAQRouter"; +import { Order, answersSortByLabels, AnswersOrderBy } from "../../lib/order"; +import { SortBySelect } from "../SortBySelect"; + +type FilterableAnswersListHeaderProps = Readonly<{ + order?: Order; + orderBy?: AnswersOrderBy; +}>; + +export const FilterableAnswersListHeader = ({ + order, + orderBy, +}: FilterableAnswersListHeaderProps) => { + const { mergeQueryParams } = useDevFAQRouter(); + + const handleSelectChange = (param: string) => (event: ChangeEvent) => { + event.preventDefault(); + mergeQueryParams({ [param]: event.target.value }); + }; + + return ( +
+ {order && orderBy && ( + + )} +
+ ); +}; diff --git a/apps/app/src/components/CtaHeader/CtaHeader.tsx b/apps/app/src/components/CtaHeader/CtaHeader.tsx index 8d75c2bb..501680a9 100644 --- a/apps/app/src/components/CtaHeader/CtaHeader.tsx +++ b/apps/app/src/components/CtaHeader/CtaHeader.tsx @@ -20,8 +20,8 @@ const CtaHeaderActiveLink = (props: CtaHeaderActiveLinkProps) => ( export const CtaHeader = () => (
- -