Refactor Components #29

Merged
an.di merged 12 commits from adilallo/maintenance/RefactorComponents into main 2026-01-30 03:59:41 +00:00
46 changed files with 209 additions and 136 deletions
Showing only changes of commit adac7d0545 - Show all commits
@@ -78,9 +78,7 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
buttonHref,
},
onContactClick as
| ((
_data: Record<string, unknown>,
) => void)
| ((_data: Record<string, unknown>) => void)
| undefined,
);
@@ -111,4 +109,3 @@ const AskOrganizerContainer = memo<AskOrganizerProps>(
AskOrganizerContainer.displayName = "AskOrganizer";
export default AskOrganizerContainer;
@@ -37,7 +37,6 @@ export interface AskOrganizerViewProps {
variant: AskOrganizerVariant;
labelledBy?: string;
onContactClick: (
event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
_event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => void;
}
@@ -53,4 +53,3 @@ function AskOrganizerView({
}
export default AskOrganizerView;
-1
View File
@@ -1,3 +1,2 @@
export { default } from "./AskOrganizer.container";
export * from "./AskOrganizer.types";
+2 -2
View File
@@ -34,6 +34,6 @@ export interface CheckboxViewProps {
checkGlyphColor: string;
labelColor: string;
accessibilityProps: React.HTMLAttributes<HTMLSpanElement>;
onToggle: (e: React.MouseEvent | React.KeyboardEvent) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLSpanElement>) => void;
onToggle: (_e: React.MouseEvent | React.KeyboardEvent) => void;
onKeyDown: (_e: React.KeyboardEvent<HTMLSpanElement>) => void;
}
@@ -38,9 +38,7 @@ function ContentContainerView({
<h3 className={titleClasses}>{post.frontmatter.title}</h3>
{/* Description */}
<p className={descriptionClasses}>
{post.frontmatter.description}
</p>
<p className={descriptionClasses}>{post.frontmatter.description}</p>
</div>
</div>
@@ -63,9 +63,7 @@ function ContentLockupView({
</div>
{/* Subtitle */}
{subtitle ? (
<h2 className={styles.subtitle}>{subtitle}</h2>
) : null}
{subtitle ? <h2 className={styles.subtitle}>{subtitle}</h2> : null}
</div>
{/* Description */}
@@ -94,11 +92,7 @@ function ContentLockupView({
</div>
{/* Large button for md and lg breakpoints */}
<div className="hidden md:block xl:hidden">
<Button
variant="primary"
size="large"
className={buttonClassName}
>
<Button variant="primary" size="large" className={buttonClassName}>
{ctaText}
</Button>
</div>
@@ -1,5 +1,4 @@
export interface ContextMenuItemProps
extends React.HTMLAttributes<HTMLDivElement> {
export interface ContextMenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
selected?: boolean;
hasSubmenu?: boolean;
@@ -18,6 +17,6 @@ export interface ContextMenuItemViewProps {
disabled: boolean;
className: string;
itemClasses: string;
handleClick: (e: React.MouseEvent<HTMLDivElement>) => void;
handleKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
handleClick: (_e: React.MouseEvent<HTMLDivElement>) => void;
handleKeyDown: (_e: React.KeyboardEvent<HTMLDivElement>) => void;
}
@@ -61,4 +61,3 @@ const FeatureGridContainer = memo<FeatureGridProps>(
FeatureGridContainer.displayName = "FeatureGrid";
export default FeatureGridContainer;
@@ -17,4 +17,3 @@ export interface FeatureGridViewProps extends FeatureGridProps {
features: Feature[];
labelledBy?: string;
}
@@ -50,4 +50,3 @@ function FeatureGridView({
}
export default FeatureGridView;
-1
View File
@@ -1,3 +1,2 @@
export { default } from "./FeatureGrid.container";
export * from "./FeatureGrid.types";
+7 -7
View File
@@ -31,15 +31,15 @@ export interface HeaderViewProps {
| "footerLg";
showText: boolean;
}>;
renderNavigationItems: (size: NavSize) => React.ReactNode;
renderLoginButton: (size: NavSize) => React.ReactNode;
renderNavigationItems: (_size: NavSize) => React.ReactNode;
renderLoginButton: (_size: NavSize) => React.ReactNode;
renderCreateRuleButton: (
buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
containerSize: "small" | "medium" | "large" | "xlarge",
avatarSize: "small" | "medium" | "large" | "xlarge",
_buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
_containerSize: "small" | "medium" | "large" | "xlarge",
_avatarSize: "small" | "medium" | "large" | "xlarge",
) => React.ReactNode;
renderLogo: (
size:
_size:
| "default"
| "homeHeaderXsmall"
| "homeHeaderSm"
@@ -52,7 +52,7 @@ export interface HeaderViewProps {
| "headerXl"
| "footer"
| "footerLg",
showText: boolean,
_showText: boolean,
) => React.ReactNode;
}
@@ -35,15 +35,15 @@ export interface HomeHeaderViewProps {
| "footerLg";
showText: boolean;
}>;
renderNavigationItems: (size: NavSize) => React.ReactNode;
renderLoginButton: (size: NavSize) => React.ReactNode;
renderNavigationItems: (_size: NavSize) => React.ReactNode;
renderLoginButton: (_size: NavSize) => React.ReactNode;
renderCreateRuleButton: (
buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
containerSize: "small" | "medium" | "large" | "xlarge",
avatarSize: "small" | "medium" | "large" | "xlarge",
_buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
_containerSize: "small" | "medium" | "large" | "xlarge",
_avatarSize: "small" | "medium" | "large" | "xlarge",
) => React.ReactNode;
renderLogo: (
size:
_size:
| "default"
| "homeHeaderXsmall"
| "homeHeaderSm"
@@ -56,6 +56,6 @@ export interface HomeHeaderViewProps {
| "headerXl"
| "footer"
| "footerLg",
showText: boolean,
_showText: boolean,
) => React.ReactNode;
}
+7 -5
View File
@@ -1,5 +1,7 @@
export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size" | "onChange" | "onFocus" | "onBlur"> {
export interface InputProps extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"size" | "onChange" | "onFocus" | "onBlur"
> {
size?: "small" | "medium" | "large";
labelVariant?: "default" | "horizontal";
state?: "default" | "active" | "hover" | "focus";
@@ -32,7 +34,7 @@ export interface InputViewProps {
labelClasses: string;
inputClasses: string;
borderRadius: string;
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleFocus: (e: React.FocusEvent<HTMLInputElement>) => void;
handleBlur: (e: React.FocusEvent<HTMLInputElement>) => void;
handleChange: (_e: React.ChangeEvent<HTMLInputElement>) => void;
handleFocus: (_e: React.FocusEvent<HTMLInputElement>) => void;
handleBlur: (_e: React.FocusEvent<HTMLInputElement>) => void;
}
@@ -1,5 +1,4 @@
export interface MenuBarItemProps
extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
export interface MenuBarItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
href?: string;
children?: React.ReactNode;
variant?: "default" | "home";
@@ -1,5 +1,7 @@
export interface NavigationItemProps
extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "isActive"> {
export interface NavigationItemProps extends Omit<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
"isActive"
> {
href?: string;
children?: React.ReactNode;
variant?: "default";
@@ -33,4 +33,3 @@ const NumberedCardsContainer = memo<NumberedCardsProps>(
NumberedCardsContainer.displayName = "NumberedCards";
export default NumberedCardsContainer;
@@ -13,4 +13,3 @@ export interface NumberedCardsProps {
export interface NumberedCardsViewProps extends NumberedCardsProps {
schemaJson: string;
}
@@ -63,4 +63,3 @@ function NumberedCardsView({
}
export default NumberedCardsView;
-1
View File
@@ -1,3 +1,2 @@
export { default } from "./NumberedCards.container";
export * from "./NumberedCards.types";
@@ -41,5 +41,5 @@ export interface QuoteBlockViewProps {
imageLoading: boolean;
currentAvatarSrc: string;
onImageLoad: () => void;
onImageError: (error: unknown) => void;
onImageError: (_error: unknown) => void;
}
@@ -30,6 +30,6 @@ export interface RadioButtonViewProps {
backgroundWhenChecked: string;
dotColor: string;
labelColor: string;
onToggle: (e: React.MouseEvent | React.KeyboardEvent) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLSpanElement>) => void;
onToggle: (_e: React.MouseEvent | React.KeyboardEvent) => void;
onKeyDown: (_e: React.KeyboardEvent<HTMLSpanElement>) => void;
}
@@ -25,5 +25,5 @@ export interface RadioGroupViewProps {
options: RadioOption[];
className: string;
ariaLabel?: string;
onOptionChange: (optionValue: string) => void;
onOptionChange: (_optionValue: string) => void;
}
@@ -11,6 +11,6 @@ export interface RelatedArticlesViewProps {
slugOrder: string[];
isMobile: boolean;
transformStyle: React.CSSProperties;
getProgressStyle: (index: number) => React.CSSProperties;
onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void;
getProgressStyle: (_index: number) => React.CSSProperties;
onMouseDown?: (_e: React.MouseEvent<HTMLDivElement>) => void;
}
+1 -1
View File
@@ -14,5 +14,5 @@ export interface RuleCardViewProps {
backgroundColor: string;
className: string;
onClick: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
onKeyDown: (_event: React.KeyboardEvent<HTMLDivElement>) => void;
}
@@ -36,7 +36,12 @@ const RuleStackContainer = memo<RuleStackProps>(({ className = "" }) => {
logger.debug(`${templateName} template clicked`);
};
return <RuleStackView className={className} onTemplateClick={handleTemplateClick} />;
return (
<RuleStackView
className={className}
onTemplateClick={handleTemplateClick}
/>
);
});
RuleStackContainer.displayName = "RuleStack";
+1 -1
View File
@@ -4,5 +4,5 @@ export interface RuleStackProps {
export interface RuleStackViewProps {
className: string;
onTemplateClick: (templateName: string) => void;
onTemplateClick: (_templateName: string) => void;
}
+2 -2
View File
@@ -47,9 +47,10 @@ const SelectContainer = forwardRef<HTMLButtonElement, SelectProps>(
// Sync internal state with external value prop
useEffect(() => {
if (value !== undefined) {
if (value !== undefined && value !== selectedValue) {
setSelectedValue(value);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
useImperativeHandle(
@@ -276,7 +277,6 @@ const SelectContainer = forwardRef<HTMLButtonElement, SelectProps>(
labelVariant={labelVariant}
className={className}
options={options}
children={children}
selectId={selectId}
labelId={labelId}
isOpen={isOpen}
+6 -8
View File
@@ -26,8 +26,8 @@ export interface SelectViewProps {
chevronClasses: string;
// Callbacks
onButtonClick: () => void;
onButtonKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
onOptionClick: (value: string, text: string) => void;
onButtonKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void;
onOptionClick: (_value: string, _text: string) => void;
// Refs
selectRef: React.RefObject<HTMLButtonElement>;
menuRef: React.RefObject<HTMLDivElement>;
@@ -38,11 +38,11 @@ export interface SelectViewProps {
export function SelectView({
label,
placeholder,
placeholder: _placeholder,
size,
disabled,
error,
labelVariant,
error: _error,
labelVariant: _labelVariant,
options,
children,
selectId,
@@ -118,9 +118,7 @@ export function SelectView({
key={option.value}
selected={option.value === selectedValue}
size={size}
onClick={() =>
onOptionClick(option.value, option.label)
}
onClick={() => onOptionClick(option.value, option.label)}
>
{option.label}
</SelectOption>
@@ -15,6 +15,6 @@ export interface SelectOptionViewProps {
disabled: boolean;
className: string;
itemClasses: string;
handleClick: (e: React.MouseEvent<HTMLDivElement>) => void;
handleKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => void;
handleClick: (_e: React.MouseEvent<HTMLDivElement>) => void;
handleKeyDown: (_e: React.KeyboardEvent<HTMLDivElement>) => void;
}
@@ -1,7 +1,10 @@
import { forwardRef } from "react";
import type { SelectOptionViewProps } from "./SelectOption.types";
export const SelectOptionView = forwardRef<HTMLDivElement, SelectOptionViewProps>(
export const SelectOptionView = forwardRef<
HTMLDivElement,
SelectOptionViewProps
>(
(
{
children,
+8 -6
View File
@@ -1,5 +1,7 @@
export interface SwitchProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
export interface SwitchProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onChange"
> {
checked?: boolean;
onChange?: (
_e:
@@ -23,8 +25,8 @@ export interface SwitchViewProps {
trackClasses: string;
thumbClasses: string;
labelClasses: string;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
onFocus: (e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur: (e: React.FocusEvent<HTMLButtonElement>) => void;
onClick: (_e: React.MouseEvent<HTMLButtonElement>) => void;
onKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void;
onFocus: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur: (_e: React.FocusEvent<HTMLButtonElement>) => void;
}
+7 -5
View File
@@ -1,5 +1,7 @@
export interface TextAreaProps
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "size" | "onChange" | "onFocus" | "onBlur"> {
export interface TextAreaProps extends Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
"size" | "onChange" | "onFocus" | "onBlur"
> {
size?: "small" | "medium" | "large";
labelVariant?: "default" | "horizontal";
state?: "default" | "active" | "hover" | "focus";
@@ -33,7 +35,7 @@ export interface TextAreaViewProps {
labelClasses: string;
textareaClasses: string;
borderRadius: string;
handleChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleFocus: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
handleBlur: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
handleChange: (_e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleFocus: (_e: React.FocusEvent<HTMLTextAreaElement>) => void;
handleBlur: (_e: React.FocusEvent<HTMLTextAreaElement>) => void;
}
+1 -1
View File
@@ -11,7 +11,7 @@ export const TextAreaView = forwardRef<HTMLTextAreaElement, TextAreaViewProps>(
value,
name,
disabled,
className,
className: _className,
rows,
containerClasses,
labelClasses,
+8 -6
View File
@@ -1,5 +1,7 @@
export interface ToggleProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
export interface ToggleProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onChange"
> {
label?: string;
checked?: boolean;
onChange?: (
@@ -34,11 +36,11 @@ export interface ToggleViewProps {
labelClasses: string;
toggleClasses: string;
onClick: (
e:
_e:
| React.MouseEvent<HTMLButtonElement>
| React.KeyboardEvent<HTMLButtonElement>,
) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
onFocus: (e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur: (e: React.FocusEvent<HTMLButtonElement>) => void;
onKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void;
onFocus: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur: (_e: React.FocusEvent<HTMLButtonElement>) => void;
}
@@ -119,7 +119,6 @@ const ToggleGroupContainer = memo(
return (
<ToggleGroupView
groupId={groupId}
children={children}
className={className}
position={position}
state={state}
@@ -131,7 +130,9 @@ const ToggleGroupContainer = memo(
onFocus={handleFocus}
onBlur={handleBlur}
{...rest}
/>
>
{children}
</ToggleGroupView>
);
}),
);
@@ -1,5 +1,7 @@
export interface ToggleGroupProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
export interface ToggleGroupProps extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onChange"
> {
children?: React.ReactNode;
className?: string;
position?: "left" | "middle" | "right";
@@ -24,8 +26,8 @@ export interface ToggleGroupViewProps {
showText: boolean;
ariaLabel?: string;
toggleClasses: string;
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
onFocus: (e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur: (e: React.FocusEvent<HTMLButtonElement>) => void;
onClick: (_e: React.MouseEvent<HTMLButtonElement>) => void;
onKeyDown: (_e: React.KeyboardEvent<HTMLButtonElement>) => void;
onFocus: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur: (_e: React.FocusEvent<HTMLButtonElement>) => void;
}
@@ -3,9 +3,9 @@ import type { ToggleGroupViewProps } from "./ToggleGroup.types";
export function ToggleGroupView({
groupId,
children,
className,
position,
state,
className: _className,
position: _position,
state: _state,
showText,
ariaLabel,
toggleClasses,
@@ -96,11 +96,14 @@ const WebVitalsDashboardContainer = memo(() => {
}, []);
return (
<WebVitalsDashboardView vitals={vitals} metrics={metrics} loading={loading} />
<WebVitalsDashboardView
vitals={vitals}
metrics={metrics}
loading={loading}
/>
);
});
WebVitalsDashboardContainer.displayName = "WebVitalsDashboard";
export default WebVitalsDashboardContainer;
@@ -31,4 +31,3 @@ export interface WebVitalsDashboardViewProps {
metrics: Metrics;
loading: boolean;
}
@@ -111,9 +111,7 @@ function WebVitalsDashboardView({
<span className="text-yellow-600">
Needs Improvement: {data.needsImprovementCount}
</span>
<span className="text-red-600">
Poor: {data.poorCount}
</span>
<span className="text-red-600">Poor: {data.poorCount}</span>
</div>
</div>
</div>
@@ -155,4 +153,3 @@ function WebVitalsDashboardView({
}
export default WebVitalsDashboardView;
@@ -1,3 +1,2 @@
export { default } from "./WebVitalsDashboard.container";
export * from "./WebVitalsDashboard.types";
+16 -13
View File
@@ -33,27 +33,30 @@ export function useMediaQuery(
query: string | keyof typeof BREAKPOINTS,
direction: "min" | "max" = "min",
): boolean {
const [matches, setMatches] = useState(false);
// Convert breakpoint key to media query string
let mediaQuery: string;
if (query in BREAKPOINTS) {
const breakpoint = BREAKPOINTS[query as keyof typeof BREAKPOINTS];
mediaQuery = `(${direction}-width: ${breakpoint}px)`;
} else {
mediaQuery = query;
}
// Initialize state with current match if available (SSR safety)
const [matches, setMatches] = useState(() => {
if (typeof window === "undefined") {
return false;
}
return window.matchMedia(mediaQuery).matches;
});
useEffect(() => {
// Convert breakpoint key to media query string
let mediaQuery: string;
if (query in BREAKPOINTS) {
const breakpoint = BREAKPOINTS[query as keyof typeof BREAKPOINTS];
mediaQuery = `(${direction}-width: ${breakpoint}px)`;
} else {
mediaQuery = query;
}
// Check if window is available (SSR safety)
if (typeof window === "undefined") {
return;
}
const media = window.matchMedia(mediaQuery);
// Initialize matches synchronously - this is safe for media queries
// eslint-disable-next-line react-hooks/rules-of-hooks
setMatches(media.matches);
// Create listener for changes
const listener = (event: MediaQueryListEvent) => {
+25 -7
View File
@@ -16,6 +16,7 @@ The Container/Presentation pattern separates component logic from presentation,
### When to Use
Use this pattern for components that have:
- Business logic or state management
- Data fetching or API calls
- Analytics tracking
@@ -40,6 +41,7 @@ app/components/[ComponentName]/
### File Responsibilities
#### `index.tsx`
- Exports the container component as the default export
- Optionally exports types for external use
- Maintains backward compatibility with existing import paths
@@ -50,7 +52,9 @@ export type { AskOrganizerProps } from "./AskOrganizer.types";
```
#### `[ComponentName].container.tsx`
**Contains all logic:**
- React hooks (`useState`, `useEffect`, custom hooks)
- Event handlers and business logic
- Data fetching and API calls
@@ -60,6 +64,7 @@ export type { AskOrganizerProps } from "./AskOrganizer.types";
- Side effects
**Should NOT contain:**
- JSX layout details (beyond composing the view)
- Inline styles or complex className logic (pass as props)
- Direct DOM manipulation
@@ -74,7 +79,7 @@ import type { AskOrganizerProps } from "./AskOrganizer.types";
function AskOrganizerContainer(props: AskOrganizerProps) {
const { trackEvent } = useAnalytics();
const handleContactClick = () => {
trackEvent({
event: "contact_button_click",
@@ -83,10 +88,10 @@ function AskOrganizerContainer(props: AskOrganizerProps) {
});
// ... additional logic
};
// Compute derived props
const variantStyles = computeVariantStyles(props.variant);
return (
<AskOrganizerView
{...props}
@@ -100,13 +105,16 @@ export default memo(AskOrganizerContainer);
```
#### `[ComponentName].view.tsx`
**Pure presentation:**
- Receives all data via props
- Renders JSX based on props
- No hooks, no state, no side effects
- Only imports other presentational components
**Should NOT contain:**
- `useState`, `useEffect`, or any hooks
- Event handler implementations (receive as callbacks)
- Data fetching or API calls
@@ -148,6 +156,7 @@ export function AskOrganizerView({
```
#### `[ComponentName].types.ts`
- Shared TypeScript interfaces and types
- Public props interface (used by consumers)
- Internal view props (used between container and view)
@@ -177,6 +186,7 @@ export interface AskOrganizerViewProps extends AskOrganizerProps {
### Container Components
**DO:**
- Use React hooks (`useState`, `useEffect`, custom hooks)
- Handle all event handlers and business logic
- Fetch data and manage loading states
@@ -185,6 +195,7 @@ export interface AskOrganizerViewProps extends AskOrganizerProps {
- Compose the view component with computed props
**DON'T:**
- Include complex JSX layout (delegate to view)
- Mix presentation logic with business logic
- Access DOM directly (use refs when necessary)
@@ -192,6 +203,7 @@ export interface AskOrganizerViewProps extends AskOrganizerProps {
### View Components
**DO:**
- Receive all data via props
- Render JSX based on props
- Import only presentational components
@@ -199,6 +211,7 @@ export interface AskOrganizerViewProps extends AskOrganizerProps {
- Accept callback props for user interactions
**DON'T:**
- Use any React hooks
- Manage state or side effects
- Fetch data or make API calls
@@ -220,11 +233,11 @@ import Button from "./Button";
const AskOrganizer = memo(({ title, variant, ...props }) => {
const { trackEvent } = useAnalytics();
const handleContactClick = () => {
trackEvent({ event: "contact_click", component: "AskOrganizer" });
};
return (
<section>
<ContentLockup title={title} />
@@ -237,6 +250,7 @@ const AskOrganizer = memo(({ title, variant, ...props }) => {
### After (Container/Presentation)
**AskOrganizer.container.tsx:**
```typescript
"use client";
@@ -247,11 +261,11 @@ import type { AskOrganizerProps } from "./AskOrganizer.types";
function AskOrganizerContainer(props: AskOrganizerProps) {
const { trackEvent } = useAnalytics();
const handleContactClick = () => {
trackEvent({ event: "contact_click", component: "AskOrganizer" });
};
return <AskOrganizerView {...props} onContactClick={handleContactClick} />;
}
@@ -259,6 +273,7 @@ export default memo(AskOrganizerContainer);
```
**AskOrganizer.view.tsx:**
```typescript
import ContentLockup from "../ContentLockup";
import Button from "../Button";
@@ -346,6 +361,7 @@ These components serve as reference implementations for the pattern.
The following components are candidates for future conversion:
### High Priority (Complex Logic)
- `Header` / `HomeHeader` - Navigation state, conditional rendering logic
- `MenuBar` - Menu state management, keyboard navigation
- `ContextMenu` - Positioning logic, click outside handling
@@ -353,12 +369,14 @@ The following components are candidates for future conversion:
- `ToggleGroup` - Group state management
### Medium Priority (Some Logic)
- `ContentContainer` - Data fetching or transformation
- `RelatedArticles` - Data fetching, filtering logic
- `RuleStack` - Complex rendering logic
- `LogoWall` - Animation or interaction logic
### Low Priority (Mostly Presentational)
- `Button`, `Avatar`, `Checkbox`, `Input`, `TextArea` - Simple presentational components
- `Separator`, `SectionHeader`, `SectionNumber` - Pure presentation
- `QuoteBlock`, `QuoteDecor`, `HeroDecor` - Decorative components
+60 -1
View File
@@ -3,9 +3,37 @@
# Local testing script - run before committing/merging
# Usage: ./scripts/test-local.sh
set -euo pipefail # Exit on error, undefined vars, pipe failures
echo "🧪 Running local tests before commit..."
echo ""
# Cleanup function to ensure servers are killed
cleanup() {
echo ""
echo "🧹 Cleaning up any running servers..."
# Kill any Next.js servers on common test ports
for port in 3000 3010; do
if lsof -ti:$port >/dev/null 2>&1; then
echo " Killing process on port $port..."
lsof -ti:$port | xargs kill -9 2>/dev/null || true
fi
done
# Kill any processes from PID files if they exist
for pidfile in .next/runner.pid .next/performance-server.pid; do
if [ -f "$pidfile" ]; then
PID=$(cat "$pidfile" 2>/dev/null || echo "")
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
echo " Killing server PID: $PID (from $pidfile)..."
kill -9 "$PID" 2>/dev/null || true
fi
rm -f "$pidfile"
fi
done
}
trap cleanup EXIT INT TERM
echo "🔍 Linting..."
npm run lint || exit 1
@@ -23,11 +51,42 @@ npm run test:e2e || exit 1
echo ""
echo "🖼️ Visual regression tests..."
# Visual tests use Playwright's webServer config, should auto-start server
npm run visual:test || exit 1
echo ""
echo "⚡ Performance tests (Lighthouse CI)..."
npm run performance:budget || exit 1
# Performance tests need a server running on port 3010
# Check if server is already running, if not start one
if ! lsof -ti:3010 >/dev/null 2>&1; then
echo " Starting server for performance tests..."
npm run build || exit 1
PORT=3010 HOST=127.0.0.1 node node_modules/next/dist/bin/next start -p 3010 -H 127.0.0.1 > .next/performance-server.log 2>&1 &
SERVER_PID=$!
echo "$SERVER_PID" > .next/performance-server.pid
echo " Server started (PID: $SERVER_PID), waiting for readiness..."
npx wait-on -t 120000 "tcp:127.0.0.1:3010" || { echo "❌ Server failed to start"; kill $SERVER_PID 2>/dev/null || true; exit 1; }
echo " ✅ Server ready"
fi
# Run performance tests
npm run performance:budget || EXIT_CODE=$?
# Cleanup performance server if we started it
if [ -f .next/performance-server.pid ]; then
PID=$(cat .next/performance-server.pid 2>/dev/null || echo "")
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
echo " Stopping performance test server..."
kill "$PID" 2>/dev/null || true
sleep 2
kill -9 "$PID" 2>/dev/null || true
fi
rm -f .next/performance-server.pid
fi
if [ -n "${EXIT_CODE:-}" ]; then
exit $EXIT_CODE
fi
echo ""
echo "✅ All tests passed! Safe to commit/merge."