diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..51a72f7ba --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,121 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Build and Testing +```bash +# Install dependencies +npm install + +# Build all packages +npm run build --workspaces + +# Test all packages (requires build first) +npm run test --workspaces + +# Test single package +npm run test --workspace @atomicjolt/atomic-elements + +# Watch tests for a specific package +npm run test:watch --workspace @atomicjolt/atomic-elements + +# Update test snapshots +npm run test:update --workspace @atomicjolt/atomic-elements +``` + +### Development Environment +```bash +# Run the interactive playground +npm run playground + +# Start Storybook development server +npm run storybook:dev + +# Start documentation development server +npm run docs:start +``` + +### Version Management (Changesets) +```bash +# Create a new changeset for package changes +npx changeset + +# Check changeset status +npx changeset status + +# Version packages based on changesets +npx changeset version + +# Publish new package versions +npx changeset publish +``` + +### Component and Package Scaffolding +```bash +# Create new component in atomic-elements +npm run scaffold:component + +# Create new package +npm run scaffold:package +``` + +## Repository Architecture + +This is a monorepo containing multiple NPM packages maintained by Atomic Jolt, built with: +- **Workspaces**: npm workspaces for monorepo management +- **Build tools**: Rollup (packages), Vite (dev/playground), TypeScript +- **Testing**: Vitest with jsdom environment +- **Documentation**: Storybook + Docusaurus +- **Components**: React with styled-components, React Aria, React Stately + +### Package Structure + +**Main packages** (all in `packages/` directory): +- `atomic-elements` - Core React component library with design system +- `atomic-fuel` - Redux utilities and React components for LTI applications +- `hooks` - Reusable React hooks +- `forms` - Form components and utilities +- `lti-client` - LTI client-side utilities and components +- `lti-server` - LTI server-side utilities and validation +- `lti-components` - LTI-specific React components +- `lti-types` - TypeScript types for LTI +- `canvas-client` - Canvas API client utilities + +### Key Directories + +- `packages/atomic-elements/src/components/` - All UI components organized by category (Buttons, Fields, Layout, etc.) +- `packages/atomic-elements/src/styles/` - Design system tokens, themes, and styling utilities +- `packages/atomic-elements/src/hooks/` - Component-specific hooks +- `.storybook/` - Storybook configuration and utilities +- `playground/` - Interactive development environment +- `templates/` - Scaffolding templates for new components and packages +- `docs/` - Documentation source files (Docusaurus) + +### Component Architecture + +Components follow a consistent structure: +- `ComponentName.component.tsx` - Main component implementation +- `ComponentName.styles.ts` - Styled-components styling +- `ComponentName.context.ts` - React context (if needed) +- `ComponentName.stories.tsx` - Storybook stories +- `ComponentName.spec.tsx` - Vitest tests +- `ComponentName.types.ts` - TypeScript types (if complex) +- `index.tsx` - Public exports + +### Build System + +- Each package has its own `tsconfig.build.json` and build configuration +- `atomic-elements` uses Rollup to generate both ESM and CJS outputs +- Path aliases configured in `vite.config.ts` for easier imports (@components, @hooks, @styles, @utils) +- TypeScript with strict mode enabled +- Components built with React 18/19 compatibility + +### Testing Strategy + +- Vitest for all testing with jsdom environment +- React Testing Library for component testing +- Each package has individual test configuration +- Shared test setup in `vitest.setup.ts` files +- Build packages before running tests to ensure proper imports diff --git a/packages/atomic-elements/CHANGELOG.md b/packages/atomic-elements/CHANGELOG.md index e8fbd29da..840c109f3 100644 --- a/packages/atomic-elements/CHANGELOG.md +++ b/packages/atomic-elements/CHANGELOG.md @@ -1,5 +1,11 @@ # @atomicjolt/atomic-elements +## 3.6.0 + +### Minor Changes + +- Add ErrorBoundary helper component + ## 3.5.1 ### Patch Changes diff --git a/packages/atomic-elements/docs/Guides/ErrorBoundary.mdx b/packages/atomic-elements/docs/Guides/ErrorBoundary.mdx new file mode 100644 index 000000000..cfc29fc76 --- /dev/null +++ b/packages/atomic-elements/docs/Guides/ErrorBoundary.mdx @@ -0,0 +1,159 @@ +--- +title: ErrorBoundary +--- + +import { ErrorBoundary } from "@atomicjolt/atomic-elements"; + +# ErrorBoundary + +The `ErrorBoundary` component provides a way to catch JavaScript errors in your component tree and display fallback UI instead of crashing the entire application. + +## Basic Usage + +### Static Fallback + +The simplest usage is with a static fallback UI: + +```tsx +import { ErrorBoundary } from "@atomicjolt/atomic-elements"; + +function App() { + return ( + Something went wrong!}> + + + ); +} +``` + +### Function Fallback + +For more dynamic error handling, use a function fallback: + +```tsx +function App() { + return ( + ( +
+

