Skip to content

Commit 6fdf8a9

Browse files
authored
Enhanced Link component to handle language switching and persistance properly (#36)
# Description The `Link` and `LanguageSwitcher` components didn't fully handle the redirects with the `?lng=X` param in the search param, this PR aims to address this issue and enhance the components ## Type of change Please mark relevant options with an `x` in the brackets. - [x] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update - [ ] Algorithm update - updates algorithm documentation/questions/answers etc. - [ ] Other (please describe): # How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration - [ ] Integration tests - [ ] Unit tests - [ ] Manual tests - [ ] No tests required # Reviewer checklist Mark everything that needs to be checked before merging the PR. - [ ] Check if the UI is working as expected and is satisfactory - [ ] Check if the code is well documented - [ ] Check if the behavior is what is expected - [ ] Check if the code is well tested - [ ] Check if the code is readable and well formatted - [ ] Additional checks (document below if any) # Screenshots (if appropriate): # Questions (if appropriate):
1 parent fe31f91 commit 6fdf8a9

File tree

6 files changed

+240
-6
lines changed

6 files changed

+240
-6
lines changed

app/library/language-switcher/LanguageSwitcher.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@ import { Link } from "../link"
66
const LanguageSwitcher = () => {
77
const { i18n } = useTranslation()
88
const location = useLocation()
9-
const to = location.pathname
109

1110
return (
1211
<div className="flex gap-2 p-2 fixed top-0 right-0 w-min z-10">
1312
{supportedLanguages.map((language) => (
1413
<Link
1514
className="text-blue-500 dark:text-white hover:underline transition-all"
1615
key={language}
17-
to={`${to}?lng=${language}`}
16+
to={`${location.pathname}`}
17+
// We override the default appending of the language to the search params via our language
18+
language={language}
19+
// We keep the search params if any on language change
20+
keepSearchParams
1821
onClick={() => i18n.changeLanguage(language)}
1922
>
2023
{language}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { waitFor } from "@testing-library/react"
2+
import { userEvent } from "@vitest/browser/context"
3+
import { useLocation } from "react-router"
4+
import type { StubRouteEntry } from "tests/setup.browser"
5+
import { Link, type LinkProps } from "./link"
6+
const getEntries: (linkProps?: LinkProps) => StubRouteEntry[] = (linkProps) => [
7+
{
8+
path: "/first",
9+
Component: () => {
10+
const url = useLocation()
11+
return (
12+
<>
13+
<p>
14+
{url.pathname} + {url.search}
15+
</p>
16+
<Link {...linkProps} to="/second">
17+
go
18+
</Link>
19+
</>
20+
)
21+
},
22+
},
23+
{
24+
path: "/second",
25+
Component: () => {
26+
const url = useLocation()
27+
return (
28+
<>
29+
<p>
30+
{url.pathname}
31+
{url.search}
32+
</p>
33+
<Link to="/first">go</Link>
34+
</>
35+
)
36+
},
37+
},
38+
]
39+
describe("Link", () => {
40+
it("if the url is /first and you redirect to /second nothing is added to the url", async ({ renderStub }) => {
41+
const { getByText } = await renderStub({
42+
entries: getEntries(),
43+
props: {
44+
initialEntries: ["/first"],
45+
},
46+
})
47+
const link = getByText("go")
48+
await userEvent.click(link)
49+
const url = getByText("/second")
50+
expect(url).toBeDefined()
51+
await waitFor(() => {
52+
expect(url.element()).toBeDefined()
53+
expect(url.element()).toHaveTextContent("/second")
54+
})
55+
})
56+
57+
it("if the url is /first?a=1 and you redirect to /second without keepSearchParams nothing is added to the url", async ({
58+
renderStub,
59+
}) => {
60+
const { getByText } = await renderStub({
61+
entries: getEntries(),
62+
props: {
63+
initialEntries: ["/first?a=1"],
64+
},
65+
})
66+
const link = getByText("go")
67+
await userEvent.click(link)
68+
const url = getByText("/second")
69+
await waitFor(() => {
70+
expect(url.element()).toBeDefined()
71+
expect(url.element()).toHaveTextContent("/second")
72+
})
73+
})
74+
75+
it("if the url is /first?a=1 and you redirect to /second with keepSearchParams search params are kept", async ({
76+
renderStub,
77+
}) => {
78+
const { getByText } = await renderStub({
79+
entries: getEntries({ keepSearchParams: true, to: "/second" }),
80+
props: {
81+
initialEntries: ["/first?a=1"],
82+
},
83+
})
84+
const link = getByText("go")
85+
await userEvent.click(link)
86+
const url = getByText("/second")
87+
await waitFor(() => {
88+
expect(url.element()).toBeDefined()
89+
expect(url.element()).toHaveTextContent("/second?a=1")
90+
})
91+
})
92+
93+
it("if the url is /first?a=1&lng=en and you redirect to /second with keepSearchParams search params and language are kept", async ({
94+
renderStub,
95+
}) => {
96+
const { getByText } = await renderStub({
97+
entries: getEntries({ keepSearchParams: true, to: "/second" }),
98+
props: {
99+
initialEntries: ["/first?a=1&lng=en"],
100+
},
101+
})
102+
const link = getByText("go")
103+
await userEvent.click(link)
104+
const url = getByText("/second")
105+
await waitFor(() => {
106+
expect(url.element()).toBeDefined()
107+
expect(url.element()).toHaveTextContent("/second?a=1&lng=en")
108+
})
109+
})
110+
111+
it("if the url is /first?a=1&lng=en and you redirect to /second without keepSearchParams language is kept", async ({
112+
renderStub,
113+
}) => {
114+
const { getByText } = await renderStub({
115+
entries: getEntries({ to: "/second" }),
116+
props: {
117+
initialEntries: ["/first?lng=en"],
118+
},
119+
})
120+
const link = getByText("go")
121+
await userEvent.click(link)
122+
const url = getByText("/second")
123+
await waitFor(() => {
124+
expect(url.element()).toBeDefined()
125+
expect(url.element()).toHaveTextContent("/second?lng=en")
126+
})
127+
})
128+
129+
it("if the url is /first?a=1&lng=en and you redirect to /second with a language override it is changed and search params are removed", async ({
130+
renderStub,
131+
}) => {
132+
const { getByText } = await renderStub({
133+
entries: getEntries({ to: "/second", language: "bs" }),
134+
props: {
135+
initialEntries: ["/first?lng=en"],
136+
},
137+
})
138+
const link = getByText("go")
139+
await userEvent.click(link)
140+
const url = getByText("/second")
141+
await waitFor(() => {
142+
expect(url.element()).toBeDefined()
143+
expect(url.element()).toHaveTextContent("/second?lng=bs")
144+
})
145+
})
146+
147+
it("if the url is /first?a=1&lng=en and you redirect to /second with a language override it is changed and search params are kept with keepSearchParams", async ({
148+
renderStub,
149+
}) => {
150+
const { getByText } = await renderStub({
151+
entries: getEntries({ to: "/second", language: "bs", keepSearchParams: true }),
152+
props: {
153+
initialEntries: ["/first?a=a&lng=en"],
154+
},
155+
})
156+
const link = getByText("go")
157+
await userEvent.click(link)
158+
const url = getByText("/second")
159+
await waitFor(() => {
160+
expect(url.element()).toBeDefined()
161+
expect(url.element()).toHaveTextContent("/second?a=a&lng=bs")
162+
})
163+
})
164+
})

app/library/link/link.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
import { Link as ReactRouterLink, type LinkProps as ReactRouterLinkProps } from "react-router"
2+
import type { Language } from "~/localization/resource"
3+
import { useEnhancedTo } from "./useEnhancedTo"
24

3-
interface LinkProps extends ReactRouterLinkProps {}
5+
export interface LinkProps extends ReactRouterLinkProps {
6+
keepSearchParams?: boolean
7+
language?: Language
8+
}
49

5-
export const Link = ({ prefetch = "intent", viewTransition = true, ...props }: LinkProps) => {
6-
return <ReactRouterLink prefetch={prefetch} viewTransition={viewTransition} {...props} />
10+
export const Link = ({
11+
prefetch = "intent",
12+
viewTransition = true,
13+
keepSearchParams = false,
14+
to,
15+
language,
16+
...props
17+
}: LinkProps) => {
18+
const enhancedTo = useEnhancedTo({ language, to, keepSearchParams })
19+
return <ReactRouterLink prefetch={prefetch} viewTransition={viewTransition} to={enhancedTo} {...props} />
720
}

app/library/link/useEnhancedTo.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useMemo } from "react"
2+
import { type To, useSearchParams } from "react-router"
3+
import type { Language } from "~/localization/resource"
4+
5+
/**
6+
* Enhances the default to prop by adding the language to the search params and conditionally keeping the search params
7+
* @param language The language to use over the search param language
8+
* @param to The new location to navigate to
9+
* @param keepSearchParams Whether to keep the search params or not
10+
*
11+
* @example
12+
* ```tsx
13+
* // override the language
14+
* function Component(){
15+
* const enhancedTo = useEnhancedTo({ language: "en", to: "/" })
16+
* return <Link to={enhancedTo} /> // Will navigate to /?lng=en even if the current url contains a different lanugage
17+
* }
18+
*
19+
* function Component(){
20+
* const enhancedTo = useEnhancedTo({ to: "/" })
21+
* return <Link to={enhancedTo} /> // Will navigate to /?lng=X where X is the current language in the url search params, or just to / if no language is found
22+
* }
23+
*
24+
* function Component(){
25+
* const enhancedTo = useEnhancedTo({ to: "/", keepSearchParams: true })
26+
* return <Link to={enhancedTo} /> // Will navigate to /?params=from_the_url_search_params&lng=en
27+
* }
28+
* ```
29+
*/
30+
export const useEnhancedTo = ({
31+
language,
32+
to,
33+
keepSearchParams,
34+
}: { language?: Language; to: To; keepSearchParams?: boolean }) => {
35+
const [params] = useSearchParams()
36+
const { lng, ...searchParams } = Object.fromEntries(params.entries())
37+
// allow language override for language switcher or manually setting the language in specific cases
38+
const lang = language ?? params.get("lng")
39+
const newSearchParams = new URLSearchParams(searchParams)
40+
const searchString = newSearchParams.toString()
41+
const hasSearchParams = searchString.length > 0
42+
const appendSearchParams = lang || hasSearchParams
43+
const newPath = useMemo(
44+
() =>
45+
to +
46+
(appendSearchParams
47+
? `?${keepSearchParams && hasSearchParams ? `${searchString}${lang ? "&" : ""}` : ""}${lang ? `lng=${lang}` : ""}`
48+
: ""),
49+
[to, appendSearchParams, keepSearchParams, hasSearchParams, searchString, lang]
50+
)
51+
return newPath
52+
}

tests/setup.browser.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Outlet, type RoutesTestStubProps, createRoutesStub } from "react-router
66
import { render } from "vitest-browser-react"
77
import i18n from "~/localization/i18n"
88
import { type Language, type Namespace, resources } from "~/localization/resource"
9-
type StubRouteEntry = Parameters<typeof createRoutesStub>[0][0]
9+
export type StubRouteEntry = Parameters<typeof createRoutesStub>[0][0]
1010

1111
const renderStub = async (args?: {
1212
props?: RoutesTestStubProps

vitest.workspace.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ export default defineWorkspace([
2626
include: ["./**.test.{ts,tsx}", "./**.browser.test.{ts,tsx}", "!./**.server.test.{ts,tsx}"],
2727
setupFiles: ["./tests/setup.browser.tsx"],
2828
name: "browser tests",
29+
2930
browser: {
3031
enabled: true,
3132
instances: [{ browser: "chromium" }],
33+
3234
provider: "playwright",
3335
// https://playwright.dev
3436
//providerOptions: {},

0 commit comments

Comments
 (0)