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 admin dashboard for managing answers #508

Open
wants to merge 40 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
9d7ae9e
feat: add ability to sort questions by update / edit date
grzegorzpokorski Jan 22, 2023
51ee25c
feat: add ability to sort questions in user and admin dashboard
grzegorzpokorski Jan 23, 2023
57c9b9f
Update apps/app/src/hooks/useGetAllQuestions.ts
grzegorzpokorski Jan 23, 2023
e708de7
Merge branch 'develop' into 507-issue
grzegorzpokorski Jan 23, 2023
b3b68ca
add "SelectLabel" component
grzegorzpokorski Jan 23, 2023
764b5c6
add "SortBySelect" component
grzegorzpokorski Jan 23, 2023
89c1b67
implement newly created components
grzegorzpokorski Jan 23, 2023
a18b490
rename sortByLabels
grzegorzpokorski Jan 23, 2023
3ef42b0
Fix 'sortByLabels' labels
grzegorzpokorski Jan 23, 2023
ab8e183
feat(api): add simple "/answers" endpoint to fetch all answers
grzegorzpokorski Jan 24, 2023
9b4bd00
style(api): rename "CreateBy" => "createdBy in "/answers" endpoint"
grzegorzpokorski Jan 24, 2023
87a9408
feat(api): add "votesCount" field to "/answers" response
grzegorzpokorski Jan 24, 2023
320979c
fix(api): move "/answers" schema to separate file
grzegorzpokorski Jan 24, 2023
0de234a
feat(api): add "socialLogin" field to "/amswers" response
grzegorzpokorski Jan 24, 2023
f153bff
remove console.log
grzegorzpokorski Jan 29, 2023
a1ce3bb
feat(api): handle 'sortBy', 'sort', 'limit', 'offset' query params in…
grzegorzpokorski Jan 29, 2023
d6e9b15
Merge branch 'add-answers-endpoint' into 478-admin-deshboard-for-mana…
grzegorzpokorski Jan 29, 2023
2c1116f
feat(app): add '/answers' page skeleton
grzegorzpokorski Jan 29, 2023
dc52c62
Merge branch 'develop' into 507-issue
grzegorzpokorski Jan 29, 2023
8b60de2
feat(api): add count of all answers to each "/answers" response
grzegorzpokorski Jan 29, 2023
7d0e804
regenerate open-api types
grzegorzpokorski Jan 29, 2023
6c1e67f
feat(app): add 'useGetAllAnswers' hook and answers.service
grzegorzpokorski Jan 29, 2023
d5f46f0
feat(app): add "AnswersDashboars" and modify some of the reusable com…
grzegorzpokorski Jan 29, 2023
ea65d38
add AnswersDashboadHedaer skeleton
grzegorzpokorski Jan 29, 2023
4ee0973
Revert "add AnswersDashboadHedaer skeleton"
grzegorzpokorski Jan 29, 2023
0d7721e
Merge branch '507-issue' into 478-admin-dashboard-for-managing-answer…
grzegorzpokorski Jan 30, 2023
4f00711
move 'sortByLabels' out of the 'SortBySelect' component
grzegorzpokorski Jan 30, 2023
9373b6a
feat(app): add hedaer with filter to AnswersDashboard
grzegorzpokorski Jan 30, 2023
49bb821
add loader version for answers in AnswersDashboard
grzegorzpokorski Jan 30, 2023
a2541ae
fix(app): switch edit mode to false after deleting answer
grzegorzpokorski Jan 30, 2023
c88de9c
change when answers list are refetching onSubmit in "AnswersForm"
grzegorzpokorski Jan 30, 2023
ff66152
make unable to save empty answer in edit mode
grzegorzpokorski Jan 30, 2023
9a7f190
remove comments
grzegorzpokorski Jan 30, 2023
420ffe3
fix searchParams in /answers
grzegorzpokorski Jan 30, 2023
92049bc
make unable to add empty answer
grzegorzpokorski Jan 30, 2023
156bc81
remove unused imports
grzegorzpokorski Jan 30, 2023
8d21ffb
set whitespace to nowrap for CtaHeader links
grzegorzpokorski Jan 30, 2023
3c5eb70
adjust position of info text in dashboards
grzegorzpokorski Jan 30, 2023
37d3c08
remove unused import
grzegorzpokorski Jan 30, 2023
dc4844b
remove duplicated styles
grzegorzpokorski Jan 31, 2023
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
22 changes: 22 additions & 0 deletions apps/api/modules/answers/answers.params.ts
Original file line number Diff line number Diff line change
@@ -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;
};
56 changes: 54 additions & 2 deletions apps/api/modules/answers/answers.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -60,9 +62,59 @@ const answersPlugin: FastifyPluginAsync = async (fastify) => {
};