Oops! Something went wrong

+

Error: {error.message}

+ +
+ )} + > + +
+ ); +} +``` + +## Reset Keys + +The `resetKeys` prop allows the ErrorBoundary to automatically reset when certain values change. This is useful for resetting the error state when the conditions that caused the error are no longer present. + +When any value in the `resetKeys` array changes (using strict equality `!==` comparison), the ErrorBoundary will: + +1. Clear the error state +2. Call the `onReset` callback (if provided) +3. Re-render the children components + +```tsx +function UserDashboard() { + const { currentUser } = useAuth(); + const [retryCount, setRetryCount] = useState(0); + + return ( + ( + +

Failed to load dashboard: {error.message}

+ +
+ )} + onReset={() => { + console.log("Dashboard error boundary reset"); + }} + > + +
+ ); +} +``` + +## Manual Reset + +You can also trigger a reset manually using the `reset` function provided to function fallbacks: + +```tsx +function AppWithManualReset() { + const [shouldThrow, setShouldThrow] = useState(true); + + return ( + ( +
+

Component Error

+

{error.message}

+ +
+ )} + > + +
+ ); +} +``` + +## Error Monitoring + +Use the `onError` callback to report errors to monitoring services: + +```tsx +function MonitoredApp() { + return ( + { + // Report to your error monitoring service + errorReporting.captureException(error, { + extra: errorInfo, + tags: { component: "App" }, + }); + }} + fallback={(error, reset) => ( + +

Something went wrong

+

We've been notified and are working on a fix.

+ +
+ )} + > + +
+ ); +} +``` + +## Best Practices + +- **Use resetKeys** - Include any state that could affect whether the error occurs +- **Provide meaningful fallbacks** - Show what went wrong and how to recover +- **Report errors** - Use the `onError` callback to send errors to monitoring services +- **Test your error boundaries** - Throw errors in development to verify your fallbacks work + +## Limitations + +- Error boundaries only catch errors in child components, not in the error boundary itself +- Error boundaries don't catch errors in event handlers - use try/catch for those +- Error boundaries don't catch errors in async code - handle Promise rejections separately diff --git a/packages/atomic-elements/package.json b/packages/atomic-elements/package.json index f8e06fe30..666fefa0b 100644 --- a/packages/atomic-elements/package.json +++ b/packages/atomic-elements/package.json @@ -1,6 +1,6 @@ { "name": "@atomicjolt/atomic-elements", - "version": "3.5.1", + "version": "3.6.0", "sideEffects": false, "module": "dist/esm/index.js", "main": "dist/cjs/index.js", diff --git a/packages/atomic-elements/src/components/Feedback/ErrorBoundary/ErrorBoundary.component.tsx b/packages/atomic-elements/src/components/Feedback/ErrorBoundary/ErrorBoundary.component.tsx new file mode 100644 index 000000000..508cc51e8 --- /dev/null +++ b/packages/atomic-elements/src/components/Feedback/ErrorBoundary/ErrorBoundary.component.tsx @@ -0,0 +1,83 @@ +import { Component, ErrorInfo, ReactNode } from "react"; + +export interface ErrorBoundaryProps { + children: ReactNode; + /** Fallback UI to display when an error is caught. Can be a ReactNode or a function that receives the error and a reset function. */ + fallback: ReactNode | ((error: Error, reset: () => void) => ReactNode); + /** Callback invoked when an error is caught. */ + onError?: (error: Error, errorInfo: ErrorInfo) => void; + /** Callback invoked when the error boundary is reset. */ + onReset?: () => void; + /** An array of values that, when changed, will reset the error boundary. */ + resetKeys?: Array; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { + hasError: true, + error, + }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + this.props.onError?.(error, errorInfo); + } + + componentDidUpdate(prevProps: ErrorBoundaryProps) { + const { resetKeys } = this.props; + const prevResetKeys = prevProps.resetKeys; + + if (this.state.hasError && prevResetKeys !== resetKeys) { + if (resetKeys && prevResetKeys) { + const hasResetKeyChanged = resetKeys.some( + (key, idx) => prevResetKeys[idx] !== key + ); + if (hasResetKeyChanged) { + this.reset(); + } + } else if (resetKeys !== prevResetKeys) { + this.reset(); + } + } + } + + reset = () => { + this.props.onReset?.(); + + this.setState({ + hasError: false, + error: null, + }); + }; + + render() { + if (this.state.hasError && this.state.error) { + const { fallback } = this.props; + + if (typeof fallback === "function") { + return fallback(this.state.error, this.reset); + } + + return fallback; + } + + return this.props.children; + } +} diff --git a/packages/atomic-elements/src/components/Feedback/ErrorBoundary/ErrorBoundary.spec.tsx b/packages/atomic-elements/src/components/Feedback/ErrorBoundary/ErrorBoundary.spec.tsx new file mode 100644 index 000000000..b0ea1ec16 --- /dev/null +++ b/packages/atomic-elements/src/components/Feedback/ErrorBoundary/ErrorBoundary.spec.tsx @@ -0,0 +1,253 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ErrorBoundary } from "./ErrorBoundary.component"; + +const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => { + if (shouldThrow) { + throw new Error("Test error"); + } + return
No error
; +}; + +describe("ErrorBoundary", () => { + // Suppress console.error for these tests + const originalError = console.error; + beforeEach(() => { + console.error = vi.fn(); + }); + + afterEach(() => { + console.error = originalError; + }); + + it("renders children when there is no error", () => { + render( + Error occurred}> + + + ); + + expect(screen.getByText("No error")).toBeInTheDocument(); + }); + + it("renders fallback when there is an error", () => { + render( + Error occurred}> + + + ); + + expect(screen.getByText("Error occurred")).toBeInTheDocument(); + }); + + it("calls onError when an error occurs", () => { + const onError = vi.fn(); + + render( + Error occurred} onError={onError}> + + + ); + + expect(onError).toHaveBeenCalledWith( + expect.any(Error), + expect.objectContaining({ + componentStack: expect.any(String), + }) + ); + }); + + it("renders function fallback with error and reset", () => { + const fallback = vi.fn((error, reset) => ( +
+ Function fallback + +
+ )); + + render( + + + + ); + + expect(screen.getByText("Function fallback")).toBeInTheDocument(); + expect(fallback).toHaveBeenCalledWith( + expect.any(Error), + expect.any(Function) + ); + }); + + it("resets when resetKeys change", () => { + const onReset = vi.fn(); + const { rerender } = render( + Error occurred} + resetKeys={[1]} + onReset={onReset} + > + + + ); + + expect(screen.getByText("Error occurred")).toBeInTheDocument(); + + rerender( + Error occurred} + resetKeys={[2]} + onReset={onReset} + > + + + ); + + expect(screen.getByText("No error")).toBeInTheDocument(); + expect(onReset).toHaveBeenCalled(); + }); + + it("calls onReset when reset function is called", () => { + const onReset = vi.fn(); + + render( + ( + + )} + onReset={onReset} + > + + + ); + + const resetButton = screen.getByText("Reset"); + fireEvent.click(resetButton); + + expect(onReset).toHaveBeenCalled(); + }); + + it("actually resets the error boundary when reset is called", () => { + const TestComponent = () => { + const [shouldThrow, setShouldThrow] = React.useState(true); + + return ( + ( +
+ Error: {error.message} + +
+ )} + > + +
+ ); + }; + + render(); + + // Should show error initially + expect(screen.getByText("Error: Test error")).toBeInTheDocument(); + + // Click reset + fireEvent.click(screen.getByText("Reset and Fix")); + + // Should now show the working component + expect(screen.getByText("No error")).toBeInTheDocument(); + }); + + it("handles resetKeys array changes correctly", () => { + const onReset = vi.fn(); + const { rerender } = render( + Error occurred} + resetKeys={["key1", "key2"]} + onReset={onReset} + > + + + ); + + expect(screen.getByText("Error occurred")).toBeInTheDocument(); + + // Change one key in the array + rerender( + Error occurred} + resetKeys={["key1", "key3"]} + onReset={onReset} + > + + + ); + + expect(screen.getByText("No error")).toBeInTheDocument(); + expect(onReset).toHaveBeenCalled(); + }); + + it("handles empty resetKeys properly", () => { + const onReset = vi.fn(); + const { rerender } = render( + Error occurred} + resetKeys={[]} + onReset={onReset} + > + + + ); + + expect(screen.getByText("Error occurred")).toBeInTheDocument(); + + // Change from empty to populated + rerender( + Error occurred} + resetKeys={["key1"]} + onReset={onReset} + > + + + ); + + expect(screen.getByText("No error")).toBeInTheDocument(); + expect(onReset).toHaveBeenCalled(); + }); + + it("does not reset when resetKeys array is the same", () => { + const onReset = vi.fn(); + const { rerender } = render( + Error occurred} + resetKeys={["key1", "key2"]} + onReset={onReset} + > + + + ); + + expect(screen.getByText("Error occurred")).toBeInTheDocument(); + + // Rerender with same keys + rerender( + Error occurred} + resetKeys={["key1", "key2"]} + onReset={onReset} + > + + + ); + + expect(screen.getByText("Error occurred")).toBeInTheDocument(); + expect(onReset).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/packages/atomic-elements/src/components/Feedback/ErrorBoundary/ErrorBoundary.stories.tsx b/packages/atomic-elements/src/components/Feedback/ErrorBoundary/ErrorBoundary.stories.tsx new file mode 100644 index 000000000..3e218a399 --- /dev/null +++ b/packages/atomic-elements/src/components/Feedback/ErrorBoundary/ErrorBoundary.stories.tsx @@ -0,0 +1,238 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; +import { ErrorBoundary } from "./ErrorBoundary.component"; +import { Button } from "../../Buttons/Button"; +import { ErrorBanner } from "@components/Banners/DismissableBanner"; +import { Text } from "@components/Typography/Text"; + +const meta: Meta = { + title: "Feedback/ErrorBoundary", + component: ErrorBoundary, + parameters: { + layout: "centered", + }, + argTypes: { + fallback: { + control: "text", + description: `The fallback UI to display when an error is caught. This can be a React node or a function that receives the error and a reset function as arguments.`, + }, + resetKeys: { + control: false, + description: `An array of values that, when changed, will reset the error boundary's state.`, + }, + onError: { + action: "error captured", + description: `Callback function that is called when an error is caught. It receives the error and additional info as arguments.`, + }, + onReset: { + action: "reset triggered", + description: `Callback function that is called when the error boundary is reset.`, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const BuggyComponent = ({ shouldThrow }: { shouldThrow: boolean }) => { + if (shouldThrow) { + throw new Error("Something went wrong in the component!"); + } + return ( + + Everything is working fine! + + ); +}; + +const BuggyCounter = () => { + const [count, setCount] = useState(0); + + if (count > 3) { + throw new Error("Count cannot be greater than 3!"); + } + + return ( +
+

