Skip to content

Commit c5b22f8

Browse files
AdiPol1359typeofwebxStrixU
authored
feat(app): improve frontend (#418)
* feat(app): improve frontend * feat(app): change background color of active pagination item on hover * feat(app): create LinkWithQuery component * refactor(app): move props over component * refactor(app): move redirects into next config * refactor(app): remove unused import * Add tests for LinkWithQuery * Add tests to pipeline * Fix types * refactor(app): add lists * feat(app): improve LinkWithQuery * Fix url Co-authored-by: Michal Miszczyszyn <michal@mmiszy.pl> Co-authored-by: xStrixU <strixu155@gmail.com>
1 parent 77cc645 commit c5b22f8

21 files changed

+720
-201
lines changed

.github/workflows/tests.yml

+3
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ jobs:
4949
- name: Check linters
5050
run: pnpm run lint --cache-dir=".turbo"
5151

52+
- name: Run tests
53+
run: pnpm run test --cache-dir=".turbo"
54+
5255
- name: Check TypeScript
5356
run: pnpm run check-types --cache-dir=".turbo"
5457

apps/app/next.config.js

+14
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ const nextConfig = {
2626

2727
return config;
2828
},
29+
async redirects() {
30+
return [
31+
{
32+
source: "/questions",
33+
destination: "/questions/js/1",
34+
permanent: true,
35+
},
36+
{
37+
source: "/questions/:technology",
38+
destination: "/questions/:technology/1",
39+
permanent: true,
40+
},
41+
];
42+
},
2943
};
3044

3145
module.exports = nextConfig;

apps/app/package.json

