refonte: backend-api go first; phase 1

This commit is contained in:
senke 2025-12-12 21:34:34 -05:00
parent 87c6461900
commit 2dfde29f7d
1005 changed files with 39778 additions and 90860 deletions

310
Makefile
View file

@ -1,127 +1,229 @@
# Veza Platform - Root Makefile
# Test Coverage targets (T0043)
# ==============================================================================
# VEZA MONOREPO - ULTIMATE CONTROL PLANE
# ==============================================================================
# Stack: Hybrid (Docker Infra + Bare Metal Apps)
# System: Linux / Bash
# ==============================================================================
.PHONY: test-coverage coverage-html help
# --- Auto-Configuration ---
-include .env
help: ## Show this help message
@echo 'Usage: make [target]'
@echo ''
@echo 'Test Coverage targets:'
@echo ' test-coverage - Run tests and generate coverage report (T0043)'
@echo ' coverage-html - Generate HTML coverage report from existing coverage.out (T0043)'
# Shell setup
SHELL := /bin/bash
.ONESHELL:
.DEFAULT_GOAL := help
test-coverage: ## Run tests and generate coverage report (T0043)
@echo "📊 Generating test coverage report..."
@bash scripts/test-coverage.sh
# --- Variables ---
# Ports
export PORT_GO ?= 8080
export PORT_CHAT ?= 3000
export PORT_STREAM ?= 3001
export PORT_WEB ?= 5173
coverage-html: ## Generate HTML coverage report from existing coverage.out (T0043)
@echo "📊 Generating HTML coverage report..."
@cd veza-backend-api && go tool cover -html=coverage/coverage.out -o coverage/coverage.html
@echo "✅ Coverage report generated: veza-backend-api/coverage/coverage.html"
# Database & Infra
export DB_USER ?= veza
export DB_PASS ?= password
export DB_NAME ?= veza
export DB_HOST ?= localhost
export DB_PORT ?= 5432
# >>> VEZA:BEGIN QA TARGETS
.PHONY: smoke e2e postman lighthouse load qa-all visual backstop-ref backstop-test loki lh a11y start-services
# Connection Strings
export DATABASE_URL = postgres://$(DB_USER):$(DB_PASS)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable
export REDIS_URL = redis://localhost:6379
export AMQP_URL = amqp://$(DB_USER):$(DB_PASS)@localhost:5672
smoke: ## Run API smoke tests (curl + httpie)
@echo "🔥 Running API smoke tests..."
@bash .veza/qa/scripts/wait_for_http.sh "$${VEZA_API_BASE_URL:-http://localhost:8080}/health" 90
@bash .veza/qa/scripts/smoke_curl.sh
@bash .veza/qa/scripts/smoke_httpie.sh || true
# Directories
DIR_GO := veza-backend-api
DIR_CHAT := veza-chat-server
DIR_STREAM := veza-stream-server
DIR_WEB := apps/web
start-services: ## Start services required for QA tests
@echo "🚀 Starting services for QA tests..."
@bash .veza/qa/scripts/start-services-for-tests.sh
# --- Aesthetics & UI ---
# Using echo -e compatible variables
BOLD := 
RED := 
GREEN := 
YELLOW := 
BLUE := 
PURPLE := 
CYAN := 
NC := 
e2e: ## Run E2E tests with Playwright
@echo "🎭 Running E2E tests..."
@cd .veza/qa/playwright && \
if [ ! -d "node_modules" ] || [ ! -f "node_modules/@playwright/test/package.json" ]; then \
echo "📦 Installing Playwright dependencies..."; \
npm install --silent; \
fi && \
npx playwright test --config=playwright.config.ts
# Helper for consistent echoing
ECHO_CMD = echo -e
postman: ## Run Postman/Newman tests
@echo "📮 Running Postman/Newman tests..."
@newman run .veza/qa/postman/veza_api_collection.json \
-e .veza/qa/data/postman_env_local.json \
--reporters cli,junit \
--reporter-junit-export reports/newman.xml || true
# ==============================================================================
# 1. HELP & DASHBOARD
# ==============================================================================
.PHONY: help
help: ## Show this dashboard
@$(ECHO_CMD) ""
@$(ECHO_CMD) "${BOLD}${PURPLE}⚡ VEZA MONOREPO CLI ⚡${NC}"
@$(ECHO_CMD) "----------------------------------------------------------------"
@$(ECHO_CMD) "${BOLD}INFRASTRUCTURE:${NC}"
@printf " ${CYAN}%-15s${NC} %s\n" "Postgres" "${DATABASE_URL}"
@printf " ${CYAN}%-15s${NC} %s\n" "Redis" "${REDIS_URL}"
@printf " ${CYAN}%-15s${NC} %s\n" "RabbitMQ" "UI: http://localhost:15672 (veza/password)"
@$(ECHO_CMD) ""
@$(ECHO_CMD) "${BOLD}AVAILABLE COMMANDS:${NC}"
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " ${YELLOW}%-20s${NC} %s\n", $$1, $$2}'
@$(ECHO_CMD) ""
lighthouse: ## Run Lighthouse CI
@echo "💡 Running Lighthouse CI..."
@npx lhci autorun --config=.veza/qa/lighthouse/lighthouserc.json || true
# ==============================================================================
# 2. SETUP & TOOLS
# ==============================================================================
.PHONY: setup install-deps install-tools check-tools
load: ## Run k6 load tests
@echo "⚡ Running k6 load tests..."
@k6 run .veza/qa/k6/smoke.js || true
setup: check-tools install-tools install-deps ## Full project initialization
@$(ECHO_CMD) "${BOLD}${GREEN}✅ Setup Complete! Ready to rock with 'make dev'.${NC}"
visual: ## Run Playwright visual regression tests
@echo "🖼️ Running Playwright visual regression tests..."
@cd .veza/qa/playwright && \
if [ ! -d "node_modules" ] || [ ! -f "node_modules/@playwright/test/package.json" ]; then \
echo "📦 Installing Playwright dependencies..."; \
npm install --silent; \
fi && \
npx playwright test tests/visual/ --config=playwright.config.ts
check-tools:
@$(ECHO_CMD) "${BLUE}Checking core requirements...${NC}"
@for tool in docker go cargo npm; do \
command -v $$tool >/dev/null 2>&1 || { $(ECHO_CMD) "${RED}$$tool is missing!${NC}"; exit 1; }; \
done
visual-update: ## Generate/update Playwright visual snapshots
@echo "📸 Generating Playwright visual snapshots..."
@cd .veza/qa/playwright && \
if [ ! -d "node_modules" ] || [ ! -f "node_modules/@playwright/test/package.json" ]; then \
echo "📦 Installing Playwright dependencies..."; \
npm install --silent; \
fi && \
npx playwright test tests/visual/ --config=playwright.config.ts --update-snapshots
install-deps: ## Install code dependencies
@$(ECHO_CMD) "${BLUE}📦 Installing dependencies...${NC}"
@$(ECHO_CMD) " -> [Go] Downloading modules..."
@(cd $(DIR_GO) && go mod download)
@$(ECHO_CMD) " -> [Rust Chat] Fetching crates..."
@(cd $(DIR_CHAT) && cargo fetch)
@$(ECHO_CMD) " -> [Rust Stream] Fetching crates..."
@(cd $(DIR_STREAM) && cargo fetch)
@$(ECHO_CMD) " -> [Web] Installing npm packages..."
@(cd $(DIR_WEB) && npm install --silent)
backstop-ref: ## Generate BackstopJS reference images
@echo "📸 Generating BackstopJS reference images..."
@cd .veza/qa/backstop && npx backstop reference --config=backstop.json || true
install-tools: ## Install Power User tools (Hot Reload, Linters)
@$(ECHO_CMD) "${BLUE}🛠️ Installing Dev Tools (Hot Reload & Linters)...${NC}"
@$(ECHO_CMD) " -> Checking air (Go Hot Reload)..."
@command -v air >/dev/null 2>&1 || go install github.com/air-verse/air@latest
@$(ECHO_CMD) " -> Checking cargo-watch (Rust Hot Reload)..."
@command -v cargo-watch >/dev/null 2>&1 || cargo install cargo-watch
@$(ECHO_CMD) " -> Checking sqlx-cli..."
@command -v sqlx >/dev/null 2>&1 || cargo install sqlx-cli --no-default-features --features native-tls,postgres
@$(ECHO_CMD) "${GREEN}✅ Tools check done.${NC}"
backstop-test: ## Run BackstopJS visual regression tests
@echo "🔍 Running BackstopJS visual regression tests..."
@cd .veza/qa/backstop && npx backstop test --config=backstop.json || true
# ==============================================================================
# 3. INFRASTRUCTURE & DB
# ==============================================================================
.PHONY: infra-up infra-down db-shell redis-shell db-migrate status
loki: ## Run Loki visual regression tests (requires Storybook)
@echo "📚 Running Loki visual regression tests..."
@echo "⚠️ Loki requires Storybook to be set up. See .veza/qa/README.md for setup instructions."
@if [ -d ".storybook" ] || [ -d "apps/web/.storybook" ]; then \
npx loki test || true; \
infra-up: ## Start Docker Infra (with health checks)
@$(ECHO_CMD) "${BLUE}🐳 Starting Infrastructure...${NC}"
@docker compose up -d
@$(MAKE) -s wait-for-infra
infra-down: ## Stop Docker Infra
@$(ECHO_CMD) "${BLUE}🛑 Stopping Infrastructure...${NC}"
@docker compose down
wait-for-infra:
@printf "${BLUE}⏳ Waiting for services...${NC}"
@until docker compose exec -T postgres pg_isready -U $(DB_USER) > /dev/null 2>&1; do printf "."; sleep 1; done
@until docker compose exec -T redis redis-cli ping > /dev/null 2>&1; do printf "."; sleep 1; done
@$(ECHO_CMD) " ${GREEN}OK${NC}"
db-shell: ## Connect to Postgres shell
@docker compose exec postgres psql -U $(DB_USER) -d $(DB_NAME)
redis-shell: ## Connect to Redis shell
@docker compose exec redis redis-cli
db-migrate: infra-up ## Run all database migrations
@$(ECHO_CMD) "${BLUE}🔄 Running Migrations...${NC}"
# Go Backend (Custom tool)
@$(ECHO_CMD) " -> [Go] Migrating..."
@(cd $(DIR_GO) && go run cmd/migrate_tool/main.go up || $(ECHO_CMD) "${YELLOW}Warning: Go migration failed or tool missing${NC}")
# Rust Services (SQLx)
@$(ECHO_CMD) " -> [Chat] Migrating..."
@(cd $(DIR_CHAT) && sqlx migrate run || $(ECHO_CMD) "${YELLOW}Warning: Chat migration failed (sqlx installed?)${NC}")
@$(ECHO_CMD) " -> [Stream] Migrating..."
@(cd $(DIR_STREAM) && sqlx migrate run || $(ECHO_CMD) "${YELLOW}Warning: Stream migration failed${NC}")
@$(ECHO_CMD) "${GREEN}✅ Migrations done.${NC}"
status: ## Show system health & stats
@$(ECHO_CMD) "${BOLD}DOCKER STATS:${NC}"
@docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}"
@$(ECHO_CMD) ""
@$(ECHO_CMD) "${BOLD}LOCAL PORTS:${NC}"
@lsof -i :$(PORT_GO) -i :$(PORT_CHAT) -i :$(PORT_STREAM) -i :$(PORT_WEB) | grep LISTEN || echo "No apps listening."
# ==============================================================================
# 4. DEVELOPMENT (SMART MODE)
# ==============================================================================
.PHONY: dev dev-backend check-ports
check-ports:
@$(ECHO_CMD) "${BLUE}🔍 Checking ports...${NC}"
@if lsof -i :$(PORT_GO) -t >/dev/null; then $(ECHO_CMD) "${RED}❌ Port $(PORT_GO) is busy!${NC}"; exit 1; fi
@if lsof -i :$(PORT_CHAT) -t >/dev/null; then $(ECHO_CMD) "${RED}❌ Port $(PORT_CHAT) is busy!${NC}"; exit 1; fi
@if lsof -i :$(PORT_STREAM) -t >/dev/null; then $(ECHO_CMD) "${RED}❌ Port $(PORT_STREAM) is busy!${NC}"; exit 1; fi
dev: check-ports infra-up ## Start Everything (Detects Hot Reload tools)
@$(ECHO_CMD) "${BOLD}${PURPLE}🚀 STARTING HYBRID DEV ENVIRONMENT${NC}"
@$(ECHO_CMD) " Go: http://localhost:${PORT_GO}"
@$(ECHO_CMD) " Chat: http://localhost:${PORT_CHAT}"
@$(ECHO_CMD) " Web: http://localhost:${PORT_WEB}"
@$(ECHO_CMD) "${YELLOW}Hit Ctrl+C to stop all.${NC}"
@(trap 'kill 0' SIGINT; \
if command -v air >/dev/null; then \
$(ECHO_CMD) "${GREEN}[Go] Hot Reload Active (Air)${NC}" && cd $(DIR_GO) && air & \
else \
echo "❌ Storybook not found. Install Storybook first to use Loki."; \
exit 1; \
fi
$(ECHO_CMD) "${YELLOW}[Go] Standard Run${NC}" && cd $(DIR_GO) && go run cmd/api/main.go & \
fi; \
if command -v cargo-watch >/dev/null; then \
$(ECHO_CMD) "${GREEN}[Chat] Hot Reload Active (Cargo Watch)${NC}" && cd $(DIR_CHAT) && cargo watch -x run -q & \
$(ECHO_CMD) "${GREEN}[Stream] Hot Reload Active (Cargo Watch)${NC}" && cd $(DIR_STREAM) && cargo watch -x run -q & \
else \
$(ECHO_CMD) "${YELLOW}[Chat] Standard Run${NC}" && cd $(DIR_CHAT) && cargo run -q & \
$(ECHO_CMD) "${YELLOW}[Stream] Standard Run${NC}" && cd $(DIR_STREAM) && cargo run -q & \
fi; \
$(ECHO_CMD) "${GREEN}[Web] Starting Vite...${NC}" && cd $(DIR_WEB) && npm run dev & \
wait)
lh: lighthouse ## Alias for lighthouse
dev-backend: check-ports infra-up ## Start Backends Only (Hot Reload supported)
@$(ECHO_CMD) "${BOLD}${PURPLE}🚀 STARTING BACKEND ONLY${NC}"
@(trap 'kill 0' SIGINT; \
if command -v air >/dev/null; then cd $(DIR_GO) && air & else cd $(DIR_GO) && go run cmd/api/main.go & fi; \
if command -v cargo-watch >/dev/null; then cd $(DIR_CHAT) && cargo watch -x run -q & else cd $(DIR_CHAT) && cargo run -q & fi; \
if command -v cargo-watch >/dev/null; then cd $(DIR_STREAM) && cargo watch -x run -q & else cd $(DIR_STREAM) && cargo run -q & fi; \
wait)
a11y: ## Run Pa11y accessibility tests
@echo "♿ Running Pa11y accessibility tests..."
@npx pa11y-ci --config .veza/qa/pa11y/.pa11yci.json || true
# ==============================================================================
# 5. TEST & QUALITY
# ==============================================================================
.PHONY: test lint fmt security clean-deep
qa-all: smoke e2e postman lighthouse load visual a11y ## Run all QA tests
@echo "✅ All QA tests completed!"
# <<< VEZA:END QA TARGETS
test: infra-up ## Run All Tests (Fastest strategy)
@$(ECHO_CMD) "${BLUE}🧪 Running Tests...${NC}"
@$(ECHO_CMD) " [Go] Unit Tests..."
@(cd $(DIR_GO) && go test ./... -short)
@$(ECHO_CMD) " [Rust] Unit Tests..."
@(cd $(DIR_CHAT) && cargo test --lib -q)
@(cd $(DIR_STREAM) && cargo test --lib -q)
@$(ECHO_CMD) " [Web] Unit Tests..."
@(cd $(DIR_WEB) && npm run test -- --run)
@$(ECHO_CMD) "${GREEN}✅ All tests passed.${NC}"
# >>> VEZA:BEGIN LAB ORCHESTRATION
.PHONY: infra-up infra-check migrate-all services-up health-all dev-lab
lint: ## Lint everything
@$(ECHO_CMD) "${BLUE}🔍 Linting Codebase...${NC}"
@(cd $(DIR_CHAT) && cargo clippy -- -D warnings)
@(cd $(DIR_STREAM) && cargo clippy -- -D warnings)
@(cd $(DIR_GO) && golangci-lint run ./...)
@(cd $(DIR_WEB) && npm run lint)
infra-up: ## Start Lab Infrastructure (Postgres, Redis, RabbitMQ)
@bash scripts/lab/start_infra.sh
fmt: ## Format everything
@$(ECHO_CMD) "${BLUE}✨ Formatting...${NC}"
@(cd $(DIR_GO) && go fmt ./...)
@(cd $(DIR_CHAT) && cargo fmt)
@(cd $(DIR_STREAM) && cargo fmt)
@(cd $(DIR_WEB) && npm run format)
infra-check: ## Check Lab Infrastructure Health
@bash scripts/lab/check_infra.sh
migrate-all: ## Apply migrations for all services
@bash scripts/lab/apply_all_migrations.sh
services-up: ## Start all services (Backend, Chat, Stream, Web)
@bash scripts/lab/start_all_services.sh
services-down: ## Stop all services
@bash scripts/lab/stop_all_services.sh
health-all: ## Check health of all services
@bash scripts/lab/check_all_health.sh
dev-lab: infra-up infra-check migrate-all services-down services-up health-all ## Start full Lab Environment (Clean Restart)
# <<< VEZA:END LAB ORCHESTRATION
clean-deep: infra-down ## ⚠️ Nuclear Clean (Confirm required)
@read -p "Are you sure you want to delete ALL builds and volumes? [y/N] " ans && [ $${ans:-N} = y ]
@$(ECHO_CMD) "${RED}☢️ DESTROYING ARTIFACTS...${NC}"
@rm -rf $(DIR_WEB)/node_modules
@rm -rf $(DIR_CHAT)/target $(DIR_STREAM)/target
@docker compose down -v
@$(ECHO_CMD) "${GREEN}System Cleaned.${NC}"

127
Makefile.old Normal file
View file

@ -0,0 +1,127 @@
# Veza Platform - Root Makefile
# Test Coverage targets (T0043)
.PHONY: test-coverage coverage-html help
help: ## Show this help message
@echo 'Usage: make [target]'
@echo ''
@echo 'Test Coverage targets:'
@echo ' test-coverage - Run tests and generate coverage report (T0043)'
@echo ' coverage-html - Generate HTML coverage report from existing coverage.out (T0043)'
test-coverage: ## Run tests and generate coverage report (T0043)
@echo "📊 Generating test coverage report..."
@bash scripts/test-coverage.sh
coverage-html: ## Generate HTML coverage report from existing coverage.out (T0043)
@echo "📊 Generating HTML coverage report..."
@cd veza-backend-api && go tool cover -html=coverage/coverage.out -o coverage/coverage.html
@echo "✅ Coverage report generated: veza-backend-api/coverage/coverage.html"
# >>> VEZA:BEGIN QA TARGETS
.PHONY: smoke e2e postman lighthouse load qa-all visual backstop-ref backstop-test loki lh a11y start-services
smoke: ## Run API smoke tests (curl + httpie)
@echo "🔥 Running API smoke tests..."
@bash .veza/qa/scripts/wait_for_http.sh "$${VEZA_API_BASE_URL:-http://localhost:8080}/health" 90
@bash .veza/qa/scripts/smoke_curl.sh
@bash .veza/qa/scripts/smoke_httpie.sh || true
start-services: ## Start services required for QA tests
@echo "🚀 Starting services for QA tests..."
@bash .veza/qa/scripts/start-services-for-tests.sh
e2e: ## Run E2E tests with Playwright
@echo "🎭 Running E2E tests..."
@cd .veza/qa/playwright && \
if [ ! -d "node_modules" ] || [ ! -f "node_modules/@playwright/test/package.json" ]; then \
echo "📦 Installing Playwright dependencies..."; \
npm install --silent; \
fi && \
npx playwright test --config=playwright.config.ts
postman: ## Run Postman/Newman tests
@echo "📮 Running Postman/Newman tests..."
@newman run .veza/qa/postman/veza_api_collection.json \
-e .veza/qa/data/postman_env_local.json \
--reporters cli,junit \
--reporter-junit-export reports/newman.xml || true
lighthouse: ## Run Lighthouse CI
@echo "💡 Running Lighthouse CI..."
@npx lhci autorun --config=.veza/qa/lighthouse/lighthouserc.json || true
load: ## Run k6 load tests
@echo "⚡ Running k6 load tests..."
@k6 run .veza/qa/k6/smoke.js || true
visual: ## Run Playwright visual regression tests
@echo "🖼️ Running Playwright visual regression tests..."
@cd .veza/qa/playwright && \
if [ ! -d "node_modules" ] || [ ! -f "node_modules/@playwright/test/package.json" ]; then \
echo "📦 Installing Playwright dependencies..."; \
npm install --silent; \
fi && \
npx playwright test tests/visual/ --config=playwright.config.ts
visual-update: ## Generate/update Playwright visual snapshots
@echo "📸 Generating Playwright visual snapshots..."
@cd .veza/qa/playwright && \
if [ ! -d "node_modules" ] || [ ! -f "node_modules/@playwright/test/package.json" ]; then \
echo "📦 Installing Playwright dependencies..."; \
npm install --silent; \
fi && \
npx playwright test tests/visual/ --config=playwright.config.ts --update-snapshots
backstop-ref: ## Generate BackstopJS reference images
@echo "📸 Generating BackstopJS reference images..."
@cd .veza/qa/backstop && npx backstop reference --config=backstop.json || true
backstop-test: ## Run BackstopJS visual regression tests
@echo "🔍 Running BackstopJS visual regression tests..."
@cd .veza/qa/backstop && npx backstop test --config=backstop.json || true
loki: ## Run Loki visual regression tests (requires Storybook)
@echo "📚 Running Loki visual regression tests..."
@echo "⚠️ Loki requires Storybook to be set up. See .veza/qa/README.md for setup instructions."
@if [ -d ".storybook" ] || [ -d "apps/web/.storybook" ]; then \
npx loki test || true; \
else \
echo "❌ Storybook not found. Install Storybook first to use Loki."; \
exit 1; \
fi
lh: lighthouse ## Alias for lighthouse
a11y: ## Run Pa11y accessibility tests
@echo "♿ Running Pa11y accessibility tests..."
@npx pa11y-ci --config .veza/qa/pa11y/.pa11yci.json || true
qa-all: smoke e2e postman lighthouse load visual a11y ## Run all QA tests
@echo "✅ All QA tests completed!"
# <<< VEZA:END QA TARGETS
# >>> VEZA:BEGIN LAB ORCHESTRATION
.PHONY: infra-up infra-check migrate-all services-up health-all dev-lab
infra-up: ## Start Lab Infrastructure (Postgres, Redis, RabbitMQ)
@bash scripts/lab/start_infra.sh
infra-check: ## Check Lab Infrastructure Health
@bash scripts/lab/check_infra.sh
migrate-all: ## Apply migrations for all services
@bash scripts/lab/apply_all_migrations.sh
services-up: ## Start all services (Backend, Chat, Stream, Web)
@bash scripts/lab/start_all_services.sh
services-down: ## Stop all services
@bash scripts/lab/stop_all_services.sh
health-all: ## Check health of all services
@bash scripts/lab/check_all_health.sh
dev-lab: infra-up infra-check migrate-all services-down services-up health-all ## Start full Lab Environment (Clean Restart)
# <<< VEZA:END LAB ORCHESTRATION

File diff suppressed because it is too large Load diff

View file

