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/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)/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..6757a382 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,7 +45,7 @@ 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 ? ( }> diff --git a/apps/app/src/components/FilterableQuestionsList/FilterableQuestionsList.tsx b/apps/app/src/components/FilterableQuestionsList/FilterableQuestionsList.tsx index 5c2a7f6c..650e379c 100644 --- a/apps/app/src/components/FilterableQuestionsList/FilterableQuestionsList.tsx +++ b/apps/app/src/components/FilterableQuestionsList/FilterableQuestionsList.tsx @@ -1,4 +1,5 @@ import { ComponentProps, ReactNode } from "react"; +import { Order, OrderBy } from "../../lib/order"; import { QuestionStatus } from "../../lib/question"; import { Technology } from "../../lib/technologies"; import { Level } from "../QuestionItem/QuestionLevel"; @@ -11,6 +12,8 @@ type FilterableQuestionsListProps = Readonly<{ status?: QuestionStatus; technology?: Technology | null; levels?: Level[] | null; + order?: Order; + orderBy?: OrderBy; }; children: ReactNode; }> & diff --git a/apps/app/src/components/FilterableQuestionsList/FilterableQuestionsListHeader.tsx b/apps/app/src/components/FilterableQuestionsList/FilterableQuestionsListHeader.tsx index 69f2d20b..7fd15e77 100644 --- a/apps/app/src/components/FilterableQuestionsList/FilterableQuestionsListHeader.tsx +++ b/apps/app/src/components/FilterableQuestionsList/FilterableQuestionsListHeader.tsx @@ -2,25 +2,28 @@ import { useRouter } from "next/navigation"; import { ChangeEvent, ReactNode } from "react"; import { useDevFAQRouter } from "../../hooks/useDevFAQRouter"; import { levels } from "../../lib/level"; +import { Order, OrderBy } from "../../lib/order"; import { QuestionStatus, statuses } from "../../lib/question"; import { technologies, technologiesLabels, Technology } from "../../lib/technologies"; import { Level } from "../QuestionItem/QuestionLevel"; import { Select } from "../Select/Select"; +import { SelectLabel } from "../SelectLabel"; +import { SortBySelect } from "../SortBySelect"; type FilterableQuestionsListHeaderProps = Readonly<{ status?: QuestionStatus; technology?: Technology | null; levels?: Level[] | null; + order?: Order; + orderBy?: OrderBy; }>; -const SelectLabel = ({ children }: { readonly children: ReactNode }) => ( - -); - export const FilterableQuestionsListHeader = ({ status, technology, levels: selectedLevels, + order, + orderBy, }: FilterableQuestionsListHeaderProps) => { const { mergeQueryParams } = useDevFAQRouter(); const router = useRouter(); @@ -66,6 +69,9 @@ export const FilterableQuestionsListHeader = ({ )} + {order && orderBy && ( + + )} {status !== undefined && ( Status: diff --git a/apps/app/src/components/QuestionsHeader.tsx b/apps/app/src/components/QuestionsHeader.tsx index 2f62e123..2b8f0ca0 100644 --- a/apps/app/src/components/QuestionsHeader.tsx +++ b/apps/app/src/components/QuestionsHeader.tsx @@ -4,8 +4,8 @@ import { ChangeEvent } from "react"; import { technologiesLabels, Technology } from "../lib/technologies"; import { pluralize } from "../utils/intl"; import { useQuestionsOrderBy } from "../hooks/useQuestionsOrderBy"; -import { sortByLabels } from "../lib/order"; -import { Select } from "./Select/Select"; +import { parseQuerySortBy } from "../lib/order"; +import { SortBySelect } from "./SortBySelect"; const questionsPluralize = pluralize("pytanie", "pytania", "pytań"); @@ -16,6 +16,7 @@ type QuestionsHeaderProps = Readonly<{ export const QuestionsHeader = ({ technology, total }: QuestionsHeaderProps) => { const { sortBy, setSortByFromString } = useQuestionsOrderBy(); + const sort = parseQuerySortBy(sortBy); const handleSelectChange = (event: ChangeEvent) => { event.preventDefault(); @@ -28,16 +29,9 @@ export const QuestionsHeader = ({ technology, total }: QuestionsHeaderProps) => {technologiesLabels[technology]}: {total} {questionsPluralize(total)} - + {sort?.order && sort?.orderBy && ( + + )} ); }; diff --git a/apps/app/src/components/SelectLabel.tsx b/apps/app/src/components/SelectLabel.tsx new file mode 100644 index 00000000..ff0c95ec --- /dev/null +++ b/apps/app/src/components/SelectLabel.tsx @@ -0,0 +1,5 @@ +import { ReactNode } from "react"; + +export const SelectLabel = ({ children }: { readonly children: ReactNode }) => ( + +); diff --git a/apps/app/src/components/SortBySelect.tsx b/apps/app/src/components/SortBySelect.tsx new file mode 100644 index 00000000..d8d8f952 --- /dev/null +++ b/apps/app/src/components/SortBySelect.tsx @@ -0,0 +1,23 @@ +import { ChangeEvent } from "react"; +import { Order, OrderBy, sortByLabels } from "../lib/order"; +import { Select } from "./Select/Select"; +import { SelectLabel } from "./SelectLabel"; + +type SortBySelectProps = { + order: Order; + orderBy: OrderBy; + onChange: (event: ChangeEvent) => void; +}; + +export const SortBySelect = ({ order, orderBy, onChange }: SortBySelectProps) => ( + + Sortowanie: + + +); diff --git a/apps/app/src/components/UserQuestions/UserQuestions.tsx b/apps/app/src/components/UserQuestions/UserQuestions.tsx index c4e4130c..384ab51e 100644 --- a/apps/app/src/components/UserQuestions/UserQuestions.tsx +++ b/apps/app/src/components/UserQuestions/UserQuestions.tsx @@ -3,6 +3,7 @@ import { Suspense, useCallback } from "react"; import { useGetAllQuestions } from "../../hooks/useGetAllQuestions"; import { useUser } from "../../hooks/useUser"; +import { Order, OrderBy } from "../../lib/order"; import { Technology } from "../../lib/technologies"; import { FilterableQuestionsList } from "../FilterableQuestionsList/FilterableQuestionsList"; import { Loading } from "../Loading"; @@ -13,15 +14,19 @@ type UserQuestionsProps = Readonly<{ page: number; technology: Technology | null; levels: Level[] | null; + order?: Order; + orderBy?: OrderBy; }>; -export const UserQuestions = ({ page, technology, levels }: UserQuestionsProps) => { +export const UserQuestions = ({ page, technology, levels, order, orderBy }: UserQuestionsProps) => { const { userData } = useUser(); const { isSuccess, data, refetch } = useGetAllQuestions({ page, technology, levels, userId: userData?._user.id, + order, + orderBy, }); const refetchQuestions = useCallback(() => { @@ -33,7 +38,7 @@ export const UserQuestions = ({ page, technology, levels }: UserQuestionsProps) page={page} total={data?.data.meta.total || 0} getHref={(page) => `/user/questions/${page}`} - data={{ technology, levels }} + data={{ technology, levels, order, orderBy }} > {isSuccess && data.data.data.length > 0 ? ( { const query = useQuery({ - queryKey: ["questions", { page, technology, levels, status, userId }], + queryKey: ["questions", { page, technology, levels, status, userId, order, orderBy }], queryFn: () => getAllQuestions({ limit: PAGE_SIZE, @@ -28,6 +33,8 @@ export const useGetAllQuestions = ({ ...(levels && { level: levels.join(",") }), status, userId, + order, + orderBy, }), keepPreviousData: true, }); diff --git a/apps/app/src/lib/order.ts b/apps/app/src/lib/order.ts index b622ad27..bcdbb922 100644 --- a/apps/app/src/lib/order.ts +++ b/apps/app/src/lib/order.ts @@ -1,20 +1,22 @@ import { QueryParam } from "../types"; -const ordersBy = ["acceptedAt", "level", "votesCount"] as const; +const ordersBy = ["acceptedAt", "level", "votesCount", "updatedAt"] as const; const orders = ["asc", "desc"] as const; export const DEFAULT_SORT_BY_QUERY = "acceptedAt*desc"; export const sortByLabels: Record<`${OrderBy}*${Order}`, string> = { - "acceptedAt*asc": "od najstarszych", - "acceptedAt*desc": "od najnowszych", - "level*asc": "od najprostszych", - "level*desc": "od najtrudniejszych", - "votesCount*asc": "od najmniej popularnych", - "votesCount*desc": "od najpopularniejszych", + "acceptedAt*asc": "data dodania: najstarsze", + "acceptedAt*desc": "data dodania: najnowsze", + "level*asc": "trudność: od najłatwiejszych", + "level*desc": "trudność: od najtrudniejszych", + "votesCount*asc": "popularność: najmniejsza", + "votesCount*desc": "popularność: największa", + "updatedAt*asc": "data edycji: najstarsze", + "updatedAt*desc": "data edycji: najnowsze", }; -type OrderBy = typeof ordersBy[number]; -type Order = typeof orders[number]; +export type OrderBy = typeof ordersBy[number]; +export type Order = typeof orders[number]; export const parseQuerySortBy = (query: QueryParam) => { if (typeof query !== "string") { diff --git a/packages/openapi-types/types.ts b/packages/openapi-types/types.ts index 3bd75e52..9033a4eb 100644 --- a/packages/openapi-types/types.ts +++ b/packages/openapi-types/types.ts @@ -79,7 +79,7 @@ export interface paths { level?: string; limit?: number; offset?: number; - orderBy?: "acceptedAt" | "level" | "votesCount"; + orderBy?: "acceptedAt" | "level" | "votesCount" | "updatedAt"; order?: "asc" | "desc"; userId?: number; }; @@ -97,6 +97,8 @@ export interface paths { _statusId: "pending" | "accepted"; /** Format: date-time */ acceptedAt?: string; + /** Format: date-time */ + updatedAt?: string; votesCount: number; }[]; meta: { @@ -130,6 +132,8 @@ export interface paths { _statusId: "pending" | "accepted"; /** Format: date-time */ acceptedAt?: string; + /** Format: date-time */ + updatedAt?: string; votesCount: number; }; }; @@ -147,7 +151,7 @@ export interface paths { level?: string; limit?: number; offset?: number; - orderBy?: "acceptedAt" | "level" | "votesCount"; + orderBy?: "acceptedAt" | "level" | "votesCount" | "updatedAt"; order?: "asc" | "desc"; userId?: number; }; @@ -246,6 +250,8 @@ export interface paths { _statusId: "pending" | "accepted"; /** Format: date-time */ acceptedAt?: string; + /** Format: date-time */ + updatedAt?: string; votesCount: number; }; }; @@ -293,6 +299,8 @@ export interface paths { _statusId: "pending" | "accepted"; /** Format: date-time */ acceptedAt?: string; + /** Format: date-time */ + updatedAt?: string; votesCount: number; }; };