Count: {count}

+ +
+ ); +}; + +export const WithStaticFallback: Story = { + render: () => { + const [shouldThrow, setShouldThrow] = useState(false); + + return ( +
+ + + + Oops! Something went wrong. Please try refreshing the page. + + } + > + + +
+ ); + }, +}; + +export const WithFunctionFallback: Story = { + render: () => { + const [shouldThrow, setShouldThrow] = useState(false); + + return ( +
+ + + ( + + Error: {error.message} +
+ +
+ )} + resetKeys={[shouldThrow]} + onError={(error, errorInfo) => { + console.log("Error caught:", error); + console.log("Error info:", errorInfo); + }} + onReset={() => { + console.log("Error boundary reset"); + }} + > + +
+
+ ); + }, +}; + +export const WithResetKeys: Story = { + render: () => { + const [resetKey, setResetKey] = useState(0); + const [userId, setUserId] = useState("user1"); + + return ( +
+
+

+ Reset key: {resetKey} | User ID: {userId} +

+
+ + +
+

+ Changing either value will reset the error boundary +

+
+ + + Component crashed! Change the reset key or user ID above to + recover. + + } + resetKeys={[resetKey, userId]} + onReset={() => { + console.log("Error boundary reset due to resetKeys change"); + }} + > + + +
+ ); + }, +}; + +export const NestedErrorBoundaries: Story = { + render: () => { + const [outerError, setOuterError] = useState(false); + const [innerError, setInnerError] = useState(false); + + return ( +
+
+ + +
+ + Outer error boundary caught an error! + } + > +
+

Outer Component

+ + + Inner error boundary caught an error! + } + resetKeys={[innerError]} + > +
+

Inner Component

+ +
+
+
+
+
+ ); + }, +}; diff --git a/packages/atomic-elements/src/components/Feedback/ErrorBoundary/index.tsx b/packages/atomic-elements/src/components/Feedback/ErrorBoundary/index.tsx new file mode 100644 index 000000000..cc723b74f --- /dev/null +++ b/packages/atomic-elements/src/components/Feedback/ErrorBoundary/index.tsx @@ -0,0 +1 @@ +export { ErrorBoundary, type ErrorBoundaryProps } from "./ErrorBoundary.component"; \ No newline at end of file diff --git a/packages/atomic-elements/src/components/index.ts b/packages/atomic-elements/src/components/index.ts index 5634b170b..a196aeec2 100644 --- a/packages/atomic-elements/src/components/index.ts +++ b/packages/atomic-elements/src/components/index.ts @@ -51,6 +51,7 @@ export { ThreeDotLoader } from "./Feedback/ThreeDotLoader"; export { SkeletonLoader } from "./Feedback/SkeletonLoader"; export { LoadingStatus } from "./Feedback/LoadingStatus"; export { ProgressCircle } from "./Feedback/ProgressCircle"; +export { ErrorBoundary } from "./Feedback/ErrorBoundary"; export { MaterialIcon } from "./Icons/MaterialIcon"; export { MaterialSymbol } from "./Icons/MaterialSymbol"; @@ -162,6 +163,7 @@ export type { ThreeDotLoaderProps } from "./Feedback/ThreeDotLoader"; export type { SkeletonLoaderProps } from "./Feedback/SkeletonLoader"; export type { LoadingStatusProps } from "./Feedback/LoadingStatus"; export type { ProgressCircleProps } from "./Feedback/ProgressCircle"; +export type { ErrorBoundaryProps } from "./Feedback/ErrorBoundary"; export type { ModalProps } from "./Overlays/Modal"; export type { ConfirmationModalProps } from "./Overlays/ConfirmationModal"; export type { ErrorModalProps } from "./Overlays/ErrorModal"; diff --git a/packages/forms/CHANGELOG.md b/packages/forms/CHANGELOG.md index 7d340d4a8..3505bf656 100644 --- a/packages/forms/CHANGELOG.md +++ b/packages/forms/CHANGELOG.md @@ -1,5 +1,12 @@ # @atomicjolt/forms +## 3.6.0 + +### Patch Changes + +- Updated dependencies + - @atomicjolt/atomic-elements@3.6.0 + ## 3.5.0 ### Patch Changes diff --git a/packages/forms/package.json b/packages/forms/package.json index c70cf425b..3b0f22207 100644 --- a/packages/forms/package.json +++ b/packages/forms/package.json @@ -1,6 +1,6 @@ { "name": "@atomicjolt/forms", - "version": "3.5.0", + "version": "3.6.0", "license": "MIT", "type": "module", "main": "dist/index.js", @@ -17,13 +17,13 @@ "prepublishOnly": "npm run build" }, "peerDependencies": { - "@atomicjolt/atomic-elements": "^3.5.0", + "@atomicjolt/atomic-elements": "^3.6.0", "react": "^18 || ^19", "react-dom": "^18 || ^19", "react-hook-form": "^7.47.0" }, "devDependencies": { - "@atomicjolt/atomic-elements": "^3.5.0", + "@atomicjolt/atomic-elements": "^3.6.0", "@types/node": "^20.8.4", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0",