@ -34,6 +34,7 @@
"clsx": "^2.1.0",
"date-fns": "^4.1.0",
"dompurify": "^3.3.0",
"emoji-picker-react": "^4.16.1",
"hls.js": "^1.6.14",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
@ -41,6 +42,7 @@
"lucide-react": "^0.321.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.49.3",
"react-hot-toast": "^2.6.0",
"react-i18next": "^15.7.3",
@ -60,6 +62,8 @@
"@types/node": "^20.11.5",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/react-dropzone": "^4.2.2",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitejs/plugin-react": "^4.2.1",
@ -155,7 +159,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -535,7 +538,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -559,7 +561,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -581,7 +582,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@ -4725,7 +4725,6 @@
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@ -4737,11 +4736,20 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/@types/react-dropzone": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/react-dropzone/-/react-dropzone-4.2.2.tgz",
"integrity": "sha512-okO6HY+w7V0uHoy6JpLY6BwY/s/oObtXZmUQdX0ycjPeLhK8Af/xf79CFkLA1fM6oVp16n1d962ejdkEXk375Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/statuses": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz",
@ -4755,6 +4763,13 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@ -4802,7 +4817,6 @@
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0",
@ -5167,7 +5181,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -5546,6 +5559,15 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/attr-accept": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/autoprefixer": {
"version": "10.4.22",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
@ -5968,7 +5990,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@ -6903,8 +6924,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/csv-parse": {
"version": "4.16.3",
@ -7248,8 +7268,7 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1312386.tgz",
"integrity": "sha512-DPnhUXvmvKT2dFA/j7B+riVLUt9Q6RKJlcppojL5CoRywJJKLDYnRlw0gTFKfgDPHP5E04UoB71SxoJlVZy8FA==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/diff": {
"version": "3.5.0",
@ -7424,6 +7443,21 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-picker-react": {
"version": "4.16.1",
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.16.1.tgz",
"integrity": "sha512-MrPX0tOCfRL3uYI4of/2GRZ7S6qS7YlacKiF78uFH84/C62vcuHE2DZyv5b4ZJMk0e06es1jjB4e31Bb+YSM8w==",
"license": "MIT",
"dependencies": {
"flairup": "1.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16"
}
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@ -7862,7 +7896,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -8504,6 +8537,18 @@
"node": ">=16.0.0"
}
},
"node_modules/file-selector": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
"license": "MIT",
"dependencies": {
"tslib": "^2.7.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/file-type": {
"version": "3.9.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz",
@ -8587,6 +8632,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flairup": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
"license": "MIT"
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
@ -9427,7 +9478,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@ -9505,7 +9555,6 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@ -11971,7 +12020,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@ -13153,7 +13201,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -13523,7 +13570,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
@ -13535,7 +13581,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/protocolify": {
@ -14060,7 +14105,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -14073,7 +14117,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@ -14082,12 +14125,28 @@
"react": "^18.3.1"
}
},
"node_modules/react-dropzone": {
"version": "14.3.8",
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
"integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
"license": "MIT",
"dependencies": {
"attr-accept": "^2.2.4",
"file-selector": "^2.1.0",
"prop-types": "^15.8.1"
},
"engines": {
"node": ">= 10.13"
},
"peerDependencies": {
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-hook-form": {
"version": "7.66.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz",
"integrity": "sha512-2KnjpgG2Rhbi+CIiIBQQ9Df6sMGH5ExNyFl4Hw9qO7pIqMBR8Bvu9RQyjl3JM4vehzCh9soiNUM/xYMswb2EiA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -14445,7 +14504,6 @@
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -16055,7 +16113,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -16381,7 +16438,6 @@
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",

View file

@ -60,6 +60,7 @@
"clsx": "^2.1.0",
"date-fns": "^4.1.0",
"dompurify": "^3.3.0",
"emoji-picker-react": "^4.16.1",
"hls.js": "^1.6.14",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
@ -67,6 +68,7 @@
"lucide-react": "^0.321.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.49.3",
"react-hot-toast": "^2.6.0",
"react-i18next": "^15.7.3",
@ -86,6 +88,8 @@
"@types/node": "^20.11.5",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/react-dropzone": "^4.2.2",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitejs/plugin-react": "^4.2.1",

View file

@ -31,11 +31,9 @@ export function App() {
return (
<ErrorBoundary>
<AppRouter />
{/* PWA Install Banner */}
<PWAInstallBanner />
</ErrorBoundary>
);
}

View file

@ -25,7 +25,7 @@ describe('ErrorBoundary', () => {
render(
<ErrorBoundary>
<div>Test content</div>
</ErrorBoundary>
</ErrorBoundary>,
);
expect(screen.getByText('Test content')).toBeInTheDocument();
@ -35,18 +35,22 @@ describe('ErrorBoundary', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
</ErrorBoundary>,
);
expect(screen.getByText(/Oups ! Une erreur est survenue/i)).toBeInTheDocument();
expect(screen.getByText(/Une erreur inattendue s'est produite/i)).toBeInTheDocument();
expect(
screen.getByText(/Oups ! Une erreur est survenue/i),
).toBeInTheDocument();
expect(
screen.getByText(/Une erreur inattendue s'est produite/i),
).toBeInTheDocument();
});
it('should display retry button', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
</ErrorBoundary>,
);
const retryButton = screen.getByRole('button', { name: /réessayer/i });
@ -57,10 +61,12 @@ describe('ErrorBoundary', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
</ErrorBoundary>,
);
const homeButton = screen.getByRole('button', { name: /retour à l'accueil/i });
const homeButton = screen.getByRole('button', {
name: /retour à l'accueil/i,
});
expect(homeButton).toBeInTheDocument();
});
@ -68,10 +74,12 @@ describe('ErrorBoundary', () => {
const { rerender } = render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
</ErrorBoundary>,
);
expect(screen.getByText(/Oups ! Une erreur est survenue/i)).toBeInTheDocument();
expect(
screen.getByText(/Oups ! Une erreur est survenue/i),
).toBeInTheDocument();
const retryButton = screen.getByRole('button', { name: /réessayer/i });
fireEvent.click(retryButton);
@ -80,7 +88,7 @@ describe('ErrorBoundary', () => {
rerender(
<ErrorBoundary>
<ThrowError shouldThrow={false} />
</ErrorBoundary>
</ErrorBoundary>,
);
// Le composant devrait afficher le contenu normal
@ -93,18 +101,20 @@ describe('ErrorBoundary', () => {
render(
<ErrorBoundary fallback={customFallback}>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
</ErrorBoundary>,
);
expect(screen.getByText('Custom error message')).toBeInTheDocument();
expect(screen.queryByText(/Oups ! Une erreur est survenue/i)).not.toBeInTheDocument();
expect(
screen.queryByText(/Oups ! Une erreur est survenue/i),
).not.toBeInTheDocument();
});
it('should log error to console', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
</ErrorBoundary>,
);
expect(console.error).toHaveBeenCalled();
@ -114,11 +124,10 @@ describe('ErrorBoundary', () => {
const { container } = render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
</ErrorBoundary>,
);
// L'ErrorBoundary devrait avoir un état d'erreur
expect(container.querySelector('.min-h-screen')).toBeInTheDocument();
});
});

View file

@ -51,44 +51,44 @@ export class ErrorBoundary extends Component<Props, State> {
}
return (
<div className='min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4'>
<Card className='w-full max-w-md'>
<CardHeader className='text-center'>
<div className='mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900'>
<AlertTriangle className='h-6 w-6 text-red-600 dark:text-red-400' />
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900">
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-400" />
</div>
<CardTitle className='text-xl'>
<CardTitle className="text-xl">
Oups ! Une erreur est survenue
</CardTitle>
<CardDescription>
Une erreur inattendue s'est produite. Veuillez réessayer.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<CardContent className="space-y-4">
{process.env.NODE_ENV === 'development' && this.state.error && (
<div className='rounded-md bg-red-50 dark:bg-red-900 p-3'>
<h4 className='text-sm font-medium text-red-800 dark:text-red-200 mb-2'>
<div className="rounded-md bg-red-50 dark:bg-red-900 p-3">
<h4 className="text-sm font-medium text-red-800 dark:text-red-200 mb-2">
Détails de l'erreur :
</h4>
<pre className='text-xs text-red-700 dark:text-red-300 whitespace-pre-wrap'>
<pre className="text-xs text-red-700 dark:text-red-300 whitespace-pre-wrap">
{this.state.error.toString()}
</pre>
{this.state.errorInfo && (
<pre className='text-xs text-red-700 dark:text-red-300 whitespace-pre-wrap mt-2'>
<pre className="text-xs text-red-700 dark:text-red-300 whitespace-pre-wrap mt-2">
{this.state.errorInfo.componentStack}
</pre>
)}
</div>
)}
<div className='flex space-x-2'>
<Button onClick={this.handleReset} className='flex-1'>
<RefreshCw className='mr-2 h-4 w-4' />
<div className="flex space-x-2">
<Button onClick={this.handleReset} className="flex-1">
<RefreshCw className="mr-2 h-4 w-4" />
Réessayer
</Button>
<Button
variant='outline'
variant="outline"
onClick={() => (window.location.href = '/')}
className='flex-1'
className="flex-1"
>
Retour à l'accueil
</Button>

View file

@ -23,8 +23,9 @@ export function OfflineIndicator() {
d="M18.364 5.636a9 9 0 010 12.728m0 0l-5.657-5.657m5.657 5.657L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m4.243 2.829L10.343 10.343m4.243 2.829L6.343 18.172"
/>
</svg>
<span>Mode hors ligne - Vos actions seront synchronisées à la reconnexion</span>
<span>
Mode hors ligne - Vos actions seront synchronisées à la reconnexion
</span>
</div>
);
}

View file

@ -43,7 +43,7 @@ describe('ProtectedRoute Component', () => {
<ProtectedRoute>
<div>Protected Content</div>
</ProtectedRoute>
</TestWrapper>
</TestWrapper>,
);
expect(getByText('Protected Content')).toBeInTheDocument();
@ -59,7 +59,7 @@ describe('ProtectedRoute Component', () => {
<div>Protected Content</div>
</ProtectedRoute>
</QueryClientProvider>
</MemoryRouter>
</MemoryRouter>,
);
// Le composant Navigate devrait rediriger vers /login
@ -78,7 +78,7 @@ describe('ProtectedRoute Component', () => {
<div>Protected Content</div>
</ProtectedRoute>
</QueryClientProvider>
</MemoryRouter>
</MemoryRouter>,
);
// Le composant Navigate devrait être présent avec replace
@ -94,7 +94,7 @@ describe('ProtectedRoute Component', () => {
<div>Child 1</div>
<div>Child 2</div>
</ProtectedRoute>
</TestWrapper>
</TestWrapper>,
);
expect(getByText('Child 1')).toBeInTheDocument();
@ -107,10 +107,9 @@ describe('ProtectedRoute Component', () => {
const { container } = render(
<TestWrapper>
<ProtectedRoute>{null}</ProtectedRoute>
</TestWrapper>
</TestWrapper>,
);
expect(container).toBeInTheDocument();
});
});

View file

@ -8,4 +8,3 @@ interface ButtonProps {
export const Button = ({ children, onClick }: ButtonProps) => {
return <button onClick={onClick}>{children}</button>;
};

View file

@ -22,4 +22,3 @@ export const Input = ({
/>
);
};

View file

@ -1,3 +1,2 @@
export { Button } from './Button';
export { Input } from './Input';

View file

@ -43,8 +43,8 @@ export function BarChart({
const normalizedData = data.map((item, index) => {
const heightPercent = (item.value / max) * (chartHeight / 10);
const x = padding / 10 + index * barSpacing / 10;
const y = (100 - padding / 10) - heightPercent;
const x = padding / 10 + (index * barSpacing) / 10;
const y = 100 - padding / 10 - heightPercent;
return {
...item,
x,
@ -149,4 +149,3 @@ export function BarChart({
</Chart>
);
}

View file

@ -11,7 +11,7 @@ describe('Chart Components', () => {
render(
<Chart>
<div>Chart Content</div>
</Chart>
</Chart>,
);
expect(screen.getByText('Chart Content')).toBeInTheDocument();
@ -21,7 +21,7 @@ describe('Chart Components', () => {
render(
<Chart title="Chart Title">
<div>Content</div>
</Chart>
</Chart>,
);
expect(screen.getByText('Chart Title')).toBeInTheDocument();
@ -31,7 +31,7 @@ describe('Chart Components', () => {
const { container } = render(
<Chart height={400}>
<div>Content</div>
</Chart>
</Chart>,
);
// Le composant Chart a une structure: div > (title?) > div avec le style
@ -46,7 +46,7 @@ describe('Chart Components', () => {
const { container } = render(
<Chart className="custom-class">
<div>Content</div>
</Chart>
</Chart>,
);
const chartContainer = container.firstChild;
@ -75,7 +75,9 @@ describe('Chart Components', () => {
});
it('applies custom color', () => {
const { container } = render(<LineChart data={mockData} color="#ff0000" />);
const { container } = render(
<LineChart data={mockData} color="#ff0000" />,
);
const path = container.querySelector('path');
expect(path).toHaveAttribute('stroke', '#ff0000');
@ -89,7 +91,9 @@ describe('Chart Components', () => {
});
it('hides dots when showDots is false', () => {
const { container } = render(<LineChart data={mockData} showDots={false} />);
const { container } = render(
<LineChart data={mockData} showDots={false} />,
);
const circles = container.querySelectorAll('circle');
expect(circles.length).toBe(0);
@ -103,7 +107,9 @@ describe('Chart Components', () => {
});
it('renders axis labels', () => {
render(<LineChart data={mockData} xAxisLabel="Month" yAxisLabel="Value" />);
render(
<LineChart data={mockData} xAxisLabel="Month" yAxisLabel="Value" />,
);
expect(screen.getByText('Month')).toBeInTheDocument();
expect(screen.getByText('Value')).toBeInTheDocument();
@ -131,7 +137,9 @@ describe('Chart Components', () => {
});
it('applies custom color', () => {
const { container } = render(<BarChart data={mockData} color="#00ff00" />);
const { container } = render(
<BarChart data={mockData} color="#00ff00" />,
);
const rects = container.querySelectorAll('rect');
expect(rects.length).toBeGreaterThan(0);
@ -146,7 +154,9 @@ describe('Chart Components', () => {
});
it('renders axis labels', () => {
render(<BarChart data={mockData} xAxisLabel="Category" yAxisLabel="Count" />);
render(
<BarChart data={mockData} xAxisLabel="Category" yAxisLabel="Count" />,
);
expect(screen.getByText('Category')).toBeInTheDocument();
expect(screen.getByText('Count')).toBeInTheDocument();
@ -216,4 +226,3 @@ describe('Chart Components', () => {
});
});
});

View file

@ -33,4 +33,3 @@ export function Chart({
</div>
);
}

View file

@ -153,4 +153,3 @@ export function LineChart({
</Chart>
);
}

View file

@ -132,7 +132,8 @@ export function PieChart({
strokeWidth="0.5"
>
<title>
{segment.label}: {segment.value} ({Math.round(segment.percentage * 100)}%)
{segment.label}: {segment.value} (
{Math.round(segment.percentage * 100)}%)
</title>
</path>
{showLabels && segment.percentage > 0.05 && (
@ -155,10 +156,7 @@ export function PieChart({
{showLegend && (
<div className="mt-4 flex flex-wrap justify-center gap-4">
{segments.map((segment, index) => (
<div
key={index}
className="flex items-center gap-2 text-sm"
>
<div key={index} className="flex items-center gap-2 text-sm">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: segment.color }}
@ -175,4 +173,3 @@ export function PieChart({
</Chart>
);
}

View file

@ -8,7 +8,7 @@ describe('Grid Component', () => {
<Grid>
<div>Item 1</div>
<div>Item 2</div>
</Grid>
</Grid>,
);
expect(getByText('Item 1')).toBeInTheDocument();
@ -19,7 +19,7 @@ describe('Grid Component', () => {
const { container } = render(
<Grid>
<div>Item</div>
</Grid>
</Grid>,
);
const grid = container.firstChild;
@ -30,7 +30,7 @@ describe('Grid Component', () => {
const { container } = render(
<Grid columns={4}>
<div>Item</div>
</Grid>
</Grid>,
);
const grid = container.firstChild;
@ -41,7 +41,7 @@ describe('Grid Component', () => {
const { container } = render(
<Grid>
<div>Item</div>
</Grid>
</Grid>,
);
const grid = container.firstChild;
@ -52,7 +52,7 @@ describe('Grid Component', () => {
const { container } = render(
<Grid gap={6}>
<div>Item</div>
</Grid>
</Grid>,
);
const grid = container.firstChild;
@ -63,7 +63,7 @@ describe('Grid Component', () => {
const { container } = render(
<Grid rowGap={2} columnGap={4}>
<div>Item</div>
</Grid>
</Grid>,
);
const grid = container.firstChild;
@ -82,7 +82,7 @@ describe('Grid Component', () => {
}}
>
<div>Item</div>
</Grid>
</Grid>,
);
const grid = container.firstChild;
@ -103,7 +103,7 @@ describe('Grid Component', () => {
}}
>
<div>Item</div>
</Grid>
</Grid>,
);
const grid = container.firstChild;
@ -121,7 +121,7 @@ describe('Grid Component', () => {
}}
>
<div>Item</div>
</Grid>
</Grid>,
);
const grid = container.firstChild;
@ -134,7 +134,7 @@ describe('Grid Component', () => {
const { container } = render(
<Grid className="custom-grid">
<div>Item</div>
</Grid>
</Grid>,
);
const grid = container.firstChild;
@ -145,7 +145,7 @@ describe('Grid Component', () => {
const { container } = render(
<Grid>
<div>Item</div>
</Grid>
</Grid>,
);
const grid = container.firstChild;
@ -156,7 +156,7 @@ describe('Grid Component', () => {
const { container } = render(
<Grid gap={0}>
<div>Item</div>
</Grid>
</Grid>,
);
const grid = container.firstChild;
@ -167,7 +167,7 @@ describe('Grid Component', () => {
const { container } = render(
<Grid gap={12}>
<div>Item</div>
</Grid>
</Grid>,
);
const grid = container.firstChild;
@ -178,7 +178,7 @@ describe('Grid Component', () => {
const { container } = render(
<Grid columns={1}>
<div>Item</div>
</Grid>
</Grid>,
);
const grid = container.firstChild;
@ -189,7 +189,7 @@ describe('Grid Component', () => {
const { container } = render(
<Grid columns={12}>
<div>Item</div>
</Grid>
</Grid>,
);
const grid = container.firstChild;
@ -200,7 +200,7 @@ describe('Grid Component', () => {
const { container } = render(
<Grid gap={8} rowGap={2} columnGap={4}>
<div>Item</div>
</Grid>
</Grid>,
);
const grid = container.firstChild;
@ -209,4 +209,3 @@ describe('Grid Component', () => {
expect(grid).not.toHaveClass('gap-x-4');
});
});

View file

@ -176,7 +176,11 @@ export function Grid({
classes.push(`gap-x-${columnGap}`);
}
// Si aucun gap n'est spécifié, on utilise gap-4 par défaut
if (gap === undefined && rowGap === undefined && columnGap === undefined) {
if (
gap === undefined &&
rowGap === undefined &&
columnGap === undefined
) {
classes.push('gap-4');
}
}
@ -206,12 +210,8 @@ export function Grid({
}, [columns]);
return (
<div
className={cn(...gridClasses, className)}
style={inlineStyle}
>
<div className={cn(...gridClasses, className)} style={inlineStyle}>
{children}
</div>
);
}

View file

@ -136,7 +136,7 @@ describe('List Component', () => {
it('applies custom className', () => {
const { container } = render(
<List items={mockItems} className="custom-class" />
<List items={mockItems} className="custom-class" />,
);
const list = container.querySelector('ul');
@ -145,7 +145,7 @@ describe('List Component', () => {
it('applies custom itemClassName', () => {
const { container } = render(
<List items={mockItems} itemClassName="custom-item-class" />
<List items={mockItems} itemClassName="custom-item-class" />,
);
const firstItem = container.querySelector('li');
@ -256,4 +256,3 @@ describe('List Component', () => {
expect(actionsContainer).not.toBeInTheDocument();
});
});

View file

@ -45,7 +45,7 @@ export function List({
variant === 'bordered' && 'divide-y divide-border rounded-md border',
variant === 'spaced' && 'space-y-2',
variant === 'default' && 'space-y-1',
className
className,
)}
>
{items.map((item) => (
@ -59,12 +59,15 @@ export function List({
variant === 'default' && 'px-2 py-2',
item.onClick && !item.disabled && 'cursor-pointer hover:bg-accent',
item.disabled && 'cursor-not-allowed opacity-50',
itemClassName
itemClassName,
)}
>
<div className="flex-1">{item.content}</div>
{item.actions && (
<div className="ml-4 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<div
className="ml-4 flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
{item.actions}
</div>
)}
@ -73,4 +76,3 @@ export function List({
</ul>
);
}

View file

@ -55,11 +55,7 @@ describe('Table Component', () => {
it('renders custom empty message', () => {
render(
<Table
columns={mockColumns}
data={[]}
emptyMessage="No data found"
/>
<Table columns={mockColumns} data={[]} emptyMessage="No data found" />,
);
expect(screen.getByText('No data found')).toBeInTheDocument();
@ -67,9 +63,7 @@ describe('Table Component', () => {
it('calls onSort when sortable column header is clicked', async () => {
const user = userEvent.setup();
render(
<Table columns={mockColumns} data={mockData} onSort={mockOnSort} />
);
render(<Table columns={mockColumns} data={mockData} onSort={mockOnSort} />);
const nameHeader = screen.getByText('Name');
await user.click(nameHeader);
@ -79,9 +73,7 @@ describe('Table Component', () => {
it('toggles sort direction when clicking same column twice', async () => {
const user = userEvent.setup();
render(
<Table columns={mockColumns} data={mockData} onSort={mockOnSort} />
);
render(<Table columns={mockColumns} data={mockData} onSort={mockOnSort} />);
const nameHeader = screen.getByText('Name');
await user.click(nameHeader);
@ -97,7 +89,7 @@ describe('Table Component', () => {
columns={mockColumns}
data={mockData}
onRowClick={mockOnRowClick}
/>
/>,
);
const firstRow = screen.getByText('John Doe').closest('tr');
@ -122,7 +114,7 @@ describe('Table Component', () => {
data={mockData}
selectable
onSelectionChange={mockOnSelectionChange}
/>
/>,
);
const checkboxes = screen.getAllByRole('checkbox');
@ -143,7 +135,7 @@ describe('Table Component', () => {
data={mockData}
selectable
onSelectionChange={mockOnSelectionChange}
/>
/>,
);
const checkboxes = screen.getAllByRole('checkbox');
@ -184,7 +176,7 @@ describe('Table Component', () => {
data={largeData}
paginated
itemsPerPage={10}
/>
/>,
);
// Pagination should be rendered
@ -206,7 +198,7 @@ describe('Table Component', () => {
data={largeData}
paginated
itemsPerPage={10}
/>
/>,
);
// First page should show first 10 items
@ -230,7 +222,7 @@ describe('Table Component', () => {
data={largeData}
paginated
itemsPerPage={10}
/>
/>,
);
// Click next page
@ -249,7 +241,7 @@ describe('Table Component', () => {
data={mockData}
selectable
getRowId={(row) => `row-${row.id}`}
/>
/>,
);
// Should render without errors
@ -258,11 +250,7 @@ describe('Table Component', () => {
it('applies custom className', () => {
const { container } = render(
<Table
columns={mockColumns}
data={mockData}
className="custom-table"
/>
<Table columns={mockColumns} data={mockData} className="custom-table" />,
);
const tableContainer = container.firstChild;
@ -275,7 +263,7 @@ describe('Table Component', () => {
columns={mockColumns}
data={mockData}
rowClassName={(row) => (row.age > 30 ? 'highlighted' : '')}
/>
/>,
);
// Bob is 35, so should have highlighted class
@ -324,7 +312,7 @@ describe('Table Component', () => {
itemsPerPage={10}
selectable
onSelectionChange={mockOnSelectionChange}
/>
/>,
);
// Select first row on page 1
@ -344,4 +332,3 @@ describe('Table Component', () => {
expect(newCheckboxes[1]).toBeChecked();
});
});

View file

@ -57,7 +57,7 @@ export function Table<T extends Record<string, any>>({
}
return index.toString();
},
[getRowId]
[getRowId],
);
const handleSort = useCallback(
@ -74,7 +74,7 @@ export function Table<T extends Record<string, any>>({
setSortDirection(newDirection);
onSort?.(columnKey, newDirection);
},
[columns, sortColumn, sortDirection, onSort]
[columns, sortColumn, sortDirection, onSort],
);
const handleSelectAll = useCallback(
@ -94,12 +94,12 @@ export function Table<T extends Record<string, any>>({
setSelectedRows(newSelected);
if (onSelectionChange) {
const selectedData = data.filter((row, index) =>
newSelected.has(getRowKey(row, index))
newSelected.has(getRowKey(row, index)),
);
onSelectionChange(selectedData);
}
},
[data, paginated, currentPage, itemsPerPage, getRowKey, onSelectionChange]
[data, paginated, currentPage, itemsPerPage, getRowKey, onSelectionChange],
);
const handleSelectRow = useCallback(
@ -116,17 +116,17 @@ export function Table<T extends Record<string, any>>({
setSelectedRows(newSelected);
if (onSelectionChange) {
const selectedData = data.filter((r, i) =>
newSelected.has(getRowKey(r, i))
newSelected.has(getRowKey(r, i)),
);
onSelectionChange(selectedData);
}
},
[selectedRows, data, getRowKey, onSelectionChange]
[selectedRows, data, getRowKey, onSelectionChange],
);
const totalPages = useMemo(
() => Math.ceil(data.length / itemsPerPage),
[data.length, itemsPerPage]
[data.length, itemsPerPage],
);
const paginatedDataMemo = useMemo(() => {
@ -146,7 +146,14 @@ export function Table<T extends Record<string, any>>({
: index;
return selectedRows.has(getRowKey(row, absoluteIndex));
});
}, [displayedData, selectedRows, paginated, currentPage, itemsPerPage, getRowKey]);
}, [
displayedData,
selectedRows,
paginated,
currentPage,
itemsPerPage,
getRowKey,
]);
const isIndeterminate = useMemo(() => {
if (displayedData.length === 0) return false;
@ -157,7 +164,14 @@ export function Table<T extends Record<string, any>>({
return selectedRows.has(getRowKey(row, absoluteIndex));
}).length;
return selectedCount > 0 && selectedCount < displayedData.length;
}, [displayedData, selectedRows, paginated, currentPage, itemsPerPage, getRowKey]);
}, [
displayedData,
selectedRows,
paginated,
currentPage,
itemsPerPage,
getRowKey,
]);
const getSortIcon = (columnKey: string) => {
if (sortColumn !== columnKey) {
@ -186,7 +200,7 @@ export function Table<T extends Record<string, any>>({
}
className={cn(
isIndeterminate &&
'data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground'
'data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
)}
/>
</th>
@ -199,7 +213,7 @@ export function Table<T extends Record<string, any>>({
column.align === 'center' && 'text-center',
column.align === 'right' && 'text-right',
column.sortable && 'cursor-pointer hover:bg-muted/80',
column.width && `w-[${column.width}]`
column.width && `w-[${column.width}]`,
)}
style={column.width ? { width: column.width } : undefined}
onClick={() => column.sortable && handleSort(column.key)}
@ -208,7 +222,7 @@ export function Table<T extends Record<string, any>>({
className={cn(
'flex items-center gap-2',
column.align === 'center' && 'justify-center',
column.align === 'right' && 'justify-end'
column.align === 'right' && 'justify-end',
)}
>
<span>{column.header}</span>
@ -243,7 +257,7 @@ export function Table<T extends Record<string, any>>({
'border-b transition-colors',
isSelected && 'bg-muted/50',
onRowClick && 'cursor-pointer hover:bg-muted/50',
rowClassName && rowClassName(row, absoluteIndex)
rowClassName && rowClassName(row, absoluteIndex),
)}
onClick={() => onRowClick?.(row, absoluteIndex)}
>
@ -252,7 +266,11 @@ export function Table<T extends Record<string, any>>({
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
handleSelectRow(row, absoluteIndex, checked === true)
handleSelectRow(
row,
absoluteIndex,
checked === true,
)
}
onClick={(e) => e.stopPropagation()}
/>
@ -264,12 +282,12 @@ export function Table<T extends Record<string, any>>({
className={cn(
'p-4',
column.align === 'center' && 'text-center',
column.align === 'right' && 'text-right'
column.align === 'right' && 'text-right',
)}
>
{column.render
? column.render(row, absoluteIndex)
: row[column.key] ?? ''}
: (row[column.key] ?? '')}
</td>
))}
</tr>
@ -293,4 +311,3 @@ export function Table<T extends Record<string, any>>({
</div>
);
}

View file

@ -8,13 +8,13 @@ describe('Timeline Component', () => {
{
id: '1',
title: 'Événement 1',
description: 'Description de l\'événement 1',
description: "Description de l'événement 1",
date: new Date('2024-01-01'),
},
{
id: '2',
title: 'Événement 2',
description: 'Description de l\'événement 2',
description: "Description de l'événement 2",
date: new Date('2024-01-15'),
},
{
@ -36,14 +36,20 @@ describe('Timeline Component', () => {
it('renders empty message when items is empty', () => {
render(<Timeline items={[]} />);
expect(screen.getByText('Aucun événement à afficher')).toBeInTheDocument();
expect(
screen.getByText('Aucun événement à afficher'),
).toBeInTheDocument();
});
it('renders descriptions when provided', () => {
render(<Timeline items={mockItems} />);
expect(screen.getByText('Description de l\'événement 1')).toBeInTheDocument();
expect(screen.getByText('Description de l\'événement 2')).toBeInTheDocument();
expect(
screen.getByText("Description de l'événement 1"),
).toBeInTheDocument();
expect(
screen.getByText("Description de l'événement 2"),
).toBeInTheDocument();
});
it('does not render description when not provided', () => {
@ -143,7 +149,9 @@ describe('Timeline Component', () => {
});
it('renders horizontal timeline when orientation is horizontal', () => {
const { container } = render(<Timeline items={mockItems} orientation="horizontal" />);
const { container } = render(
<Timeline items={mockItems} orientation="horizontal" />,
);
// Vérifier que la structure horizontale est présente
const timeline = container.firstChild;
@ -154,7 +162,9 @@ describe('Timeline Component', () => {
});
it('applies correct classes for horizontal orientation', () => {
const { container } = render(<Timeline items={mockItems} orientation="horizontal" />);
const { container } = render(
<Timeline items={mockItems} orientation="horizontal" />,
);
const flexContainer = container.querySelector('.flex.items-start');
expect(flexContainer).toBeInTheDocument();
@ -166,12 +176,16 @@ describe('Timeline Component', () => {
const { container } = render(<Timeline items={mockItems} />);
// Il devrait y avoir 2 lignes de connexion pour 3 items (n-1)
const verticalLines = container.querySelectorAll('.h-full.w-0\\.5.bg-border');
const verticalLines = container.querySelectorAll(
'.h-full.w-0\\.5.bg-border',
);
expect(verticalLines.length).toBe(2);
});
it('renders connecting lines between items in horizontal orientation', () => {
const { container } = render(<Timeline items={mockItems} orientation="horizontal" />);
const { container } = render(
<Timeline items={mockItems} orientation="horizontal" />,
);
// Il devrait y avoir 2 lignes de connexion pour 3 items (n-1)
const horizontalLines = container.querySelectorAll('.h-0\\.5.bg-border');
@ -204,7 +218,9 @@ describe('Timeline Component', () => {
describe('Custom className', () => {
it('applies custom className', () => {
const { container } = render(<Timeline items={mockItems} className="custom-timeline" />);
const { container } = render(
<Timeline items={mockItems} className="custom-timeline" />,
);
const timeline = container.firstChild;
expect(timeline).toHaveClass('custom-timeline');
@ -231,7 +247,10 @@ describe('Timeline Component', () => {
});
// Helper function pour formater les dates (copie de l'utilitaire)
function formatDate(date: Date | string, format: 'short' | 'long' | 'relative' = 'short'): string {
function formatDate(
date: Date | string,
format: 'short' | 'long' | 'relative' = 'short',
): string {
const d = new Date(date);
if (isNaN(d.getTime())) {
@ -255,4 +274,3 @@ function formatDate(date: Date | string, format: 'short' | 'long' | 'relative' =
return d.toLocaleDateString();
}
}

View file

@ -26,7 +26,12 @@ export function Timeline({
}: TimelineProps) {
if (items.length === 0) {
return (
<div className={cn('flex items-center justify-center py-8 text-muted-foreground', className)}>
<div
className={cn(
'flex items-center justify-center py-8 text-muted-foreground',
className,
)}
>
Aucun événement à afficher
</div>
);
@ -37,7 +42,10 @@ export function Timeline({
<div className={cn('relative', className)}>
<div className="flex items-start gap-4 overflow-x-auto pb-8">
{items.map((item, index) => (
<div key={item.id} className="relative flex min-w-[200px] flex-shrink-0 flex-col items-center">
<div
key={item.id}
className="relative flex min-w-[200px] flex-shrink-0 flex-col items-center"
>
{/* Ligne horizontale */}
{index < items.length - 1 && (
<div className="absolute left-[50%] top-6 h-0.5 w-full translate-x-1/2 bg-border" />
@ -56,9 +64,13 @@ export function Timeline({
{/* Contenu */}
<div className="flex w-full flex-col items-center text-center">
<div className="mb-1 font-semibold text-foreground">{item.title}</div>
<div className="mb-1 font-semibold text-foreground">
{item.title}
</div>
{item.description && (
<div className="mb-2 text-sm text-muted-foreground">{item.description}</div>
<div className="mb-2 text-sm text-muted-foreground">
{item.description}
</div>
)}
<div className="text-xs text-muted-foreground">
{formatDate(item.date, 'short')}
@ -95,9 +107,13 @@ export function Timeline({
{/* Contenu */}
<div className="flex-1 pt-1">
<div className="mb-1 font-semibold text-foreground">{item.title}</div>
<div className="mb-1 font-semibold text-foreground">
{item.title}
</div>
{item.description && (
<div className="mb-2 text-sm text-muted-foreground">{item.description}</div>
<div className="mb-2 text-sm text-muted-foreground">
{item.description}
</div>
)}
<div className="text-xs text-muted-foreground">
{formatDate(item.date, 'short')}
@ -109,4 +125,3 @@ export function Timeline({
</div>
);
}

View file

@ -12,11 +12,7 @@ describe('Alert Component', () => {
});
it('renders alert with title', () => {
render(
<Alert title="Alert Title">
Alert message
</Alert>
);
render(<Alert title="Alert Title">Alert message</Alert>);
expect(screen.getByText('Alert Title')).toBeInTheDocument();
expect(screen.getByText('Alert message')).toBeInTheDocument();
@ -39,14 +35,18 @@ describe('Alert Component', () => {
});
it('renders success variant', () => {
const { container } = render(<Alert variant="success">Success alert</Alert>);
const { container } = render(
<Alert variant="success">Success alert</Alert>,
);
const alert = container.firstChild;
expect(alert).toHaveClass('bg-green-50');
});
it('renders warning variant', () => {
const { container } = render(<Alert variant="warning">Warning alert</Alert>);
const { container } = render(
<Alert variant="warning">Warning alert</Alert>,
);
const alert = container.firstChild;
expect(alert).toHaveClass('bg-yellow-50');
@ -69,14 +69,18 @@ describe('Alert Component', () => {
});
it('renders success icon for success variant', () => {
const { container } = render(<Alert variant="success">Success alert</Alert>);
const { container } = render(
<Alert variant="success">Success alert</Alert>,
);
const icon = container.querySelector('svg');
expect(icon).toBeInTheDocument();
});
it('renders warning icon for warning variant', () => {
const { container } = render(<Alert variant="warning">Warning alert</Alert>);
const { container } = render(
<Alert variant="warning">Warning alert</Alert>,
);
const icon = container.querySelector('svg');
expect(icon).toBeInTheDocument();
@ -108,7 +112,7 @@ describe('Alert Component', () => {
render(
<Alert dismissible onClose={onClose}>
Alert message
</Alert>
</Alert>,
);
expect(screen.getByLabelText('Fermer')).toBeInTheDocument();
@ -121,7 +125,7 @@ describe('Alert Component', () => {
render(
<Alert dismissible onClose={onClose}>
Alert message
</Alert>
</Alert>,
);
const closeButton = screen.getByLabelText('Fermer');
@ -143,7 +147,7 @@ describe('Alert Component', () => {
render(
<Alert dismissible onClose={onClose}>
Alert message
</Alert>
</Alert>,
);
const closeButton = screen.getByLabelText('Fermer');
@ -154,7 +158,7 @@ describe('Alert Component', () => {
describe('Custom className', () => {
it('applies custom className', () => {
const { container } = render(
<Alert className="custom-alert">Alert message</Alert>
<Alert className="custom-alert">Alert message</Alert>,
);
const alert = container.firstChild;
@ -168,7 +172,7 @@ describe('Alert Component', () => {
<Alert title="Complex Alert">
<p>First paragraph</p>
<p>Second paragraph</p>
</Alert>
</Alert>,
);
expect(screen.getByText('Complex Alert')).toBeInTheDocument();
@ -177,4 +181,3 @@ describe('Alert Component', () => {
});
});
});

View file

@ -20,9 +20,12 @@ const ALERT_ICONS = {
const ALERT_STYLES = {
info: 'bg-blue-50 border-blue-200 text-blue-900 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-200',
success: 'bg-green-50 border-green-200 text-green-900 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-900 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200',
error: 'bg-red-50 border-red-200 text-red-900 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200',
success:
'bg-green-50 border-green-200 text-green-900 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200',
warning:
'bg-yellow-50 border-yellow-200 text-yellow-900 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200',
error:
'bg-red-50 border-red-200 text-red-900 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200',
};
const ICON_STYLES = {
@ -51,11 +54,13 @@ export function Alert({
className={cn(
'relative rounded-lg border p-4',
ALERT_STYLES[variant],
className
className,
)}
>
<div className="flex items-start gap-3">
<Icon className={cn('h-5 w-5 flex-shrink-0 mt-0.5', ICON_STYLES[variant])} />
<Icon
className={cn('h-5 w-5 flex-shrink-0 mt-0.5', ICON_STYLES[variant])}
/>
<div className="flex-1 min-w-0">
{title && (
<h3 className="mb-1 font-semibold leading-none tracking-tight">
@ -77,4 +82,3 @@ export function Alert({
</div>
);
}

View file

@ -71,7 +71,9 @@ describe('Progress Component', () => {
});
it('applies custom className', () => {
const { container } = render(<Progress value={50} className="custom-progress" />);
const { container } = render(
<Progress value={50} className="custom-progress" />,
);
const wrapper = container.firstChild;
expect(wrapper).toHaveClass('custom-progress');
@ -108,7 +110,7 @@ describe('Progress Component', () => {
it('applies custom color', () => {
const { container } = render(
<Progress value={50} variant="circular" color="#00ff00" />
<Progress value={50} variant="circular" color="#00ff00" />,
);
const circle = container.querySelector('circle[stroke-dasharray]');
@ -126,7 +128,7 @@ describe('Progress Component', () => {
it('applies custom className', () => {
const { container } = render(
<Progress value={50} variant="circular" className="custom-circular" />
<Progress value={50} variant="circular" className="custom-circular" />,
);
const wrapper = container.firstChild;
@ -135,7 +137,7 @@ describe('Progress Component', () => {
it('clamps value to max in circular variant', () => {
const { container } = render(
<Progress value={150} variant="circular" max={100} />
<Progress value={150} variant="circular" max={100} />,
);
const svg = container.querySelector('svg');
@ -173,4 +175,3 @@ describe('Progress Component', () => {
});
});
});

View file

@ -34,7 +34,12 @@ export function Progress({
const strokeDashoffset = circumference - (percentage / 100) * circumference;
return (
<div className={cn('relative inline-flex items-center justify-center', className)}>
<div
className={cn(
'relative inline-flex items-center justify-center',
className,
)}
>
<svg
className="w-16 h-16 transform -rotate-90"
viewBox="0 0 50 50"
@ -66,7 +71,7 @@ export function Progress({
strokeLinecap="round"
className={cn(
'transition-all duration-300 ease-in-out',
!color && 'text-primary'
!color && 'text-primary',
)}
style={color ? { stroke: color } : undefined}
/>
@ -86,7 +91,9 @@ export function Progress({
<div className="flex justify-between mb-1 text-sm">
{label && <span className="text-muted-foreground">{label}</span>}
{showLabel && (
<span className="text-muted-foreground font-medium">{percentage}%</span>
<span className="text-muted-foreground font-medium">
{percentage}%
</span>
)}
</div>
)}
@ -96,12 +103,14 @@ export function Progress({
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={max}
aria-label={label ? `${label}: ${percentage}%` : `Progress: ${percentage}%`}
aria-label={
label ? `${label}: ${percentage}%` : `Progress: ${percentage}%`
}
>
<div
className={cn(
'h-full rounded-full transition-all duration-300 ease-in-out',
!color && 'bg-primary'
!color && 'bg-primary',
)}
style={{
width: `${percentage}%`,
@ -112,4 +121,3 @@ export function Progress({
</div>
);
}

View file

@ -24,11 +24,16 @@ describe('Toast Components', () => {
it('renders with correct type styling', async () => {
const onDismiss = vi.fn();
const { container } = render(
<ToastComponent toast={{ ...mockToast, type: 'error' }} onDismiss={onDismiss} />
<ToastComponent
toast={{ ...mockToast, type: 'error' }}
onDismiss={onDismiss}
/>,
);
await waitFor(() => {
const toast = container.querySelector('.bg-red-50, .dark\\:bg-red-900\\/20');
const toast = container.querySelector(
'.bg-red-50, .dark\\:bg-red-900\\/20',
);
expect(toast).toBeInTheDocument();
});
});
@ -36,7 +41,12 @@ describe('Toast Components', () => {
it('auto-dismisses after duration', async () => {
vi.useFakeTimers();
const onDismiss = vi.fn();
render(<ToastComponent toast={{ ...mockToast, duration: 1000 }} onDismiss={onDismiss} />);
render(
<ToastComponent
toast={{ ...mockToast, duration: 1000 }}
onDismiss={onDismiss}
/>,
);
await waitFor(() => {
expect(screen.getByText('Test message')).toBeInTheDocument();
@ -56,7 +66,12 @@ describe('Toast Components', () => {
it('does not auto-dismiss when duration is 0', async () => {
vi.useFakeTimers();
const onDismiss = vi.fn();
render(<ToastComponent toast={{ ...mockToast, duration: 0 }} onDismiss={onDismiss} />);
render(
<ToastComponent
toast={{ ...mockToast, duration: 0 }}
onDismiss={onDismiss}
/>,
);
await waitFor(() => {
expect(screen.getByText('Test message')).toBeInTheDocument();
@ -84,15 +99,21 @@ describe('Toast Components', () => {
const closeButton = screen.getByLabelText('Fermer');
closeButton.click();
await waitFor(() => {
await waitFor(
() => {
expect(onDismiss).toHaveBeenCalledWith('1');
}, { timeout: 500 });
},
{ timeout: 500 },
);
});
it('renders correct icon for each type', async () => {
const onDismiss = vi.fn();
const { container, rerender } = render(
<ToastComponent toast={{ ...mockToast, type: 'success' }} onDismiss={onDismiss} />
<ToastComponent
toast={{ ...mockToast, type: 'success' }}
onDismiss={onDismiss}
/>,
);
await waitFor(() => {
@ -100,20 +121,40 @@ describe('Toast Components', () => {
expect(icon).toBeInTheDocument();
});
rerender(<ToastComponent toast={{ ...mockToast, type: 'error' }} onDismiss={onDismiss} />);
rerender(<ToastComponent toast={{ ...mockToast, type: 'warning' }} onDismiss={onDismiss} />);
rerender(<ToastComponent toast={{ ...mockToast, type: 'info' }} onDismiss={onDismiss} />);
rerender(
<ToastComponent
toast={{ ...mockToast, type: 'error' }}
onDismiss={onDismiss}
/>,
);
rerender(
<ToastComponent
toast={{ ...mockToast, type: 'warning' }}
onDismiss={onDismiss}
/>,
);
rerender(
<ToastComponent
toast={{ ...mockToast, type: 'info' }}
onDismiss={onDismiss}
/>,
);
});
it('renders with default type when type is not provided', async () => {
const onDismiss = vi.fn();
const { container } = render(
<ToastComponent toast={{ id: '1', message: 'Test' }} onDismiss={onDismiss} />
<ToastComponent
toast={{ id: '1', message: 'Test' }}
onDismiss={onDismiss}
/>,
);
await waitFor(() => {
expect(screen.getByText('Test')).toBeInTheDocument();
const toast = container.querySelector('.bg-blue-50, .dark\\:bg-blue-900\\/20');
const toast = container.querySelector(
'.bg-blue-50, .dark\\:bg-blue-900\\/20',
);
expect(toast).toBeInTheDocument();
});
});
@ -133,7 +174,7 @@ describe('Toast Components', () => {
render(
<ToastProvider>
<TestComponent />
</ToastProvider>
</ToastProvider>,
);
});
@ -143,7 +184,9 @@ describe('Toast Components', () => {
useToastContext();
return <div>Should not render</div>;
} catch (error: any) {
expect(error.message).toContain('useToastContext must be used within ToastProvider');
expect(error.message).toContain(
'useToastContext must be used within ToastProvider',
);
return <div>Error caught</div>;
}
};
@ -156,7 +199,9 @@ describe('Toast Components', () => {
const { addToast, toasts } = useToastContext();
return (
<div>
<button onClick={() => addToast({ message: 'Test', type: 'success' })}>
<button
onClick={() => addToast({ message: 'Test', type: 'success' })}
>
Add Toast
</button>
<div data-testid="toast-count">{toasts.length}</div>
@ -167,7 +212,7 @@ describe('Toast Components', () => {
render(
<ToastProvider>
<TestComponent />
</ToastProvider>
</ToastProvider>,
);
const button = screen.getByText('Add Toast');
@ -183,7 +228,9 @@ describe('Toast Components', () => {
const { addToast, removeToast, toasts } = useToastContext();
return (
<div>
<button onClick={() => addToast({ message: 'Test', type: 'success' })}>
<button
onClick={() => addToast({ message: 'Test', type: 'success' })}
>
Add Toast
</button>
<button onClick={() => removeToast(toasts[0]?.id || '')}>
@ -197,7 +244,7 @@ describe('Toast Components', () => {
render(
<ToastProvider>
<TestComponent />
</ToastProvider>
</ToastProvider>,
);
const addButton = screen.getByText('Add Toast');
@ -219,7 +266,11 @@ describe('Toast Components', () => {
const TestComponent = () => {
const { addToast } = useToastContext();
return (
<button onClick={() => addToast({ message: 'Test message', type: 'success' })}>
<button
onClick={() =>
addToast({ message: 'Test message', type: 'success' })
}
>
Add Toast
</button>
);
@ -228,7 +279,7 @@ describe('Toast Components', () => {
render(
<ToastProvider>
<TestComponent />
</ToastProvider>
</ToastProvider>,
);
const button = screen.getByText('Add Toast');
@ -240,7 +291,7 @@ describe('Toast Components', () => {
() => {
expect(screen.getByText('Test message')).toBeInTheDocument();
},
{ timeout: 1000 }
{ timeout: 1000 },
);
});
@ -248,7 +299,7 @@ describe('Toast Components', () => {
const { container, rerender } = render(
<ToastProvider position="top-right">
<div>Test</div>
</ToastProvider>
</ToastProvider>,
);
let positionDiv = container.querySelector('.top-4.right-4');
@ -257,7 +308,7 @@ describe('Toast Components', () => {
rerender(
<ToastProvider position="bottom-left">
<div>Test</div>
</ToastProvider>
</ToastProvider>,
);
positionDiv = container.querySelector('.bottom-4.left-4');
@ -280,7 +331,7 @@ describe('Toast Components', () => {
render(
<ToastProvider>
<TestComponent />
</ToastProvider>
</ToastProvider>,
);
});
@ -297,7 +348,7 @@ describe('Toast Components', () => {
render(
<ToastProvider>
<TestComponent />
</ToastProvider>
</ToastProvider>,
);
const button = screen.getByText('Show Success');
@ -311,7 +362,7 @@ describe('Toast Components', () => {
const toast = screen.queryByText('Success message');
expect(toast).toBeInTheDocument();
},
{ timeout: 1000 }
{ timeout: 1000 },
);
});
@ -328,7 +379,7 @@ describe('Toast Components', () => {
render(
<ToastProvider>
<TestComponent />
</ToastProvider>
</ToastProvider>,
);
const button = screen.getByText('Show Error');
@ -340,7 +391,7 @@ describe('Toast Components', () => {
() => {
expect(screen.getByText('Error message')).toBeInTheDocument();
},
{ timeout: 1000 }
{ timeout: 1000 },
);
});
@ -357,7 +408,7 @@ describe('Toast Components', () => {
render(
<ToastProvider>
<TestComponent />
</ToastProvider>
</ToastProvider>,
);
const button = screen.getByText('Show Warning');
@ -369,7 +420,7 @@ describe('Toast Components', () => {
() => {
expect(screen.getByText('Warning message')).toBeInTheDocument();
},
{ timeout: 1000 }
{ timeout: 1000 },
);
});
@ -377,16 +428,14 @@ describe('Toast Components', () => {
const TestComponent = () => {
const toast = useToast();
return (
<button onClick={() => toast.info('Info message')}>
Show Info
</button>
<button onClick={() => toast.info('Info message')}>Show Info</button>
);
};
render(
<ToastProvider>
<TestComponent />
</ToastProvider>
</ToastProvider>,
);
const button = screen.getByText('Show Info');
@ -398,7 +447,7 @@ describe('Toast Components', () => {
() => {
expect(screen.getByText('Info message')).toBeInTheDocument();
},
{ timeout: 1000 }
{ timeout: 1000 },
);
});
@ -423,7 +472,7 @@ describe('Toast Components', () => {
render(
<ToastProvider>
<TestComponent />
</ToastProvider>
</ToastProvider>,
);
const button = screen.getByText('Show Custom');
@ -435,9 +484,8 @@ describe('Toast Components', () => {
() => {
expect(screen.getByText('Custom message')).toBeInTheDocument();
},
{ timeout: 1000 }
{ timeout: 1000 },
);
});
});
});

View file

@ -22,9 +22,12 @@ const TOAST_ICONS = {
};
const TOAST_STYLES = {
success: 'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200',
error: 'bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200',
success:
'bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200',
error:
'bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200',
warning:
'bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200',
info: 'bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-200',
};
@ -70,7 +73,9 @@ export function ToastComponent({ toast, onDismiss }: ToastProps) {
className={cn(
'relative flex min-w-[300px] max-w-md items-start gap-3 rounded-lg border p-4 shadow-lg transition-all duration-300',
styles,
isVisible && !isLeaving ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-full'
isVisible && !isLeaving
? 'opacity-100 translate-x-0'
: 'opacity-0 translate-x-full',
)}
role="alert"
aria-live="polite"
@ -89,4 +94,3 @@ export function ToastComponent({ toast, onDismiss }: ToastProps) {
</div>
);
}

View file

@ -1,4 +1,10 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import {
createContext,
useContext,
useState,
useCallback,
ReactNode,
} from 'react';
import { Toast, ToastComponent } from './Toast';
import { cn } from '@/lib/utils';
@ -20,7 +26,13 @@ export function useToastContext() {
export interface ToastProviderProps {
children: ReactNode;
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center';
position?:
| 'top-right'
| 'top-left'
| 'bottom-right'
| 'bottom-left'
| 'top-center'
| 'bottom-center';
className?: string;
}
@ -70,14 +82,17 @@ export function ToastProvider({
className={cn(
'fixed z-50 flex flex-col gap-2',
POSITION_CLASSES[position],
className
className,
)}
>
{toasts.map((toast) => (
<ToastComponent key={toast.id} toast={toast} onDismiss={removeToast} />
<ToastComponent
key={toast.id}
toast={toast}
onDismiss={removeToast}
/>
))}
</div>
</ToastContext.Provider>
);
}

View file

@ -48,7 +48,7 @@ describe('Filters Component', () => {
values={{}}
onChange={mockOnChange}
onReset={mockOnReset}
/>
/>,
);
expect(screen.getByText('Category')).toBeInTheDocument();
@ -63,12 +63,14 @@ describe('Filters Component', () => {
filters={[mockFilters[0]]}
values={{}}
onChange={mockOnChange}
/>
/>,
);
// Le Select utilise un bouton, vérifions qu'il existe
const buttons = screen.getAllByRole('button');
const selectButton = buttons.find(btn => btn.getAttribute('aria-haspopup') === 'true');
const selectButton = buttons.find(
(btn) => btn.getAttribute('aria-haspopup') === 'true',
);
expect(selectButton).toBeInTheDocument();
});
@ -79,11 +81,13 @@ describe('Filters Component', () => {
filters={[mockFilters[0]]}
values={{}}
onChange={mockOnChange}
/>
/>,
);
const buttons = screen.getAllByRole('button');
const selectButton = buttons.find(btn => btn.getAttribute('aria-haspopup') === 'true');
const selectButton = buttons.find(
(btn) => btn.getAttribute('aria-haspopup') === 'true',
);
expect(selectButton).toBeInTheDocument();
if (selectButton) {
await user.click(selectButton);
@ -104,7 +108,7 @@ describe('Filters Component', () => {
filters={[mockFilters[1]]}
values={{}}
onChange={mockOnChange}
/>
/>,
);
const checkbox = screen.getByRole('checkbox');
@ -119,7 +123,7 @@ describe('Filters Component', () => {
filters={[mockFilters[1]]}
values={{}}
onChange={mockOnChange}
/>
/>,
);
const checkbox = screen.getByRole('checkbox');
@ -134,7 +138,7 @@ describe('Filters Component', () => {
filters={[mockFilters[2]]}
values={{}}
onChange={mockOnChange}
/>
/>,
);
const minInput = screen.getByLabelText(/min:/i);
@ -151,7 +155,7 @@ describe('Filters Component', () => {
filters={[mockFilters[2]]}
values={{}}
onChange={mockOnChange}
/>
/>,
);
const minInput = screen.getByLabelText(/min:/i);
@ -172,12 +176,14 @@ describe('Filters Component', () => {
filters={[mockFilters[3]]}
values={{}}
onChange={mockOnChange}
/>
/>,
);
// Le DatePicker utilise un bouton, vérifions qu'il existe
const buttons = screen.getAllByRole('button');
const dateButton = buttons.find(btn => btn.getAttribute('aria-haspopup') === 'true');
const dateButton = buttons.find(
(btn) => btn.getAttribute('aria-haspopup') === 'true',
);
expect(dateButton).toBeInTheDocument();
});
@ -188,7 +194,7 @@ describe('Filters Component', () => {
values={{ category: '1', active: true }}
onChange={mockOnChange}
onReset={mockOnReset}
/>
/>,
);
const resetButton = screen.getByText('Réinitialiser');
@ -202,7 +208,7 @@ describe('Filters Component', () => {
values={{}}
onChange={mockOnChange}
onReset={mockOnReset}
/>
/>,
);
const resetButton = screen.queryByText('Réinitialiser');
@ -217,7 +223,7 @@ describe('Filters Component', () => {
values={{ category: '1', active: true }}
onChange={mockOnChange}
onReset={mockOnReset}
/>
/>,
);
const resetButton = screen.getByText('Réinitialiser');
@ -240,7 +246,7 @@ describe('Filters Component', () => {
values={{ category: '1', active: true, price: { min: 20, max: 80 } }}
onChange={mockOnChange}
onReset={mockOnReset}
/>
/>,
);
const resetButton = screen.getByText('Réinitialiser');
@ -262,7 +268,7 @@ describe('Filters Component', () => {
onChange={mockOnChange}
onReset={mockOnReset}
showReset={false}
/>
/>,
);
const resetButton = screen.queryByText('Réinitialiser');
@ -277,7 +283,7 @@ describe('Filters Component', () => {
onChange={mockOnChange}
onReset={mockOnReset}
resetLabel="Clear All"
/>
/>,
);
const resetButton = screen.getByText('Clear All');
@ -291,7 +297,7 @@ describe('Filters Component', () => {
values={{}}
onChange={mockOnChange}
className="custom-class"
/>
/>,
);
const filtersContainer = container.firstChild;
@ -307,17 +313,13 @@ describe('Filters Component', () => {
];
render(
<Filters
filters={disabledFilters}
values={{}}
onChange={mockOnChange}
/>
<Filters filters={disabledFilters} values={{}} onChange={mockOnChange} />,
);
// Le Select utilise un button pour le trigger, cherchons le bouton
const buttons = screen.getAllByRole('button');
const selectButton = buttons.find(btn =>
btn.getAttribute('aria-haspopup') === 'true'
const selectButton = buttons.find(
(btn) => btn.getAttribute('aria-haspopup') === 'true',
);
expect(selectButton).toBeInTheDocument();
// Note: Le composant Select peut ne pas désactiver directement le bouton,
@ -335,7 +337,7 @@ describe('Filters Component', () => {
price: { min: 20, max: 80 },
}}
onChange={mockOnChange}
/>
/>,
);
const checkbox = screen.getByRole('checkbox');
@ -363,7 +365,7 @@ describe('Filters Component', () => {
filters={[customRangeFilter]}
values={{}}
onChange={mockOnChange}
/>
/>,
);
const minInput = screen.getByLabelText(/min:/i);
@ -375,4 +377,3 @@ describe('Filters Component', () => {
expect(maxInput).toHaveAttribute('max', '10');
});
});

View file

@ -48,7 +48,7 @@ export function Filters({
(filterId: string, value: any) => {
onChange({ ...values, [filterId]: value });
},
[values, onChange]
[values, onChange],
);
const handleReset = useCallback(() => {
@ -62,7 +62,10 @@ export function Filters({
resetValues[filter.id] = false;
break;
case 'range':
resetValues[filter.id] = { min: filter.min || 0, max: filter.max || 100 };
resetValues[filter.id] = {
min: filter.min || 0,
max: filter.max || 100,
};
break;
case 'date':
resetValues[filter.id] = undefined;
@ -108,7 +111,10 @@ export function Filters({
}
value={value}
onChange={(newValue) => handleFilterChange(filter.id, newValue)}
placeholder={filter.placeholder || `Sélectionner ${filter.label.toLowerCase()}`}
placeholder={
filter.placeholder ||
`Sélectionner ${filter.label.toLowerCase()}`
}
disabled={filter.disabled}
/>
</div>
@ -132,13 +138,19 @@ export function Filters({
);
case 'range': {
const rangeValue = value || { min: filter.min || 0, max: filter.max || 100 };
const rangeValue = value || {
min: filter.min || 0,
max: filter.max || 100,
};
return (
<div key={filter.id} className="space-y-2">
<Label>{filter.label}</Label>
<div className="flex items-center gap-4">
<div className="flex-1">
<Label htmlFor={`${filter.id}-min`} className="text-xs text-muted-foreground">
<Label
htmlFor={`${filter.id}-min`}
className="text-xs text-muted-foreground"
>
Min: {rangeValue.min}
</Label>
<Input
@ -158,7 +170,10 @@ export function Filters({
/>
</div>
<div className="flex-1">
<Label htmlFor={`${filter.id}-max`} className="text-xs text-muted-foreground">
<Label
htmlFor={`${filter.id}-max`}
className="text-xs text-muted-foreground"
>
Max: {rangeValue.max}
</Label>
<Input
@ -255,4 +270,3 @@ export function Filters({
</div>
);
}

View file

@ -27,7 +27,13 @@ describe('FormBuilder Component', () => {
});
it('uses custom submit label', () => {
render(<FormBuilder fields={basicFields} onSubmit={mockOnSubmit} submitLabel="Save" />);
render(
<FormBuilder
fields={basicFields}
onSubmit={mockOnSubmit}
submitLabel="Save"
/>,
);
expect(screen.getByText('Save')).toBeInTheDocument();
});
@ -263,7 +269,9 @@ describe('FormBuilder Component', () => {
});
it('disables form when disabled prop is true', () => {
render(<FormBuilder fields={basicFields} onSubmit={mockOnSubmit} disabled />);
render(
<FormBuilder fields={basicFields} onSubmit={mockOnSubmit} disabled />,
);
const input = screen.getByLabelText(/Name/);
expect(input).toBeDisabled();
@ -330,4 +338,3 @@ describe('FormBuilder Component', () => {
expect(screen.getByLabelText('Email')).toBeInTheDocument();
});
});

View file

@ -9,7 +9,15 @@ import { cn } from '@/lib/utils';
export interface FormField {
name: string;
type: 'text' | 'email' | 'password' | 'number' | 'textarea' | 'select' | 'date' | 'file';
type:
| 'text'
| 'email'
| 'password'
| 'number'
| 'textarea'
| 'select'
| 'date'
| 'file';
label: string;
placeholder?: string;
required?: boolean;
@ -48,7 +56,7 @@ export function FormBuilder({
}: FormBuilderProps) {
const [formData, setFormData] = useState<Record<string, any>>(() => {
const initial: Record<string, any> = {};
fields.forEach(field => {
fields.forEach((field) => {
if (field.defaultValue !== undefined) {
initial[field.name] = field.defaultValue;
} else if (field.type === 'select') {
@ -56,7 +64,8 @@ export function FormBuilder({
} else if (field.type === 'file') {
initial[field.name] = field.multiple ? [] : null;
} else if (field.type === 'date') {
initial[field.name] = field.mode === 'range' ? { start: null, end: null } : null;
initial[field.name] =
field.mode === 'range' ? { start: null, end: null } : null;
} else {
initial[field.name] = '';
}
@ -67,13 +76,20 @@ export function FormBuilder({
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const validateField = useCallback((field: FormField, value: any): string | null => {
const validateField = useCallback(
(field: FormField, value: any): string | null => {
// Validation required
if (field.required) {
if (value === null || value === undefined || value === '' ||
if (
value === null ||
value === undefined ||
value === '' ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' && field.type === 'date' && field.mode === 'range' &&
(!value.start || !value.end))) {
(typeof value === 'object' &&
field.type === 'date' &&
field.mode === 'range' &&
(!value.start || !value.end))
) {
return `${field.label} is required`;
}
}
@ -95,20 +111,23 @@ export function FormBuilder({
}
return null;
}, []);
},
[],
);
const handleFieldChange = useCallback((fieldName: string, value: any) => {
setFormData(prev => ({
const handleFieldChange = useCallback(
(fieldName: string, value: any) => {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
// Valider le champ si déjà touché
if (touched[fieldName]) {
const field = fields.find(f => f.name === fieldName);
const field = fields.find((f) => f.name === fieldName);
if (field) {
const error = validateField(field, value);
setErrors(prev => {
setErrors((prev) => {
if (error) {
return { ...prev, [fieldName]: error };
} else {
@ -119,16 +138,19 @@ export function FormBuilder({
});
}
}
}, [fields, touched, validateField]);
},
[fields, touched, validateField],
);
const handleFieldBlur = useCallback((fieldName: string) => {
setTouched(prev => ({ ...prev, [fieldName]: true }));
const handleFieldBlur = useCallback(
(fieldName: string) => {
setTouched((prev) => ({ ...prev, [fieldName]: true }));
const field = fields.find(f => f.name === fieldName);
const field = fields.find((f) => f.name === fieldName);
if (field) {
const value = formData[fieldName];
const error = validateField(field, value);
setErrors(prev => {
setErrors((prev) => {
if (error) {
return { ...prev, [fieldName]: error };
} else {
@ -138,16 +160,19 @@ export function FormBuilder({
}
});
}
}, [fields, formData, validateField]);
},
[fields, formData, validateField],
);
const handleSubmit = useCallback((e: React.FormEvent) => {
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
// Marquer tous les champs comme touchés
const allTouched: Record<string, boolean> = {};
const newErrors: Record<string, string> = {};
fields.forEach(field => {
fields.forEach((field) => {
allTouched[field.name] = true;
const value = formData[field.name];
const error = validateField(field, value);
@ -163,7 +188,9 @@ export function FormBuilder({
if (Object.keys(newErrors).length === 0) {
onSubmit(formData);
}
}, [fields, formData, validateField, onSubmit]);
},
[fields, formData, validateField, onSubmit],
);
const renderField = (field: FormField, hasError: boolean) => {
switch (field.type) {
@ -193,7 +220,7 @@ export function FormBuilder({
disabled={disabled || field.disabled}
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
hasError && 'border-destructive'
hasError && 'border-destructive',
)}
/>
);
@ -205,7 +232,9 @@ export function FormBuilder({
value={formData[field.name]}
onChange={(value) => handleFieldChange(field.name, value)}
multiple={field.multiple}
placeholder={field.placeholder || `Select ${field.label.toLowerCase()}`}
placeholder={
field.placeholder || `Select ${field.label.toLowerCase()}`
}
disabled={disabled || field.disabled}
/>
);
@ -218,7 +247,9 @@ export function FormBuilder({
mode={field.mode || 'single'}
minDate={field.minDate}
maxDate={field.maxDate}
placeholder={field.placeholder || `Select ${field.label.toLowerCase()}`}
placeholder={
field.placeholder || `Select ${field.label.toLowerCase()}`
}
disabled={disabled || field.disabled}
/>
);
@ -242,7 +273,7 @@ export function FormBuilder({
return (
<form onSubmit={handleSubmit} className={cn('space-y-6', className)}>
{fields.map(field => {
{fields.map((field) => {
const fieldError = errors[field.name];
const isTouched = touched[field.name];
const showError = isTouched && fieldError;
@ -251,7 +282,9 @@ export function FormBuilder({
<div key={field.name} className="space-y-2">
<Label htmlFor={field.name}>
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
{field.required && (
<span className="text-destructive ml-1">*</span>
)}
</Label>
{renderField(field, showError)}
{showError && (
@ -267,4 +300,3 @@ export function FormBuilder({
</form>
);
}

View file

@ -118,7 +118,7 @@ describe('LoginForm', () => {
const user = userEvent.setup();
// Mock a delayed submission
mockOnSubmit.mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 100))
() => new Promise((resolve) => setTimeout(resolve, 100)),
);
render(<LoginForm onSubmit={mockOnSubmit} />);
@ -170,14 +170,20 @@ describe('LoginForm', () => {
await user.type(passwordInput, 'password123');
await user.click(submitButton);
await waitFor(() => {
await waitFor(
() => {
expect(mockOnSubmit).toHaveBeenCalledTimes(1);
}, { timeout: 2000 });
},
{ timeout: 2000 },
);
// Wait for loading state to finish
await waitFor(() => {
await waitFor(
() => {
expect(screen.getByText(/login/i)).toBeInTheDocument();
}, { timeout: 2000 });
},
{ timeout: 2000 },
);
// Form should still be functional after error
expect(submitButton).not.toBeDisabled();
@ -215,4 +221,3 @@ describe('LoginForm', () => {
expect(emailInput).toHaveAttribute('aria-describedby', 'email-error');
});
});

View file

@ -83,7 +83,11 @@ export function LoginForm({ onSubmit, disabled = false }: LoginFormProps) {
aria-describedby={errors.password ? 'password-error' : undefined}
/>
{errors.password && (
<p id="password-error" className="text-sm text-destructive" role="alert">
<p
id="password-error"
className="text-sm text-destructive"
role="alert"
>
{errors.password.message}
</p>
)}
@ -112,4 +116,3 @@ export function LoginForm({ onSubmit, disabled = false }: LoginFormProps) {
</form>
);
}

View file

@ -55,8 +55,12 @@ describe('PasswordStrengthIndicator', () => {
});
it('should have correct progress bar width for score', () => {
const { container } = render(<PasswordStrengthIndicator password="SecurePass123!" />);
const progressBar = container.querySelector('[role="progressbar"]') as HTMLElement;
const { container } = render(
<PasswordStrengthIndicator password="SecurePass123!" />,
);
const progressBar = container.querySelector(
'[role="progressbar"]',
) as HTMLElement;
expect(progressBar).toBeInTheDocument();
expect(progressBar.style.width).toBe('100%'); // 5/5 = 100%
expect(progressBar.getAttribute('aria-valuenow')).toBe('5');
@ -64,8 +68,12 @@ describe('PasswordStrengthIndicator', () => {
it('should have correct progress bar width for partial score', () => {
// Password1234 has 12 characters, upper, lower, number = 4 points = 80%
const { container } = render(<PasswordStrengthIndicator password="Password1234" />);
const progressBar = container.querySelector('[role="progressbar"]') as HTMLElement;
const { container } = render(
<PasswordStrengthIndicator password="Password1234" />,
);
const progressBar = container.querySelector(
'[role="progressbar"]',
) as HTMLElement;
expect(progressBar).toBeInTheDocument();
expect(progressBar.getAttribute('aria-valuenow')).toBe('4');
expect(progressBar.style.width).toContain('%'); // Should have percentage width
@ -73,21 +81,29 @@ describe('PasswordStrengthIndicator', () => {
it('should correctly identify special characters', () => {
// Test with a single special character
const { container } = render(<PasswordStrengthIndicator password="Password1234!" />);
const { container } = render(
<PasswordStrengthIndicator password="Password1234!" />,
);
expect(container.firstChild).not.toBeNull();
// Should show "Strong" for password with all requirements including special char
expect(screen.getByText('Strong')).toBeInTheDocument();
});
it('should have accessible progress bar attributes', () => {
const { container } = render(<PasswordStrengthIndicator password="SecurePass123!" />);
const progressBar = container.querySelector('[role="progressbar"]') as HTMLElement;
const { container } = render(
<PasswordStrengthIndicator password="SecurePass123!" />,
);
const progressBar = container.querySelector(
'[role="progressbar"]',
) as HTMLElement;
expect(progressBar.getAttribute('role')).toBe('progressbar');
expect(progressBar.getAttribute('aria-valuenow')).toBe('5');
expect(progressBar.getAttribute('aria-valuemin')).toBe('0');
expect(progressBar.getAttribute('aria-valuemax')).toBe('5');
expect(progressBar.getAttribute('aria-label')).toContain('Password strength');
expect(progressBar.getAttribute('aria-label')).toContain(
'Password strength',
);
});
it('should update score when password changes', () => {
@ -98,4 +114,3 @@ describe('PasswordStrengthIndicator', () => {
expect(screen.getByText('Strong')).toBeInTheDocument();
});
});

View file

@ -17,7 +17,9 @@ interface PasswordStrength {
};
}
export function PasswordStrengthIndicator({ password }: PasswordStrengthIndicatorProps) {
export function PasswordStrengthIndicator({
password,
}: PasswordStrengthIndicatorProps) {
// T0197: Use validatePasswordStrength from passwordValidator
const strength = useMemo<PasswordStrength>(() => {
const validation = validatePasswordStrength(password);
@ -52,9 +54,12 @@ export function PasswordStrengthIndicator({ password }: PasswordStrengthIndicato
if (!password) return null;
const currentStrengthLabel = strengthLabels[strength.score - 1] || 'Very Weak';
const currentStrengthColor = strengthColors[strength.score - 1] || 'bg-gray-400';
const currentTextColor = strengthTextColors[strength.score - 1] || 'text-gray-600';
const currentStrengthLabel =
strengthLabels[strength.score - 1] || 'Very Weak';
const currentStrengthColor =
strengthColors[strength.score - 1] || 'bg-gray-400';
const currentTextColor =
strengthTextColors[strength.score - 1] || 'text-gray-600';
return (
<div className="space-y-2">
@ -63,7 +68,7 @@ export function PasswordStrengthIndicator({ password }: PasswordStrengthIndicato
<div
className={cn(
'h-2 rounded-full transition-all duration-300',
currentStrengthColor
currentStrengthColor,
)}
style={{ width: `${(strength.score / 5) * 100}%` }}
role="progressbar"
@ -73,7 +78,12 @@ export function PasswordStrengthIndicator({ password }: PasswordStrengthIndicato
aria-label={`Password strength: ${currentStrengthLabel}`}
/>
</div>
<span className={cn('text-sm font-medium min-w-[80px] text-right', currentTextColor)}>
<span
className={cn(
'text-sm font-medium min-w-[80px] text-right',
currentTextColor,
)}
>
{currentStrengthLabel}
</span>
</div>
@ -81,7 +91,7 @@ export function PasswordStrengthIndicator({ password }: PasswordStrengthIndicato
<li
className={cn(
'flex items-center gap-1',
strength.checks.length ? 'text-green-600 dark:text-green-400' : ''
strength.checks.length ? 'text-green-600 dark:text-green-400' : '',
)}
>
<span>{strength.checks.length ? '✓' : '○'}</span>
@ -90,7 +100,7 @@ export function PasswordStrengthIndicator({ password }: PasswordStrengthIndicato
<li
className={cn(
'flex items-center gap-1',
strength.checks.upper ? 'text-green-600 dark:text-green-400' : ''
strength.checks.upper ? 'text-green-600 dark:text-green-400' : '',
)}
>
<span>{strength.checks.upper ? '✓' : '○'}</span>
@ -99,7 +109,7 @@ export function PasswordStrengthIndicator({ password }: PasswordStrengthIndicato
<li
className={cn(
'flex items-center gap-1',
strength.checks.lower ? 'text-green-600 dark:text-green-400' : ''
strength.checks.lower ? 'text-green-600 dark:text-green-400' : '',
)}
>
<span>{strength.checks.lower ? '✓' : '○'}</span>
@ -108,7 +118,7 @@ export function PasswordStrengthIndicator({ password }: PasswordStrengthIndicato
<li
className={cn(
'flex items-center gap-1',
strength.checks.number ? 'text-green-600 dark:text-green-400' : ''
strength.checks.number ? 'text-green-600 dark:text-green-400' : '',
)}
>
<span>{strength.checks.number ? '✓' : '○'}</span>
@ -117,7 +127,7 @@ export function PasswordStrengthIndicator({ password }: PasswordStrengthIndicato
<li
className={cn(
'flex items-center gap-1',
strength.checks.special ? 'text-green-600 dark:text-green-400' : ''
strength.checks.special ? 'text-green-600 dark:text-green-400' : '',
)}
>
<span>{strength.checks.special ? '✓' : '○'}</span>
@ -127,4 +137,3 @@ export function PasswordStrengthIndicator({ password }: PasswordStrengthIndicato
</div>
);
}

View file

@ -10,14 +10,16 @@ import { CheckCircle2, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
const registerSchema = z.object({
const registerSchema = z
.object({
email: z.string().email('Invalid email address'),
password: z.string().min(12, 'Password must be at least 12 characters'),
passwordConfirm: z.string(),
}).refine((data) => data.password === data.passwordConfirm, {
})
.refine((data) => data.password === data.passwordConfirm, {
message: "Passwords don't match",
path: ['passwordConfirm'],
});
});
export type RegisterFormData = z.infer<typeof registerSchema>;
@ -26,7 +28,10 @@ interface RegisterFormProps {
disabled?: boolean;
}
export function RegisterForm({ onSubmit, disabled = false }: RegisterFormProps) {
export function RegisterForm({
onSubmit,
disabled = false,
}: RegisterFormProps) {
const {
register,
handleSubmit,
@ -41,7 +46,10 @@ export function RegisterForm({ onSubmit, disabled = false }: RegisterFormProps)
const [isLoading, setIsLoading] = useState(false);
const isFormDisabled = disabled || isLoading;
const [emailValue, setEmailValue] = useState('');
const [emailValidation, setEmailValidation] = useState<{ valid: boolean; message?: string } | null>(null);
const [emailValidation, setEmailValidation] = useState<{
valid: boolean;
message?: string;
} | null>(null);
// Watch fields for real-time validation
const watchedEmail = watch('email');
@ -78,27 +86,39 @@ export function RegisterForm({ onSubmit, disabled = false }: RegisterFormProps)
id="email"
type="email"
{...register('email')}
aria-invalid={errors.email || (emailValidation && !emailValidation.valid) ? 'true' : 'false'}
aria-describedby={errors.email || (emailValidation && !emailValidation.valid) ? 'email-error' : undefined}
aria-invalid={
errors.email || (emailValidation && !emailValidation.valid)
? 'true'
: 'false'
}
aria-describedby={
errors.email || (emailValidation && !emailValidation.valid)
? 'email-error'
: undefined
}
className={cn(
emailValue && emailValidation && (
emailValidation.valid
emailValue &&
emailValidation &&
(emailValidation.valid
? 'border-green-500 focus-visible:ring-green-500'
: 'border-red-500 focus-visible:ring-red-500'
)
: 'border-red-500 focus-visible:ring-red-500'),
)}
/>
{emailValue && emailValidation && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{emailValidation.valid ? (
<CheckCircle2 className="h-5 w-5 text-green-500" aria-hidden="true" />
<CheckCircle2
className="h-5 w-5 text-green-500"
aria-hidden="true"
/>
) : (
<XCircle className="h-5 w-5 text-red-500" aria-hidden="true" />
)}
</div>
)}
</div>
{(errors.email || (emailValidation && !emailValidation.valid && emailValue)) && (
{(errors.email ||
(emailValidation && !emailValidation.valid && emailValue)) && (
<p id="email-error" className="text-sm text-destructive">
{errors.email?.message || emailValidation?.message}
</p>
@ -113,7 +133,9 @@ export function RegisterForm({ onSubmit, disabled = false }: RegisterFormProps)
aria-invalid={errors.password ? 'true' : 'false'}
aria-describedby={errors.password ? 'password-error' : undefined}
/>
{watchedPassword && <PasswordStrengthIndicator password={watchedPassword} />}
{watchedPassword && (
<PasswordStrengthIndicator password={watchedPassword} />
)}
{errors.password && (
<p id="password-error" className="text-sm text-destructive">
{errors.password.message}
@ -127,7 +149,9 @@ export function RegisterForm({ onSubmit, disabled = false }: RegisterFormProps)
type="password"
{...register('passwordConfirm')}
aria-invalid={errors.passwordConfirm ? 'true' : 'false'}
aria-describedby={errors.passwordConfirm ? 'passwordConfirm-error' : undefined}
aria-describedby={
errors.passwordConfirm ? 'passwordConfirm-error' : undefined
}
/>
{errors.passwordConfirm && (
<p id="passwordConfirm-error" className="text-sm text-destructive">
@ -141,4 +165,3 @@ export function RegisterForm({ onSubmit, disabled = false }: RegisterFormProps)
</form>
);
}

View file

@ -62,11 +62,13 @@ describe('DashboardLayout Component', () => {
<DashboardLayout>
<div>Dashboard Content</div>
</DashboardLayout>
</TestWrapper>
</TestWrapper>,
);
// Vérifier que le layout est présent
const layout = screen.getByText('Dashboard Content').closest('.flex.h-screen');
const layout = screen
.getByText('Dashboard Content')
.closest('.flex.h-screen');
expect(layout).toBeInTheDocument();
});
@ -76,7 +78,7 @@ describe('DashboardLayout Component', () => {
<DashboardLayout>
<div>Dashboard Content</div>
</DashboardLayout>
</TestWrapper>
</TestWrapper>,
);
expect(screen.getByText('Dashboard Content')).toBeInTheDocument();
@ -88,7 +90,7 @@ describe('DashboardLayout Component', () => {
<DashboardLayout>
<div>Dashboard Content</div>
</DashboardLayout>
</TestWrapper>
</TestWrapper>,
);
// Vérifier que la sidebar est présente (chercher un élément caractéristique)
@ -102,7 +104,7 @@ describe('DashboardLayout Component', () => {
<DashboardLayout>
<div>Dashboard Content</div>
</DashboardLayout>
</TestWrapper>
</TestWrapper>,
);
// Vérifier que le header est présent
@ -116,7 +118,7 @@ describe('DashboardLayout Component', () => {
<DashboardLayout>
<div>Dashboard Content</div>
</DashboardLayout>
</TestWrapper>
</TestWrapper>,
);
const layout = container.querySelector('.flex.h-screen');
@ -136,11 +138,10 @@ describe('DashboardLayout Component', () => {
<div>Child 1</div>
<div>Child 2</div>
</DashboardLayout>
</TestWrapper>
</TestWrapper>,
);
expect(screen.getByText('Child 1')).toBeInTheDocument();
expect(screen.getByText('Child 2')).toBeInTheDocument();
});
});

View file

@ -21,4 +21,3 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
</div>
);
}

View file

@ -40,23 +40,23 @@ export function Header() {
const getThemeIcon = () => {
switch (theme) {
case 'light':
return <Sun className='h-4 w-4' />;
return <Sun className="h-4 w-4" />;
case 'dark':
return <Moon className='h-4 w-4' />;
return <Moon className="h-4 w-4" />;
default:
return <Monitor className='h-4 w-4' />;
return <Monitor className="h-4 w-4" />;
}
};
return (
<header className='sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60'>
<div className='container flex h-16 items-center justify-between'>
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center justify-between">
{/* Logo et menu mobile */}
<div className='flex items-center space-x-4'>
<div className="flex items-center space-x-4">
<Button
variant='ghost'
size='icon'
className='md:hidden'
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label={
sidebarOpen ? t('navigation.close') : t('navigation.menu')
@ -64,50 +64,50 @@ export function Header() {
aria-expanded={sidebarOpen}
>
{sidebarOpen ? (
<X className='h-5 w-5' />
<X className="h-5 w-5" />
) : (
<Menu className='h-5 w-5' />
<Menu className="h-5 w-5" />
)}
</Button>
<Link to='/dashboard' className='flex items-center space-x-2'>
<Link to="/dashboard" className="flex items-center space-x-2">
<div
className='h-8 w-8 rounded-lg bg-primary flex items-center justify-center'
aria-hidden='true'
className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center"
aria-hidden="true"
>
<span className='text-primary-foreground font-bold text-lg'>
<span className="text-primary-foreground font-bold text-lg">
V
</span>
</div>
<span className='font-bold text-xl'>Veza</span>
<span className="font-bold text-xl">Veza</span>
</Link>
</div>
{/* Barre de recherche (desktop) */}
<div className='hidden md:flex flex-1 max-w-md mx-4'>
<div className='relative w-full'>
<div className="hidden md:flex flex-1 max-w-md mx-4">
<div className="relative w-full">
<Search
className='absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground'
aria-hidden='true'
className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"
aria-hidden="true"
/>
<input
type='text'
type="text"
placeholder={t('common.search')}
aria-label={t('common.search')}
className='w-full pl-10 pr-4 py-2 border border-input rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2'
className="w-full pl-10 pr-4 py-2 border border-input rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
/>
</div>
</div>
{/* Actions utilisateur */}
<div className='flex items-center space-x-2'>
<div className="flex items-center space-x-2">
{/* Notifications */}
<NotificationMenu />
{/* Thème */}
<Button
variant='ghost'
size='icon'
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label={`${t('common.changeTheme')} - ${theme}`}
>
@ -115,16 +115,16 @@ export function Header() {
</Button>
{/* Menu utilisateur */}
<div className='relative'>
<div className="relative">
<Button
variant='ghost'
size='icon'
variant="ghost"
size="icon"
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
aria-label={t('common.userMenu')}
aria-expanded={isUserMenuOpen}
aria-haspopup='menu'
aria-haspopup="menu"
>
<User className='h-5 w-5' />
<User className="h-5 w-5" />
</Button>
{isUserMenuOpen && (
@ -133,35 +133,35 @@ export function Header() {
onEscape={() => setIsUserMenuOpen(false)}
>
<div
className='absolute right-0 mt-2 w-48 bg-popover border rounded-md shadow-lg z-50'
role='menu'
aria-orientation='vertical'
className="absolute right-0 mt-2 w-48 bg-popover border rounded-md shadow-lg z-50"
role="menu"
aria-orientation="vertical"
>
<div className='p-2'>
<div className='px-3 py-2 text-sm font-medium text-foreground border-b space-y-2'>
<div className="p-2">
<div className="px-3 py-2 text-sm font-medium text-foreground border-b space-y-2">
<div>{user?.username}</div>
{/* T0190: Afficher badge si email non vérifié */}
{user && !user.is_verified && (
<EmailVerificationBadge verified={false} />
)}
</div>
<div className='py-1'>
<div className="py-1">
<Link
to='/profile'
className='flex items-center px-3 py-2 text-sm text-foreground hover:bg-accent rounded-sm focus:outline-none focus:ring-2 focus:ring-ring'
to="/profile"
className="flex items-center px-3 py-2 text-sm text-foreground hover:bg-accent rounded-sm focus:outline-none focus:ring-2 focus:ring-ring"
onClick={() => setIsUserMenuOpen(false)}
role='menuitem'
role="menuitem"
>
<User className='mr-2 h-4 w-4' aria-hidden='true' />
<User className="mr-2 h-4 w-4" aria-hidden="true" />
{t('navigation.profile')}
</Link>
<Link
to='/settings'
className='flex items-center px-3 py-2 text-sm text-foreground hover:bg-accent rounded-sm focus:outline-none focus:ring-2 focus:ring-ring'
to="/settings"
className="flex items-center px-3 py-2 text-sm text-foreground hover:bg-accent rounded-sm focus:outline-none focus:ring-2 focus:ring-ring"
onClick={() => setIsUserMenuOpen(false)}
role='menuitem'
role="menuitem"
>
<Settings className='mr-2 h-4 w-4' aria-hidden='true' />
<Settings className="mr-2 h-4 w-4" aria-hidden="true" />
{t('navigation.settings')}
</Link>
<button
@ -169,10 +169,10 @@ export function Header() {
handleLogout();
setIsUserMenuOpen(false);
}}
className='flex items-center w-full px-3 py-2 text-sm text-foreground hover:bg-accent rounded-sm focus:outline-none focus:ring-2 focus:ring-ring'
role='menuitem'
className="flex items-center w-full px-3 py-2 text-sm text-foreground hover:bg-accent rounded-sm focus:outline-none focus:ring-2 focus:ring-ring"
role="menuitem"
>
<LogOut className='mr-2 h-4 w-4' aria-hidden='true' />
<LogOut className="mr-2 h-4 w-4" aria-hidden="true" />
{t('common.logout')}
</button>
</div>

View file

@ -12,17 +12,17 @@ export function Layout({ children }: LayoutProps) {
const { sidebarOpen } = useUIStore();
return (
<div className='min-h-screen bg-background'>
<div className="min-h-screen bg-background">
<Header />
<div className='flex'>
<div className="flex">
<Sidebar />
<main
className={cn(
'flex-1 transition-all duration-200 ease-in-out',
sidebarOpen ? 'md:ml-64' : 'ml-0'
sidebarOpen ? 'md:ml-64' : 'ml-0',
)}
>
<div className='p-6'>{children}</div>
<div className="p-6">{children}</div>
</main>
</div>
</div>

View file

@ -3,7 +3,14 @@ import { useUIStore } from '@/stores/ui';
import { useAuthStore } from '@/stores/auth';
import { useTranslation } from '@/hooks/useTranslation';
import { cn } from '@/lib/utils';
import { Home, MessageSquare, Library, Users, Settings, Shield } from 'lucide-react';
import {
Home,
MessageSquare,
Library,
Users,
Settings,
Shield,
} from 'lucide-react';
export function Sidebar() {
const { sidebarOpen } = useUIStore();
@ -36,42 +43,42 @@ export function Sidebar() {
className={cn(
'fixed inset-y-0 left-0 z-40 w-64 bg-background border-r transform transition-transform duration-200 ease-in-out',
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
'md:translate-x-0'
'md:translate-x-0',
)}
role='navigation'
role="navigation"
aria-label={t('navigation.menu')}
>
<div className='flex flex-col h-full'>
<div className="flex flex-col h-full">
{/* Logo */}
<div className='flex items-center h-16 px-6 border-b'>
<div className="flex items-center h-16 px-6 border-b">
<div
className='h-8 w-8 rounded-lg bg-primary flex items-center justify-center'
aria-hidden='true'
className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center"
aria-hidden="true"
>
<span className='text-primary-foreground font-bold text-lg'>V</span>
<span className="text-primary-foreground font-bold text-lg">V</span>
</div>
<span className='ml-2 font-bold text-xl'>Veza</span>
<span className="ml-2 font-bold text-xl">Veza</span>
</div>
{/* Navigation */}
<nav className='flex-1 px-4 py-6 space-y-2' role='menubar'>
{navigation.map(item => {
<nav className="flex-1 px-4 py-6 space-y-2" role="menubar">
{navigation.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
role='menuitem'
role="menuitem"
tabIndex={0}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
: 'text-muted-foreground hover:text-foreground hover:bg-accent',
)}
>
<item.icon className='mr-3 h-5 w-5' aria-hidden='true' />
<item.icon className="mr-3 h-5 w-5" aria-hidden="true" />
{item.name}
</Link>
);
@ -79,8 +86,8 @@ export function Sidebar() {
</nav>
{/* Footer */}
<footer className='p-4 border-t'>
<div className='text-xs text-muted-foreground'>
<footer className="p-4 border-t">
<div className="text-xs text-muted-foreground">
<p>Veza v1.0.0</p>
<p>© 2024 Veza Team</p>
</div>

View file

@ -68,7 +68,9 @@ describe('Breadcrumbs Component', () => {
});
it('renders separators between items', () => {
const { container } = renderWithRouter(<Breadcrumbs items={mockItems} showHome={false} />);
const { container } = renderWithRouter(
<Breadcrumbs items={mockItems} showHome={false} />,
);
const separators = container.querySelectorAll('[aria-hidden="true"]');
// 2 items = 1 separator
@ -100,7 +102,11 @@ describe('Breadcrumbs Component', () => {
it('renders custom separator', () => {
const customSeparator = <span>/</span>;
renderWithRouter(
<Breadcrumbs items={mockItems} separator={customSeparator} showHome={false} />
<Breadcrumbs
items={mockItems}
separator={customSeparator}
showHome={false}
/>,
);
const separators = screen.getAllByText('/');
@ -109,7 +115,11 @@ describe('Breadcrumbs Component', () => {
it('renders item with icon', () => {
const itemsWithIcon = [
{ label: 'Dashboard', href: '/dashboard', icon: <span data-testid="icon">📊</span> },
{
label: 'Dashboard',
href: '/dashboard',
icon: <span data-testid="icon">📊</span>,
},
];
renderWithRouter(<Breadcrumbs items={itemsWithIcon} showHome={false} />);
@ -127,7 +137,7 @@ describe('Breadcrumbs Component', () => {
it('applies custom className', () => {
const { container } = renderWithRouter(
<Breadcrumbs items={mockItems} className="custom-class" />
<Breadcrumbs items={mockItems} className="custom-class" />,
);
const nav = container.querySelector('nav');
@ -172,4 +182,3 @@ describe('Breadcrumbs Component', () => {
expect(linkElement).toHaveClass('text-muted-foreground');
});
});

View file

@ -27,7 +27,10 @@ export function Breadcrumbs({
className,
}: BreadcrumbsProps) {
const allItems = showHome
? [{ label: 'Home', href: homeHref, icon: <Home className="h-4 w-4" /> }, ...items]
? [
{ label: 'Home', href: homeHref, icon: <Home className="h-4 w-4" /> },
...items,
]
: items;
const defaultSeparator = separator || (
@ -35,27 +38,21 @@ export function Breadcrumbs({
);
return (
<nav
aria-label="Breadcrumb"
className={cn('flex items-center', className)}
>
<nav aria-label="Breadcrumb" className={cn('flex items-center', className)}>
<ol className="flex flex-wrap items-center gap-1 sm:gap-2">
{allItems.map((item, index) => {
const isLast = index === allItems.length - 1;
const isClickable = !isLast && item.href;
return (
<li
key={index}
className="flex items-center gap-1 sm:gap-2"
>
<li key={index} className="flex items-center gap-1 sm:gap-2">
{isClickable ? (
<Link
to={item.href!}
className={cn(
'flex items-center gap-1 text-sm font-medium text-muted-foreground',
'transition-colors hover:text-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm'
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm',
)}
>
{item.icon && <span className="shrink-0">{item.icon}</span>}
@ -65,7 +62,7 @@ export function Breadcrumbs({
<span
className={cn(
'flex items-center gap-1 text-sm font-medium',
isLast ? 'text-foreground' : 'text-muted-foreground'
isLast ? 'text-foreground' : 'text-muted-foreground',
)}
aria-current={isLast ? 'page' : undefined}
>
@ -88,4 +85,3 @@ export function Breadcrumbs({
</nav>
);
}

View file

@ -16,7 +16,7 @@ describe('Pagination Component', () => {
currentPage={1}
totalPages={5}
onPageChange={mockOnPageChange}
/>
/>,
);
expect(screen.getByText('1')).toBeInTheDocument();
@ -32,7 +32,7 @@ describe('Pagination Component', () => {
currentPage={2}
totalPages={5}
onPageChange={mockOnPageChange}
/>
/>,
);
const previousButton = screen.getByLabelText('Previous page');
@ -48,7 +48,7 @@ describe('Pagination Component', () => {
currentPage={1}
totalPages={5}
onPageChange={mockOnPageChange}
/>
/>,
);
const previousButton = screen.getByLabelText('Previous page');
@ -61,7 +61,7 @@ describe('Pagination Component', () => {
currentPage={5}
totalPages={5}
onPageChange={mockOnPageChange}
/>
/>,
);
const nextButton = screen.getByLabelText('Next page');
@ -75,7 +75,7 @@ describe('Pagination Component', () => {
currentPage={1}
totalPages={5}
onPageChange={mockOnPageChange}
/>
/>,
);
const page3 = screen.getByText('3');
@ -91,7 +91,7 @@ describe('Pagination Component', () => {
currentPage={3}
totalPages={5}
onPageChange={mockOnPageChange}
/>
/>,
);
const previousButton = screen.getByLabelText('Previous page');
@ -107,7 +107,7 @@ describe('Pagination Component', () => {
currentPage={3}
totalPages={5}
onPageChange={mockOnPageChange}
/>
/>,
);
const nextButton = screen.getByLabelText('Next page');
@ -122,7 +122,7 @@ describe('Pagination Component', () => {
currentPage={3}
totalPages={5}
onPageChange={mockOnPageChange}
/>
/>,
);
const currentPageButton = screen.getByText('3');
@ -137,14 +137,18 @@ describe('Pagination Component', () => {
totalPages={20}
onPageChange={mockOnPageChange}
maxVisiblePages={5}
/>
/>,
);
// Devrait afficher des ellipsis (MoreHorizontal icons)
const ellipsisIcons = container.querySelectorAll('svg');
Array.from(ellipsisIcons).some(icon => {
Array.from(ellipsisIcons).some((icon) => {
const parent = icon.closest('div');
return parent && parent.classList.contains('flex') && parent.classList.contains('items-center');
return (
parent &&
parent.classList.contains('flex') &&
parent.classList.contains('items-center')
);
});
// Vérifier que la première et dernière page sont affichées (indiquant ellipsis)
@ -159,7 +163,7 @@ describe('Pagination Component', () => {
totalPages={20}
onPageChange={mockOnPageChange}
showFirstLast={true}
/>
/>,
);
const firstButton = screen.getByLabelText('First page');
@ -177,7 +181,7 @@ describe('Pagination Component', () => {
totalPages={20}
onPageChange={mockOnPageChange}
showFirstLast={true}
/>
/>,
);
const firstButton = screen.getByLabelText('First page');
@ -194,7 +198,7 @@ describe('Pagination Component', () => {
totalPages={20}
onPageChange={mockOnPageChange}
showFirstLast={true}
/>
/>,
);
const lastButton = screen.getByLabelText('Last page');
@ -210,7 +214,7 @@ describe('Pagination Component', () => {
totalPages={10}
onPageChange={mockOnPageChange}
showFirstLast={true}
/>
/>,
);
const firstButton = screen.getByLabelText('First page');
@ -224,7 +228,7 @@ describe('Pagination Component', () => {
totalPages={10}
onPageChange={mockOnPageChange}
showFirstLast={true}
/>
/>,
);
const lastButton = screen.getByLabelText('Last page');
@ -238,13 +242,11 @@ describe('Pagination Component', () => {
totalPages={20}
onPageChange={mockOnPageChange}
maxVisiblePages={3}
/>
/>,
);
// Devrait afficher seulement 3 pages visibles (plus première et dernière)
const pageButtons = screen
.getAllByRole('button')
.filter(btn => {
const pageButtons = screen.getAllByRole('button').filter((btn) => {
const text = btn.textContent;
return text && /^\d+$/.test(text.trim());
});
@ -259,7 +261,7 @@ describe('Pagination Component', () => {
currentPage={1}
totalPages={1}
onPageChange={mockOnPageChange}
/>
/>,
);
expect(container.firstChild).toBeNull();
@ -271,7 +273,7 @@ describe('Pagination Component', () => {
currentPage={1}
totalPages={0}
onPageChange={mockOnPageChange}
/>
/>,
);
expect(container.firstChild).toBeNull();
@ -284,7 +286,7 @@ describe('Pagination Component', () => {
totalPages={5}
onPageChange={mockOnPageChange}
className="custom-class"
/>
/>,
);
const nav = container.querySelector('nav');
@ -297,7 +299,7 @@ describe('Pagination Component', () => {
currentPage={1}
totalPages={5}
onPageChange={mockOnPageChange}
/>
/>,
);
const nav = container.querySelector('nav');
@ -311,7 +313,7 @@ describe('Pagination Component', () => {
totalPages={20}
onPageChange={mockOnPageChange}
maxVisiblePages={5}
/>
/>,
);
expect(screen.getByText('1')).toBeInTheDocument();
@ -324,10 +326,9 @@ describe('Pagination Component', () => {
totalPages={20}
onPageChange={mockOnPageChange}
maxVisiblePages={5}
/>
/>,
);
expect(screen.getByText('20')).toBeInTheDocument();
});
});

View file

@ -142,7 +142,7 @@ export function Pagination({
aria-current={currentPage === page ? 'page' : undefined}
className={cn(
'h-9 w-9',
currentPage === page && 'bg-primary text-primary-foreground'
currentPage === page && 'bg-primary text-primary-foreground',
)}
>
{page}
@ -175,4 +175,3 @@ export function Pagination({
</nav>
);
}

View file

@ -156,7 +156,12 @@ describe('Tabs Component', () => {
it('disables tab when disabled prop is true', () => {
const itemsWithDisabled = [
{ id: 'tab1', label: 'Tab 1', content: <div>Content 1</div> },
{ id: 'tab2', label: 'Tab 2', content: <div>Content 2</div>, disabled: true },
{
id: 'tab2',
label: 'Tab 2',
content: <div>Content 2</div>,
disabled: true,
},
];
render(<Tabs items={itemsWithDisabled} />);
@ -169,7 +174,12 @@ describe('Tabs Component', () => {
const user = userEvent.setup();
const itemsWithDisabled = [
{ id: 'tab1', label: 'Tab 1', content: <div>Content 1</div> },
{ id: 'tab2', label: 'Tab 2', content: <div>Content 2</div>, disabled: true },
{
id: 'tab2',
label: 'Tab 2',
content: <div>Content 2</div>,
disabled: true,
},
{ id: 'tab3', label: 'Tab 3', content: <div>Content 3</div> },
];
@ -190,7 +200,12 @@ describe('Tabs Component', () => {
const user = userEvent.setup();
const itemsWithDisabled = [
{ id: 'tab1', label: 'Tab 1', content: <div>Content 1</div> },
{ id: 'tab2', label: 'Tab 2', content: <div>Content 2</div>, disabled: true },
{
id: 'tab2',
label: 'Tab 2',
content: <div>Content 2</div>,
disabled: true,
},
];
render(<Tabs items={itemsWithDisabled} />);
@ -262,7 +277,9 @@ describe('Tabs Component', () => {
});
it('applies custom className', () => {
const { container } = render(<Tabs items={mockItems} className="custom-class" />);
const { container } = render(
<Tabs items={mockItems} className="custom-class" />,
);
const tabsContainer = container.firstChild;
expect(tabsContainer).toHaveClass('custom-class');
@ -276,7 +293,12 @@ describe('Tabs Component', () => {
it('selects first enabled tab when defaultActiveId points to disabled tab', async () => {
const itemsWithDisabled = [
{ id: 'tab1', label: 'Tab 1', content: <div>Content 1</div>, disabled: true },
{
id: 'tab1',
label: 'Tab 1',
content: <div>Content 1</div>,
disabled: true,
},
{ id: 'tab2', label: 'Tab 2', content: <div>Content 2</div> },
];
@ -287,4 +309,3 @@ describe('Tabs Component', () => {
});
});
});

View file

@ -30,7 +30,7 @@ export function Tabs({
className,
}: TabsProps) {
const [internalActiveId, setInternalActiveId] = useState(
defaultActiveId || items.find(item => !item.disabled)?.id || items[0]?.id
defaultActiveId || items.find((item) => !item.disabled)?.id || items[0]?.id,
);
const tabRefs = useRef<Record<string, HTMLButtonElement>>({});
const isControlled = controlledActiveId !== undefined;
@ -43,7 +43,7 @@ export function Tabs({
}
onChange?.(id);
},
[isControlled, onChange]
[isControlled, onChange],
);
const handleKeyDown = useCallback(
@ -99,20 +99,20 @@ export function Tabs({
}, 0);
}
},
[items, handleTabChange]
[items, handleTabChange],
);
// Mettre à jour l'ID actif quand les items changent
useEffect(() => {
if (!activeId || !items.find(item => item.id === activeId)) {
const firstEnabled = items.find(item => !item.disabled);
if (!activeId || !items.find((item) => item.id === activeId)) {
const firstEnabled = items.find((item) => !item.disabled);
if (firstEnabled) {
handleTabChange(firstEnabled.id);
}
}
}, [items, activeId, handleTabChange]);
const activeTab = items.find(item => item.id === activeId);
const activeTab = items.find((item) => item.id === activeId);
const variantClasses = {
default: {
@ -123,9 +123,8 @@ export function Tabs({
'border-b-2 border-transparent',
'hover:text-foreground hover:border-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isActive &&
'border-primary text-foreground border-b-primary',
'disabled:pointer-events-none disabled:opacity-50'
isActive && 'border-primary text-foreground border-b-primary',
'disabled:pointer-events-none disabled:opacity-50',
),
},
pills: {
@ -137,7 +136,7 @@ export function Tabs({
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
'disabled:pointer-events-none disabled:opacity-50'
'disabled:pointer-events-none disabled:opacity-50',
),
},
underline: {
@ -151,7 +150,7 @@ export function Tabs({
isActive && 'text-foreground',
'disabled:pointer-events-none disabled:opacity-50',
isActive &&
'after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary'
'after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-primary',
),
},
};
@ -206,4 +205,3 @@ export function Tabs({
</div>
);
}

View file

@ -7,7 +7,12 @@ import { cn } from '@/lib/utils';
export function NotificationMenu() {
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { notifications, markNotificationAsRead, removeNotification, clearNotifications } = useUIStore();
const {
notifications,
markNotificationAsRead,
removeNotification,
clearNotifications,
} = useUIStore();
// Fermer le menu si on clique en dehors
useEffect(() => {
@ -26,7 +31,7 @@ export function NotificationMenu() {
};
}, [isOpen]);
const unreadCount = notifications.filter(n => !n.read).length;
const unreadCount = notifications.filter((n) => !n.read).length;
const handleMarkAsRead = (id: string) => {
markNotificationAsRead(id);
@ -37,7 +42,7 @@ export function NotificationMenu() {
};
const handleMarkAllAsRead = () => {
notifications.forEach(n => {
notifications.forEach((n) => {
if (!n.read) {
markNotificationAsRead(n.id);
}
@ -115,8 +120,8 @@ export function NotificationMenu() {
<div
key={notification.id}
className={cn(
"p-4 hover:bg-accent transition-colors",
!notification.read && "bg-accent/50"
'p-4 hover:bg-accent transition-colors',
!notification.read && 'bg-accent/50',
)}
>
<div className="flex items-start justify-between">
@ -125,10 +130,12 @@ export function NotificationMenu() {
{!notification.read && (
<span className="h-2 w-2 bg-primary rounded-full flex-shrink-0" />
)}
<p className={cn(
"text-sm font-medium",
!notification.read && "font-semibold"
)}>
<p
className={cn(
'text-sm font-medium',
!notification.read && 'font-semibold',
)}
>
{notification.title}
</p>
</div>
@ -139,12 +146,15 @@ export function NotificationMenu() {
)}
{notification.timestamp && (
<p className="text-xs text-muted-foreground">
{new Date(notification.timestamp).toLocaleString('fr-FR', {
{new Date(notification.timestamp).toLocaleString(
'fr-FR',
{
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})}
},
)}
</p>
)}
</div>
@ -181,4 +191,3 @@ export function NotificationMenu() {
</div>
);
}

View file

@ -2,7 +2,17 @@ import React, { useRef, useEffect } from 'react';
import { usePlayerStore } from '@/stores/player';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Repeat, Shuffle, List } from 'lucide-react';
import {
Play,
Pause,
SkipBack,
SkipForward,
Volume2,
VolumeX,
Repeat,
Shuffle,
List,
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { QueuePanel } from './QueuePanel';
import { useState } from 'react';
@ -90,7 +100,7 @@ export function AudioPlayer() {
audio.src = currentTrack.url || '';
if (isPlaying) {
audio.play().catch(err => {
audio.play().catch((err) => {
console.error('Playback error:', err);
pause();
});
@ -120,7 +130,11 @@ export function AudioPlayer() {
};
const handleRepeatCycle = () => {
const modes: Array<'off' | 'track' | 'playlist'> = ['off', 'track', 'playlist'];
const modes: Array<'off' | 'track' | 'playlist'> = [
'off',
'track',
'playlist',
];
const currentIndex = modes.indexOf(repeat);
setRepeat(modes[(currentIndex + 1) % modes.length]);
};
@ -184,8 +198,12 @@ export function AudioPlayer() {
/>
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{currentTrack.title}</p>
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
<p className="text-sm font-medium truncate">
{currentTrack.title}
</p>
<p className="text-xs text-muted-foreground truncate">
{currentTrack.artist}
</p>
</div>
</div>
@ -205,7 +223,11 @@ export function AudioPlayer() {
</Button>
<Button size="icon" onClick={handlePlayPause}>
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
{isPlaying ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5" />
)}
</Button>
<Button variant="ghost" size="icon" onClick={next}>
@ -242,7 +264,11 @@ export function AudioPlayer() {
{/* Volume Controls */}
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={toggleMute}>
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
{muted ? (
<VolumeX className="h-4 w-4" />
) : (
<Volume2 className="h-4 w-4" />
)}
</Button>
<Slider
value={[volume]}
@ -271,4 +297,3 @@ export function AudioPlayer() {
</>
);
}

View file

@ -9,7 +9,8 @@ interface QueuePanelProps {
}
export function QueuePanel({ onClose }: QueuePanelProps) {
const { queue, currentIndex, removeFromQueue, reorderQueue } = usePlayerStore();
const { queue, currentIndex, removeFromQueue, reorderQueue } =
usePlayerStore();
return (
<div className="fixed bottom-16 right-4 w-96 bg-background border border-border rounded-lg shadow-lg z-40 max-h-[400px] flex flex-col">
@ -39,7 +40,9 @@ export function QueuePanel({ onClose }: QueuePanelProps) {
{index === currentIndex && '▶ '}
{track.title}
</p>
<p className="text-xs text-muted-foreground truncate">{track.artist}</p>
<p className="text-xs text-muted-foreground truncate">
{track.artist}
</p>
</div>
<div className="flex items-center gap-1">
@ -63,7 +66,11 @@ export function QueuePanel({ onClose }: QueuePanelProps) {
</Button>
)}
<Button variant="ghost" size="icon" onClick={() => removeFromQueue(index)}>
<Button
variant="ghost"
size="icon"
onClick={() => removeFromQueue(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
@ -75,4 +82,3 @@ export function QueuePanel({ onClose }: QueuePanelProps) {
</div>
);
}

View file

@ -10,7 +10,8 @@ import { usePWAInstallBanner } from '@/hooks/usePWA';
import { useTranslation } from 'react-i18next';
export function PWAInstallBanner() {
const { showBanner, isInstalling, handleInstall, handleDismiss } = usePWAInstallBanner();
const { showBanner, isInstalling, handleInstall, handleDismiss } =
usePWAInstallBanner();
const { t } = useTranslation();
if (!showBanner) {
@ -30,7 +31,10 @@ export function PWAInstallBanner() {
{t('pwa.install.title', 'Installer Veza')}
</h3>
<p className="text-xs text-muted-foreground mb-3">
{t('pwa.install.description', 'Accédez rapidement à Veza depuis votre écran d\'accueil')}
{t(
'pwa.install.description',
"Accédez rapidement à Veza depuis votre écran d'accueil",
)}
</p>
<div className="flex gap-2">
@ -43,8 +47,7 @@ export function PWAInstallBanner() {
<Download className="h-4 w-4 mr-1" />
{isInstalling
? t('pwa.install.installing', 'Installation...')
: t('pwa.install.button', 'Installer')
}
: t('pwa.install.button', 'Installer')}
</Button>
<Button

View file

@ -37,7 +37,7 @@ describe('Search Component', () => {
const mockFetchSuggestions = vi.fn(async (query: string) => {
return mockSuggestions.filter((s) =>
s.title.toLowerCase().includes(query.toLowerCase())
s.title.toLowerCase().includes(query.toLowerCase()),
);
});
@ -60,7 +60,7 @@ describe('Search Component', () => {
onSearch={mockOnSearch}
debounceDelay={300}
fetchSuggestions={mockFetchSuggestions}
/>
/>,
);
const input = screen.getByPlaceholderText('Rechercher...');
@ -71,7 +71,7 @@ describe('Search Component', () => {
() => {
expect(mockOnSearch).toHaveBeenCalledWith('test');
},
{ timeout: 1000 }
{ timeout: 1000 },
);
});
@ -82,7 +82,7 @@ describe('Search Component', () => {
onSearch={mockOnSearch}
fetchSuggestions={mockFetchSuggestions}
showSuggestions={true}
/>
/>,
);
const input = screen.getByPlaceholderText('Rechercher...');
@ -92,14 +92,14 @@ describe('Search Component', () => {
() => {
expect(mockFetchSuggestions).toHaveBeenCalled();
},
{ timeout: 1000 }
{ timeout: 1000 },
);
await waitFor(
() => {
expect(screen.getByText('Track 1')).toBeInTheDocument();
},
{ timeout: 1000 }
{ timeout: 1000 },
);
});
@ -111,7 +111,7 @@ describe('Search Component', () => {
onResultSelect={mockOnResultSelect}
fetchSuggestions={mockFetchSuggestions}
showSuggestions={true}
/>
/>,
);
const input = screen.getByPlaceholderText('Rechercher...');
@ -121,7 +121,7 @@ describe('Search Component', () => {
() => {
expect(screen.getByText('Track 1')).toBeInTheDocument();
},
{ timeout: 1000 }
{ timeout: 1000 },
);
const suggestion = screen.getByText('Track 1');
@ -133,7 +133,7 @@ describe('Search Component', () => {
it('shows history when input is empty and showHistory is true', async () => {
localStorageMock.setItem(
'veza_search_history',
JSON.stringify(['previous search 1', 'previous search 2'])
JSON.stringify(['previous search 1', 'previous search 2']),
);
render(<Search onSearch={mockOnSearch} showHistory={true} />);
@ -157,22 +157,18 @@ describe('Search Component', () => {
await waitFor(
() => {
const history = JSON.parse(
localStorageMock.getItem('veza_search_history') || '[]'
localStorageMock.getItem('veza_search_history') || '[]',
);
expect(history).toContain('new search');
},
{ timeout: 1000 }
{ timeout: 1000 },
);
});
it('limits history items to maxHistoryItems', async () => {
const user = userEvent.setup();
render(
<Search
onSearch={mockOnSearch}
showHistory={true}
maxHistoryItems={3}
/>
<Search onSearch={mockOnSearch} showHistory={true} maxHistoryItems={3} />,
);
// Ajouter plus de 3 items
@ -186,18 +182,18 @@ describe('Search Component', () => {
await waitFor(
() => {
const history = JSON.parse(
localStorageMock.getItem('veza_search_history') || '[]'
localStorageMock.getItem('veza_search_history') || '[]',
);
expect(history.length).toBeLessThanOrEqual(3);
},
{ timeout: 2000 }
{ timeout: 2000 },
);
});
it('clears history when clear button is clicked', async () => {
localStorageMock.setItem(
'veza_search_history',
JSON.stringify(['search 1', 'search 2'])
JSON.stringify(['search 1', 'search 2']),
);
const user = userEvent.setup();
@ -248,7 +244,7 @@ describe('Search Component', () => {
onSearch={mockOnSearch}
fetchSuggestions={mockFetchSuggestions}
showSuggestions={true}
/>
/>,
);
const input = screen.getByPlaceholderText('Rechercher...');
@ -258,7 +254,7 @@ describe('Search Component', () => {
() => {
expect(screen.getByText('Track 1')).toBeInTheDocument();
},
{ timeout: 1000 }
{ timeout: 1000 },
);
await user.keyboard('{ArrowDown}');
@ -279,7 +275,7 @@ describe('Search Component', () => {
onResultSelect={mockOnResultSelect}
fetchSuggestions={mockFetchSuggestions}
showSuggestions={true}
/>
/>,
);
const input = screen.getByPlaceholderText('Rechercher...');
@ -289,7 +285,7 @@ describe('Search Component', () => {
() => {
expect(screen.getByText('Track 1')).toBeInTheDocument();
},
{ timeout: 1000 }
{ timeout: 1000 },
);
await user.keyboard('{ArrowDown}');
@ -307,7 +303,7 @@ describe('Search Component', () => {
onSearch={mockOnSearch}
fetchSuggestions={mockFetchSuggestions}
showSuggestions={true}
/>
/>,
);
const input = screen.getByPlaceholderText('Rechercher...');
@ -317,7 +313,7 @@ describe('Search Component', () => {
() => {
expect(screen.getByText('Track 1')).toBeInTheDocument();
},
{ timeout: 1000 }
{ timeout: 1000 },
);
await user.keyboard('{Escape}');
@ -332,7 +328,7 @@ describe('Search Component', () => {
async (_query: string): Promise<SearchResult[]> => {
await new Promise((resolve) => setTimeout(resolve, 100));
return mockSuggestions;
}
},
);
const user = userEvent.setup();
@ -341,7 +337,7 @@ describe('Search Component', () => {
onSearch={mockOnSearch}
fetchSuggestions={slowFetchSuggestions}
showSuggestions={true}
/>
/>,
);
const input = screen.getByPlaceholderText('Rechercher...');
@ -351,7 +347,7 @@ describe('Search Component', () => {
() => {
expect(screen.getByText('Recherche en cours...')).toBeInTheDocument();
},
{ timeout: 1000 }
{ timeout: 1000 },
);
});
@ -364,7 +360,7 @@ describe('Search Component', () => {
onSearch={mockOnSearch}
fetchSuggestions={emptyFetchSuggestions}
showSuggestions={true}
/>
/>,
);
const input = screen.getByPlaceholderText('Rechercher...');
@ -374,7 +370,7 @@ describe('Search Component', () => {
() => {
expect(screen.getByText('Aucun résultat trouvé')).toBeInTheDocument();
},
{ timeout: 1000 }
{ timeout: 1000 },
);
});
@ -385,7 +381,7 @@ describe('Search Component', () => {
onSearch={mockOnSearch}
fetchSuggestions={mockFetchSuggestions}
showSuggestions={false}
/>
/>,
);
const input = screen.getByPlaceholderText('Rechercher...');
@ -400,7 +396,7 @@ describe('Search Component', () => {
it('does not show history when showHistory is false', () => {
localStorageMock.setItem(
'veza_search_history',
JSON.stringify(['previous search'])
JSON.stringify(['previous search']),
);
render(<Search onSearch={mockOnSearch} showHistory={false} />);
@ -413,11 +409,10 @@ describe('Search Component', () => {
it('applies custom className', () => {
const { container } = render(
<Search onSearch={mockOnSearch} className="custom-class" />
<Search onSearch={mockOnSearch} className="custom-class" />,
);
const searchContainer = container.firstChild;
expect(searchContainer).toHaveClass('custom-class');
});
});

View file

@ -1,7 +1,14 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useDebounce, useLocalStorage } from '@/hooks/useDebounce';
import { cn } from '@/lib/utils';
import { Search as SearchIcon, X, Clock, Music, User, List } from 'lucide-react';
import {
Search as SearchIcon,
X,
Clock,
Music,
User,
List,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
export interface SearchResult {
@ -19,7 +26,9 @@ export interface SearchProps {
showSuggestions?: boolean;
showHistory?: boolean;
maxHistoryItems?: number;
fetchSuggestions?: (query: string) => Promise<SearchResult[]> | SearchResult[];
fetchSuggestions?: (
query: string,
) => Promise<SearchResult[]> | SearchResult[];
className?: string;
debounceDelay?: number;
}
@ -48,7 +57,7 @@ export function Search({
const [activeIndex, setActiveIndex] = useState(-1);
const [history, setHistory, removeHistory] = useLocalStorage<string[]>(
HISTORY_KEY,
[]
[],
);
const searchRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@ -127,9 +136,7 @@ export function Search({
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex((prev) =>
prev < items.length - 1 ? prev + 1 : prev
);
setActiveIndex((prev) => (prev < items.length - 1 ? prev + 1 : prev));
break;
case 'ArrowUp':
e.preventDefault();
@ -138,7 +145,10 @@ export function Search({
case 'Enter':
e.preventDefault();
if (activeIndex >= 0 && activeIndex < items.length) {
if (activeIndex < (showHistory && query.trim() === '' ? history.length : 0)) {
if (
activeIndex <
(showHistory && query.trim() === '' ? history.length : 0)
) {
// C'est un item de l'historique
const historyItem = history[activeIndex];
setQuery(historyItem);
@ -146,7 +156,10 @@ export function Search({
} else {
// C'est une suggestion
const suggestionIndex =
activeIndex - (showHistory && query.trim() === '' ? Math.min(history.length, 5) : 0);
activeIndex -
(showHistory && query.trim() === ''
? Math.min(history.length, 5)
: 0);
const result = suggestions[suggestionIndex];
handleResultSelect(result);
}
@ -169,7 +182,10 @@ export function Search({
if (showHistory) {
setHistory((prev) => {
const filtered = prev.filter((item) => item !== searchQuery);
const updated = [searchQuery, ...filtered].slice(0, maxHistoryItems);
const updated = [searchQuery, ...filtered].slice(
0,
maxHistoryItems,
);
return updated;
});
}
@ -177,7 +193,7 @@ export function Search({
onSearch(searchQuery);
}
},
[onSearch, showHistory, setHistory, maxHistoryItems]
[onSearch, showHistory, setHistory, maxHistoryItems],
);
const handleResultSelect = useCallback(
@ -187,7 +203,7 @@ export function Search({
setActiveIndex(-1);
onResultSelect?.(result);
},
[onResultSelect]
[onResultSelect],
);
const handleHistoryItemClick = (item: string) => {
@ -218,17 +234,25 @@ export function Search({
};
const displayItems = useMemo(() => {
const items: Array<{ type: 'history' | 'suggestion'; data: string | SearchResult }> = [];
const items: Array<{
type: 'history' | 'suggestion';
data: string | SearchResult;
}> = [];
if (showHistory && query.trim() === '' && history.length > 0) {
items.push(
...history.slice(0, 5).map((item) => ({ type: 'history' as const, data: item }))
...history
.slice(0, 5)
.map((item) => ({ type: 'history' as const, data: item })),
);
}
if (showSuggestions && suggestions.length > 0) {
items.push(
...suggestions.map((result) => ({ type: 'suggestion' as const, data: result }))
...suggestions.map((result) => ({
type: 'suggestion' as const,
data: result,
})),
);
}
@ -256,7 +280,7 @@ export function Search({
'w-full rounded-md border border-input bg-background px-9 py-2 text-sm',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50'
'disabled:cursor-not-allowed disabled:opacity-50',
)}
/>
{query && (
@ -287,9 +311,7 @@ export function Search({
{!isLoading && displayItems.length > 0 && (
<div className="max-h-96 overflow-y-auto">
{showHistory &&
query.trim() === '' &&
history.length > 0 && (
{showHistory && query.trim() === '' && history.length > 0 && (
<div className="border-b p-2">
<div className="flex items-center justify-between px-2 py-1">
<span className="text-xs font-medium text-muted-foreground">
@ -312,7 +334,7 @@ export function Search({
className={cn(
'w-full px-3 py-2 text-left text-sm hover:bg-accent',
'flex items-center gap-2',
activeIndex === index && 'bg-accent'
activeIndex === index && 'bg-accent',
)}
>
<Clock className="h-4 w-4 text-muted-foreground" />
@ -322,8 +344,7 @@ export function Search({
</div>
)}
{showSuggestions &&
suggestions.length > 0 && (
{showSuggestions && suggestions.length > 0 && (
<div className="p-2">
{query.trim() && (
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
@ -343,7 +364,7 @@ export function Search({
className={cn(
'w-full px-3 py-2 text-left text-sm hover:bg-accent',
'flex items-center gap-3',
activeIndex === itemIndex && 'bg-accent'
activeIndex === itemIndex && 'bg-accent',
)}
>
<div className="flex h-8 w-8 items-center justify-center rounded bg-muted">
@ -369,4 +390,3 @@ export function Search({
</div>
);
}

View file

@ -101,7 +101,7 @@ export const Modal: React.FC<ModalProps> = ({
className={cn(
'relative bg-white dark:bg-gray-800 rounded-lg shadow-xl',
'w-full mx-4',
sizeClasses[size]
sizeClasses[size],
)}
>
{/* Header */}
@ -117,8 +117,18 @@ export const Modal: React.FC<ModalProps> = ({
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
@ -126,9 +136,7 @@ export const Modal: React.FC<ModalProps> = ({
)}
{/* Content */}
<div className="p-6">
{children}
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
@ -153,17 +161,12 @@ export const Dropdown: React.FC<DropdownProps> = ({
}) => {
return (
<div className="relative">
<div onClick={onToggle}>
{trigger}
</div>
<div onClick={onToggle}>{trigger}</div>
{isOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-10"
onClick={onToggle}
/>
<div className="fixed inset-0 z-10" onClick={onToggle} />
{/* Dropdown */}
<div
@ -171,7 +174,7 @@ export const Dropdown: React.FC<DropdownProps> = ({
'absolute z-20 mt-1 bg-white dark:bg-gray-800 rounded-md shadow-lg',
'border border-gray-200 dark:border-gray-700',
align === 'right' ? 'right-0' : 'left-0',
className
className,
)}
>
{children}

View file

@ -53,14 +53,15 @@ export const Input: React.FC<InputProps> = ({
error
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600',
className
className,
)}
{...props}
/>
);
};
interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
error?: boolean;
}
@ -78,7 +79,7 @@ export const Textarea: React.FC<TextareaProps> = ({
error
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600',
className
className,
)}
{...props}
/>
@ -105,7 +106,7 @@ export const Select: React.FC<SelectProps> = ({
error
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 dark:border-gray-600',
className
className,
)}
{...props}
>

View file

@ -7,12 +7,12 @@ interface LazyComponentProps {
export function createLazyComponent<T extends ComponentType<any>>(
importFunc: () => Promise<{ default: T }>,
fallback?: React.ReactNode
fallback?: React.ReactNode,
) {
const LazyComponent = lazy(importFunc);
return function WrappedLazyComponent(
props: React.ComponentProps<T> & LazyComponentProps
props: React.ComponentProps<T> & LazyComponentProps,
) {
return (
<Suspense fallback={fallback || <LoadingSpinner />}>
@ -23,57 +23,65 @@ export function createLazyComponent<T extends ComponentType<any>>(
}
// Composants lazy communs
export const LazyDashboard = createLazyComponent(
() => import('@/pages/DashboardPage').then(m => ({ default: m.DashboardPage }))
export const LazyDashboard = createLazyComponent(() =>
import('@/pages/DashboardPage').then((m) => ({ default: m.DashboardPage })),
);
export const LazyChat = createLazyComponent(
() => import('@/features/chat/pages/ChatPage')
() => import('@/features/chat/pages/ChatPage'),
);
export const LazyLibrary = createLazyComponent(
() => import('@/features/library/pages/LibraryPage')
() => import('@/features/library/pages/LibraryPage'),
);
export const LazyProfile = createLazyComponent(
() => import('@/pages/ProfilePage').then(m => ({ default: m.ProfilePage }))
export const LazyProfile = createLazyComponent(() =>
import('@/pages/ProfilePage').then((m) => ({ default: m.ProfilePage })),
);
export const LazySettings = createLazyComponent(
() => import('@/features/settings/pages/SettingsPage')
() => import('@/features/settings/pages/SettingsPage'),
);
export const LazyLogin = createLazyComponent(
() => import('@/pages/LoginPage').then(m => ({ default: m.LoginPage }))
export const LazyLogin = createLazyComponent(() =>
import('@/pages/LoginPage').then((m) => ({ default: m.LoginPage })),
);
export const LazyRegister = createLazyComponent(
() => import('@/pages/RegisterPage').then(m => ({ default: m.RegisterPage }))
export const LazyRegister = createLazyComponent(() =>
import('@/pages/RegisterPage').then((m) => ({ default: m.RegisterPage })),
);
export const LazyForgotPassword = createLazyComponent(
() => import('@/features/auth/pages/ForgotPasswordPage')
() => import('@/features/auth/pages/ForgotPasswordPage'),
);
export const LazyVerifyEmail = createLazyComponent(
() => import('@/features/auth/pages/VerifyEmailPage')
() => import('@/features/auth/pages/VerifyEmailPage'),
);
export const LazyResetPassword = createLazyComponent(
() => import('@/features/auth/pages/ResetPasswordPage')
() => import('@/features/auth/pages/ResetPasswordPage'),
);
export const LazySessions = createLazyComponent(
() => import('@/features/auth/pages/SessionsPage')
() => import('@/features/auth/pages/SessionsPage'),
);
export const LazyNotFound = createLazyComponent(
() => import('@/features/error/pages/NotFoundPage')
() => import('@/features/error/pages/NotFoundPage'),
);
export const LazyServerError = createLazyComponent(
() => import('@/features/error/pages/ServerErrorPage')
() => import('@/features/error/pages/ServerErrorPage'),
);
export const LazyUserProfile = createLazyComponent(
() => import('@/features/profile/pages/UserProfilePage').then(m => ({ default: m.UserProfilePage }))
export const LazyUserProfile = createLazyComponent(() =>
import('@/features/profile/pages/UserProfilePage').then((m) => ({
default: m.UserProfilePage,
})),
);
export const LazyRoles = createLazyComponent(
() => import('@/features/roles/pages/RolesPage').then(m => ({ default: m.RolesPage }))
export const LazyRoles = createLazyComponent(() =>
import('@/features/roles/pages/RolesPage').then((m) => ({
default: m.RolesPage,
})),
);
export const LazyTrackDetail = createLazyComponent(
() => import('@/features/tracks/pages/TrackDetailPage').then(m => ({ default: m.TrackDetailPage }))
export const LazyTrackDetail = createLazyComponent(() =>
import('@/features/tracks/pages/TrackDetailPage').then((m) => ({
default: m.TrackDetailPage,
})),
);
export const LazyPlaylistRoutes = createLazyComponent(
() => import('@/features/playlists/routes').then(m => ({ default: m.PlaylistRoutes }))
export const LazyPlaylistRoutes = createLazyComponent(() =>
import('@/features/playlists/routes').then((m) => ({
default: m.PlaylistRoutes,
})),
);
export const LazyMarketplace = createLazyComponent(
() => import('@/pages/marketplace/MarketplaceHome')
() => import('@/pages/marketplace/MarketplaceHome'),
);

View file

@ -16,7 +16,7 @@ const alertVariants = cva(
defaultVariants: {
variant: 'default',
},
}
},
);
const Alert = React.forwardRef<
@ -25,7 +25,7 @@ const Alert = React.forwardRef<
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role='alert'
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>

View file

@ -97,4 +97,3 @@ describe('Avatar', () => {
expect(screen.getByText('JD')).toBeInTheDocument();
});
});

View file

@ -1,6 +1,6 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
// Primitives
const Avatar = React.forwardRef<
@ -10,13 +10,13 @@ const Avatar = React.forwardRef<
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
@ -24,11 +24,11 @@ const AvatarImage = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full object-cover", className)}
className={cn('aspect-square h-full w-full object-cover', className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
@ -37,13 +37,13 @@ const AvatarFallback = React.forwardRef<
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className,
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
// Smart Component (renamed to UserAvatar)
export interface UserAvatarProps {
@ -61,7 +61,13 @@ const sizeClasses = {
xl: 'w-32 h-32 text-xl',
};
export function UserAvatar({ src, alt, name, size = 'md', className = '' }: UserAvatarProps) {
export function UserAvatar({
src,
alt,
name,
size = 'md',
className = '',
}: UserAvatarProps) {
const [imageError, setImageError] = React.useState(false);
const sizeClass = sizeClasses[size];

View file

@ -14,7 +14,7 @@ describe('Badge Component', () => {
render(
<Badge>
<span>Custom content</span>
</Badge>
</Badge>,
);
expect(screen.getByText('Custom content')).toBeInTheDocument();
@ -100,7 +100,7 @@ describe('Badge Component', () => {
const { container } = render(
<Badge dot size="sm">
Small with dot
</Badge>
</Badge>,
);
const dot = container.querySelector('.rounded-full.bg-current');
@ -111,7 +111,7 @@ describe('Badge Component', () => {
const { container } = render(
<Badge dot size="lg">
Large with dot
</Badge>
</Badge>,
);
const dot = container.querySelector('.rounded-full.bg-current');
@ -148,7 +148,7 @@ describe('Badge Component', () => {
const { container } = render(
<Badge dot count={3}>
With dot and count
</Badge>
</Badge>,
);
expect(screen.getByText('(3)')).toBeInTheDocument();
@ -162,7 +162,7 @@ describe('Badge Component', () => {
const { container } = render(
<Badge variant="success" size="lg" dot count={10}>
Complete badge
</Badge>
</Badge>,
);
const badge = container.firstChild;
@ -177,7 +177,7 @@ describe('Badge Component', () => {
describe('Custom className', () => {
it('applies custom className', () => {
const { container } = render(
<Badge className="custom-badge">Custom</Badge>
<Badge className="custom-badge">Custom</Badge>,
);
const badge = container.firstChild;
@ -185,4 +185,3 @@ describe('Badge Component', () => {
});
});
});

View file

@ -38,7 +38,7 @@ export function Badge({
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-200',
variant === 'error' &&
'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-200',
className
className,
)}
>
{dot && (
@ -46,7 +46,7 @@ export function Badge({
className={cn(
'w-2 h-2 rounded-full bg-current mr-1',
size === 'sm' && 'w-1.5 h-1.5',
size === 'lg' && 'w-2.5 h-2.5'
size === 'lg' && 'w-2.5 h-2.5',
)}
/>
)}
@ -57,4 +57,3 @@ export function Badge({
</span>
);
}

View file

@ -12,24 +12,24 @@ describe('Button Component', () => {
});
it('renders with different variants', () => {
const { rerender } = render(<Button variant='secondary'>Secondary</Button>);
const { rerender } = render(<Button variant="secondary">Secondary</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-secondary');
rerender(<Button variant='destructive'>Destructive</Button>);
rerender(<Button variant="destructive">Destructive</Button>);
expect(screen.getByRole('button')).toHaveClass('bg-destructive');
rerender(<Button variant='outline'>Outline</Button>);
rerender(<Button variant="outline">Outline</Button>);
expect(screen.getByRole('button')).toHaveClass('border');
});
it('renders with different sizes', () => {
const { rerender } = render(<Button size='sm'>Small</Button>);
const { rerender } = render(<Button size="sm">Small</Button>);
expect(screen.getByRole('button')).toHaveClass('h-9');
rerender(<Button size='lg'>Large</Button>);
rerender(<Button size="lg">Large</Button>);
expect(screen.getByRole('button')).toHaveClass('h-11');
rerender(<Button size='icon'>Icon</Button>);
rerender(<Button size="icon">Icon</Button>);
expect(screen.getByRole('button')).toHaveClass('h-10');
});
@ -48,7 +48,7 @@ describe('Button Component', () => {
render(
<Button disabled onClick={handleClick}>
Disabled
</Button>
</Button>,
);
const button = screen.getByRole('button');
@ -59,7 +59,7 @@ describe('Button Component', () => {
});
it('applies custom className', () => {
render(<Button className='custom-class'>Custom</Button>);
render(<Button className="custom-class">Custom</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('custom-class');
@ -68,8 +68,8 @@ describe('Button Component', () => {
it('renders as different element when asChild is true', () => {
render(
<Button asChild>
<a href='/test'>Link Button</a>
</Button>
<a href="/test">Link Button</a>
</Button>,
);
const link = screen.getByRole('link');
@ -93,7 +93,7 @@ describe('Button Component', () => {
});
it('renders with icon when provided', () => {
const TestIcon = () => <span data-testid='test-icon'>Icon</span>;
const TestIcon = () => <span data-testid="test-icon">Icon</span>;
render(<Button icon={<TestIcon />}>With Icon</Button>);
expect(screen.getByTestId('test-icon')).toBeInTheDocument();

View file

@ -30,7 +30,7 @@ const buttonVariants = cva(
variant: 'default',
size: 'default',
},
}
},
);
export interface ButtonProps
@ -49,7 +49,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{...props}
/>
);
}
},
);
Button.displayName = 'Button';

View file

@ -14,7 +14,7 @@ describe('Card Component', () => {
render(
<Card>
<div>Card content</div>
</Card>
</Card>,
);
expect(screen.getByText('Card content')).toBeInTheDocument();
@ -24,7 +24,7 @@ describe('Card Component', () => {
const { container } = render(
<Card>
<div>Content</div>
</Card>
</Card>,
);
const card = container.querySelector('.rounded-lg.border.bg-card');
@ -38,7 +38,7 @@ describe('Card Component', () => {
const { container } = render(
<Card variant="outlined">
<div>Content</div>
</Card>
</Card>,
);
const card = container.querySelector('.rounded-lg');
@ -49,7 +49,7 @@ describe('Card Component', () => {
const { container } = render(
<Card variant="elevated">
<div>Content</div>
</Card>
</Card>,
);
const card = container.querySelector('.rounded-lg');
@ -60,7 +60,7 @@ describe('Card Component', () => {
const { container } = render(
<Card className="custom-class">
<div>Content</div>
</Card>
</Card>,
);
const card = container.querySelector('.custom-class');
@ -73,7 +73,7 @@ describe('Card Component', () => {
<CardHeader>
<div>Header content</div>
</CardHeader>
</Card>
</Card>,
);
expect(screen.getByText('Header content')).toBeInTheDocument();
@ -85,7 +85,7 @@ describe('Card Component', () => {
<CardHeader>
<CardTitle>Card Title</CardTitle>
</CardHeader>
</Card>
</Card>,
);
const title = screen.getByText('Card Title');
@ -99,7 +99,7 @@ describe('Card Component', () => {
<CardHeader>
<CardDescription>Card description</CardDescription>
</CardHeader>
</Card>
</Card>,
);
expect(screen.getByText('Card description')).toBeInTheDocument();
@ -111,7 +111,7 @@ describe('Card Component', () => {
<CardContent>
<div>Content area</div>
</CardContent>
</Card>
</Card>,
);
expect(screen.getByText('Content area')).toBeInTheDocument();
@ -123,7 +123,7 @@ describe('Card Component', () => {
<CardFooter>
<div>Footer content</div>
</CardFooter>
</Card>
</Card>,
);
expect(screen.getByText('Footer content')).toBeInTheDocument();
@ -142,7 +142,7 @@ describe('Card Component', () => {
<CardFooter>
<div>Footer</div>
</CardFooter>
</Card>
</Card>,
);
expect(screen.getByText('Title')).toBeInTheDocument();
@ -155,7 +155,7 @@ describe('Card Component', () => {
const { container } = render(
<Card>
<div>Content</div>
</Card>
</Card>,
);
const card = container.querySelector('.bg-card.text-card-foreground');
@ -167,7 +167,7 @@ describe('Card Component', () => {
render(
<Card data-testid="card" aria-label="Test card">
<div>Content</div>
</Card>
</Card>,
);
const card = screen.getByTestId('card');
@ -175,4 +175,3 @@ describe('Card Component', () => {
expect(card.getAttribute('aria-label')).toBe('Test card');
});
});

View file

@ -14,11 +14,11 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
'rounded-lg border bg-card text-card-foreground shadow-sm',
variant === 'outlined' && 'border-2',
variant === 'elevated' && 'shadow-lg',
className
className,
)}
{...props}
/>
)
),
);
Card.displayName = 'Card';
@ -42,7 +42,7 @@ const CardTitle = React.forwardRef<
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
className,
)}
{...props}
/>

View file

@ -12,7 +12,7 @@ const Checkbox = React.forwardRef<
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
className,
)}
{...props}
>
@ -26,4 +26,3 @@ const Checkbox = React.forwardRef<
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View file

@ -32,11 +32,21 @@ describe('DatePicker Component', () => {
it('displays selected date range', () => {
const start = new Date(2024, 0, 15);
const end = new Date(2024, 0, 20);
render(<DatePicker value={{ start, end }} onChange={mockOnChange} mode="range" />);
render(
<DatePicker
value={{ start, end }}
onChange={mockOnChange}
mode="range"
/>,
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const displayText = `${start.toLocaleDateString()} - ${end.toLocaleDateString()}`;
expect(screen.getByText(new RegExp(start.toLocaleDateString().replace(/\//g, '/')))).toBeInTheDocument();
expect(
screen.getByText(
new RegExp(start.toLocaleDateString().replace(/\//g, '/')),
),
).toBeInTheDocument();
});
it('opens calendar when trigger is clicked', async () => {
@ -44,7 +54,9 @@ describe('DatePicker Component', () => {
render(<DatePicker onChange={mockOnChange} />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select date...')) || triggers[0];
const trigger =
triggers.find((btn) => btn.textContent?.includes('Select date...')) ||
triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -58,13 +70,25 @@ describe('DatePicker Component', () => {
render(<DatePicker onChange={mockOnChange} />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select date...')) || triggers[0];
const trigger =
triggers.find((btn) => btn.textContent?.includes('Select date...')) ||
triggers[0];
await user.click(trigger);
await waitFor(() => {
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
const monthText = `${monthNames[now.getMonth()]} ${now.getFullYear()}`;
expect(screen.getByText(monthText)).toBeInTheDocument();
@ -75,19 +99,22 @@ describe('DatePicker Component', () => {
const user = userEvent.setup();
const now = new Date();
const prevMonth = now.getMonth() === 0 ? 11 : now.getMonth() - 1;
const prevYear = now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear();
const prevYear =
now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear();
render(<DatePicker onChange={mockOnChange} />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select date...')) || triggers[0];
const trigger =
triggers.find((btn) => btn.textContent?.includes('Select date...')) ||
triggers[0];
await user.click(trigger);
await waitFor(() => {
expect(screen.getByText('Today')).toBeInTheDocument();
});
const prevButton = screen.getAllByRole('button').find(btn => {
const prevButton = screen.getAllByRole('button').find((btn) => {
const svg = btn.querySelector('svg');
return svg && svg.getAttribute('d')?.includes('m15 18-6-6 6-6');
});
@ -97,8 +124,18 @@ describe('DatePicker Component', () => {
await waitFor(() => {
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
const monthText = `${monthNames[prevMonth]} ${prevYear}`;
expect(screen.getByText(monthText)).toBeInTheDocument();
@ -110,12 +147,15 @@ describe('DatePicker Component', () => {
const user = userEvent.setup();
const now = new Date();
const nextMonth = now.getMonth() === 11 ? 0 : now.getMonth() + 1;
const nextYear = now.getMonth() === 11 ? now.getFullYear() + 1 : now.getFullYear();
const nextYear =
now.getMonth() === 11 ? now.getFullYear() + 1 : now.getFullYear();
render(<DatePicker onChange={mockOnChange} />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select date...')) || triggers[0];
const trigger =
triggers.find((btn) => btn.textContent?.includes('Select date...')) ||
triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -123,13 +163,15 @@ describe('DatePicker Component', () => {
});
const nextButtons = screen.getAllByRole('button');
const nextButton = nextButtons.find(btn => {
const nextButton = nextButtons.find((btn) => {
const svg = btn.querySelector('svg');
return svg && btn.getAttribute('aria-label') !== 'Fermer';
});
// Chercher le bouton avec ChevronRight
const chevronRightButtons = Array.from(document.querySelectorAll('svg')).filter(svg => {
const chevronRightButtons = Array.from(
document.querySelectorAll('svg'),
).filter((svg) => {
const path = svg.querySelector('path');
return path && path.getAttribute('d')?.includes('m9 18 6-6-6-6');
});
@ -141,8 +183,18 @@ describe('DatePicker Component', () => {
await waitFor(() => {
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
const monthText = `${monthNames[nextMonth]} ${nextYear}`;
expect(screen.getByText(monthText)).toBeInTheDocument();
@ -156,7 +208,9 @@ describe('DatePicker Component', () => {
render(<DatePicker onChange={mockOnChange} mode="single" />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select date...')) || triggers[0];
const trigger =
triggers.find((btn) => btn.textContent?.includes('Select date...')) ||
triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -178,7 +232,10 @@ describe('DatePicker Component', () => {
render(<DatePicker onChange={mockOnChange} mode="range" />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select date range...')) || triggers[0];
const trigger =
triggers.find((btn) =>
btn.textContent?.includes('Select date range...'),
) || triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -204,11 +261,12 @@ describe('DatePicker Component', () => {
value={{ start: startDate, end: startDate }}
onChange={mockOnChange}
mode="range"
/>
/>,
);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('15')) || triggers[0];
const trigger =
triggers.find((btn) => btn.textContent?.includes('15')) || triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -220,7 +278,8 @@ describe('DatePicker Component', () => {
if (day20) {
await user.click(day20);
expect(mockOnChange).toHaveBeenCalled();
const call = mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1][0];
const call =
mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1][0];
expect(call).toHaveProperty('start');
expect(call).toHaveProperty('end');
expect(call.start).toBeInstanceOf(Date);
@ -234,7 +293,9 @@ describe('DatePicker Component', () => {
render(<DatePicker onChange={mockOnChange} minDate={minDate} />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select date...')) || triggers[0];
const trigger =
triggers.find((btn) => btn.textContent?.includes('Select date...')) ||
triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -246,7 +307,11 @@ describe('DatePicker Component', () => {
if (day1) {
const dayButton = day1.closest('button');
// Vérifier que le bouton est désactivé ou a les classes de désactivation
expect(dayButton?.classList.contains('opacity-50') || dayButton?.classList.contains('cursor-not-allowed') || dayButton?.hasAttribute('disabled')).toBe(true);
expect(
dayButton?.classList.contains('opacity-50') ||
dayButton?.classList.contains('cursor-not-allowed') ||
dayButton?.hasAttribute('disabled'),
).toBe(true);
}
});
@ -256,7 +321,9 @@ describe('DatePicker Component', () => {
render(<DatePicker onChange={mockOnChange} maxDate={maxDate} />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select date...')) || triggers[0];
const trigger =
triggers.find((btn) => btn.textContent?.includes('Select date...')) ||
triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -279,7 +346,7 @@ describe('DatePicker Component', () => {
render(<DatePicker value={date} onChange={mockOnChange} mode="single" />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('15'));
const trigger = triggers.find((btn) => btn.textContent?.includes('15'));
const clearButton = trigger?.querySelector('svg');
if (clearButton) {
@ -288,7 +355,8 @@ describe('DatePicker Component', () => {
expect(mockOnChange).toHaveBeenCalled();
});
// Vérifier que onChange a été appelé avec undefined ou null
const lastCall = mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1];
const lastCall =
mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1];
expect(lastCall[0] === undefined || lastCall[0] === null).toBe(true);
}
});
@ -298,7 +366,9 @@ describe('DatePicker Component', () => {
render(<DatePicker onChange={mockOnChange} />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select date...')) || triggers[0];
const trigger =
triggers.find((btn) => btn.textContent?.includes('Select date...')) ||
triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -317,8 +387,8 @@ describe('DatePicker Component', () => {
render(<DatePicker onChange={mockOnChange} disabled />);
const buttons = document.querySelectorAll('button');
const selectButton = Array.from(buttons).find(btn =>
btn.textContent?.includes('Select date...') && btn.disabled
const selectButton = Array.from(buttons).find(
(btn) => btn.textContent?.includes('Select date...') && btn.disabled,
);
expect(selectButton).toBeInTheDocument();
});
@ -328,7 +398,9 @@ describe('DatePicker Component', () => {
render(<DatePicker onChange={mockOnChange} />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select date...')) || triggers[0];
const trigger =
triggers.find((btn) => btn.textContent?.includes('Select date...')) ||
triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -342,4 +414,3 @@ describe('DatePicker Component', () => {
});
});
});

View file

@ -2,7 +2,12 @@ import { useState, useMemo } from 'react';
import { Button } from './button';
import { Dropdown } from './dropdown';
import { cn } from '@/lib/utils';
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, X } from 'lucide-react';
import {
Calendar as CalendarIcon,
ChevronLeft,
ChevronRight,
X,
} from 'lucide-react';
export interface DatePickerProps {
value?: Date | { start: Date; end: Date };
@ -17,8 +22,18 @@ export interface DatePickerProps {
const DAYS_OF_WEEK = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const MONTHS = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
/**
@ -52,7 +67,11 @@ export function DatePicker({
};
const isDateInRange = (date: Date): boolean => {
if (mode !== 'range' || !value || typeof value === 'object' && !('start' in value)) {
if (
mode !== 'range' ||
!value ||
(typeof value === 'object' && !('start' in value))
) {
return false;
}
const range = value as { start: Date; end: Date };
@ -69,26 +88,47 @@ export function DatePicker({
if (!value || value instanceof Date === false) return false;
return normalized.getTime() === normalizeDate(value as Date).getTime();
} else {
if (!value || typeof value !== 'object' || !('start' in value)) return false;
if (!value || typeof value !== 'object' || !('start' in value))
return false;
const range = value as { start: Date; end: Date };
if (!range.start && !range.end) return false;
if (range.start && normalized.getTime() === normalizeDate(range.start).getTime()) return true;
if (range.end && normalized.getTime() === normalizeDate(range.end).getTime()) return true;
if (
range.start &&
normalized.getTime() === normalizeDate(range.start).getTime()
)
return true;
if (
range.end &&
normalized.getTime() === normalizeDate(range.end).getTime()
)
return true;
return false;
}
};
const isDateStart = (date: Date): boolean => {
if (mode !== 'range' || !value || typeof value !== 'object' || !('start' in value)) {
if (
mode !== 'range' ||
!value ||
typeof value !== 'object' ||
!('start' in value)
) {
return false;
}
const range = value as { start: Date; end: Date };
if (!range.start) return false;
return normalizeDate(date).getTime() === normalizeDate(range.start).getTime();
return (
normalizeDate(date).getTime() === normalizeDate(range.start).getTime()
);
};
const isDateEnd = (date: Date): boolean => {
if (mode !== 'range' || !value || typeof value !== 'object' || !('end' in value)) {
if (
mode !== 'range' ||
!value ||
typeof value !== 'object' ||
!('end' in value)
) {
return false;
}
const range = value as { start: Date; end: Date };
@ -135,11 +175,15 @@ export function DatePicker({
};
const handlePreviousMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
setCurrentMonth(
new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1),
);
};
const handleNextMonth = () => {
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
setCurrentMonth(
new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1),
);
};
const handleToday = () => {
@ -200,7 +244,7 @@ export function DatePicker({
className={cn(
'w-full justify-start text-left font-normal',
!value && 'text-muted-foreground',
className
className,
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
@ -258,7 +302,7 @@ export function DatePicker({
{/* Jours de la semaine */}
<div className="grid grid-cols-7 gap-1 mb-2">
{DAYS_OF_WEEK.map(day => (
{DAYS_OF_WEEK.map((day) => (
<div
key={day}
className="text-xs font-medium text-muted-foreground text-center py-1"
@ -281,7 +325,8 @@ export function DatePicker({
const isEnd = isDateEnd(day);
const isDisabled = isDateDisabled(day);
const isToday =
normalizeDate(day).getTime() === normalizeDate(new Date()).getTime();
normalizeDate(day).getTime() ===
normalizeDate(new Date()).getTime();
return (
<button
@ -293,12 +338,14 @@ export function DatePicker({
'h-9 w-9 text-sm rounded-md transition-colors',
'hover:bg-accent hover:text-accent-foreground',
'focus:bg-accent focus:text-accent-foreground',
isSelected && 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground',
isSelected &&
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground',
isInRange && !isSelected && 'bg-accent',
isStart && 'rounded-l-md',
isEnd && 'rounded-r-md',
isDisabled && 'opacity-50 cursor-not-allowed pointer-events-none',
isToday && !isSelected && 'border border-primary'
isDisabled &&
'opacity-50 cursor-not-allowed pointer-events-none',
isToday && !isSelected && 'border border-primary',
)}
>
{day.getDate()}
@ -311,4 +358,3 @@ export function DatePicker({
</Dropdown>
);
}

View file

@ -15,7 +15,7 @@ describe('Dialog Component', () => {
render(
<Dialog open={false} onClose={mockOnClose} title="Test Dialog">
<div>Dialog content</div>
</Dialog>
</Dialog>,
);
expect(screen.queryByText('Dialog content')).not.toBeInTheDocument();
@ -25,7 +25,7 @@ describe('Dialog Component', () => {
render(
<Dialog open={true} onClose={mockOnClose} title="Test Dialog">
<div>Dialog content</div>
</Dialog>
</Dialog>,
);
expect(screen.getByText('Dialog content')).toBeInTheDocument();
@ -36,7 +36,7 @@ describe('Dialog Component', () => {
render(
<Dialog open={true} onClose={mockOnClose} title="Test Dialog">
<div>Dialog content</div>
</Dialog>
</Dialog>,
);
expect(screen.getByText('Test Dialog')).toBeInTheDocument();
@ -46,7 +46,7 @@ describe('Dialog Component', () => {
render(
<Dialog open={true} onClose={mockOnClose} title="Test Dialog">
<div>Dialog content</div>
</Dialog>
</Dialog>,
);
const header = screen.getByText('Test Dialog').closest('.border-b');
@ -57,7 +57,7 @@ describe('Dialog Component', () => {
render(
<Dialog open={true} onClose={mockOnClose} title="Test Dialog">
<div>Dialog content</div>
</Dialog>
</Dialog>,
);
expect(screen.getByText('Dialog content')).toBeInTheDocument();
@ -72,7 +72,7 @@ describe('Dialog Component', () => {
footer={<button>Custom Footer</button>}
>
<div>Dialog content</div>
</Dialog>
</Dialog>,
);
expect(screen.getByText('Custom Footer')).toBeInTheDocument();
@ -90,7 +90,7 @@ describe('Dialog Component', () => {
showCancel={true}
>
<div>Are you sure?</div>
</Dialog>
</Dialog>,
);
expect(screen.getByText('Confirm')).toBeInTheDocument();
@ -109,7 +109,7 @@ describe('Dialog Component', () => {
onConfirm={mockOnConfirm}
>
<div>Are you sure?</div>
</Dialog>
</Dialog>,
);
const confirmButton = screen.getByText('Confirm');
@ -132,7 +132,7 @@ describe('Dialog Component', () => {
showCancel={true}
>
<div>Are you sure?</div>
</Dialog>
</Dialog>,
);
const cancelButton = screen.getByText('Cancel');
@ -151,10 +151,13 @@ describe('Dialog Component', () => {
variant="alert"
>
<div>Alert message</div>
</Dialog>
</Dialog>,
);
const icon = screen.getByText('Alert Dialog').closest('.border-b')?.querySelector('svg');
const icon = screen
.getByText('Alert Dialog')
.closest('.border-b')
?.querySelector('svg');
expect(icon).toBeInTheDocument();
});
@ -167,10 +170,13 @@ describe('Dialog Component', () => {
variant="info"
>
<div>Info message</div>
</Dialog>
</Dialog>,
);
const icon = screen.getByText('Info Dialog').closest('.border-b')?.querySelector('svg');
const icon = screen
.getByText('Info Dialog')
.closest('.border-b')
?.querySelector('svg');
expect(icon).toBeInTheDocument();
});
@ -185,14 +191,16 @@ describe('Dialog Component', () => {
onConfirm={mockOnConfirm}
>
<div>Alert message</div>
</Dialog>
</Dialog>,
);
const confirmButton = screen.getByText('Confirm');
const buttonElement = confirmButton.closest('button');
// Vérifier que le bouton a les classes de variant destructive
expect(buttonElement?.classList.contains('bg-destructive') ||
buttonElement?.classList.contains('text-destructive-foreground')).toBe(true);
expect(
buttonElement?.classList.contains('bg-destructive') ||
buttonElement?.classList.contains('text-destructive-foreground'),
).toBe(true);
});
it('uses custom confirm and cancel labels', () => {
@ -209,7 +217,7 @@ describe('Dialog Component', () => {
showCancel={true}
>
<div>Are you sure?</div>
</Dialog>
</Dialog>,
);
expect(screen.getByText('Yes')).toBeInTheDocument();
@ -228,7 +236,7 @@ describe('Dialog Component', () => {
showCancel={false}
>
<div>Are you sure?</div>
</Dialog>
</Dialog>,
);
expect(screen.queryByText('Cancel')).not.toBeInTheDocument();
@ -239,7 +247,7 @@ describe('Dialog Component', () => {
render(
<Dialog open={true} onClose={mockOnClose} title="Default Dialog">
<div>Dialog content</div>
</Dialog>
</Dialog>,
);
const footer = document.querySelector('.border-t');
@ -258,7 +266,7 @@ describe('Dialog Component', () => {
onConfirm={mockOnConfirm}
>
<div>Are you sure?</div>
</Dialog>
</Dialog>,
);
const confirmButton = screen.getByText('Confirm');
@ -283,7 +291,7 @@ describe('Dialog Component', () => {
<DialogBody>
<div>Body content</div>
</DialogBody>
</Dialog>
</Dialog>,
);
expect(screen.getByText('Header content')).toBeInTheDocument();
@ -297,7 +305,7 @@ describe('Dialog Component', () => {
<DialogBody>
<div>Body content</div>
</DialogBody>
</Dialog>
</Dialog>,
);
expect(screen.getByText('Body content')).toBeInTheDocument();
@ -314,11 +322,10 @@ describe('Dialog Component', () => {
<DialogFooter>
<button>Footer button</button>
</DialogFooter>
</Dialog>
</Dialog>,
);
expect(screen.getByText('Footer button')).toBeInTheDocument();
});
});
});

View file

@ -135,7 +135,7 @@ export function DialogHeader({
<div
className={cn(
'flex items-center justify-between p-6 border-b',
className
className,
)}
>
{children}
@ -159,7 +159,7 @@ export function DialogBody({
className={cn(
'p-6',
variant === 'alert' && 'text-destructive-foreground',
className
className,
)}
>
{children}
@ -177,11 +177,10 @@ export function DialogFooter({ children, className }: DialogFooterProps) {
<div
className={cn(
'flex items-center justify-end gap-2 p-6 border-t',
className
className,
)}
>
{children}
</div>
);
}

View file

@ -1,42 +1,42 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@ -45,14 +45,14 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
))
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@ -63,32 +63,32 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@ -97,8 +97,8 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
@ -110,9 +110,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@ -121,8 +121,8 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
@ -133,26 +133,26 @@ const DropdownMenuRadioItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className,
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@ -160,11 +160,11 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
@ -172,12 +172,12 @@ const DropdownMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
@ -195,10 +195,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
};

View file

@ -12,7 +12,7 @@ describe('Dropdown Component', () => {
render(
<Dropdown trigger={<button>Open Menu</button>}>
<div>Menu content</div>
</Dropdown>
</Dropdown>,
);
expect(screen.getByText('Open Menu')).toBeInTheDocument();
@ -22,7 +22,7 @@ describe('Dropdown Component', () => {
render(
<Dropdown trigger={<button>Open Menu</button>}>
<div>Menu content</div>
</Dropdown>
</Dropdown>,
);
expect(screen.queryByText('Menu content')).not.toBeInTheDocument();
@ -33,7 +33,7 @@ describe('Dropdown Component', () => {
render(
<Dropdown trigger={<button>Open Menu</button>}>
<div>Menu content</div>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -47,7 +47,7 @@ describe('Dropdown Component', () => {
render(
<Dropdown trigger={<button>Open Menu</button>}>
<div>Menu content</div>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -68,7 +68,7 @@ describe('Dropdown Component', () => {
<div>Menu content</div>
</Dropdown>
<div>Outside content</div>
</div>
</div>,
);
const trigger = screen.getByText('Open Menu');
@ -87,7 +87,7 @@ describe('Dropdown Component', () => {
render(
<Dropdown trigger={<button>Open Menu</button>}>
<div>Menu content</div>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -105,7 +105,7 @@ describe('Dropdown Component', () => {
render(
<Dropdown trigger={<button>Open Menu</button>}>
<div>Menu content</div>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -120,7 +120,7 @@ describe('Dropdown Component', () => {
render(
<Dropdown trigger={<button>Open Menu</button>}>
<div>Menu content</div>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -137,7 +137,7 @@ describe('Dropdown Component', () => {
<button>Item 1</button>
<button>Item 2</button>
<button>Item 3</button>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -162,7 +162,7 @@ describe('Dropdown Component', () => {
<button>Item 1</button>
<button>Item 2</button>
<button>Item 3</button>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -185,7 +185,7 @@ describe('Dropdown Component', () => {
<Dropdown trigger={<button>Open Menu</button>}>
<button>Item 1</button>
<button>Item 2</button>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -208,7 +208,7 @@ describe('Dropdown Component', () => {
<Dropdown trigger={<button>Open Menu</button>}>
<button>Item 1</button>
<button>Item 2</button>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -230,7 +230,7 @@ describe('Dropdown Component', () => {
<Dropdown trigger={<button>Open Menu</button>}>
<button onClick={handleClick}>Item 1</button>
<button>Item 2</button>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -251,7 +251,7 @@ describe('Dropdown Component', () => {
<Dropdown trigger={<button>Open Menu</button>}>
<button onClick={handleClick}>Item 1</button>
<button>Item 2</button>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -270,7 +270,7 @@ describe('Dropdown Component', () => {
render(
<Dropdown trigger={<button>Open Menu</button>}>
<div>Menu content</div>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -284,7 +284,7 @@ describe('Dropdown Component', () => {
render(
<Dropdown trigger={<button>Open Menu</button>} align="right">
<div>Menu content</div>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -298,7 +298,7 @@ describe('Dropdown Component', () => {
render(
<Dropdown trigger={<button>Open Menu</button>} align="center">
<div>Menu content</div>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -316,7 +316,7 @@ describe('Dropdown Component', () => {
onOpenChange={onOpenChange}
>
<div>Menu content</div>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -333,7 +333,7 @@ describe('Dropdown Component', () => {
onOpenChange={onOpenChange}
>
<div>Menu content</div>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -349,7 +349,7 @@ describe('Dropdown Component', () => {
render(
<Dropdown trigger={<button>Open Menu</button>}>
<div>Menu content</div>
</Dropdown>
</Dropdown>,
);
const triggerWrapper = screen
@ -370,7 +370,7 @@ describe('Dropdown Component', () => {
<Dropdown trigger={<button>Open Menu</button>}>
<button>Item 1</button>
<button>Item 2</button>
</Dropdown>
</Dropdown>,
);
const trigger = screen.getByText('Open Menu');
@ -389,4 +389,3 @@ describe('Dropdown Component', () => {
expect(item1).toBeInTheDocument();
});
});

View file

@ -33,7 +33,7 @@ export function Dropdown({
focusedIndexRef.current = -1;
}
},
[onOpenChange]
[onOpenChange],
);
// Fermer le dropdown quand on clique en dehors
@ -63,7 +63,7 @@ export function Dropdown({
if (!menuRef.current) return;
const focusableElements = menuRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [role="menuitem"], [tabindex]:not([tabindex="-1"])'
'button, [href], input, select, textarea, [role="menuitem"], [tabindex]:not([tabindex="-1"])',
);
const elements = Array.from(focusableElements);
@ -96,7 +96,10 @@ export function Dropdown({
case 'Enter':
case ' ':
event.preventDefault();
if (focusedIndexRef.current >= 0 && elements[focusedIndexRef.current]) {
if (
focusedIndexRef.current >= 0 &&
elements[focusedIndexRef.current]
) {
elements[focusedIndexRef.current].click();
}
break;
@ -125,7 +128,7 @@ export function Dropdown({
useEffect(() => {
if (open && menuRef.current) {
const focusableElements = menuRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [role="menuitem"], [tabindex]:not([tabindex="-1"])'
'button, [href], input, select, textarea, [role="menuitem"], [tabindex]:not([tabindex="-1"])',
);
if (focusableElements.length > 0) {
focusedIndexRef.current = 0;
@ -179,7 +182,7 @@ export function Dropdown({
className={cn(
'absolute z-50 mt-2 min-w-[8rem] bg-popover border rounded-md shadow-lg',
'overflow-hidden',
alignClasses[align]
alignClasses[align],
)}
role="menu"
aria-orientation="vertical"
@ -191,4 +194,3 @@ export function Dropdown({
</div>
);
}

View file

@ -13,18 +13,24 @@ describe('FileUpload Component', () => {
it('renders file upload component correctly', () => {
render(<FileUpload onFileSelect={mockOnFileSelect} />);
expect(screen.getByText('Drag & drop files here, or click to select')).toBeInTheDocument();
expect(
screen.getByText('Drag & drop files here, or click to select'),
).toBeInTheDocument();
expect(screen.getByText('Select Files')).toBeInTheDocument();
});
it('displays accepted file types', () => {
render(<FileUpload onFileSelect={mockOnFileSelect} accept="image/*, .pdf" />);
render(
<FileUpload onFileSelect={mockOnFileSelect} accept="image/*, .pdf" />,
);
expect(screen.getByText(/Accepted types:/)).toBeInTheDocument();
});
it('displays max file size', () => {
render(<FileUpload onFileSelect={mockOnFileSelect} maxSize={5 * 1024 * 1024} />);
render(
<FileUpload onFileSelect={mockOnFileSelect} maxSize={5 * 1024 * 1024} />,
);
expect(screen.getByText(/Max size: 5 MB/)).toBeInTheDocument();
});
@ -51,7 +57,9 @@ describe('FileUpload Component', () => {
const user = userEvent.setup();
render(<FileUpload onFileSelect={mockOnFileSelect} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const fileInput = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
expect(fileInput).toBeInTheDocument();
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
@ -70,7 +78,9 @@ describe('FileUpload Component', () => {
const user = userEvent.setup();
render(<FileUpload onFileSelect={mockOnFileSelect} multiple />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const fileInput = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
const file1 = new File(['content1'], 'test1.txt', { type: 'text/plain' });
const file2 = new File(['content2'], 'test2.txt', { type: 'text/plain' });
@ -87,7 +97,9 @@ describe('FileUpload Component', () => {
it('handles drag and drop', async () => {
render(<FileUpload onFileSelect={mockOnFileSelect} />);
const dropZone = screen.getByText('Drag & drop files here, or click to select').closest('.border-2');
const dropZone = screen
.getByText('Drag & drop files here, or click to select')
.closest('.border-2');
expect(dropZone).toBeInTheDocument();
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
@ -116,8 +128,12 @@ describe('FileUpload Component', () => {
const user = userEvent.setup();
render(<FileUpload onFileSelect={mockOnFileSelect} accept="image/*" />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const invalidFile = new File(['content'], 'test.txt', { type: 'text/plain' });
const fileInput = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
const invalidFile = new File(['content'], 'test.txt', {
type: 'text/plain',
});
await user.upload(fileInput, invalidFile);
@ -127,10 +143,13 @@ describe('FileUpload Component', () => {
});
// onFileSelect ne devrait pas être appelé pour les fichiers invalides
await waitFor(() => {
await waitFor(
() => {
// Si des fichiers valides sont présents, onFileSelect est appelé, sinon non
// Dans ce cas, aucun fichier valide, donc onFileSelect peut ne pas être appelé
}, { timeout: 500 });
},
{ timeout: 500 },
);
});
it('validates file size and rejects oversized files', async () => {
@ -138,10 +157,14 @@ describe('FileUpload Component', () => {
const maxSize = 1024; // 1KB
render(<FileUpload onFileSelect={mockOnFileSelect} maxSize={maxSize} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const fileInput = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
// Créer un fichier plus grand que maxSize
const largeContent = new Array(2048).fill('a').join('');
const oversizedFile = new File([largeContent], 'large.txt', { type: 'text/plain' });
const oversizedFile = new File([largeContent], 'large.txt', {
type: 'text/plain',
});
await user.upload(fileInput, oversizedFile);
@ -155,7 +178,9 @@ describe('FileUpload Component', () => {
const user = userEvent.setup();
render(<FileUpload onFileSelect={mockOnFileSelect} showPreview />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const fileInput = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
// Créer une image fictive
const imageBlob = new Blob(['image content'], { type: 'image/png' });
@ -163,16 +188,21 @@ describe('FileUpload Component', () => {
await user.upload(fileInput, imageFile);
await waitFor(() => {
await waitFor(
() => {
expect(screen.getByText('test.png')).toBeInTheDocument();
}, { timeout: 1000 });
},
{ timeout: 1000 },
);
});
it('removes file from list when remove button is clicked', async () => {
const user = userEvent.setup();
render(<FileUpload onFileSelect={mockOnFileSelect} showPreview />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const fileInput = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
await user.upload(fileInput, file);
@ -182,7 +212,7 @@ describe('FileUpload Component', () => {
});
const removeButtons = document.querySelectorAll('button[type="button"]');
const removeButton = Array.from(removeButtons).find(btn => {
const removeButton = Array.from(removeButtons).find((btn) => {
const svg = btn.querySelector('svg');
return svg && svg.getAttribute('d')?.includes('m18 6-6 6');
});
@ -198,7 +228,9 @@ describe('FileUpload Component', () => {
it('disables component when disabled prop is true', () => {
render(<FileUpload onFileSelect={mockOnFileSelect} disabled />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const fileInput = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
expect(fileInput).toBeDisabled();
const button = screen.getByText('Select Files');
@ -209,7 +241,9 @@ describe('FileUpload Component', () => {
const user = userEvent.setup();
render(<FileUpload onFileSelect={mockOnFileSelect} showPreview={false} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const fileInput = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
await user.upload(fileInput, file);
@ -224,9 +258,17 @@ describe('FileUpload Component', () => {
it('replaces files when multiple is false', async () => {
const user = userEvent.setup();
render(<FileUpload onFileSelect={mockOnFileSelect} multiple={false} showPreview />);
render(
<FileUpload
onFileSelect={mockOnFileSelect}
multiple={false}
showPreview
/>,
);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const fileInput = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
const file1 = new File(['content1'], 'test1.txt', { type: 'text/plain' });
const file2 = new File(['content2'], 'test2.txt', { type: 'text/plain' });
@ -244,4 +286,3 @@ describe('FileUpload Component', () => {
});
});
});

View file

@ -2,7 +2,17 @@ import { useState, useRef, useCallback } from 'react';
import { Button } from './button';
import { Card } from './card';
import { cn } from '@/lib/utils';
import { Upload, X, File, Image, FileText, Video, Music, CheckCircle, AlertCircle } from 'lucide-react';
import {
Upload,
X,
File,
Image,
FileText,
Video,
Music,
CheckCircle,
AlertCircle,
} from 'lucide-react';
export interface FileUploadProps {
onFileSelect: (files: File[]) => void;
@ -33,7 +43,7 @@ const formatFileSize = (bytes: number): string => {
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Math.round(bytes / Math.pow(k, i) * 100) / 100 } ${ sizes[i]}`;
return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;
};
const getFileIcon = (file: File) => {
@ -76,16 +86,19 @@ export function FileUpload({
const validateFile = (file: File): string | null => {
// Validation du type
if (accept) {
const acceptedTypes = accept.split(',').map(type => type.trim());
const fileExtension = `.${ file.name.split('.').pop()?.toLowerCase()}`;
const acceptedTypes = accept.split(',').map((type) => type.trim());
const fileExtension = `.${file.name.split('.').pop()?.toLowerCase()}`;
const fileType = file.type.toLowerCase();
const isAccepted = acceptedTypes.some(type => {
const isAccepted = acceptedTypes.some((type) => {
if (type.startsWith('.')) {
return type.toLowerCase() === fileExtension;
}
if (type.includes('/')) {
return fileType === type.toLowerCase() || fileType.startsWith(`${type.toLowerCase().split('/')[0] }/`);
return (
fileType === type.toLowerCase() ||
fileType.startsWith(`${type.toLowerCase().split('/')[0]}/`)
);
}
return false;
});
@ -103,13 +116,14 @@ export function FileUpload({
return null;
};
const processFiles = useCallback(async (fileList: File[]) => {
const processFiles = useCallback(
async (fileList: File[]) => {
const newErrors: string[] = [];
const validFiles: FileWithPreview[] = [];
const filesToProcess: File[] = [];
// Valider tous les fichiers d'abord
fileList.forEach(file => {
fileList.forEach((file) => {
const error = validateFile(file);
if (error) {
newErrors.push(`${file.name}: ${error}`);
@ -141,14 +155,18 @@ export function FileUpload({
if (validFiles.length > 0) {
const updatedFiles = multiple ? [...files, ...validFiles] : validFiles;
setFiles(updatedFiles);
onFileSelect(updatedFiles.map(f => {
onFileSelect(
updatedFiles.map((f) => {
// Destructure to remove internal properties before passing to parent
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { preview, status, progress, error, ...file } = f;
return file;
}));
}),
);
}
}, [files, multiple, accept, maxSize, showPreview, onFileSelect]);
},
[files, multiple, accept, maxSize, showPreview, onFileSelect],
);
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
@ -160,7 +178,8 @@ export function FileUpload({
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
@ -171,9 +190,12 @@ export function FileUpload({
const fileList = Array.from(e.dataTransfer.files);
processFiles(fileList);
}
}, [disabled, processFiles]);
},
[disabled, processFiles],
);
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const fileList = Array.from(e.target.files);
processFiles(fileList);
@ -182,18 +204,25 @@ export function FileUpload({
fileInputRef.current.value = '';
}
}
}, [processFiles]);
},
[processFiles],
);
const handleRemoveFile = useCallback((index: number) => {
const handleRemoveFile = useCallback(
(index: number) => {
const updatedFiles = files.filter((_, i) => i !== index);
setFiles(updatedFiles);
onFileSelect(updatedFiles.map(f => {
onFileSelect(
updatedFiles.map((f) => {
// Destructure to remove internal properties before passing to parent
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { preview, status, progress, error, ...file } = f;
return file;
}));
}, [files, onFileSelect]);
}),
);
},
[files, onFileSelect],
);
const handleClick = () => {
if (!disabled && fileInputRef.current) {
@ -209,7 +238,7 @@ export function FileUpload({
'border-2 border-dashed transition-colors cursor-pointer',
dragActive && 'border-primary bg-primary/5',
disabled && 'opacity-50 cursor-not-allowed',
!disabled && 'hover:border-primary/50'
!disabled && 'hover:border-primary/50',
)}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
@ -287,7 +316,9 @@ export function FileUpload({
{/* Info fichier */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-sm font-medium truncate">
{file.name}
</p>
<Button
type="button"
variant="ghost"
@ -302,7 +333,8 @@ export function FileUpload({
</Button>
</div>
<p className="text-xs text-muted-foreground mb-2">
{formatFileSize(file.size)} {file.type || 'Unknown type'}
{formatFileSize(file.size)} {' '}
{file.type || 'Unknown type'}
</p>
{/* Barre de progression */}
@ -338,4 +370,3 @@ export function FileUpload({
</div>
);
}

View file

@ -22,7 +22,7 @@ export function FocusTrap({
// Trouver tous les éléments focusables
const focusableElements = containerRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0] as HTMLElement;

View file

@ -12,13 +12,13 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
className,
)}
ref={ref}
{...props}
/>
);
}
},
);
Input.displayName = 'Input';

View file

@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<

View file

@ -69,4 +69,3 @@ describe('LoadingSpinner', () => {
expect(spinner).toHaveClass('animate-spin');
});
});

View file

@ -21,21 +21,21 @@ export function LoadingSpinner({
<div
className={cn(
'flex flex-col items-center justify-center min-h-[200px]',
className
className,
)}
>
<div
className={cn(
'animate-spin rounded-full border-2 border-gray-300 border-t-blue-600',
sizeClasses[size]
sizeClasses[size],
)}
role='status'
aria-label='Chargement en cours'
role="status"
aria-label="Chargement en cours"
>
<span className='sr-only'>Chargement...</span>
<span className="sr-only">Chargement...</span>
</div>
{text && (
<p className='mt-2 text-sm text-gray-600 dark:text-gray-400'>{text}</p>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{text}</p>
)}
</div>
);

View file

@ -16,7 +16,7 @@ describe('Modal Component', () => {
render(
<Modal open={false} onClose={mockOnClose}>
<div>Modal content</div>
</Modal>
</Modal>,
);
expect(screen.queryByText('Modal content')).not.toBeInTheDocument();
@ -26,7 +26,7 @@ describe('Modal Component', () => {
render(
<Modal open={true} onClose={mockOnClose}>
<div>Modal content</div>
</Modal>
</Modal>,
);
expect(screen.getByText('Modal content')).toBeInTheDocument();
@ -36,7 +36,7 @@ describe('Modal Component', () => {
render(
<Modal open={true} onClose={mockOnClose} title="Test Modal">
<div>Modal content</div>
</Modal>
</Modal>,
);
expect(screen.getByText('Test Modal')).toBeInTheDocument();
@ -48,7 +48,7 @@ describe('Modal Component', () => {
render(
<Modal open={true} onClose={mockOnClose} closeOnOverlayClick={true}>
<div>Modal content</div>
</Modal>
</Modal>,
);
const overlay = screen.getByRole('dialog');
@ -62,7 +62,7 @@ describe('Modal Component', () => {
render(
<Modal open={true} onClose={mockOnClose} closeOnOverlayClick={true}>
<div>Modal content</div>
</Modal>
</Modal>,
);
const content = screen.getByText('Modal content');
@ -74,13 +74,9 @@ describe('Modal Component', () => {
it('does not close when closeOnOverlayClick is false', async () => {
const user = userEvent.setup();
render(
<Modal
open={true}
onClose={mockOnClose}
closeOnOverlayClick={false}
>
<Modal open={true} onClose={mockOnClose} closeOnOverlayClick={false}>
<div>Modal content</div>
</Modal>
</Modal>,
);
const overlay = screen.getByRole('dialog');
@ -93,7 +89,7 @@ describe('Modal Component', () => {
render(
<Modal open={true} onClose={mockOnClose} closeOnEscape={true}>
<div>Modal content</div>
</Modal>
</Modal>,
);
fireEvent.keyDown(document, { key: 'Escape' });
@ -107,21 +103,24 @@ describe('Modal Component', () => {
render(
<Modal open={true} onClose={mockOnClose} closeOnEscape={false}>
<div>Modal content</div>
</Modal>
</Modal>,
);
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => {
await waitFor(
() => {
expect(mockOnClose).not.toHaveBeenCalled();
}, { timeout: 100 });
},
{ timeout: 100 },
);
});
it('renders close button when closeOnEscape is true', () => {
render(
<Modal open={true} onClose={mockOnClose} closeOnEscape={true}>
<div>Modal content</div>
</Modal>
</Modal>,
);
const closeButton = screen.getByLabelText('Fermer');
@ -133,7 +132,7 @@ describe('Modal Component', () => {
render(
<Modal open={true} onClose={mockOnClose} closeOnEscape={true}>
<div>Modal content</div>
</Modal>
</Modal>,
);
const closeButton = screen.getByLabelText('Fermer');
@ -146,7 +145,7 @@ describe('Modal Component', () => {
const { rerender } = render(
<Modal open={true} onClose={mockOnClose} size="sm">
<div>Content</div>
</Modal>
</Modal>,
);
let modal = screen.getByText('Content').closest('.max-w-sm');
@ -155,7 +154,7 @@ describe('Modal Component', () => {
rerender(
<Modal open={true} onClose={mockOnClose} size="lg">
<div>Content</div>
</Modal>
</Modal>,
);
modal = screen.getByText('Content').closest('.max-w-lg');
@ -164,13 +163,9 @@ describe('Modal Component', () => {
it('applies custom className', () => {
render(
<Modal
open={true}
onClose={mockOnClose}
className="custom-modal-class"
>
<Modal open={true} onClose={mockOnClose} className="custom-modal-class">
<div>Content</div>
</Modal>
</Modal>,
);
const modal = screen.getByText('Content').closest('.custom-modal-class');
@ -181,7 +176,7 @@ describe('Modal Component', () => {
render(
<Modal open={true} onClose={mockOnClose}>
<div>Content</div>
</Modal>
</Modal>,
);
expect(document.body.style.overflow).toBe('hidden');
@ -191,7 +186,7 @@ describe('Modal Component', () => {
const { rerender } = render(
<Modal open={true} onClose={mockOnClose}>
<div>Content</div>
</Modal>
</Modal>,
);
expect(document.body.style.overflow).toBe('hidden');
@ -199,7 +194,7 @@ describe('Modal Component', () => {
rerender(
<Modal open={false} onClose={mockOnClose}>
<div>Content</div>
</Modal>
</Modal>,
);
expect(document.body.style.overflow).toBe('');
@ -209,7 +204,7 @@ describe('Modal Component', () => {
render(
<Modal open={true} onClose={mockOnClose} title="Test Modal">
<div>Content</div>
</Modal>
</Modal>,
);
const dialog = screen.getByRole('dialog');
@ -221,11 +216,10 @@ describe('Modal Component', () => {
render(
<Modal open={true} onClose={mockOnClose}>
<div>Content</div>
</Modal>
</Modal>,
);
const dialog = screen.getByRole('dialog');
expect(dialog).not.toHaveAttribute('aria-labelledby');
});
});

View file

@ -77,7 +77,7 @@ export function Modal({
'relative z-50 w-full bg-background rounded-lg shadow-xl',
'transform transition-all',
sizeClasses[size],
className
className,
)}
onClick={(e) => e.stopPropagation()}
>
@ -113,4 +113,3 @@ export function Modal({
</div>
);
}

View file

@ -25,7 +25,7 @@ const FALLBACK_FORMAT = 'jpeg';
function generateImageSources(src: string, sizes?: string) {
const baseUrl = src.replace(/\.[^/.]+$/, '');
return SUPPORTED_FORMATS.map(format => {
return SUPPORTED_FORMATS.map((format) => {
const formatSrc = `${baseUrl}.${format}`;
return {
src: formatSrc,
@ -40,7 +40,7 @@ function BlurPlaceholder({
blurDataURL,
width,
height,
className
className,
}: {
blurDataURL?: string;
width?: number;
@ -79,14 +79,16 @@ function useImageFormatSupport() {
const webpSupported = await new Promise<boolean>((resolve) => {
const webp = new Image();
webp.onload = webp.onerror = () => resolve(webp.height === 2);
webp.src = 'data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA';
webp.src =
'data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA';
});
// Test AVIF
const avifSupported = await new Promise<boolean>((resolve) => {
const avif = new Image();
avif.onload = avif.onerror = () => resolve(avif.height === 2);
avif.src = 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAABcAAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQAMAAAAABNjb2xybmNseAACAAIABoAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB9tZGF0EgAKCBgABogQEAwgMgkAAAAAAAAG8AAAAA==';
avif.src =
'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAABcAAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQAMAAAAABNjb2xybmNseAACAAIABoAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB9tZGF0EgAKCBgABogQEAwgMgkAAAAAAAAG8AAAAA==';
});
if (webpSupported) formats.push('webp');
@ -136,12 +138,12 @@ export function OptimizedImage({
// Sélectionner la meilleure source supportée
const selectBestSource = useCallback(() => {
const bestFormat = supportedFormats.find(format =>
SUPPORTED_FORMATS.includes(format)
) || FALLBACK_FORMAT;
const bestFormat =
supportedFormats.find((format) => SUPPORTED_FORMATS.includes(format)) ||
FALLBACK_FORMAT;
const bestSource = imageSources.find(source =>
source.type === `image/${bestFormat}`
const bestSource = imageSources.find(
(source) => source.type === `image/${bestFormat}`,
);
return bestSource?.src || src;
@ -187,13 +189,15 @@ export function OptimizedImage({
// Rendu du fallback en cas d'erreur
if (hasError) {
return fallback || (
return (
fallback || (
<div
className={`bg-gray-200 flex items-center justify-center ${className}`}
style={{ width, height }}
>
<span className="text-gray-400 text-sm">Image non disponible</span>
</div>
)
);
}
@ -267,10 +271,13 @@ export function useImagePreloader() {
});
}, []);
const preloadImages = useCallback(async (srcs: string[]) => {
const promises = srcs.map(src => preloadImage(src));
const preloadImages = useCallback(
async (srcs: string[]) => {
const promises = srcs.map((src) => preloadImage(src));
return Promise.allSettled(promises);
}, [preloadImage]);
},
[preloadImage],
);
return { preloadImage, preloadImages };
}
@ -288,9 +295,7 @@ export function ResponsiveImage({
// Générer srcset pour différentes tailles
const generateSrcSet = useCallback((baseSrc: string) => {
const widths = [320, 640, 768, 1024, 1280, 1920];
return widths
.map(width => `${baseSrc}?w=${width} ${width}w`)
.join(', ');
return widths.map((width) => `${baseSrc}?w=${width} ${width}w`).join(', ');
}, []);
const srcSet = generateSrcSet(src);

View file

@ -0,0 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Progress = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { value?: number }
>(({ className, value, ...props }, ref) => (
<div
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className,
)}
{...props}
>
<div
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</div>
));
Progress.displayName = 'Progress';
export { Progress };

View file

@ -27,7 +27,7 @@ const RadioGroupItem = React.forwardRef<
ref={ref}
className={cn(
'aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
className,
)}
{...props}
>
@ -40,4 +40,3 @@ const RadioGroupItem = React.forwardRef<
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View file

@ -17,7 +17,11 @@ describe('Select Component', () => {
it('renders select trigger correctly', () => {
render(
<Select options={options} onChange={mockOnChange} placeholder="Select..." />
<Select
options={options}
onChange={mockOnChange}
placeholder="Select..."
/>,
);
expect(screen.getByText('Select...')).toBeInTheDocument();
@ -28,7 +32,10 @@ describe('Select Component', () => {
render(<Select options={options} onChange={mockOnChange} />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select an option...')) || triggers[0];
const trigger =
triggers.find((btn) =>
btn.textContent?.includes('Select an option...'),
) || triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -37,7 +44,9 @@ describe('Select Component', () => {
});
it('displays selected value for single select', () => {
render(<Select options={options} value="option1" onChange={mockOnChange} />);
render(
<Select options={options} value="option1" onChange={mockOnChange} />,
);
expect(screen.getByText('Option 1')).toBeInTheDocument();
});
@ -49,7 +58,7 @@ describe('Select Component', () => {
value={['option1', 'option2']}
onChange={mockOnChange}
multiple
/>
/>,
);
expect(screen.getByText('2 selected')).toBeInTheDocument();
@ -60,7 +69,10 @@ describe('Select Component', () => {
render(<Select options={options} onChange={mockOnChange} />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select an option...')) || triggers[0];
const trigger =
triggers.find((btn) =>
btn.textContent?.includes('Select an option...'),
) || triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -78,7 +90,10 @@ describe('Select Component', () => {
render(<Select options={options} onChange={mockOnChange} multiple />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select an option...')) || triggers[0];
const trigger =
triggers.find((btn) =>
btn.textContent?.includes('Select an option...'),
) || triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -99,11 +114,13 @@ describe('Select Component', () => {
value={['option1']}
onChange={mockOnChange}
multiple
/>
/>,
);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('1 selected')) || triggers[0];
const trigger =
triggers.find((btn) => btn.textContent?.includes('1 selected')) ||
triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -121,7 +138,10 @@ describe('Select Component', () => {
render(<Select options={options} onChange={mockOnChange} searchable />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select an option...')) || triggers[0];
const trigger =
triggers.find((btn) =>
btn.textContent?.includes('Select an option...'),
) || triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -136,13 +156,20 @@ describe('Select Component', () => {
const searchInput = screen.getByPlaceholderText('Search...');
await user.type(searchInput, 'Option 1');
await waitFor(() => {
await waitFor(
() => {
const menuItems = screen.getAllByRole('menuitem');
const option1Item = menuItems.find(item => item.textContent?.includes('Option 1'));
const option1Item = menuItems.find((item) =>
item.textContent?.includes('Option 1'),
);
expect(option1Item).toBeInTheDocument();
const option2Item = menuItems.find(item => item.textContent?.includes('Option 2'));
const option2Item = menuItems.find((item) =>
item.textContent?.includes('Option 2'),
);
expect(option2Item).not.toBeInTheDocument();
}, { timeout: 2000 });
},
{ timeout: 2000 },
);
});
it('displays grouped options', async () => {
@ -156,7 +183,10 @@ describe('Select Component', () => {
render(<Select options={groupedOptions} onChange={mockOnChange} />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select an option...')) || triggers[0];
const trigger =
triggers.find((btn) =>
btn.textContent?.includes('Select an option...'),
) || triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -166,8 +196,12 @@ describe('Select Component', () => {
// Chercher les labels de groupe
const groupLabels = Array.from(document.querySelectorAll('.text-xs'));
const group1Label = groupLabels.find(el => el.textContent?.trim() === 'GROUP 1');
const group2Label = groupLabels.find(el => el.textContent?.trim() === 'GROUP 2');
const group1Label = groupLabels.find(
(el) => el.textContent?.trim() === 'GROUP 1',
);
const group2Label = groupLabels.find(
(el) => el.textContent?.trim() === 'GROUP 2',
);
expect(group1Label).toBeInTheDocument();
expect(group2Label).toBeInTheDocument();
});
@ -180,10 +214,15 @@ describe('Select Component', () => {
{ value: 'opt3', label: 'Carrot', group: 'Vegetables' },
];
render(<Select options={groupedOptions} onChange={mockOnChange} searchable />);
render(
<Select options={groupedOptions} onChange={mockOnChange} searchable />,
);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select an option...')) || triggers[0];
const trigger =
triggers.find((btn) =>
btn.textContent?.includes('Select an option...'),
) || triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -200,15 +239,24 @@ describe('Select Component', () => {
it('shows checkmark for selected option in single mode', async () => {
const user = userEvent.setup();
render(<Select options={options} value="option1" onChange={mockOnChange} />);
render(
<Select options={options} value="option1" onChange={mockOnChange} />,
);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Option 1') && !btn.textContent?.includes('truncate')) || triggers[0];
const trigger =
triggers.find(
(btn) =>
btn.textContent?.includes('Option 1') &&
!btn.textContent?.includes('truncate'),
) || triggers[0];
await user.click(trigger);
await waitFor(() => {
const menuItems = screen.getAllByRole('menuitem');
const option1Item = menuItems.find(item => item.textContent?.trim() === 'Option 1');
const option1Item = menuItems.find(
(item) => item.textContent?.trim() === 'Option 1',
);
expect(option1Item).toBeInTheDocument();
expect(option1Item?.querySelector('svg')).toBeInTheDocument();
});
@ -222,11 +270,13 @@ describe('Select Component', () => {
value={['option1']}
onChange={mockOnChange}
multiple
/>
/>,
);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('1 selected')) || triggers[0];
const trigger =
triggers.find((btn) => btn.textContent?.includes('1 selected')) ||
triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -246,13 +296,14 @@ describe('Select Component', () => {
render(<Select options={optionsWithDisabled} onChange={mockOnChange} />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select an option...')) || triggers[0];
const trigger =
triggers.find((btn) =>
btn.textContent?.includes('Select an option...'),
) || triggers[0];
await user.click(trigger);
await waitFor(() => {
const option2 = screen
.getByText('Option 2')
.closest('[role="menuitem"]');
const option2 = screen.getByText('Option 2').closest('[role="menuitem"]');
expect(option2).toHaveClass('opacity-50');
expect(option2).toHaveClass('cursor-not-allowed');
});
@ -260,10 +311,14 @@ describe('Select Component', () => {
it('clears selection when clear button is clicked', async () => {
const user = userEvent.setup();
render(<Select options={options} value="option1" onChange={mockOnChange} />);
render(
<Select options={options} value="option1" onChange={mockOnChange} />,
);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Option 1'));
const trigger = triggers.find((btn) =>
btn.textContent?.includes('Option 1'),
);
const clearButton = trigger?.querySelector('svg');
expect(clearButton).toBeInTheDocument();
@ -281,11 +336,13 @@ describe('Select Component', () => {
value={['option1', 'option2']}
onChange={mockOnChange}
multiple
/>
/>,
);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('2 selected'));
const trigger = triggers.find((btn) =>
btn.textContent?.includes('2 selected'),
);
const clearButton = trigger?.querySelector('svg');
expect(clearButton).toBeInTheDocument();
@ -300,7 +357,10 @@ describe('Select Component', () => {
render(<Select options={options} onChange={mockOnChange} searchable />);
const triggers = screen.getAllByRole('button');
const trigger = triggers.find(btn => btn.textContent?.includes('Select an option...')) || triggers[0];
const trigger =
triggers.find((btn) =>
btn.textContent?.includes('Select an option...'),
) || triggers[0];
await user.click(trigger);
await waitFor(() => {
@ -322,10 +382,12 @@ describe('Select Component', () => {
value="option1"
onChange={mockOnChange}
name="test-select"
/>
/>,
);
const hiddenInput = document.querySelector('input[type="hidden"][name="test-select"]');
const hiddenInput = document.querySelector(
'input[type="hidden"][name="test-select"]',
);
expect(hiddenInput).toBeInTheDocument();
expect(hiddenInput).toHaveValue('option1');
});
@ -338,10 +400,12 @@ describe('Select Component', () => {
onChange={mockOnChange}
multiple
name="test-select"
/>
/>,
);
const hiddenInput = document.querySelector('input[type="hidden"][name="test-select"]');
const hiddenInput = document.querySelector(
'input[type="hidden"][name="test-select"]',
);
expect(hiddenInput).toBeInTheDocument();
expect(hiddenInput).toHaveValue('option1,option2');
});
@ -351,10 +415,9 @@ describe('Select Component', () => {
// Le Button dans le trigger devrait être disabled
const buttons = document.querySelectorAll('button');
const selectButton = Array.from(buttons).find(btn =>
btn.textContent?.includes('Select an option...') && btn.disabled
const selectButton = Array.from(buttons).find(
(btn) => btn.textContent?.includes('Select an option...') && btn.disabled,
);
expect(selectButton).toBeInTheDocument();
});
});

View file

@ -52,7 +52,7 @@ export function Select({
const groups: Record<string, SelectOption[]> = {};
const ungrouped: SelectOption[] = [];
options.forEach(option => {
options.forEach((option) => {
if (option.group) {
if (!groups[option.group]) {
groups[option.group] = [];
@ -69,26 +69,31 @@ export function Select({
// Filtrer les options selon la recherche
const filteredOptions = useMemo(() => {
if (!searchable || !search) {
return { groups: groupedOptions.groups, ungrouped: groupedOptions.ungrouped };
return {
groups: groupedOptions.groups,
ungrouped: groupedOptions.ungrouped,
};
}
const searchLower = search.toLowerCase();
const filteredGroups: Record<string, SelectOption[]> = {};
const filteredUngrouped: SelectOption[] = [];
Object.entries(groupedOptions.groups).forEach(([groupLabel, groupOptions]) => {
const filtered = groupOptions.filter(opt =>
opt.label.toLowerCase().includes(searchLower)
Object.entries(groupedOptions.groups).forEach(
([groupLabel, groupOptions]) => {
const filtered = groupOptions.filter((opt) =>
opt.label.toLowerCase().includes(searchLower),
);
if (filtered.length > 0) {
filteredGroups[groupLabel] = filtered;
}
});
},
);
filteredUngrouped.push(
...groupedOptions.ungrouped.filter(opt =>
opt.label.toLowerCase().includes(searchLower)
)
...groupedOptions.ungrouped.filter((opt) =>
opt.label.toLowerCase().includes(searchLower),
),
);
return { groups: filteredGroups, ungrouped: filteredUngrouped };
@ -99,7 +104,7 @@ export function Select({
if (!value) return [];
const values = Array.isArray(value) ? value : [value];
return values
.map(v => options.find(opt => opt.value === v)?.label)
.map((v) => options.find((opt) => opt.value === v)?.label)
.filter(Boolean) as string[];
};
@ -122,7 +127,7 @@ export function Select({
if (multiple) {
const currentValues = Array.isArray(value) ? value : [];
const newValues = currentValues.includes(optionValue)
? currentValues.filter(v => v !== optionValue)
? currentValues.filter((v) => v !== optionValue)
: [...currentValues, optionValue];
onChange(newValues);
} else {
@ -160,14 +165,15 @@ export function Select({
!value || (Array.isArray(value) && value.length === 0)
? 'text-muted-foreground'
: '',
className
className,
)}
type="button"
>
<span className="truncate">{displayValue}</span>
<div className="flex items-center gap-1 ml-2">
{value &&
((Array.isArray(value) && value.length > 0) || !Array.isArray(value)) && (
((Array.isArray(value) && value.length > 0) ||
!Array.isArray(value)) && (
<X
className="h-4 w-4 shrink-0 opacity-50 hover:opacity-100"
onClick={handleClear}
@ -188,8 +194,8 @@ export function Select({
type="text"
placeholder="Search..."
value={search}
onChange={e => setSearch(e.target.value)}
onClick={e => e.stopPropagation()}
onChange={(e) => setSearch(e.target.value)}
onClick={(e) => e.stopPropagation()}
className="w-full"
/>
</div>
@ -198,7 +204,7 @@ export function Select({
{/* Options non groupées */}
{filteredOptions.ungrouped.length > 0 && (
<div className="py-1">
{filteredOptions.ungrouped.map(option => (
{filteredOptions.ungrouped.map((option) => (
<SelectOptionItem
key={option.value}
option={option}
@ -211,12 +217,13 @@ export function Select({
)}
{/* Options groupées */}
{Object.entries(filteredOptions.groups).map(([groupLabel, groupOptions]) => (
{Object.entries(filteredOptions.groups).map(
([groupLabel, groupOptions]) => (
<div key={groupLabel} className="py-1">
<div className="px-3 py-1.5 text-xs font-semibold text-muted-foreground uppercase">
{groupLabel}
</div>
{groupOptions.map(option => (
{groupOptions.map((option) => (
<SelectOptionItem
key={option.value}
option={option}
@ -226,7 +233,8 @@ export function Select({
/>
))}
</div>
))}
),
)}
{/* Message si aucune option */}
{filteredOptions.ungrouped.length === 0 &&
@ -282,7 +290,7 @@ function SelectOptionItem({
'focus:bg-accent focus:text-accent-foreground',
'transition-colors',
isSelected && 'bg-accent text-accent-foreground',
option.disabled && 'opacity-50 cursor-not-allowed pointer-events-none'
option.disabled && 'opacity-50 cursor-not-allowed pointer-events-none',
)}
onClick={() => !option.disabled && onSelect(option.value)}
tabIndex={option.disabled ? -1 : 0}
@ -291,17 +299,14 @@ function SelectOptionItem({
<div
className={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border',
isSelected && 'bg-primary border-primary text-primary-foreground'
isSelected && 'bg-primary border-primary text-primary-foreground',
)}
>
{isSelected && <Check className="h-3 w-3" />}
</div>
)}
<span className="flex-1">{option.label}</span>
{!multiple && isSelected && (
<Check className="h-4 w-4 text-primary" />
)}
{!multiple && isSelected && <Check className="h-4 w-4 text-primary" />}
</div>
);
}

View file

@ -99,4 +99,3 @@ describe('Skeleton', () => {
expect(skeleton).toHaveClass('dark:bg-gray-700');
});
});

View file

@ -41,14 +41,13 @@ export function Skeleton({
'bg-gray-200 dark:bg-gray-700',
variantClasses[variant],
animationClasses[animation],
className
className,
)}
style={style}
role='status'
aria-label='Chargement...'
role="status"
aria-label="Chargement..."
>
<span className='sr-only'>Chargement...</span>
<span className="sr-only">Chargement...</span>
</div>
);
}

View file

@ -10,7 +10,7 @@ const Slider = React.forwardRef<
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className
className,
)}
{...props}
>
@ -23,4 +23,3 @@ const Slider = React.forwardRef<
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View file

@ -1,7 +1,7 @@
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
@ -9,25 +9,19 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
'peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View file

@ -1,6 +1,6 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Table = React.forwardRef<
HTMLTableElement,
@ -9,20 +9,20 @@ const Table = React.forwardRef<
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
));
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
@ -30,11 +30,11 @@ const TableBody = React.forwardRef<
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
@ -43,13 +43,13 @@ const TableFooter = React.forwardRef<
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<
HTMLTableRowElement,
@ -58,13 +58,13 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className,
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
));
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
@ -73,13 +73,13 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
@ -87,14 +87,11 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-4 align-middle [&:has([role=checkbox])]:pr-0",
className
)}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
@ -102,11 +99,11 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
));
TableCaption.displayName = 'TableCaption';
export {
Table,
@ -117,10 +114,4 @@ export {
TableRow,
TableCell,
TableCaption,
}
};

View file

@ -13,7 +13,7 @@ const TabsList = React.forwardRef<
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className
className,
)}
{...props}
/>
@ -28,7 +28,7 @@ const TabsTrigger = React.forwardRef<
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className
className,
)}
{...props}
/>
@ -43,7 +43,7 @@ const TabsContent = React.forwardRef<
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
className,
)}
{...props}
/>

View file

@ -1,7 +1,8 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
@ -9,15 +10,14 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
className,
)}
ref={ref}
{...props}
/>
);
}
},
);
Textarea.displayName = 'Textarea';
export { Textarea };

Some files were not shown because too many files have changed in this diff Show more