fastify.withTypeProvider<TypeBoxTypeProvider>().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<string, string | number>,
},
votesCount: a._count.QuestionAnswerVote,
};
}),
meta: { total },
};
},
});

fastify.withTypeProvider<TypeBoxTypeProvider>().route({
url: "/questions/:id/answers",
method: "GET",
schema: getAnswersRelatedToPostSchema,
async handler(request) {
const {
params: { id },
Expand Down
46 changes: 44 additions & 2 deletions apps/api/modules/answers/answers.schemas.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Type } from "@sinclair/typebox";
import { Static, Type } from "@sinclair/typebox";

const answerSchema = Type.Object({
id: Type.Number(),
Expand All @@ -15,7 +15,7 @@ const answerSchema = Type.Object({
}),
});

export const getAnswersSchema = {
export const getAnswersRelatedToPostSchema = {
params: Type.Object({
id: Type.Integer(),
}),
Expand Down Expand Up @@ -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<typeof generateGetAnswersQuerySchema>;
2 changes: 1 addition & 1 deletion apps/api/modules/questions/questions.params.ts
Original file line number Diff line number Diff line change
@@ -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 = (
{
Expand Down
2 changes: 2 additions & 0 deletions apps/api/modules/questions/questions.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const questionsPlugin: FastifyPluginAsync = async (fastify) => {
levelId: true,
statusId: true,
acceptedAt: true,
updatedAt: true,
_count: {
select: {
QuestionVote: true,
Expand All @@ -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,
};
});
Expand Down
2 changes: 2 additions & 0 deletions apps/api/modules/questions/questions.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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;
};

Expand Down
5 changes: 5 additions & 0 deletions apps/app/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ const nextConfig = {
destination: "/user/questions/1",
permanent: false,
},
{
source: "/answers",
destination: "/answers/1",
permanent: false,
},
];
},
};
Expand Down
13 changes: 11 additions & 2 deletions apps/app/src/app/(main-layout)/admin/[status]/[page]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() =>
Expand All @@ -19,19 +20,27 @@ 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");
}

return (
<PrivateRoute role="admin" loginPreviousPath="/">
<AdminPanel page={page} technology={technology} status={params.status} levels={levels} />
<AdminPanel
page={page}
technology={technology}
status={params.status}
levels={levels}
order={sortBy?.order}
orderBy={sortBy?.orderBy}
/>
</PrivateRoute>
);
}
26 changes: 26 additions & 0 deletions apps/app/src/app/(main-layout)/answers/[page]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PrivateRoute loginPreviousPath="/">
<AnswersDashboard page={page} order={sortBy?.order} orderBy={sortBy?.orderBy} />
</PrivateRoute>
);
}
5 changes: 5 additions & 0 deletions apps/app/src/app/(main-layout)/answers/head.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { HeadTags } from "../../../components/HeadTags";

export default function Head() {
return <HeadTags title="Odpowiedzi do pytań" />;
}
6 changes: 6 additions & 0 deletions apps/app/src/app/(main-layout)/answers/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ReactNode } from "react";
import { Container } from "../../../components/Container";

export default function UserPageLayout({ children }: { readonly children: ReactNode }) {
return <Container as="main">{children}</Container>;
}
12 changes: 10 additions & 2 deletions apps/app/src/app/(main-layout)/user/questions/[page]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -10,19 +11,26 @@ 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");
}

return (
<PrivateRoute loginPreviousPath="/">
<UserQuestions page={page} technology={technology} levels={levels} />
<UserQuestions
page={page}
technology={technology}
levels={levels}
order={sortBy?.order}
orderBy={sortBy?.orderBy}
/>
</PrivateRoute>
);
}
18 changes: 15 additions & 3 deletions apps/app/src/components/AdminPanel/AdminPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(() => {
Expand All @@ -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 ? (
<Suspense fallback={<Loading label="ładowanie pytań" type="article" admin />}>
<AdminPanelQuestionsList questions={data.data.data} refetchQuestions={refetchQuestions} />
</Suspense>
) : (
<p className="mt-10 text-2xl font-bold uppercase text-primary dark:text-neutral-200">
<p className="mt-10 text-center text-2xl font-bold uppercase text-primary dark:text-neutral-200">
{status === "accepted"
? "Nie znaleziono żadnego pytania"
: "Brak pytań do zaakceptowania"}
Expand Down
Loading