Add ESLint back into CI pipeline

This commit is contained in:
adilallo
2026-01-28 11:52:42 -07:00
parent 29a3bd3824
commit 01468ab5c8
18 changed files with 171 additions and 117 deletions
+13 -14
View File
@@ -491,20 +491,19 @@ jobs:
# - run: npm run test:sb
# env: { CI: true }
# Temporarily disabled - 523 pre-existing ESLint issues will be addressed in separate ticket
# lint:
# runs-on: [self-hosted, macos-latest]
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-node@v4
# if: ${{ github.server_url == 'https://github.com' }}
# with: { node-version: 20, cache: npm }
# - uses: actions/setup-node@v4
# if: ${{ github.server_url != 'https://github.com' || !github.server_url }}
# with: { node-version: 20 }
# - run: npm ci
# - run: npm run lint
# - run: npm exec prettier -- --check "**/*.{js,jsx,ts,tsx,json,css,md}"
lint:
runs-on: [self-hosted, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
if: ${{ github.server_url == 'https://github.com' }}
with: { node-version: 20, cache: npm }
- uses: actions/setup-node@v4
if: ${{ github.server_url != 'https://github.com' || !github.server_url }}
with: { node-version: 20 }
- run: npm ci
- run: npm run lint
- run: npm exec prettier -- --check "**/*.{js,jsx,ts,tsx,json,css,md}"
build:
runs-on: [self-hosted, macos-latest]
+6 -7
View File
@@ -151,13 +151,12 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
`.trim();
// Form field handlers with disabled state handling
const { handleChange, handleFocus, handleBlur } = useFormField<
HTMLInputElement
>(disabled, {
onChange,
onFocus,
onBlur,
});
const { handleChange, handleFocus, handleBlur } =
useFormField<HTMLInputElement>(disabled, {
onChange,
onFocus,
onBlur,
});
return (
<div className={containerClasses}>
+16 -18
View File
@@ -10,25 +10,23 @@ interface NumberedCardProps {
iconColor?: string;
}
const NumberedCard = memo<NumberedCardProps>(
({ number, text }) => {
return (
<div className="bg-[var(--color-surface-inverse-primary)] rounded-[12px] p-5 shadow-lg flex flex-col gap-4 sm:p-8 sm:gap-8 sm:flex-row sm:items-center lg:p-8 lg:gap-0 lg:flex-row lg:items-stretch lg:relative lg:h-[238px]">
{/* Section Number - Top right (lg breakpoint) */}
<div className="flex justify-end sm:justify-start sm:flex-shrink-0 lg:absolute lg:top-8 lg:right-8">
<SectionNumber number={number} />
</div>
{/* Card Content - Bottom left (lg breakpoint) */}
<div className="sm:flex-1 lg:absolute lg:bottom-8 lg:left-8 lg:right-16">
<p className="font-bricolage-grotesque font-medium text-[24px] leading-[32px] sm:font-normal sm:leading-[24px] sm:text-[24px] lg:text-[24px] lg:leading-[24px] xl:text-[32px] xl:leading-[32px] text-[#141414]">
{text}
</p>
</div>
const NumberedCard = memo<NumberedCardProps>(({ number, text }) => {
return (
<div className="bg-[var(--color-surface-inverse-primary)] rounded-[12px] p-5 shadow-lg flex flex-col gap-4 sm:p-8 sm:gap-8 sm:flex-row sm:items-center lg:p-8 lg:gap-0 lg:flex-row lg:items-stretch lg:relative lg:h-[238px]">
{/* Section Number - Top right (lg breakpoint) */}
<div className="flex justify-end sm:justify-start sm:flex-shrink-0 lg:absolute lg:top-8 lg:right-8">
<SectionNumber number={number} />
</div>
);
},
);
{/* Card Content - Bottom left (lg breakpoint) */}
<div className="sm:flex-1 lg:absolute lg:bottom-8 lg:left-8 lg:right-16">
<p className="font-bricolage-grotesque font-medium text-[24px] leading-[32px] sm:font-normal sm:leading-[24px] sm:text-[24px] lg:text-[24px] lg:leading-[24px] xl:text-[32px] xl:leading-[32px] text-[#141414]">
{text}
</p>
</div>
</div>
);
});
NumberedCard.displayName = "NumberedCard";
+1 -1
View File
@@ -108,7 +108,7 @@ const RelatedArticles = memo<RelatedArticlesProps>(
}
return (
<section
<section
className="py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]"
data-testid="related-articles"
>
+6 -7
View File
@@ -155,13 +155,12 @@ const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
`.trim();
// Form field handlers with disabled state handling
const { handleChange, handleFocus, handleBlur } = useFormField<
HTMLTextAreaElement
>(disabled, {
onChange,
onFocus,
onBlur,
});
const { handleChange, handleFocus, handleBlur } =
useFormField<HTMLTextAreaElement>(disabled, {
onChange,
onFocus,
onBlur,
});
return (
<div className={containerClasses}>
+7 -2
View File
@@ -1,6 +1,6 @@
/**
* Custom hooks for reusable component logic
*
*
* This module exports all custom hooks used throughout the application.
* Hooks encapsulate complex logic and state management that can be reused
* across multiple components.
@@ -12,7 +12,12 @@ export { useComponentId } from "./useComponentId";
export { useFormField } from "./useFormField";
export { useComponentStyles } from "./useComponentStyles";
export { useSchemaData } from "./useSchemaData";
export { useMediaQuery, useIsMobile, useIsDesktop, BREAKPOINTS } from "./useMediaQuery";
export {
useMediaQuery,
useIsMobile,
useIsDesktop,
BREAKPOINTS,
} from "./useMediaQuery";
export { useFormValidation, validationRules } from "./useFormValidation";
export type {
SizeStyleConfig,
+1 -3
View File
@@ -61,9 +61,7 @@ export interface UseComponentStylesOptions {
* });
* ```
*/
export function useComponentStyles(
options: UseComponentStylesOptions,
): {
export function useComponentStyles(options: UseComponentStylesOptions): {
sizeClasses: Record<string, string>;
stateClasses: Record<string, string>;
} {
+32 -23
View File
@@ -22,24 +22,30 @@ export const validationRules = {
return emailRegex.test(value) ? null : "Please enter a valid email address";
},
minLength: (min: number) => (value: string): string | null => {
if (!value) return null;
return value.length >= min
? null
: `Must be at least ${min} characters long`;
},
minLength:
(min: number) =>
(value: string): string | null => {
if (!value) return null;
return value.length >= min
? null
: `Must be at least ${min} characters long`;
},
maxLength: (max: number) => (value: string): string | null => {
if (!value) return null;
return value.length <= max
? null
: `Must be no more than ${max} characters long`;
},
maxLength:
(max: number) =>
(value: string): string | null => {
if (!value) return null;
return value.length <= max
? null
: `Must be no more than ${max} characters long`;
},
pattern: (regex: RegExp, message: string) => (value: string): string | null => {
if (!value) return null;
return regex.test(value) ? null : message;
},
pattern:
(regex: RegExp, message: string) =>
(value: string): string | null => {
if (!value) return null;
return regex.test(value) ? null : message;
},
};
/**
@@ -182,13 +188,16 @@ export function useFormValidation(options: UseFormValidationOptions) {
}, [initialValues]);
// Set field value programmatically
const setValue = useCallback((name: string, value: string) => {
setValues((prev) => ({ ...prev, [name]: value }));
if (validateOnChange) {
const error = validateField(name, value);
setErrors((prev) => ({ ...prev, [name]: error }));
}
}, [validateOnChange, validateField]);
const setValue = useCallback(
(name: string, value: string) => {
setValues((prev) => ({ ...prev, [name]: value }));
if (validateOnChange) {
const error = validateField(name, value);
setErrors((prev) => ({ ...prev, [name]: error }));
}
},
[validateOnChange, validateField],
);
return {
values,
+9 -2
View File
@@ -144,7 +144,12 @@ export function useSchemaData(
type: "BreadcrumbList";
items: Array<{ name: string; url: string }>;
},
): SchemaOrganization | SchemaWebSite | SchemaHowTo | SchemaArticle | SchemaBreadcrumbList {
):
| SchemaOrganization
| SchemaWebSite
| SchemaHowTo
| SchemaArticle
| SchemaBreadcrumbList {
return useMemo(() => {
switch (config.type) {
case "Organization":
@@ -216,7 +221,9 @@ export function useSchemaData(
"@id": config.mainEntityOfPage,
},
}),
...(config.articleSection && { articleSection: config.articleSection }),
...(config.articleSection && {
articleSection: config.articleSection,
}),
...(config.keywords && { keywords: config.keywords }),
} as SchemaArticle;
+26 -12
View File
@@ -15,6 +15,7 @@ Detects clicks outside of specified elements. Useful for closing dropdowns, moda
**Location:** `app/hooks/useClickOutside.ts`
**Usage:**
```tsx
import { useClickOutside } from "../hooks";
@@ -26,6 +27,7 @@ useClickOutside([menuRef, buttonRef], () => setIsOpen(false), isOpen);
```
**Parameters:**
- `refs`: Array of refs to elements that should not trigger the callback
- `handler`: Callback function to execute when clicking outside
- `enabled`: Whether the hook is enabled (default: true)
@@ -41,6 +43,7 @@ Centralized analytics tracking for component interactions. Supports both Google
**Location:** `app/hooks/useAnalytics.ts`
**Usage:**
```tsx
import { useAnalytics } from "../hooks";
@@ -66,6 +69,7 @@ trackCustomEvent(
```
**Returns:**
- `trackEvent`: Function to track standard analytics events
- `trackCustomEvent`: Function to track custom events with optional callback
@@ -80,6 +84,7 @@ Generates unique component IDs for accessibility. Provides consistent ID generat
**Location:** `app/hooks/useComponentId.ts`
**Usage:**
```tsx
import { useComponentId } from "../hooks";
@@ -89,10 +94,12 @@ const { id, labelId } = useComponentId("input", props.id);
```
**Parameters:**
- `prefix`: Prefix for the generated ID (e.g., "input", "select")
- `providedId`: Optional ID provided via props (takes precedence)
**Returns:**
- `id`: Component ID
- `labelId`: Associated label ID for accessibility
@@ -107,6 +114,7 @@ Manages form field event handlers with disabled state handling. Ensures handlers
**Location:** `app/hooks/useFormField.ts`
**Usage:**
```tsx
import { useFormField } from "../hooks";
@@ -117,18 +125,16 @@ const { handleChange, handleFocus, handleBlur } = useFormField(disabled, {
});
// Use in component
<input
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
/>
<input onChange={handleChange} onFocus={handleFocus} onBlur={handleBlur} />;
```
**Parameters:**
- `disabled`: Whether the field is disabled
- `handlers`: Object containing onChange, onFocus, onBlur handlers
**Returns:**
- `handleChange`: Wrapped onChange handler
- `handleFocus`: Wrapped onFocus handler
- `handleBlur`: Wrapped onBlur handler
@@ -144,6 +150,7 @@ Manages component size and state styles. Provides a consistent pattern for styli
**Location:** `app/hooks/useComponentStyles.ts`
**Usage:**
```tsx
import { useComponentStyles } from "../hooks";
@@ -178,6 +185,7 @@ Generates Schema.org structured data (JSON-LD) for SEO and search engines.
**Location:** `app/hooks/useSchemaData.ts`
**Usage:**
```tsx
import { useSchemaData } from "../hooks";
@@ -205,10 +213,11 @@ const orgSchema = useSchemaData({
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
/>
/>;
```
**Supported Types:**
- `Organization` - Organization information
- `WebSite` - Website navigation and search
- `HowTo` - Step-by-step instructions
@@ -226,6 +235,7 @@ Responsive breakpoint detection using window.matchMedia.
**Location:** `app/hooks/useMediaQuery.ts`
**Usage:**
```tsx
import { useMediaQuery, useIsMobile, useIsDesktop } from "../hooks";
@@ -242,6 +252,7 @@ const isDesktop = useIsDesktop(); // lg breakpoint and above
```
**Available Breakpoints:**
- `sm`: 640px
- `md`: 768px
- `lg`: 1024px
@@ -259,6 +270,7 @@ Form validation with field-level error handling.
**Location:** `app/hooks/useFormValidation.ts`
**Usage:**
```tsx
import { useFormValidation, validationRules } from "../hooks";
@@ -275,10 +287,7 @@ const {
initialValues: { email: "", password: "" },
validationRules: {
email: [validationRules.required, validationRules.email],
password: [
validationRules.required,
validationRules.minLength(8),
],
password: [validationRules.required, validationRules.minLength(8)],
},
validateOnChange: true,
validateOnBlur: true,
@@ -290,11 +299,14 @@ const {
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
/>
{errors.email && touched.email && <span>{errors.email}</span>}
/>;
{
errors.email && touched.email && <span>{errors.email}</span>;
}
```
**Available Validation Rules:**
- `validationRules.required` - Field is required
- `validationRules.email` - Valid email format
- `validationRules.minLength(n)` - Minimum length
@@ -302,6 +314,7 @@ const {
- `validationRules.pattern(regex, message)` - Custom regex pattern
**Returns:**
- `values` - Current form values
- `errors` - Field error messages
- `touched` - Fields that have been interacted with
@@ -317,6 +330,7 @@ const {
## Best Practices
1. **Import from index:** Always import hooks from `app/hooks` index file:
```tsx
import { useAnalytics, useComponentId } from "../hooks";
```
+6
View File
@@ -9,29 +9,35 @@ This directory contains all project documentation organized by topic.
Comprehensive guides for different aspects of the project:
#### Testing
- **[testing.md](./guides/testing.md)** - Complete testing strategy and philosophy
- **[testing-framework.md](./guides/testing-framework.md)** - Detailed testing framework documentation
- **[testing-quick-reference.md](./guides/testing-quick-reference.md)** - Quick reference for daily development
- **[visual-regression.md](./guides/visual-regression.md)** - Visual regression testing guide
#### Performance
- **[performance.md](./guides/performance.md)** - Performance optimization and monitoring guide
#### Content
- **[content-creation.md](./guides/content-creation.md)** - Content creation guidelines
## 🎯 Quick Navigation
### For New Team Members
1. Start with **[testing.md](./guides/testing.md)** to understand the testing strategy
2. Use **[testing-quick-reference.md](./guides/testing-quick-reference.md)** for daily development
3. Reference **[performance.md](./guides/performance.md)** for performance optimization
### For Daily Development
- **[testing-quick-reference.md](./guides/testing-quick-reference.md)** - Essential commands and troubleshooting
- **[testing-framework.md](./guides/testing-framework.md)** - Detailed testing explanations
### For Specific Topics
- **Visual Testing**: [visual-regression.md](./guides/visual-regression.md)
- **Performance**: [performance.md](./guides/performance.md)
- **Content**: [content-creation.md](./guides/content-creation.md)
-1
View File
@@ -24,4 +24,3 @@ export const logger = {
console.error(...args);
},
};
@@ -160,7 +160,7 @@ describe("Page Flow Integration", () => {
);
expect(cards.length).toBeGreaterThan(0);
});
// Check that all three cards are rendered
const cards = screen.getAllByText(
/Document how your community|Build an operating manual|Get a link to your manual/,
@@ -206,7 +206,7 @@ describe("Page Flow Integration", () => {
const headings = screen.getAllByRole("heading");
expect(headings.length).toBeGreaterThan(4); // Should have multiple headings
});
// Check for proper heading hierarchy
const headings = screen.getAllByRole("heading");
expect(headings.length).toBeGreaterThan(4); // Should have multiple headings
+17 -7
View File
@@ -326,17 +326,25 @@ class PlaywrightPerformanceMonitor extends PerformanceMonitor {
// Navigate to the page
// Use "load" instead of "networkidle" to handle dynamically imported components
// "networkidle" can timeout with code splitting as chunks load asynchronously
await this.page.goto(url, {
await this.page.goto(url, {
waitUntil: "load",
timeout: 60000, // 60 second timeout for slower networks
});
} catch (error) {
// Handle interstitial/blocking errors
if (error.message.includes("interstitial") || error.message.includes("prevented")) {
console.warn("Page load was blocked, attempting to continue:", error.message);
if (
error.message.includes("interstitial") ||
error.message.includes("prevented")
) {
console.warn(
"Page load was blocked, attempting to continue:",
error.message,
);
// Try to wait for the page to be in a usable state
try {
await this.page.waitForLoadState("domcontentloaded", { timeout: 10000 });
await this.page.waitForLoadState("domcontentloaded", {
timeout: 10000,
});
} catch {
throw new Error(`Page failed to load: ${error.message}`);
}
@@ -349,9 +357,11 @@ class PlaywrightPerformanceMonitor extends PerformanceMonitor {
// This ensures code-split components have loaded
try {
// Wait for main content sections that use dynamic imports
await this.page.waitForSelector("section", { timeout: 10000 }).catch(() => {
// Ignore if sections don't appear - page might still be valid
});
await this.page
.waitForSelector("section", { timeout: 10000 })
.catch(() => {
// Ignore if sections don't appear - page might still be valid
});
} catch (error) {
// Continue even if some components haven't loaded - we still want to measure performance
console.warn("Some components may not have loaded:", error.message);
+1 -1
View File
@@ -200,7 +200,7 @@ describe("BlogPostPage", () => {
await waitFor(() => {
expect(screen.getByTestId("related-articles")).toBeInTheDocument();
});
expect(screen.getByText("Related Articles")).toBeInTheDocument();
expect(screen.getByTestId("related-related-1")).toBeInTheDocument();
expect(screen.getByTestId("related-related-2")).toBeInTheDocument();
+10 -6
View File
@@ -100,7 +100,8 @@ describe("Page", () => {
// Wait for dynamically imported FeatureGrid component to load
await waitFor(() => {
expect(
screen.getAllByText("We've got your back, every step of the way").length,
screen.getAllByText("We've got your back, every step of the way")
.length,
).toBeGreaterThan(0);
});
expect(
@@ -143,7 +144,8 @@ describe("Page", () => {
// FeatureGrid
await waitFor(() => {
expect(
screen.getAllByText("We've got your back, every step of the way").length,
screen.getAllByText("We've got your back, every step of the way")
.length,
).toBeGreaterThan(0);
});
@@ -212,7 +214,7 @@ describe("Page", () => {
// Check all section titles (using getAllByText since there are multiple instances)
expect(screen.getAllByText("Collaborate").length).toBeGreaterThan(0);
// Wait for dynamically imported components
await waitFor(() => {
expect(
@@ -221,7 +223,8 @@ describe("Page", () => {
});
await waitFor(() => {
expect(
screen.getAllByText("We've got your back, every step of the way").length,
screen.getAllByText("We've got your back, every step of the way")
.length,
).toBeGreaterThan(0);
});
expect(screen.getAllByText("Still have questions?").length).toBeGreaterThan(
@@ -236,7 +239,8 @@ describe("Page", () => {
await waitFor(() => {
// Check all three numbered card items (using getAllByText since there are multiple instances)
expect(
screen.getAllByText("Document how your community makes decisions").length,
screen.getAllByText("Document how your community makes decisions")
.length,
).toBeGreaterThan(0);
});
expect(
@@ -256,7 +260,7 @@ describe("Page", () => {
// Check subtitles (using getAllByText since there are multiple instances)
expect(screen.getAllByText("with clarity").length).toBeGreaterThan(0);
// Wait for dynamically imported components
await waitFor(() => {
expect(
+9 -3
View File
@@ -26,7 +26,9 @@ describe("useClickOutside", () => {
result.current.current = div;
act(() => {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
document.body.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
});
expect(handler).toHaveBeenCalledTimes(1);
@@ -62,7 +64,9 @@ describe("useClickOutside", () => {
});
act(() => {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
document.body.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
});
expect(handler).not.toHaveBeenCalled();
@@ -84,7 +88,9 @@ describe("useClickOutside", () => {
result.current.ref2.current = div2;
act(() => {
document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
document.body.dispatchEvent(
new MouseEvent("mousedown", { bubbles: true }),
);
});
expect(handler).toHaveBeenCalledTimes(1);
+9 -8
View File
@@ -16,7 +16,7 @@ vi.mock("next/dynamic", () => {
return function DynamicComponent(props: any) {
const [Component, setComponent] = React.useState(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
importFn()
.then((mod: any) => {
@@ -27,15 +27,15 @@ vi.mock("next/dynamic", () => {
setLoading(false);
});
}, []);
if (loading && options?.loading) {
return options.loading();
}
if (Component) {
return React.createElement(Component, props);
}
return null;
};
},
@@ -49,18 +49,19 @@ Object.defineProperty(window, "matchMedia", {
// Parse the media query to determine if it matches
const minWidthMatch = query.match(/min-width:\s*(\d+)px/);
const maxWidthMatch = query.match(/max-width:\s*(\d+)px/);
// Use window.innerWidth if set by tests, otherwise default to desktop (1200px)
// This allows tests to override viewport width by setting window.innerWidth
const viewportWidth = (typeof window !== "undefined" && window.innerWidth) || 1200;
const viewportWidth =
(typeof window !== "undefined" && window.innerWidth) || 1200;
let matches = true;
if (minWidthMatch) {
matches = viewportWidth >= parseInt(minWidthMatch[1], 10);
} else if (maxWidthMatch) {
matches = viewportWidth <= parseInt(maxWidthMatch[1], 10);
}
return {
matches,
media: query,