+4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"dev": "next dev",
77
"build": "next build",
88
"start": "next start",
9+
"test": "vitest",
910
"lint": "next lint --dir .",
1011
"lint:fix": "next lint --dir . --fix --quiet",
1112
"check-types": "tsc --noEmit",
@@ -42,12 +43,14 @@
4243
"@types/prismjs": "1.26.0",
4344
"@types/react": "18.0.26",
4445
"@types/react-dom": "18.0.9",
46+
"@vitejs/plugin-react": "3.0.0",
4547
"@types/remark-prism": "1.3.4",
4648
"@vercel/analytics": "0.1.6",
4749
"autoprefixer": "^10.4.13",
4850
"css-loader": "^6.7.3",
4951
"eslint": "8.29.0",
5052
"eslint-config-devfaq": "workspace:*",
53+
"jsdom": "20.0.3",
5154
"eslint-plugin-storybook": "^0.6.8",
5255
"openapi-types": "workspace:*",
5356
"postcss": "^8.4.20",
@@ -56,6 +59,7 @@
5659
"style-loader": "^3.3.1",
5760
"tailwindcss": "^3.2.4",
5861
"tsconfig": "workspace:*",
62+
"vitest": "0.25.8",
5963
"typescript": "4.9.4"
6064
},
6165
"nextBundleAnalysis": {

apps/app/src/app/(main-layout)/questions/[technology]/page.tsx

-5
This file was deleted.

apps/app/src/app/(main-layout)/questions/page.tsx

-5
This file was deleted.
+9-12
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,25 @@
11
"use client";
22

3-
import Link from "next/link";
43
import { usePathname } from "next/navigation";
54
import { twMerge } from "tailwind-merge";
65
import type { ComponentProps } from "react";
6+
import { LinkWithQuery } from "./LinkWithQuery/LinkWithQuery";
77

88
type ActiveLinkProps = Readonly<{
99
activeClassName: string;
1010
}> &
11-
ComponentProps<typeof Link>;
11+
ComponentProps<typeof LinkWithQuery>;
1212

13-
export const ActiveLink = ({
14-
href,
15-
className,
16-
activeClassName,
17-
children,
18-
...rest
19-
}: ActiveLinkProps) => {
13+
export const ActiveLink = ({ href, className, activeClassName, ...props }: ActiveLinkProps) => {
2014
const pathname = usePathname();
15+
2116
const isActive = pathname?.startsWith(href.toString());
2217

2318
return (
24-
<Link href={href} className={twMerge(className, isActive && activeClassName)} {...rest}>
25-
{children}
26-
</Link>
19+
<LinkWithQuery
20+
href={href}
21+
className={twMerge(className, isActive && activeClassName)}
22+
{...props}
23+
/>
2724
);
2825
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createQueryHref } from "./LinkWithQuery";
3+
4+
describe("LinkWithQuery", () => {
5+
describe("createQueryHref", () => {
6+
it("should return given href and query", () => {
7+
expect(createQueryHref("test", { q1: "abc", q2: "123" })).toEqual("test?q1=abc&q2=123");
8+
});
9+
it("should work with empty href and query", () => {
10+
expect(createQueryHref("", {})).toEqual("");
11+
});
12+
it("should merge href and query when href is object", () => {
13+
expect(createQueryHref({ pathname: "test2" }, { q1: "abc", q2: "123" })).toEqual(
14+
"/test2?q1=abc&q2=123",
15+
);
16+
});
17+
it("should merge href and query when href is object and has query inside", () => {
18+
expect(
19+
createQueryHref(
20+
{ pathname: "test3", query: { q1: "href1", q2: "href2", q3: "href3" }, hash: "fragment" },
21+
{ q0: "query0", q1: "query1", q2: "query2" },
22+
),
23+
).toEqual("/test3?q1=query1&q2=query2&q3=href3&q0=query0#fragment");
24+
});
25+
it("should preserve other fields in href", () => {
26+
expect(createQueryHref({ pathname: "test3", hash: "blablabla" }, {})).toEqual(
27+
"/test3#blablabla",
28+
);
29+
});
30+
it("should merge queries when href is a string", () => {
31+
expect(
32+
createQueryHref("test4?q1=href1&q2=href2&q3=href3#fragment", {
33+
q0: "query0",
34+
q1: "query1",
35+
q2: "query2",
36+
q3: "href3",
37+
}),
38+
).toEqual("test4?q1=query1&q2=query2&q3=href3&q0=query0#fragment");
39+
});
40+
it("should merge queries when href is a an absolute URL", () => {
41+
expect(
42+
createQueryHref("https://google.com/test5?q1=href1&q2=href2&q3=href3#fragment", {
43+
q0: "query0",
44+
q1: "query1",
45+
q2: "query2",
46+
q3: "href3",
47+
}),
48+
).toEqual("https://google.com/test5?q1=query1&q2=query2&q3=href3&q0=query0#fragment");
49+
});
50+
});
51+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"use client";
2+
import { UrlObject } from "url";
3+
import Link, { LinkProps } from "next/link";
4+
import { ComponentProps } from "react";
5+
import { useDevFAQRouter } from "../../hooks/useDevFAQRouter";
6+
import { escapeStringRegexp } from "../../lib/escapeStringRegex";
7+
8+
type Url = LinkProps["href"];
9+
10+
const origin = process.env.NEXT_PUBLIC_APP_URL || "http://dummy.localhost:8080";
11+
12+
const urlObjectToUrl = (urlObject: UrlObject, origin: string): URL => {
13+
const url = new URL(origin);
14+
if (urlObject.protocol) url.protocol = urlObject.protocol;
15+
if (urlObject.auth) {
16+
const auth = urlObject.auth.split(":");
17+
url.username = auth[0] || url.username;
18+
url.password = auth[1] || url.password;
19+
}
20+
if (urlObject.host) url.host = urlObject.host;
21+
if (urlObject.hostname) url.hostname = urlObject.hostname;
22+
if (urlObject.port) url.port = urlObject.port.toString();
23+
if (urlObject.hash) url.hash = urlObject.hash;
24+
if (urlObject.search) url.search = urlObject.search;
25+
if (urlObject.query)
26+
url.search = new URLSearchParams(urlObject.query as Record<string, string> | string).toString();
27+
if (urlObject.pathname) url.pathname = urlObject.pathname;
28+
29+
return url;
30+
};
31+
32+
export const createQueryHref = (href: Url, query: Record<string, string>): string => {
33+
const url = typeof href === "string" ? new URL(href, origin) : urlObjectToUrl(href, origin);
34+
Object.entries(query).forEach(([key, value]) => url.searchParams.set(key, value));
35+
36+
const newHref = url.toString().replace(new RegExp("^" + escapeStringRegexp(origin)), "");
37+
38+
if (newHref.startsWith("/") && typeof href === "string" && !href.startsWith("/")) {
39+
// trim slash
40+
return newHref.slice(1);
41+
}
42+
return newHref;
43+
};
44+
45+
type LinkWithQueryProps = Readonly<{
46+
mergeQuery?: boolean;
47+
}> &
48+
ComponentProps<typeof Link>;
49+
50+
export const LinkWithQuery = ({ href, mergeQuery, ...props }: LinkWithQueryProps) => {
51+
const { queryParams } = useDevFAQRouter();
52+
53+
const linkHref = mergeQuery ? createQueryHref(href, queryParams) : createQueryHref(href, {});
54+
55+
return <Link href={linkHref} {...props} />;
56+
};

apps/app/src/components/QuestionsHeader.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { ChangeEvent, Fragment } from "react";
3+
import { ChangeEvent } from "react";
44
import { technologiesLabel, Technology } from "../lib/technologies";
55
import { pluralize } from "../utils/intl";
66
import { useQuestionsOrderBy } from "../hooks/useQuestionsOrderBy";

apps/app/src/components/QuestionsList/QuestionsList.tsx

+13-12
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,27 @@ export const QuestionsList = ({ questions, questionFilter }: QuestionsListProps)
1717
};
1818

1919
return (
20-
<>
20+
<ul className="space-y-10">
2121
{questions.map(({ id, mdxContent, _levelId, acceptedAt }) => {
2222
const questionVote = questionsVotes?.find((questionVote) => questionVote.id === id);
2323
const [votes, voted] = questionVote
2424
? [questionVote.votesCount, questionVote.currentUserVotedOn]
2525
: [0, false];
2626

2727
return (
28-
<QuestionItem
29-
key={id}
30-
id={id}
31-
mdxContent={mdxContent}
32-
level={_levelId}
33-
creationDate={new Date(acceptedAt || "")}
34-
votes={votes}
35-
voted={voted}
36-
onQuestionVote={onQuestionVote}
37-
/>
28+
<li key={id}>
29+
<QuestionItem
30+
id={id}
31+
mdxContent={mdxContent}
32+
level={_levelId}
33+
creationDate={new Date(acceptedAt || "")}
34+
votes={votes}
35+
voted={voted}
36+
onQuestionVote={onQuestionVote}
37+
/>
38+
</li>
3839
);
3940
})}
40-
</>
41+
</ul>
4142
);
4243
};

apps/app/src/components/QuestionsPagination.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ export const QuestionsPagination = ({ technology, total }: QuestionsPaginationPr
1616
key={i}
1717
href={`/questions/${technology}/${i + 1}`}
1818
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-full border-2 border-primary text-primary transition-colors duration-300 hover:bg-violet-100 dark:text-white dark:hover:bg-violet-800"
19-
activeClassName="bg-primary text-white"
19+
activeClassName="bg-primary text-white hover:bg-primary"
20+
mergeQuery
2021
>
2122
{i + 1}
2223
</ActiveLink>

apps/app/src/components/QuestionsSidebar/LevelFilter/LevelFilter.tsx

+7-10
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,20 @@ export const LevelFilter = () => {
1010

1111
return (
1212
<QuestionsSidebarSection title="Wybierz poziom">
13-
<div className="flex justify-center gap-3 sm:flex-col small-filters:flex-row">
13+
<ul className="flex justify-center gap-3 sm:flex-col small-filters:flex-row">
1414
{levels.map((level) => {
1515
const isActive = Boolean(queryLevels?.includes(level));
1616
const handleClick = isActive ? removeLevel : addLevel;
1717

1818
return (
19-
<LevelButton
20-
key={level}
21-
variant={level}
22-
isActive={isActive}
23-
onClick={() => handleClick(level)}
24-
>
25-
{level}
26-
</LevelButton>
19+
<li key={level}>
20+
<LevelButton variant={level} isActive={isActive} onClick={() => handleClick(level)}>
21+
{level}
22+
</LevelButton>
23+
</li>
2724
);
2825
})}
29-
</div>
26+
</ul>
3027
</QuestionsSidebarSection>
3128
);
3229
};

apps/app/src/components/QuestionsSidebar/QuestionsSidebar.tsx

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
"use client";
22

3+
import { useEffect } from "react";
34
import { twMerge } from "tailwind-merge";
45
import { useUIContext } from "../../providers/UIProvider";
56
import { Button } from "../Button/Button";
67
import { CloseButton } from "../CloseButton/CloseButton";
8+
import { lockScroll, unlockScroll } from "../../utils/pageScroll";
79
import { LevelFilter } from "./LevelFilter/LevelFilter";
810
import { TechnologyFilter } from "./TechnologyFilter/TechnologyFilter";
911

1012
export const QuestionsSidebar = () => {
1113
const { isSidebarOpen, closeSidebar } = useUIContext();
1214

15+
useEffect(() => {
16+
if (isSidebarOpen) {
17+
lockScroll();
18+
} else {
19+
unlockScroll();
20+
}
21+
}, [isSidebarOpen]);
22+
1323
return (
1424
<aside
1525
className={twMerge(
@@ -19,7 +29,7 @@ export const QuestionsSidebar = () => {
1929
>
2030
<TechnologyFilter />
2131
<LevelFilter />
22-
<Button variant="brandingInverse" className="mt-auto sm:hidden">
32+
<Button variant="brandingInverse" className="mt-auto sm:hidden" onClick={closeSidebar}>
2333
Pokaż wyniki
2434
</Button>
2535
<CloseButton className="absolute top-1 right-1 sm:hidden" onClick={closeSidebar} />

apps/app/src/components/QuestionsSidebar/TechnologyFilter/Technology.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const Technology = ({ href, title, icon }: TechnologyProps) => (
1313
activeClassName="border border-primary bg-violet-50 dark:bg-violet-900"
1414
title={title}
1515
href={`/questions/${href}`}
16+
mergeQuery
1617
>
1718
<span className="text-sm text-neutral-500 dark:text-neutral-200 small-filters:text-xs">
1819
{title}

apps/app/src/components/QuestionsSidebar/TechnologyFilter/TechnologyFilter.tsx

+5-3
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ const technologyFilters = [
2323
export const TechnologyFilter = () => {
2424
return (
2525
<QuestionsSidebarSection title="Wybierz technologię">
26-
<div className="flex justify-between gap-x-4 overflow-x-auto px-4 pb-4 sm:flex-wrap sm:gap-x-0 sm:gap-y-7 sm:overflow-x-visible sm:p-0 small-filters:gap-y-4">
26+
<ul className="flex justify-between gap-x-4 overflow-x-auto px-4 pb-4 sm:flex-wrap sm:gap-x-0 sm:gap-y-7 sm:overflow-x-visible sm:p-0 small-filters:gap-y-4">
2727
{technologyFilters.map((tech) => (
28-
<Technology key={tech.href} {...tech} />
28+
<li key={tech.href}>
29+
<Technology {...tech} />
30+
</li>
2931
))}
30-
</div>
32+
</ul>
3133
</QuestionsSidebarSection>
3234
);
3335
};

apps/app/src/hooks/useDevFAQRouter.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ export const useDevFAQRouter = () => {
77
const pathname = usePathname();
88
const { userData } = useUser();
99

10+
const queryParams = Object.fromEntries(searchParams.entries());
11+
1012
const mergeQueryParams = (data: Record<string, string>) => {
11-
const params = { ...Object.fromEntries(searchParams.entries()), ...data };
13+
const params = { ...queryParams, ...data };
1214
const query = new URLSearchParams(params).toString();
1315

1416
if (pathname) {
@@ -24,5 +26,5 @@ export const useDevFAQRouter = () => {
2426
return callback;
2527
};
2628

27-
return { mergeQueryParams, requireLoggedIn };
29+
return { queryParams, mergeQueryParams, requireLoggedIn };
2830
};

apps/app/src/lib/escapeStringRegex.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js
2+
export function escapeStringRegexp(string: string): string {
3+
// Escape characters with special meaning either inside or outside character sets.
4+
// Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns’ stricter grammar.
5+
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
6+
}

0 commit comments

Comments
 (0)