Skip to content

feat(app): improve frontend #418

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

Merged
merged 17 commits into from
Dec 17, 2022
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
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ jobs:
- name: Check linters
run: pnpm run lint --cache-dir=".turbo"

- name: Run tests
run: pnpm run test --cache-dir=".turbo"

- name: Check TypeScript
run: pnpm run check-types --cache-dir=".turbo"

Expand Down
14 changes: 14 additions & 0 deletions apps/app/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ const nextConfig = {

return config;
},
async redirects() {
return [
{
source: "/questions",
destination: "/questions/js/1",
permanent: true,
},
{
source: "/questions/:technology",
destination: "/questions/:technology/1",
permanent: true,
},
];
},
};

module.exports = nextConfig;
4 changes: 4 additions & 0 deletions apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "vitest",
"lint": "next lint --dir .",
"lint:fix": "next lint --dir . --fix --quiet",
"check-types": "tsc --noEmit",
Expand Down Expand Up @@ -42,12 +43,14 @@
"@types/prismjs": "1.26.0",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.9",
"@vitejs/plugin-react": "3.0.0",
"@types/remark-prism": "1.3.4",
"@vercel/analytics": "0.1.6",
"autoprefixer": "^10.4.13",
"css-loader": "^6.7.3",
"eslint": "8.29.0",
"eslint-config-devfaq": "workspace:*",
"jsdom": "20.0.3",
"eslint-plugin-storybook": "^0.6.8",
"openapi-types": "workspace:*",
"postcss": "^8.4.20",
Expand All @@ -56,6 +59,7 @@
"style-loader": "^3.3.1",
"tailwindcss": "^3.2.4",
"tsconfig": "workspace:*",
"vitest": "0.25.8",
"typescript": "4.9.4"
},
"nextBundleAnalysis": {
Expand Down

This file was deleted.

5 changes: 0 additions & 5 deletions apps/app/src/app/(main-layout)/questions/page.tsx

This file was deleted.

21 changes: 9 additions & 12 deletions apps/app/src/components/ActiveLink.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,25 @@
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { twMerge } from "tailwind-merge";
import type { ComponentProps } from "react";
import { LinkWithQuery } from "./LinkWithQuery/LinkWithQuery";

type ActiveLinkProps = Readonly<{
activeClassName: string;
}> &
ComponentProps<typeof Link>;
ComponentProps<typeof LinkWithQuery>;

export const ActiveLink = ({
href,
className,
activeClassName,
children,
...rest
}: ActiveLinkProps) => {
export const ActiveLink = ({ href, className, activeClassName, ...props }: ActiveLinkProps) => {
const pathname = usePathname();

const isActive = pathname?.startsWith(href.toString());

return (
<Link href={href} className={twMerge(className, isActive && activeClassName)} {...rest}>
{children}
</Link>
<LinkWithQuery
href={href}
className={twMerge(className, isActive && activeClassName)}
{...props}
/>
);
};
51 changes: 51 additions & 0 deletions apps/app/src/components/LinkWithQuery/LinkWithQuery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { createQueryHref } from "./LinkWithQuery";

describe("LinkWithQuery", () => {
describe("createQueryHref", () => {
it("should return given href and query", () => {
expect(createQueryHref("test", { q1: "abc", q2: "123" })).toEqual("test?q1=abc&q2=123");
});
it("should work with empty href and query", () => {
expect(createQueryHref("", {})).toEqual("");
});
it("should merge href and query when href is object", () => {
expect(createQueryHref({ pathname: "test2" }, { q1: "abc", q2: "123" })).toEqual(
"/test2?q1=abc&q2=123",
);
});
it("should merge href and query when href is object and has query inside", () => {
expect(
createQueryHref(
{ pathname: "test3", query: { q1: "href1", q2: "href2", q3: "href3" }, hash: "fragment" },
{ q0: "query0", q1: "query1", q2: "query2" },
),
).toEqual("/test3?q1=query1&q2=query2&q3=href3&q0=query0#fragment");
});
it("should preserve other fields in href", () => {
expect(createQueryHref({ pathname: "test3", hash: "blablabla" }, {})).toEqual(
"/test3#blablabla",
);
});
it("should merge queries when href is a string", () => {
expect(
createQueryHref("test4?q1=href1&q2=href2&q3=href3#fragment", {
q0: "query0",
q1: "query1",
q2: "query2",
q3: "href3",
}),
).toEqual("test4?q1=query1&q2=query2&q3=href3&q0=query0#fragment");
});
it("should merge queries when href is a an absolute URL", () => {
expect(
createQueryHref("https://google.com/test5?q1=href1&q2=href2&q3=href3#fragment", {
q0: "query0",
q1: "query1",
q2: "query2",
q3: "href3",
}),
).toEqual("https://google.com/test5?q1=query1&q2=query2&q3=href3&q0=query0#fragment");
});
});
});
56 changes: 56 additions & 0 deletions apps/app/src/components/LinkWithQuery/LinkWithQuery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client";
import { UrlObject } from "url";
import Link, { LinkProps } from "next/link";
import { ComponentProps } from "react";
import { useDevFAQRouter } from "../../hooks/useDevFAQRouter";
import { escapeStringRegexp } from "../../lib/escapeStringRegex";

type Url = LinkProps["href"];

const origin = process.env.NEXT_PUBLIC_APP_URL || "http://dummy.localhost:8080";

const urlObjectToUrl = (urlObject: UrlObject, origin: string): URL => {
const url = new URL(origin);
if (urlObject.protocol) url.protocol = urlObject.protocol;
if (urlObject.auth) {
const auth = urlObject.auth.split(":");
url.username = auth[0] || url.username;
url.password = auth[1] || url.password;
}
if (urlObject.host) url.host = urlObject.host;
if (urlObject.hostname) url.hostname = urlObject.hostname;
if (urlObject.port) url.port = urlObject.port.toString();
if (urlObject.hash) url.hash = urlObject.hash;
if (urlObject.search) url.search = urlObject.search;
if (urlObject.query)
url.search = new URLSearchParams(urlObject.query as Record<string, string> | string).toString();
if (urlObject.pathname) url.pathname = urlObject.pathname;

return url;
};

export const createQueryHref = (href: Url, query: Record<string, string>): string => {
const url = typeof href === "string" ? new URL(href, origin) : urlObjectToUrl(href, origin);
Object.entries(query).forEach(([key, value]) => url.searchParams.set(key, value));

const newHref = url.toString().replace(new RegExp("^" + escapeStringRegexp(origin)), "");

if (newHref.startsWith("/") && typeof href === "string" && !href.startsWith("/")) {
// trim slash
return newHref.slice(1);
}
return newHref;
};

type LinkWithQueryProps = Readonly<{
mergeQuery?: boolean;
}> &
ComponentProps<typeof Link>;

export const LinkWithQuery = ({ href, mergeQuery, ...props }: LinkWithQueryProps) => {
const { queryParams } = useDevFAQRouter();

const linkHref = mergeQuery ? createQueryHref(href, queryParams) : createQueryHref(href, {});

return <Link href={linkHref} {...props} />;
};
2 changes: 1 addition & 1 deletion apps/app/src/components/QuestionsHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { ChangeEvent, Fragment } from "react";
import { ChangeEvent } from "react";
import { technologiesLabel, Technology } from "../lib/technologies";
import { pluralize } from "../utils/intl";
import { useQuestionsOrderBy } from "../hooks/useQuestionsOrderBy";
Expand Down
25 changes: 13 additions & 12 deletions apps/app/src/components/QuestionsList/QuestionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,27 @@ export const QuestionsList = ({ questions, questionFilter }: QuestionsListProps)
};

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

return (
<QuestionItem
key={id}
id={id}
mdxContent={mdxContent}
level={_levelId}
creationDate={new Date(acceptedAt || "")}
votes={votes}
voted={voted}
onQuestionVote={onQuestionVote}
/>
<li key={id}>
<QuestionItem
id={id}
mdxContent={mdxContent}
level={_levelId}
creationDate={new Date(acceptedAt || "")}
votes={votes}
voted={voted}
onQuestionVote={onQuestionVote}
/>
</li>
);
})}
</>
</ul>
);
};
3 changes: 2 additions & 1 deletion apps/app/src/components/QuestionsPagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export const QuestionsPagination = ({ technology, total }: QuestionsPaginationPr
key={i}
href={`/questions/${technology}/${i + 1}`}
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"
activeClassName="bg-primary text-white"
activeClassName="bg-primary text-white hover:bg-primary"
mergeQuery
>
{i + 1}
</ActiveLink>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,20 @@ export const LevelFilter = () => {

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

return (
<LevelButton
key={level}
variant={level}
isActive={isActive}
onClick={() => handleClick(level)}
>
{level}
</LevelButton>
<li key={level}>
<LevelButton variant={level} isActive={isActive} onClick={() => handleClick(level)}>
{level}
</LevelButton>
</li>
);
})}
</div>
</ul>
</QuestionsSidebarSection>
);
};
12 changes: 11 additions & 1 deletion apps/app/src/components/QuestionsSidebar/QuestionsSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
"use client";

