Adjust testing with localization

This commit is contained in:
adilallo
2026-01-30 18:39:15 -07:00
parent 1280844706
commit ebd025fe27
23 changed files with 139 additions and 47 deletions
@@ -13,32 +13,48 @@ const FeatureGridContainer = memo<FeatureGridProps>(
() => [ () => [
{ {
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]", backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
labelLine1: t("pages.home.featureGrid.features.decisionMaking.labelLine1"), labelLine1: t(
labelLine2: t("pages.home.featureGrid.features.decisionMaking.labelLine2"), "pages.home.featureGrid.features.decisionMaking.labelLine1",
),
labelLine2: t(
"pages.home.featureGrid.features.decisionMaking.labelLine2",
),
panelContent: "/assets/Feature_Support.png", panelContent: "/assets/Feature_Support.png",
ariaLabel: t("featureGrid.features.decisionMaking.ariaLabel"), ariaLabel: t("featureGrid.features.decisionMaking.ariaLabel"),
href: "#decision-making", href: "#decision-making",
}, },
{ {
backgroundColor: "bg-[#D1FFE2]", backgroundColor: "bg-[#D1FFE2]",
labelLine1: t("pages.home.featureGrid.features.valuesAlignment.labelLine1"), labelLine1: t(
labelLine2: t("pages.home.featureGrid.features.valuesAlignment.labelLine2"), "pages.home.featureGrid.features.valuesAlignment.labelLine1",
),
labelLine2: t(
"pages.home.featureGrid.features.valuesAlignment.labelLine2",
),
panelContent: "/assets/Feature_Exercises.png", panelContent: "/assets/Feature_Exercises.png",
ariaLabel: t("featureGrid.features.valuesAlignment.ariaLabel"), ariaLabel: t("featureGrid.features.valuesAlignment.ariaLabel"),
href: "#values-alignment", href: "#values-alignment",
}, },
{ {
backgroundColor: "bg-[#F4CAFF]", backgroundColor: "bg-[#F4CAFF]",
labelLine1: t("pages.home.featureGrid.features.membershipGuidance.labelLine1"), labelLine1: t(
labelLine2: t("pages.home.featureGrid.features.membershipGuidance.labelLine2"), "pages.home.featureGrid.features.membershipGuidance.labelLine1",
),
labelLine2: t(
"pages.home.featureGrid.features.membershipGuidance.labelLine2",
),
panelContent: "/assets/Feature_Guidance.png", panelContent: "/assets/Feature_Guidance.png",
ariaLabel: t("featureGrid.features.membershipGuidance.ariaLabel"), ariaLabel: t("featureGrid.features.membershipGuidance.ariaLabel"),
href: "#membership-guidance", href: "#membership-guidance",
}, },
{ {
backgroundColor: "bg-[#CBDDFF]", backgroundColor: "bg-[#CBDDFF]",
labelLine1: t("pages.home.featureGrid.features.conflictResolution.labelLine1"), labelLine1: t(
labelLine2: t("pages.home.featureGrid.features.conflictResolution.labelLine2"), "pages.home.featureGrid.features.conflictResolution.labelLine1",
),
labelLine2: t(
"pages.home.featureGrid.features.conflictResolution.labelLine2",
),
panelContent: "/assets/Feature_Tools.png", panelContent: "/assets/Feature_Tools.png",
ariaLabel: t("featureGrid.features.conflictResolution.ariaLabel"), ariaLabel: t("featureGrid.features.conflictResolution.ariaLabel"),
href: "#conflict-resolution", href: "#conflict-resolution",
+1 -4
View File
@@ -17,10 +17,7 @@ const Footer = memo(() => {
name: t("organization.name"), name: t("organization.name"),
email: t("organization.email"), email: t("organization.email"),
url: t("organization.url"), url: t("organization.url"),
sameAs: [ sameAs: [t("social.bluesky.url"), t("social.gitlab.url")],
t("social.bluesky.url"),
t("social.gitlab.url"),
],
}; };
return ( return (
+6 -5
View File
@@ -96,7 +96,11 @@ const HeaderContainer = memo<HeaderProps>(() => {
const renderLoginButton = (size: NavSize) => { const renderLoginButton = (size: NavSize) => {
return ( return (
<MenuBarItem href="#" size={size} ariaLabel={t("ariaLabels.logInToAccount")}> <MenuBarItem
href="#"
size={size}
ariaLabel={t("ariaLabels.logInToAccount")}
>
{t("buttons.logIn")} {t("buttons.logIn")}
</MenuBarItem> </MenuBarItem>
); );
@@ -108,10 +112,7 @@ const HeaderContainer = memo<HeaderProps>(() => {
avatarSize: "small" | "medium" | "large" | "xlarge", avatarSize: "small" | "medium" | "large" | "xlarge",
) => { ) => {
return ( return (
<Button <Button size={buttonSize} ariaLabel={t("ariaLabels.createNewRule")}>
size={buttonSize}
ariaLabel={t("ariaLabels.createNewRule")}
>
{renderAvatarGroup(containerSize, avatarSize)} {renderAvatarGroup(containerSize, avatarSize)}
<span>{t("buttons.createRule")}</span> <span>{t("buttons.createRule")}</span>
</Button> </Button>
+1 -1
View File
@@ -1,3 +1,3 @@
export { default } from "./Header.container"; export { default } from "./Header.container";
export type { HeaderProps } from "./Header.types"; export type { HeaderProps } from "./Header.types";
export { navigationItems, avatarImages, logoConfig } from "./Header.container"; export { avatarImages, logoConfig } from "./Header.container";
+4 -1
View File
@@ -12,7 +12,10 @@ interface MessagesProviderProps {
children: ReactNode; children: ReactNode;
} }
export function MessagesProvider({ messages, children }: MessagesProviderProps) { export function MessagesProvider({
messages,
children,
}: MessagesProviderProps) {
return ( return (
<MessagesContext.Provider value={messages}> <MessagesContext.Provider value={messages}>
{children} {children}
+1 -5
View File
@@ -87,11 +87,7 @@ export const metadata: Metadata = {
}, },
}; };
export default function RootLayout({ export default function RootLayout({ children }: { children: ReactNode }) {
children,
}: {
children: ReactNode;
}) {
// Load messages for the default locale (single locale setup) // Load messages for the default locale (single locale setup)
return ( return (
+28 -5
View File
@@ -5,6 +5,7 @@ This guide explains how to work with translations in the CommunityRule applicati
## Overview ## Overview
All UI text is stored in JSON files under `messages/en/`. The structure follows best practices: All UI text is stored in JSON files under `messages/en/`. The structure follows best practices:
- **Page-specific content** lives in `pages/` (varies by page context) - **Page-specific content** lives in `pages/` (varies by page context)
- **Component defaults** live in `components/` (shared across pages) - **Component defaults** live in `components/` (shared across pages)
- **Common strings** live in `common.json` (shared UI elements) - **Common strings** live in `common.json` (shared UI elements)
@@ -35,6 +36,7 @@ messages/
## When to Use `pages/` vs `components/` ## When to Use `pages/` vs `components/`
### Use `pages/` for: ### Use `pages/` for:
- **Page-specific content**: Titles, subtitles, descriptions that vary by page - **Page-specific content**: Titles, subtitles, descriptions that vary by page
- **Context-aware text**: Content that changes based on where the component is used - **Context-aware text**: Content that changes based on where the component is used
- **User-facing content**: All text that users see on a specific page - **User-facing content**: All text that users see on a specific page
@@ -42,6 +44,7 @@ messages/
**Example:** The home page hero banner title "Collaborate" goes in `pages/home.json`, not `components/heroBanner.json` **Example:** The home page hero banner title "Collaborate" goes in `pages/home.json`, not `components/heroBanner.json`
### Use `components/` for: ### Use `components/` for:
- **Component defaults**: Aria-labels, alt text patterns, shared behavior text - **Component defaults**: Aria-labels, alt text patterns, shared behavior text
- **Shared across pages**: Text that doesn't vary by page context - **Shared across pages**: Text that doesn't vary by page context
- **Accessibility text**: Aria-labels and alt texts that are component-level - **Accessibility text**: Aria-labels and alt texts that are component-level
@@ -49,6 +52,7 @@ messages/
**Example:** The hero banner image alt text "Hero illustration" stays in `components/heroBanner.json` because it's the same across all pages **Example:** The hero banner image alt text "Hero illustration" stays in `components/heroBanner.json` because it's the same across all pages
### Use `common.json` for: ### Use `common.json` for:
- **Shared UI strings**: Buttons, links, labels used across multiple components - **Shared UI strings**: Buttons, links, labels used across multiple components
- **Global strings**: Text that appears in many places - **Global strings**: Text that appears in many places
@@ -57,23 +61,25 @@ messages/
For page-specific content, use the `pages.*` namespace pattern: For page-specific content, use the `pages.*` namespace pattern:
**Server Components:** **Server Components:**
```typescript ```typescript
import messages from "../../messages/en/index"; import messages from "../../messages/en/index";
import { getTranslation } from "../../lib/i18n/getTranslation"; import { getTranslation } from "../../lib/i18n/getTranslation";
export default function LearnPage() { export default function LearnPage() {
const t = (key: string) => getTranslation(messages, key); const t = (key: string) => getTranslation(messages, key);
const contentLockupData = { const contentLockupData = {
title: t("pages.learn.contentLockup.title"), title: t("pages.learn.contentLockup.title"),
subtitle: t("pages.learn.contentLockup.subtitle"), subtitle: t("pages.learn.contentLockup.subtitle"),
}; };
return <ContentLockup {...contentLockupData} />; return <ContentLockup {...contentLockupData} />;
} }
``` ```
**Client Components:** **Client Components:**
```typescript ```typescript
"use client"; "use client";
import { useTranslation } from "../../contexts/MessagesContext"; import { useTranslation } from "../../contexts/MessagesContext";
@@ -95,6 +101,7 @@ Determine which component needs the translation. If it's a shared string (like a
Open the appropriate JSON file and add your translation key. Use descriptive, semantic keys: Open the appropriate JSON file and add your translation key. Use descriptive, semantic keys:
**Good:** **Good:**
```json ```json
{ {
"heroBanner": { "heroBanner": {
@@ -105,6 +112,7 @@ Open the appropriate JSON file and add your translation key. Use descriptive, se
``` ```
**Bad:** **Bad:**
```json ```json
{ {
"text1": "Collaborate", "text1": "Collaborate",
@@ -131,24 +139,26 @@ Group related translations together:
### 4. Update the Component or Page ### 4. Update the Component or Page
**For Page Components (Server Components):** **For Page Components (Server Components):**
```typescript ```typescript
import messages from "../../messages/en/index"; import messages from "../../messages/en/index";
import { getTranslation } from "../../lib/i18n/getTranslation"; import { getTranslation } from "../../lib/i18n/getTranslation";
export default function MyPage() { export default function MyPage() {
const t = (key: string) => getTranslation(messages, key); const t = (key: string) => getTranslation(messages, key);
// Use page-specific keys // Use page-specific keys
const data = { const data = {
title: t("pages.home.heroBanner.title"), title: t("pages.home.heroBanner.title"),
subtitle: t("pages.home.heroBanner.subtitle"), subtitle: t("pages.home.heroBanner.subtitle"),
}; };
return <HeroBanner {...data} />; return <HeroBanner {...data} />;
} }
``` ```
**For Client Components:** **For Client Components:**
```typescript ```typescript
"use client"; "use client";
import { useTranslation } from "../../contexts/MessagesContext"; import { useTranslation } from "../../contexts/MessagesContext";
@@ -157,7 +167,7 @@ export default function MyComponent() {
// For page-specific content // For page-specific content
const t = useTranslation("pages.home.heroBanner"); const t = useTranslation("pages.home.heroBanner");
return <h1>{t("title")}</h1>; return <h1>{t("title")}</h1>;
// For component defaults // For component defaults
const tDefault = useTranslation("heroBanner"); const tDefault = useTranslation("heroBanner");
return <img alt={tDefault("imageAlt")} />; return <img alt={tDefault("imageAlt")} />;
@@ -173,6 +183,7 @@ export default function MyComponent() {
5. **Include context in comments**: Use `_comment` fields for clarity 5. **Include context in comments**: Use `_comment` fields for clarity
Example: Example:
```json ```json
{ {
"_comment": "HeroBanner component translations", "_comment": "HeroBanner component translations",
@@ -194,6 +205,7 @@ When migrating a component to use translations:
### Example Migration ### Example Migration
**Before:** **Before:**
```typescript ```typescript
export default function HeroBanner() { export default function HeroBanner() {
return ( return (
@@ -206,6 +218,7 @@ export default function HeroBanner() {
``` ```
**After:** **After:**
```typescript ```typescript
"use client"; "use client";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -231,6 +244,7 @@ When creating a new page that needs translations:
4. **Use in page component**: Use `t("pages.about.*")` pattern in your page 4. **Use in page component**: Use `t("pages.about.*")` pattern in your page
**Example:** **Example:**
```typescript ```typescript
// messages/en/pages/about.json // messages/en/pages/about.json
{ {
@@ -280,6 +294,7 @@ When adding support for a new language:
## Common Patterns ## Common Patterns
### Buttons and CTAs ### Buttons and CTAs
```json ```json
{ {
"buttons": { "buttons": {
@@ -290,6 +305,7 @@ When adding support for a new language:
``` ```
### Aria Labels ### Aria Labels
```json ```json
{ {
"ariaLabels": { "ariaLabels": {
@@ -300,7 +316,9 @@ When adding support for a new language:
``` ```
### Dynamic Content ### Dynamic Content
For content that varies (like card text), use arrays or numbered keys: For content that varies (like card text), use arrays or numbered keys:
```json ```json
{ {
"cards": { "cards": {
@@ -316,6 +334,7 @@ For content that varies (like card text), use arrays or numbered keys:
### Translation Key Not Found ### Translation Key Not Found
If you see a key path like `heroBanner.title` instead of the text: If you see a key path like `heroBanner.title` instead of the text:
1. Check the JSON file exists and has the key 1. Check the JSON file exists and has the key
2. Verify the key path matches exactly (case-sensitive) 2. Verify the key path matches exactly (case-sensitive)
3. Restart the dev server if you just added the key 3. Restart the dev server if you just added the key
@@ -323,6 +342,7 @@ If you see a key path like `heroBanner.title` instead of the text:
### TypeScript Errors ### TypeScript Errors
If TypeScript complains about translation keys: If TypeScript complains about translation keys:
1. Ensure the key exists in the JSON file 1. Ensure the key exists in the JSON file
2. Check for typos in the key path 2. Check for typos in the key path
3. Verify the namespace is correct if using `useTranslations("namespace")` 3. Verify the namespace is correct if using `useTranslations("namespace")`
@@ -330,6 +350,7 @@ If TypeScript complains about translation keys:
### Missing Translations ### Missing Translations
If text doesn't appear: If text doesn't appear:
1. Check the browser console for errors 1. Check the browser console for errors
2. Verify the component is wrapped in `MessagesProvider` (for client components) 2. Verify the component is wrapped in `MessagesProvider` (for client components)
3. Ensure `getTranslation()` is called correctly in server components 3. Ensure `getTranslation()` is called correctly in server components
@@ -338,10 +359,12 @@ If text doesn't appear:
## Architecture: Hybrid Approach ## Architecture: Hybrid Approach
This implementation follows the recognized best practice of combining: This implementation follows the recognized best practice of combining:
- **Globalized, shared UI elements**: Component defaults in `components/` (aria-labels, alt texts) - **Globalized, shared UI elements**: Component defaults in `components/` (aria-labels, alt texts)
- **Context-aware, localized content pages**: Page-specific content in `pages/` (titles, descriptions) - **Context-aware, localized content pages**: Page-specific content in `pages/` (titles, descriptions)
This allows: This allows:
- Components to remain flexible and reusable - Components to remain flexible and reusable
- Page content to be easily edited without code changes - Page content to be easily edited without code changes
- Clear separation between shared defaults and page-specific content - Clear separation between shared defaults and page-specific content
+1 -4
View File
@@ -11,10 +11,7 @@ type Messages = typeof messages;
* @param key - Dot-separated key path (e.g., "heroBanner.title") * @param key - Dot-separated key path (e.g., "heroBanner.title")
* @returns The translation string or the key if not found * @returns The translation string or the key if not found
*/ */
export function getTranslation( export function getTranslation(messages: Messages, key: string): string {
messages: Messages,
key: string,
): string {
const keys = key.split("."); const keys = key.split(".");
let value: any = messages; let value: any = messages;
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* Type definitions for translation keys * Type definitions for translation keys
* *
* These types provide type safety when accessing translation keys. * These types provide type safety when accessing translation keys.
* The actual types are inferred from the JSON files in messages/en/ * The actual types are inferred from the JSON files in messages/en/
*/ */
+6 -1
View File
@@ -3,6 +3,11 @@
"home": { "home": {
"title": "CommunityRule - Build operating manuals for successful communities", "title": "CommunityRule - Build operating manuals for successful communities",
"description": "Help your community make important decisions in a way that reflects its unique values.", "description": "Help your community make important decisions in a way that reflects its unique values.",
"keywords": ["community", "governance", "decision-making", "operating manual"] "keywords": [
"community",
"governance",
"decision-making",
"operating manual"
]
} }
} }
+1 -1
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { render, screen } from "@testing-library/react"; import { renderWithProviders as render, screen } from "../utils/test-utils";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import AskOrganizer from "../../app/components/AskOrganizer"; import AskOrganizer from "../../app/components/AskOrganizer";
import { import {
+1 -1
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { render, screen } from "@testing-library/react"; import { renderWithProviders as render, screen } from "../utils/test-utils";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import FeatureGrid from "../../app/components/FeatureGrid"; import FeatureGrid from "../../app/components/FeatureGrid";
import { import {
+1 -1
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { render, screen } from "@testing-library/react"; import { renderWithProviders as render, screen } from "../utils/test-utils";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import Footer from "../../app/components/Footer"; import Footer from "../../app/components/Footer";
import { import {
+1 -1
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { render, screen } from "@testing-library/react"; import { renderWithProviders as render, screen } from "../utils/test-utils";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import HeroBanner from "../../app/components/HeroBanner"; import HeroBanner from "../../app/components/HeroBanner";
import { import {
+5 -1
View File
@@ -1,5 +1,9 @@
import { describe, test, expect } from "vitest"; import { describe, test, expect } from "vitest";
import { render, screen, waitFor } from "@testing-library/react"; import {
renderWithProviders as render,
screen,
waitFor,
} from "../utils/test-utils";
import Page from "../../app/page"; import Page from "../../app/page";
describe("Page", () => { describe("Page", () => {
+6 -1
View File
@@ -1,4 +1,9 @@
import { render, screen, cleanup, waitFor } from "@testing-library/react"; import {
renderWithProviders as render,
screen,
cleanup,
waitFor,
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { vi, describe, test, expect, afterEach } from "vitest"; import { vi, describe, test, expect, afterEach } from "vitest";
import React from "react"; import React from "react";
+6 -1
View File
@@ -1,4 +1,9 @@
import { render, screen, cleanup, waitFor } from "@testing-library/react"; import {
renderWithProviders as render,
screen,
cleanup,
waitFor,
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { vi, describe, test, expect, afterEach } from "vitest"; import { vi, describe, test, expect, afterEach } from "vitest";
import React from "react"; import React from "react";
+5 -1
View File
@@ -1,4 +1,8 @@
import { render, screen, cleanup } from "@testing-library/react"; import {
renderWithProviders as render,
screen,
cleanup,
} from "../utils/test-utils";
import { describe, test, expect, afterEach } from "vitest"; import { describe, test, expect, afterEach } from "vitest";
import NumberedCards from "../../app/components/NumberedCards"; import NumberedCards from "../../app/components/NumberedCards";
+5 -1
View File
@@ -1,4 +1,8 @@
import { render, screen, cleanup } from "@testing-library/react"; import {
renderWithProviders as render,
screen,
cleanup,
} from "../utils/test-utils";
import { vi, describe, test, expect, afterEach } from "vitest"; import { vi, describe, test, expect, afterEach } from "vitest";
import QuoteBlock from "../../app/components/QuoteBlock"; import QuoteBlock from "../../app/components/QuoteBlock";
+5 -1
View File
@@ -1,4 +1,8 @@
import { render, screen, fireEvent } from "@testing-library/react"; import {
renderWithProviders as render,
screen,
fireEvent,
} from "../utils/test-utils";
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import RuleCard from "../../app/components/RuleCard"; import RuleCard from "../../app/components/RuleCard";
+5 -1
View File
@@ -1,4 +1,8 @@
import { render, screen, cleanup } from "@testing-library/react"; import {
renderWithProviders as render,
screen,
cleanup,
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { vi, describe, test, expect, afterEach } from "vitest"; import { vi, describe, test, expect, afterEach } from "vitest";
import { logger } from "../../lib/logger"; import { logger } from "../../lib/logger";
+2 -1
View File
@@ -1,8 +1,9 @@
import React from "react"; import React from "react";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react"; import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe"; import { axe } from "jest-axe";
import { renderWithProviders as render } from "./test-utils";
type TestCases = { type TestCases = {
renders?: boolean; renders?: boolean;
+23
View File
@@ -0,0 +1,23 @@
import React, { type ReactElement } from "react";
import { render, type RenderOptions } from "@testing-library/react";
import { MessagesProvider } from "../../app/contexts/MessagesContext";
import messages from "../../messages/en/index";
/**
* Custom render function that wraps components with MessagesProvider
* Use this instead of the default render from @testing-library/react
* for components that use useTranslation hook
*/
export function renderWithProviders(
ui: ReactElement,
options?: Omit<RenderOptions, "wrapper">,
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return <MessagesProvider messages={messages}>{children}</MessagesProvider>;
}
return render(ui, { wrapper: Wrapper, ...options });
}
// Re-export everything from @testing-library/react for convenience
export * from "@testing-library/react";