refonte: backend-api go first; phase 1
This commit is contained in:
parent
87c6461900
commit
2dfde29f7d
1005 changed files with 39778 additions and 90860 deletions
310
Makefile
310
Makefile
|
|
@ -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 := [1m
|
||||
RED := [0;31m
|
||||
GREEN := [0;32m
|
||||
YELLOW := [0;33m
|
||||
BLUE := [0;34m
|
||||
PURPLE := [0;35m
|
||||
CYAN := [0;36m
|
||||
NC := [0m
|
||||
|
||||
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
127
Makefile.old
Normal 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
|
||||
1547
apps/web/AUDIT_TECHNIQUE_EXHAUSTIF.md
Normal file
1547
apps/web/AUDIT_TECHNIQUE_EXHAUSTIF.md
Normal file
File diff suppressed because it is too large
Load diff
108
apps/web/package-lock.json
generated
108
apps/web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -31,11 +31,9 @@ export function App() {
|
|||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
|
||||
<AppRouter />
|
||||
{/* PWA Install Banner */}
|
||||
<PWAInstallBanner />
|
||||
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,4 +8,3 @@ interface ButtonProps {
|
|||
export const Button = ({ children, onClick }: ButtonProps) => {
|
||||
return <button onClick={onClick}>{children}</button>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,4 +22,3 @@ export const Input = ({
|
|||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,2 @@
|
|||
export { Button } from './Button';
|
||||
export { Input } from './Input';
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -33,4 +33,3 @@ export function Chart({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -153,4 +153,3 @@ export function LineChart({
|
|||
</Chart>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -21,4 +21,3 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -97,4 +97,3 @@ describe('Avatar', () => {
|
|||
expect(screen.getByText('JD')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -69,4 +69,3 @@ describe('LoadingSpinner', () => {
|
|||
expect(spinner).toHaveClass('animate-spin');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
24
apps/web/src/components/ui/progress.tsx
Normal file
24
apps/web/src/components/ui/progress.tsx
Normal 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 };
|
||||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -99,4 +99,3 @@ describe('Skeleton', () => {
|
|||
expect(skeleton).toHaveClass('dark:bg-gray-700');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue