From 730dac73ae68b2d96e3f15c08873b77d99e07323 Mon Sep 17 00:00:00 2001 From: xingzihai Date: Wed, 25 Mar 2026 17:24:08 +0000 Subject: [PATCH 1/2] docs: Improve CONTRIBUTING.md with comprehensive contributor guide - Add table of contents for easy navigation - Add project architecture overview with tech stack - Add quick start checklist for new contributors - Add finding issues section with categorized links - Add development setup quick guide - Add pull request process guidelines - Add community support channels - Improve overall structure and organization --- CONTRIBUTING.md | 266 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 251 insertions(+), 15 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8252ed59e735..3ceb0ad85742 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,27 +1,263 @@ # Contributing to Appsmith Thank you for your interest in Appsmith and for taking the time to contribute to this project. 🙌 -Appsmith is a project by developers for developers and there are a lot of ways you can contribute. -If you don't know where to start contributing, ask us on our [Discord channel](https://discord.com/invite/rBTTVJp). -## Code of conduct +Appsmith is a project by developers for developers and there are a lot of ways you can contribute. If you don't know where to start contributing, ask us on our [Discord channel](https://discord.com/invite/rBTTVJp). -Read our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing +## Table of Contents -## How can I contribute? +- [Code of Conduct](#code-of-conduct) +- [Project Architecture](#project-architecture) +- [Quick Start Checklist](#quick-start-checklist) +- [How Can I Contribute?](#how-can-i-contribute) +- [Development Setup](#development-setup) +- [Pull Request Process](#pull-request-process) +- [Community Support](#community-support) -There are many ways in which you can contribute to Appsmith. +--- + +## Code of Conduct + +Read our [Code of Conduct](CODE_OF_CONDUCT.md) before contributing. We are committed to fostering an open, welcoming, and safe environment in the community. + +--- + +## Project Architecture + +Understanding the project structure will help you navigate the codebase more effectively: + +``` +appsmith/ +├── app/ +│ ├── client/ # Frontend (React + TypeScript) +│ │ ├── src/ # React components, widgets, utilities +│ │ ├── cypress/ # Cypress integration tests +│ │ └── packages/ # Shared packages including RTS (Real-Time Server) +│ └── server/ # Backend (Java + Spring + WebFlux) +│ ├── appsmith-server/ # Main server application +│ └── appsmith-plugins/ # Database/API connectors +├── deploy/ # Docker & Kubernetes deployment configs +├── contributions/ # Contribution guides and documentation +└── static/ # Static assets for documentation +``` + +### Tech Stack Overview + +| Component | Technologies | +|-----------|--------------| +| **Frontend** | React, TypeScript, Redux, Redux-Saga | +| **Backend** | Java 25, Spring, WebFlux, MongoDB, Redis | +| **Testing** | Jest (unit), Cypress (integration), JUnit (server) | +| **Deployment** | Docker, Kubernetes | + +--- + +## Quick Start Checklist + +New to contributing? Follow this checklist to get started: + +### Before You Start +- [ ] Read this CONTRIBUTING guide +- [ ] Read our [Code of Conduct](CODE_OF_CONDUCT.md) +- [ ] Join our [Discord community](https://discord.com/invite/rBTTVJp) for support + +### For Code Contributions +- [ ] Find an issue to work on (see [Finding Issues](#finding-issues)) +- [ ] Comment on the issue to get it assigned to you +- [ ] Fork and clone the repository +- [ ] Set up your development environment (see [Development Setup](#development-setup)) +- [ ] Create a feature branch from `release` +- [ ] Make your changes and write tests +- [ ] Submit a pull request + +### For Documentation Contributions +- [ ] Visit the [appsmith-docs repository](https://github.com/appsmithorg/appsmith-docs) +- [ ] Read the [Docs Contribution Guide](https://github.com/appsmithorg/appsmith-docs/blob/main/CONTRIBUTING.md) + +--- + +## How Can I Contribute? + +### 🐛 Report a Bug -#### 🐛 Report a bug Report all issues through GitHub Issues using the [Report a Bug](https://github.com/appsmithorg/appsmith/issues/new?assignees=Nikhil-Nandagopal&labels=Bug%2CNeeds+Triaging&template=--bug-report.yaml&title=%5BBug%5D%3A+) template. -To help resolve your issue as quickly as possible, read the template and provide all the requested information. -#### 🛠 File a feature request -We welcome all feature requests, whether it's to add new functionality to an existing extension or to offer an idea for a brand new extension. -File your feature request through GitHub Issues using the [Feature Request](https://github.com/appsmithorg/appsmith/issues/new?assignees=Nikhil-Nandagopal&labels=Enhancement&template=--feature-request.yaml&title=%5BFeature%5D%3A+) template. +**Tips for a good bug report:** +- Use a clear and descriptive title +- Include steps to reproduce the issue +- Describe the expected vs. actual behavior +- Include screenshots or screen recordings if helpful +- Mention your environment (OS, browser, Appsmith version) + +### 🛠 File a Feature Request + +We welcome all feature requests! File your request through GitHub Issues using the [Feature Request](https://github.com/appsmithorg/appsmith/issues/new?assignees=Nikhil-Nandagopal&labels=Enhancement&template=--feature-request.yaml&title=%5BFeature%5D%3A+) template. + +**Tips for a good feature request:** +- Describe the problem you're trying to solve +- Explain how your suggestion would help solve it +- Include examples or mockups if available + +### 📝 Improve the Documentation + +Help us keep our documentation up to date! You can: +- Suggest improvements using the [Documentation templates](https://github.com/appsmithorg/appsmith-docs/issues/new/choose) +- Contribute directly to our [Docs repository](https://github.com/appsmithorg/appsmith-docs) + +### ⚙️ Contribute Code + +#### Finding Issues + +Looking for issues to contribute to? Here are some great starting points: + +| Issue Type | Link | +|------------|------| +| Good First Issues | [Browse →](https://github.com/appsmithorg/appsmith/issues?q=is%3Aopen+is%3Aissue+label%3A%22Good+First+Issue%22) | +| Inviting Contributions | [Browse →](https://github.com/appsmithorg/appsmith/issues?q=is%3Aopen+is%3Aissue+label%3A%22Inviting+Contribution%22) | +| Help Wanted | [Browse →](https://github.com/appsmithorg/appsmith/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22) | + +> ⚠️ **Important:** Always get an issue assigned to you before starting work. Comment on the issue expressing your interest. Tag `@contributor-support` if needed. Working on issues without assignment may result in your contribution being rejected. + +#### Types of Code Contributions + +| Contribution Type | Guide | +|-------------------|-------| +| Frontend (React/TypeScript) | [Client Setup Guide](contributions/ClientSetup.md) | +| Backend (Java/Spring) | [Server Setup Guide](contributions/ServerSetup.md) | +| New Widget | [Widget Development Guide](contributions/AppsmithWidgetDevelopmentGuide.md) | +| New Plugin/Connector | [Plugin Contribution Guide](contributions/ServerCodeContributionsGuidelines/PluginCodeContributionsGuidelines.md) | +| Add Custom JS Library | [Custom JS Library Guide](contributions/CustomJsLibrary.md) | +| Write Tests | [Test Automation Guide](contributions/docs/TestAutomation.md) | + +--- + +## Development Setup + +### Prerequisites + +Before setting up the development environment, ensure you have: + +| Tool | Version | Notes | +|------|---------|-------| +| Docker | Latest | Required for containerized services | +| Node.js | 20.11.1 | Use nvm or fnm for version management | +| Java | OpenJDK 25 | Eclipse Temurin recommended | +| Maven | 3.9+ | Preferably 3.9.12 | +| Git | Latest | For version control | +| mkcert | Latest | For local HTTPS certificates | + +### Quick Setup + +#### Frontend Only (for UI contributions) + +```bash +# Clone your fork +git clone https://github.com/YOUR_USERNAME/appsmith.git +cd appsmith + +# Set up local HTTPS certificates +cd app/client/docker && mkcert -install && mkcert "*.appsmith.com" && cd ../../.. + +# Add dev domain to hosts +echo "127.0.0.1 dev.appsmith.com" | sudo tee -a /etc/hosts + +# Copy environment file +cp .env.example .env + +# Install dependencies and start +cd app/client +yarn install +./start-https.sh https://release.app.appsmith.com # Use staging backend +yarn start +``` + +Your frontend will be running at https://dev.appsmith.com + +#### Full Stack Setup + +1. **Set up MongoDB and Redis** (using Docker): + ```bash + # MongoDB + docker run -d -p 127.0.0.1:27017:27017 --name appsmith-mongodb \ + -e MONGO_INITDB_DATABASE=appsmith mongo --replSet rs0 + + # Redis + docker run -d -p 127.0.0.1:6379:6379 --name appsmith-redis redis + ``` + +2. **Build and run the server**: + ```bash + cd app/server + mvn clean compile + cp envs/dev.env.example .env + # Edit .env to point to your local MongoDB and Redis + ./build.sh -Dmaven.test.skip + ./scripts/start-dev-server.sh + ``` + +For detailed setup instructions, see: +- [Client Setup Guide](contributions/ClientSetup.md) - Full frontend setup with troubleshooting +- [Server Setup Guide](contributions/ServerSetup.md) - Complete backend setup including IntelliJ configuration + +--- + +## Pull Request Process + +### Branch Naming + +Use descriptive branch names following these patterns: +- `fix/bug-description` - For bug fixes +- `feature/feature-name` - For new features +- `docs/description` - For documentation changes + +### Commit Messages + +Write clear, descriptive commit messages: +- Use the present tense ("Add feature" not "Added feature") +- Reference issue numbers when applicable +- Keep the first line under 72 characters + +### Before Submitting + +- [ ] Code compiles without errors +- [ ] Tests pass locally (Jest for frontend, JUnit for backend) +- [ ] New code includes appropriate tests +- [ ] Code follows the existing style conventions +- [ ] PR description clearly explains the changes + +### PR Guidelines + +1. **Create PR from your fork** to `appsmithorg/appsmith` `release` branch +2. **Link the issue** in your PR description (e.g., "Fixes #123") +3. **Tag the maintainer** you're collaborating with +4. **Wait for CI** to pass before requesting review +5. **Address review feedback** promptly and push new commits + +### What NOT to Do + +❌ Work on issues without getting them assigned first +❌ Create PRs without proper description +❌ Request review before CI passes +❌ Submit PRs without tests +❌ Skip reading the contribution guidelines + +--- + +## Community Support + +Need help? We're here for you! + +| Channel | Best For | +|---------|----------| +| [Discord](https://discord.com/invite/rBTTVJp) | Real-time help, discussions, community | +| [GitHub Discussions](https://github.com/appsmithorg/appsmith/discussions) | Questions, ideas, announcements | +| [GitHub Issues](https://github.com/appsmithorg/appsmith/issues) | Bug reports, feature requests | +| [support@appsmith.com](mailto:support@appsmith.com) | Private inquiries, security issues | + +--- + +## Recognition -#### 📝 Improve the documentation -In the process of shipping features quickly, we may forget to keep our docs up to date. You can help by suggesting improvements to our documentation using the [Documentation templates](https://github.com/appsmithorg/appsmith-docs/issues/new/choose) or dive right into our [Docs Contribution Guide](https://github.com/appsmithorg/appsmith-docs/blob/main/CONTRIBUTING.md)! +We ❤️ our contributors! All contributors are recognized in our [README](README.md#top-contributors). Your contributions help make Appsmith better for everyone. -#### ⚙️ Close a Bug / Feature issue -We welcome contributions that help make appsmith bug free & improve the experience of our users. You can also find issues tagged [Good First Issues](https://github.com/appsmithorg/appsmith/issues?q=is%3Aopen+is%3Aissue+label%3A%22Good+First+Issue%22+bug). Check out our [Code Contribution Guide](contributions/CodeContributionsGuidelines.md) to begin. +Let's build great software together! 🚀 \ No newline at end of file From 97bc1ace8972ebbaa5a0e35d9596a261e5448538 Mon Sep 17 00:00:00 2001 From: xingzihai <1315258019@qq.com> Date: Thu, 26 Mar 2026 02:53:23 +0000 Subject: [PATCH 2/2] test: add unit tests for utility hooks and URLUtils - Add tests for usePrevious hook - Add tests for useWindowDimensions hook - Add tests for useOnClickOutside hook - Add tests for URLUtils utility functions These tests cover: - Various data types and edge cases for usePrevious - Window resize events and cleanup for useWindowDimensions - Click/touch outside detection for useOnClickOutside - Query parameter parsing, URL validation, and string sanitization for URLUtils Total: 75 new test cases --- app/client/src/utils/URLUtils.test.ts | 312 ++++++++++++++++++ .../utils/hooks/useOnClickOutside.test.tsx | 217 ++++++++++++ .../src/utils/hooks/usePrevious.test.tsx | 150 +++++++++ .../utils/hooks/useWindowDimensions.test.tsx | 209 ++++++++++++ 4 files changed, 888 insertions(+) create mode 100644 app/client/src/utils/URLUtils.test.ts create mode 100644 app/client/src/utils/hooks/useOnClickOutside.test.tsx create mode 100644 app/client/src/utils/hooks/usePrevious.test.tsx create mode 100644 app/client/src/utils/hooks/useWindowDimensions.test.tsx diff --git a/app/client/src/utils/URLUtils.test.ts b/app/client/src/utils/URLUtils.test.ts new file mode 100644 index 000000000000..8721594b4ae8 --- /dev/null +++ b/app/client/src/utils/URLUtils.test.ts @@ -0,0 +1,312 @@ +import { + getQueryParams, + convertObjectToQueryParams, + isValidURL, + matchesURLPattern, + sanitizeString, +} from "./URLUtils"; + +describe("URLUtils", () => { + describe("getQueryParams", () => { + let originalLocation: Location; + + beforeEach(() => { + originalLocation = window.location; + delete (window as any).location; + }); + + afterEach(() => { + window.location = originalLocation; + }); + + it("should return an empty object when there are no query params", () => { + (window as any).location = { search: "" }; + + const result = getQueryParams(); + + expect(result).toEqual({}); + }); + + it("should return query params as an object", () => { + (window as any).location = { search: "?key1=value1&key2=value2" }; + + const result = getQueryParams(); + + expect(result).toEqual({ + key1: "value1", + key2: "value2", + }); + }); + + it("should handle a single query param", () => { + (window as any).location = { search: "?key=value" }; + + const result = getQueryParams(); + + expect(result).toEqual({ key: "value" }); + }); + + it("should handle empty values", () => { + (window as any).location = { search: "?key1=&key2=value2" }; + + const result = getQueryParams(); + + expect(result).toEqual({ + key1: "", + key2: "value2", + }); + }); + + it("should handle encoded values", () => { + (window as any).location = { search: "?name=John%20Doe&email=test%40example.com" }; + + const result = getQueryParams(); + + expect(result).toEqual({ + name: "John Doe", + email: "test@example.com", + }); + }); + + it("should handle special characters in values", () => { + (window as any).location = { search: "?key=value%26with%3Dspecial%20chars" }; + + const result = getQueryParams(); + + expect(result).toEqual({ + key: "value&with=special chars", + }); + }); + }); + + describe("convertObjectToQueryParams", () => { + it("should convert a simple object to query params", () => { + const result = convertObjectToQueryParams({ key1: "value1", key2: "value2" }); + + expect(result).toBe("?key1=value1&key2=value2"); + }); + + it("should return an empty string for null input", () => { + const result = convertObjectToQueryParams(null); + + expect(result).toBe(""); + }); + + it("should return an empty string for undefined input", () => { + const result = convertObjectToQueryParams(undefined); + + expect(result).toBe(""); + }); + + it("should handle an empty object", () => { + const result = convertObjectToQueryParams({}); + + expect(result).toBe("?"); + }); + + it("should encode special characters in keys and values", () => { + const result = convertObjectToQueryParams({ + "key with spaces": "value with spaces", + "key&special": "value&special", + }); + + expect(result).toContain("key%20with%20spaces=value%20with%20spaces"); + expect(result).toContain("key%26special=value%26special"); + }); + + it("should handle numeric values", () => { + const result = convertObjectToQueryParams({ count: 42, price: 19.99 }); + + expect(result).toBe("?count=42&price=19.99"); + }); + + it("should handle boolean values", () => { + const result = convertObjectToQueryParams({ active: true, disabled: false }); + + expect(result).toBe("?active=true&disabled=false"); + }); + + it("should handle empty string values", () => { + const result = convertObjectToQueryParams({ key: "" }); + + expect(result).toBe("?key="); + }); + + it("should handle array values by converting to string", () => { + const result = convertObjectToQueryParams({ items: "a,b,c" }); + + expect(result).toBe("?items=a%2Cb%2Cc"); + }); + }); + + describe("isValidURL", () => { + it("should return true for valid HTTP URLs", () => { + expect(isValidURL("http://example.com")).toBe(true); + expect(isValidURL("http://www.example.com")).toBe(true); + expect(isValidURL("http://example.com/path")).toBe(true); + expect(isValidURL("http://example.com/path?query=value")).toBe(true); + }); + + it("should return true for valid HTTPS URLs", () => { + expect(isValidURL("https://example.com")).toBe(true); + expect(isValidURL("https://www.example.com")).toBe(true); + expect(isValidURL("https://example.com/path")).toBe(true); + expect(isValidURL("https://example.com/path?query=value")).toBe(true); + }); + + it("should return true for valid FTP URLs", () => { + expect(isValidURL("ftp://ftp.example.com")).toBe(true); + }); + + it("should return true for valid mailto URLs", () => { + expect(isValidURL("mailto:test@example.com")).toBe(true); + }); + + it("should return true for valid tel URLs", () => { + expect(isValidURL("tel:+1234567890")).toBe(true); + }); + + it("should return true for localhost URLs", () => { + expect(isValidURL("http://localhost")).toBe(true); + expect(isValidURL("http://localhost:3000")).toBe(true); + }); + + it("should return true for URLs with ports", () => { + expect(isValidURL("http://example.com:8080")).toBe(true); + expect(isValidURL("https://example.com:443")).toBe(true); + }); + + it("should return false for invalid URLs", () => { + expect(isValidURL("not a url")).toBe(false); + expect(isValidURL("example.com")).toBe(false); + expect(isValidURL("")).toBe(false); + expect(isValidURL("http://")).toBe(false); + }); + + it("should return false for URLs without protocol", () => { + expect(isValidURL("www.example.com")).toBe(false); + }); + + it("should return true for URLs with fragments", () => { + expect(isValidURL("http://example.com#section")).toBe(true); + expect(isValidURL("http://example.com/path#section")).toBe(true); + }); + + it("should return true for URLs with complex paths", () => { + expect(isValidURL("http://example.com/path/to/resource")).toBe(true); + expect(isValidURL("http://example.com/path/to/resource/file.html")).toBe(true); + }); + }); + + describe("matchesURLPattern", () => { + it("should match standard HTTP URLs", () => { + expect(matchesURLPattern("http://example.com")).toBe(true); + }); + + it("should match standard HTTPS URLs", () => { + expect(matchesURLPattern("https://example.com")).toBe(true); + }); + + it("should match URLs with www prefix", () => { + expect(matchesURLPattern("www.example.com")).toBe(true); + }); + + it("should match URLs with paths", () => { + expect(matchesURLPattern("http://example.com/path/to/page")).toBe(true); + }); + + it("should match URLs with query strings", () => { + expect(matchesURLPattern("http://example.com?key=value")).toBe(true); + }); + + it("should match URLs with fragments", () => { + expect(matchesURLPattern("http://example.com#section")).toBe(true); + }); + + it("should match localhost URLs with trailing slash", () => { + // Note: The regex has localhost(?=\/) which requires localhost to be followed by / + expect(matchesURLPattern("localhost/")).toBe(true); + expect(matchesURLPattern("localhost/path")).toBe(true); + }); + + it("should not match localhost URLs without trailing slash", () => { + // localhost without / doesn't match the pattern + expect(matchesURLPattern("localhost")).toBe(false); + expect(matchesURLPattern("http://localhost:3000")).toBe(false); + }); + + it("should match IP addresses", () => { + expect(matchesURLPattern("192.168.1.1")).toBe(true); + expect(matchesURLPattern("http://192.168.1.1:8080")).toBe(true); + }); + + it("should match URLs with ports", () => { + expect(matchesURLPattern("http://example.com:8080")).toBe(true); + }); + + it("should match URLs with subdomains", () => { + expect(matchesURLPattern("http://sub.example.com")).toBe(true); + expect(matchesURLPattern("http://api.v2.example.com")).toBe(true); + }); + + it("should match FTP URLs", () => { + expect(matchesURLPattern("ftp://ftp.example.com")).toBe(true); + }); + + it("should match mailto URLs", () => { + expect(matchesURLPattern("mailto:test@example.com")).toBe(true); + }); + + it("should not match tel URLs", () => { + // The regex doesn't properly match tel: URLs + expect(matchesURLPattern("tel:+1234567890")).toBe(false); + expect(matchesURLPattern("tel://+1234567890")).toBe(false); + }); + + it("should not match invalid strings", () => { + // Note: The regex is quite permissive, so many strings might match + // This test documents the behavior rather than asserting failure + expect(matchesURLPattern("")).toBe(false); + }); + }); + + describe("sanitizeString", () => { + it("should convert uppercase letters to lowercase", () => { + expect(sanitizeString("HELLO")).toBe("hello"); + expect(sanitizeString("Hello World")).toBe("hello_world"); + }); + + it("should replace special characters with underscores", () => { + expect(sanitizeString("hello-world")).toBe("hello_world"); + expect(sanitizeString("hello@world")).toBe("hello_world"); + expect(sanitizeString("hello world")).toBe("hello_world"); + }); + + it("should keep alphanumeric characters", () => { + expect(sanitizeString("abc123")).toBe("abc123"); + expect(sanitizeString("ABC123")).toBe("abc123"); + }); + + it("should handle multiple special characters in a row", () => { + expect(sanitizeString("hello--world")).toBe("hello__world"); + expect(sanitizeString("hello@@world")).toBe("hello__world"); + }); + + it("should handle strings starting or ending with special characters", () => { + expect(sanitizeString("-hello-")).toBe("_hello_"); + expect(sanitizeString("@hello@")).toBe("_hello_"); + }); + + it("should handle empty string", () => { + expect(sanitizeString("")).toBe(""); + }); + + it("should handle strings with only special characters", () => { + expect(sanitizeString("@#$%")).toBe("____"); + }); + + it("should handle strings with mixed content", () => { + expect(sanitizeString("My-App_v2.0")).toBe("my_app_v2_0"); + }); + }); +}); \ No newline at end of file diff --git a/app/client/src/utils/hooks/useOnClickOutside.test.tsx b/app/client/src/utils/hooks/useOnClickOutside.test.tsx new file mode 100644 index 000000000000..022f4d170192 --- /dev/null +++ b/app/client/src/utils/hooks/useOnClickOutside.test.tsx @@ -0,0 +1,217 @@ +import { renderHook } from "@testing-library/react-hooks"; +import { useOnClickOutside } from "./useOnClickOutside"; +import { createRef } from "react"; + +describe("useOnClickOutside hook", () => { + let handler: jest.Mock; + + beforeEach(() => { + handler = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + document.body.innerHTML = ""; + }); + + it("should call handler when clicking outside the referenced element", () => { + const ref = createRef(); + const element = document.createElement("div"); + document.body.appendChild(element); + // @ts-expect-error - assigning to ref.current for testing + ref.current = element; + + renderHook(() => useOnClickOutside([ref], handler)); + + // Simulate a click outside + const clickEvent = new MouseEvent("mousedown", { bubbles: true }); + document.body.dispatchEvent(clickEvent); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(clickEvent); + }); + + it("should not call handler when clicking inside the referenced element", () => { + const ref = createRef(); + const element = document.createElement("div"); + document.body.appendChild(element); + // @ts-expect-error - assigning to ref.current for testing + ref.current = element; + + renderHook(() => useOnClickOutside([ref], handler)); + + // Simulate a click inside + const clickEvent = new MouseEvent("mousedown", { bubbles: true }); + element.dispatchEvent(clickEvent); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("should handle multiple refs correctly", () => { + const ref1 = createRef(); + const ref2 = createRef(); + const element1 = document.createElement("div"); + const element2 = document.createElement("div"); + document.body.appendChild(element1); + document.body.appendChild(element2); + // @ts-expect-error - assigning to ref.current for testing + ref1.current = element1; + // @ts-expect-error - assigning to ref.current for testing + ref2.current = element2; + + renderHook(() => useOnClickOutside([ref1, ref2], handler)); + + // Click outside both elements + const clickEvent = new MouseEvent("mousedown", { bubbles: true }); + document.body.dispatchEvent(clickEvent); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("should not call handler when clicking inside any of the referenced elements", () => { + const ref1 = createRef(); + const ref2 = createRef(); + const element1 = document.createElement("div"); + const element2 = document.createElement("div"); + document.body.appendChild(element1); + document.body.appendChild(element2); + // @ts-expect-error - assigning to ref.current for testing + ref1.current = element1; + // @ts-expect-error - assigning to ref.current for testing + ref2.current = element2; + + renderHook(() => useOnClickOutside([ref1, ref2], handler)); + + // Click inside element1 + const clickEvent1 = new MouseEvent("mousedown", { bubbles: true }); + element1.dispatchEvent(clickEvent1); + + expect(handler).not.toHaveBeenCalled(); + + // Click inside element2 + const clickEvent2 = new MouseEvent("mousedown", { bubbles: true }); + element2.dispatchEvent(clickEvent2); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("should handle touch events (touchstart)", () => { + const ref = createRef(); + const element = document.createElement("div"); + document.body.appendChild(element); + // @ts-expect-error - assigning to ref.current for testing + ref.current = element; + + renderHook(() => useOnClickOutside([ref], handler)); + + // Simulate a touch outside + const touchEvent = new TouchEvent("touchstart", { bubbles: true }); + document.body.dispatchEvent(touchEvent); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(touchEvent); + }); + + it("should not call handler when touching inside the referenced element", () => { + const ref = createRef(); + const element = document.createElement("div"); + document.body.appendChild(element); + // @ts-expect-error - assigning to ref.current for testing + ref.current = element; + + renderHook(() => useOnClickOutside([ref], handler)); + + // Simulate a touch inside + const touchEvent = new TouchEvent("touchstart", { bubbles: true }); + element.dispatchEvent(touchEvent); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("should clean up event listeners on unmount", () => { + const ref = createRef(); + const element = document.createElement("div"); + document.body.appendChild(element); + // @ts-expect-error - assigning to ref.current for testing + ref.current = element; + + const removeEventListenerSpy = jest.spyOn(document.body, "removeEventListener"); + + const { unmount } = renderHook(() => useOnClickOutside([ref], handler)); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "mousedown", + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "touchstart", + expect.any(Function), + ); + + removeEventListenerSpy.mockRestore(); + }); + + it("should not call handler if ref.current is null", () => { + const ref = createRef(); + + renderHook(() => useOnClickOutside([ref], handler)); + + // Click anywhere + const clickEvent = new MouseEvent("mousedown", { bubbles: true }); + document.body.dispatchEvent(clickEvent); + + // When ref.current is null, the handler should NOT be called + // because the hook returns early when el is null + expect(handler).not.toHaveBeenCalled(); + }); + + it("should handle nested elements correctly", () => { + const ref = createRef(); + const parentElement = document.createElement("div"); + const childElement = document.createElement("span"); + parentElement.appendChild(childElement); + document.body.appendChild(parentElement); + // @ts-expect-error - assigning to ref.current for testing + ref.current = parentElement; + + renderHook(() => useOnClickOutside([ref], handler)); + + // Click on child element (inside parent) + const clickEvent = new MouseEvent("mousedown", { bubbles: true }); + childElement.dispatchEvent(clickEvent); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("should update when handler changes", () => { + const ref = createRef(); + const element = document.createElement("div"); + document.body.appendChild(element); + // @ts-expect-error - assigning to ref.current for testing + ref.current = element; + + const { rerender } = renderHook( + ({ handler }: { handler: jest.Mock }) => useOnClickOutside([ref], handler), + { initialProps: { handler } }, + ); + + // Click outside + const clickEvent1 = new MouseEvent("mousedown", { bubbles: true }); + document.body.dispatchEvent(clickEvent1); + + expect(handler).toHaveBeenCalledTimes(1); + + // Update handler + const newHandler = jest.fn(); + rerender({ handler: newHandler }); + + // Click outside again + const clickEvent2 = new MouseEvent("mousedown", { bubbles: true }); + document.body.dispatchEvent(clickEvent2); + + expect(newHandler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledTimes(1); // Should still be 1, not called again + }); +}); \ No newline at end of file diff --git a/app/client/src/utils/hooks/usePrevious.test.tsx b/app/client/src/utils/hooks/usePrevious.test.tsx new file mode 100644 index 000000000000..d69dea7d95a5 --- /dev/null +++ b/app/client/src/utils/hooks/usePrevious.test.tsx @@ -0,0 +1,150 @@ +import { renderHook } from "@testing-library/react-hooks"; +import usePrevious from "./usePrevious"; + +describe("usePrevious hook", () => { + it("should return undefined on the first render", () => { + const { result } = renderHook(() => usePrevious("initial")); + + expect(result.current).toBeUndefined(); + }); + + it("should return the previous value after the value changes", () => { + const { result, rerender } = renderHook( + ({ value }) => usePrevious(value), + { initialProps: { value: "first" } }, + ); + + // First render - should be undefined + expect(result.current).toBeUndefined(); + + // Rerender with a new value + rerender({ value: "second" }); + + // Now it should return the previous value ("first") + expect(result.current).toBe("first"); + + // Rerender again + rerender({ value: "third" }); + + // Now it should return "second" + expect(result.current).toBe("second"); + }); + + it("should work with different types of values", () => { + // Test with numbers + const { result: numberResult, rerender: numberRerender } = renderHook( + ({ value }) => usePrevious(value), + { initialProps: { value: 1 } }, + ); + + expect(numberResult.current).toBeUndefined(); + numberRerender({ value: 2 }); + expect(numberResult.current).toBe(1); + numberRerender({ value: 3 }); + expect(numberResult.current).toBe(2); + + // Test with objects + const { result: objectResult, rerender: objectRerender } = renderHook( + ({ value }) => usePrevious(value), + { initialProps: { value: { a: 1 } } }, + ); + + expect(objectResult.current).toBeUndefined(); + objectRerender({ value: { a: 2 } }); + expect(objectResult.current).toEqual({ a: 1 }); + }); + + it("should handle undefined values correctly", () => { + const { result, rerender } = renderHook( + ({ value }) => usePrevious(value), + { initialProps: { value: undefined as string | undefined } }, + ); + + expect(result.current).toBeUndefined(); + + rerender({ value: "defined" }); + expect(result.current).toBeUndefined(); + + rerender({ value: undefined }); + expect(result.current).toBe("defined"); + }); + + it("should handle null values correctly", () => { + const { result, rerender } = renderHook( + ({ value }) => usePrevious(value), + { initialProps: { value: null as string | null } }, + ); + + expect(result.current).toBeUndefined(); + + rerender({ value: "not null" }); + expect(result.current).toBeNull(); + + rerender({ value: null }); + expect(result.current).toBe("not null"); + }); + + it("should handle boolean values correctly", () => { + const { result, rerender } = renderHook( + ({ value }) => usePrevious(value), + { initialProps: { value: true } }, + ); + + expect(result.current).toBeUndefined(); + + rerender({ value: false }); + expect(result.current).toBe(true); + + rerender({ value: true }); + expect(result.current).toBe(false); + }); + + it("should handle array values correctly", () => { + const { result, rerender } = renderHook( + ({ value }) => usePrevious(value), + { initialProps: { value: [1, 2, 3] } }, + ); + + expect(result.current).toBeUndefined(); + + rerender({ value: [4, 5, 6] }); + expect(result.current).toEqual([1, 2, 3]); + + rerender({ value: [] }); + expect(result.current).toEqual([4, 5, 6]); + }); + + it("should handle empty strings correctly", () => { + const { result, rerender } = renderHook( + ({ value }) => usePrevious(value), + { initialProps: { value: "" } }, + ); + + expect(result.current).toBeUndefined(); + + rerender({ value: "not empty" }); + expect(result.current).toBe(""); + + rerender({ value: "" }); + expect(result.current).toBe("not empty"); + }); + + it("should maintain reference stability for object values", () => { + const firstObject = { a: 1 }; + const secondObject = { a: 2 }; + const thirdObject = { a: 3 }; + + const { result, rerender } = renderHook( + ({ value }) => usePrevious(value), + { initialProps: { value: firstObject } }, + ); + + expect(result.current).toBeUndefined(); + + rerender({ value: secondObject }); + expect(result.current).toBe(firstObject); + + rerender({ value: thirdObject }); + expect(result.current).toBe(secondObject); + }); +}); \ No newline at end of file diff --git a/app/client/src/utils/hooks/useWindowDimensions.test.tsx b/app/client/src/utils/hooks/useWindowDimensions.test.tsx new file mode 100644 index 000000000000..8f10de0768ff --- /dev/null +++ b/app/client/src/utils/hooks/useWindowDimensions.test.tsx @@ -0,0 +1,209 @@ +import { renderHook, act } from "@testing-library/react-hooks"; +import useWindowDimensions from "./useWindowDimensions"; + +describe("useWindowDimensions hook", () => { + let originalInnerWidth: number; + let originalInnerHeight: number; + + beforeEach(() => { + originalInnerWidth = window.innerWidth; + originalInnerHeight = window.innerHeight; + }); + + afterEach(() => { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: originalInnerWidth, + }); + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: originalInnerHeight, + }); + + // Clean up event listeners + window.removeEventListener("resize", () => {}); + }); + + it("should return current window dimensions on initial render", () => { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 1024, + }); + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: 768, + }); + + const { result } = renderHook(() => useWindowDimensions()); + + const [width, height] = result.current; + + expect(width).toBe(1024); + expect(height).toBe(768); + }); + + it("should update dimensions when window is resized", () => { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 800, + }); + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: 600, + }); + + const { result } = renderHook(() => useWindowDimensions()); + + // Initial dimensions + expect(result.current[0]).toBe(800); + expect(result.current[1]).toBe(600); + + // Simulate resize + act(() => { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 1200, + }); + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: 900, + }); + + window.dispatchEvent(new Event("resize")); + }); + + // Updated dimensions + expect(result.current[0]).toBe(1200); + expect(result.current[1]).toBe(900); + }); + + it("should handle multiple resize events", () => { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 500, + }); + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: 400, + }); + + const { result } = renderHook(() => useWindowDimensions()); + + expect(result.current[0]).toBe(500); + expect(result.current[1]).toBe(400); + + // First resize + act(() => { + Object.defineProperty(window, "innerWidth", { value: 600 }); + Object.defineProperty(window, "innerHeight", { value: 500 }); + window.dispatchEvent(new Event("resize")); + }); + + expect(result.current[0]).toBe(600); + expect(result.current[1]).toBe(500); + + // Second resize + act(() => { + Object.defineProperty(window, "innerWidth", { value: 700 }); + Object.defineProperty(window, "innerHeight", { value: 600 }); + window.dispatchEvent(new Event("resize")); + }); + + expect(result.current[0]).toBe(700); + expect(result.current[1]).toBe(600); + }); + + it("should clean up event listener on unmount", () => { + const removeEventListenerSpy = jest.spyOn(window, "removeEventListener"); + + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 1024, + }); + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: 768, + }); + + const { unmount } = renderHook(() => useWindowDimensions()); + + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "resize", + expect.any(Function), + ); + + removeEventListenerSpy.mockRestore(); + }); + + it("should return an array with width as first element and height as second", () => { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 1920, + }); + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: 1080, + }); + + const { result } = renderHook(() => useWindowDimensions()); + + const dimensions = result.current; + + expect(Array.isArray(dimensions)).toBe(true); + expect(dimensions.length).toBe(2); + expect(dimensions[0]).toBe(1920); + expect(dimensions[1]).toBe(1080); + }); + + it("should handle very small window dimensions", () => { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 320, + }); + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: 240, + }); + + const { result } = renderHook(() => useWindowDimensions()); + + expect(result.current[0]).toBe(320); + expect(result.current[1]).toBe(240); + }); + + it("should handle large window dimensions", () => { + Object.defineProperty(window, "innerWidth", { + writable: true, + configurable: true, + value: 3840, + }); + Object.defineProperty(window, "innerHeight", { + writable: true, + configurable: true, + value: 2160, + }); + + const { result } = renderHook(() => useWindowDimensions()); + + expect(result.current[0]).toBe(3840); + expect(result.current[1]).toBe(2160); + }); +}); \ No newline at end of file