import { useEffect } from "react";
import { twMerge } from "tailwind-merge";
import { useUIContext } from "../../providers/UIProvider";
import { Button } from "../Button/Button";
import { CloseButton } from "../CloseButton/CloseButton";
import { lockScroll, unlockScroll } from "../../utils/pageScroll";
import { LevelFilter } from "./LevelFilter/LevelFilter";
import { TechnologyFilter } from "./TechnologyFilter/TechnologyFilter";

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

useEffect(() => {
if (isSidebarOpen) {
lockScroll();
} else {
unlockScroll();
}
}, [isSidebarOpen]);

return (
<aside
className={twMerge(
Expand All @@ -19,7 +29,7 @@ export const QuestionsSidebar = () => {
>
<TechnologyFilter />
<LevelFilter />
<Button variant="brandingInverse" className="mt-auto sm:hidden">
<Button variant="brandingInverse" className="mt-auto sm:hidden" onClick={closeSidebar}>
Pokaż wyniki
</Button>
<CloseButton className="absolute top-1 right-1 sm:hidden" onClick={closeSidebar} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const Technology = ({ href, title, icon }: TechnologyProps) => (
activeClassName="border border-primary bg-violet-50 dark:bg-violet-900"
title={title}
href={`/questions/${href}`}
mergeQuery
>
<span className="text-sm text-neutral-500 dark:text-neutral-200 small-filters:text-xs">
{title}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ const technologyFilters = [
export const TechnologyFilter = () => {
return (
<QuestionsSidebarSection title="Wybierz technologię">
<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">
<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">
{technologyFilters.map((tech) => (
<Technology key={tech.href} {...tech} />
<li key={tech.href}>
<Technology {...tech} />
</li>
))}
</div>
</ul>
</QuestionsSidebarSection>
);
};
6 changes: 4 additions & 2 deletions apps/app/src/hooks/useDevFAQRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ export const useDevFAQRouter = () => {
const pathname = usePathname();
const { userData } = useUser();

const queryParams = Object.fromEntries(searchParams.entries());

const mergeQueryParams = (data: Record<string, string>) => {
const params = { ...Object.fromEntries(searchParams.entries()), ...data };
const params = { ...queryParams, ...data };
const query = new URLSearchParams(params).toString();

if (pathname) {
Expand All @@ -24,5 +26,5 @@ export const useDevFAQRouter = () => {
return callback;
};

return { mergeQueryParams, requireLoggedIn };
return { queryParams, mergeQueryParams, requireLoggedIn };
};
6 changes: 6 additions & 0 deletions apps/app/src/lib/escapeStringRegex.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js
export function escapeStringRegexp(string: string): string {
// Escape characters with special meaning either inside or outside character sets.
// 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.
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
}
Loading