Files
vip-coordinator/frontend/e2e/ipad-ui.spec.ts
kyle d2754db377
Some checks failed
CI/CD Pipeline / Backend Tests (push) Has been cancelled
CI/CD Pipeline / Frontend Tests (push) Has been cancelled
CI/CD Pipeline / Build Docker Images (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
Major: Unified Activity System with Multi-VIP Support & Enhanced Search/Filtering
## Overview
Complete architectural overhaul merging dual event systems into a unified activity model
with multi-VIP support, enhanced search capabilities, and improved UX throughout.

## Database & Schema Changes

### Unified Activity Model (Breaking Change)
- Merged Event/EventTemplate/EventAttendance into single ScheduleEvent model
- Dropped duplicate tables: Event, EventAttendance, EventTemplate
- Single source of truth for all activities (transport, meals, meetings, events)
- Migration: 20260131180000_drop_duplicate_event_tables

### Multi-VIP Support (Breaking Change)
- Changed schema from single vipId to vipIds array (String[])
- Enables multiple VIPs per activity (ridesharing, group events)
- Migration: 20260131122613_multi_vip_support
- Updated all backend services to handle multi-VIP queries

### Seed Data Updates
- Rebuilt seed.ts with unified activity model
- Added multi-VIP rideshare examples (3 VIPs in SUV, 4 VIPs in van)
- Includes mix of transport + non-transport activities
- Balanced VIP test data (50% OFFICE_OF_DEVELOPMENT, 50% ADMIN)

## Backend Changes

### Services Cleanup
- Removed deprecated common-events endpoints
- Updated EventsService for multi-VIP support
- Enhanced VipsService with multi-VIP activity queries
- Updated DriversService, VehiclesService for unified model
- Added add-vips-to-event.dto for bulk VIP assignment

### Abilities & Permissions
- Updated ability.factory.ts: Event → ScheduleEvent subject
- Enhanced guards for unified activity permissions
- Maintained RBAC (Administrator, Coordinator, Driver roles)

### DTOs
- Updated create-event.dto: vipId → vipIds array
- Updated update-event.dto: vipId → vipIds array
- Added add-vips-to-event.dto for bulk operations
- Removed obsolete event-template DTOs

## Frontend Changes

### UI/UX Improvements

**Renamed "Schedule" → "Activities" Throughout**
- More intuitive terminology for coordinators
- Updated navigation, page titles, buttons
- Changed "Schedule Events" to "Activities" in Admin Tools

**Activities Page Enhancements**
- Added comprehensive search bar (searches: title, location, description, VIP names, driver, vehicle)
- Added sortable columns: Title, Type, VIPs, Start Time, Status
- Visual sort indicators (↑↓ arrows)
- Real-time result count when searching
- Empty state with helpful messaging

**Admin Tools Updates**
- Balanced VIP test data: 10 OFFICE_OF_DEVELOPMENT + 10 ADMIN
- More BSA-relevant organizations (Coca-Cola, AT&T, Walmart vs generic orgs)
- BSA leadership titles (National President, Chief Scout Executive, Regional Directors)
- Relabeled "Schedule Events" → "Activities"

### Component Updates

**EventList.tsx (Activities Page)**
- Added search state management with real-time filtering
- Implemented multi-field sorting with direction toggle
- Enhanced empty states for search + no data scenarios
- Filter tabs + search work together seamlessly

**VIPSchedule.tsx**
- Updated for multi-VIP schema (vipIds array)
- Shows complete itinerary timeline per VIP
- Displays all activities for selected VIP
- Groups by day with formatted dates

**EventForm.tsx**
- Updated to handle vipIds array instead of single vipId
- Multi-select VIP assignment
- Maintains backward compatibility

**AdminTools.tsx**
- New balanced VIP test data (10/10 split)
- BSA-context organizations
- Updated button labels ("Add Test Activities")

### Routing & Navigation
- Removed /common-events routes
- Updated navigation menu labels
- Maintained protected route structure
- Cleaner URL structure

## New Features

### Multi-VIP Activity Support
- Activities can have multiple VIPs (ridesharing, group events)
- Efficient seat utilization tracking (3/6 seats, 4/12 seats)
- Better coordination for shared transport

### Advanced Search & Filtering
- Full-text search across multiple fields
- Instant filtering as you type
- Search + type filters work together
- Clear visual feedback (result counts)

### Sortable Data Tables
- Click column headers to sort
- Toggle ascending/descending
- Visual indicators for active sort
- Sorts persist with search/filter

### Enhanced Admin Tools
- One-click test data generation
- Realistic BSA Jamboree scenario data
- Balanced department representation
- Complete 3-day itineraries per VIP

## Testing & Validation

### Playwright E2E Tests
- Added e2e/ directory structure
- playwright.config.ts configured
- PLAYWRIGHT_GUIDE.md documentation
- Ready for comprehensive E2E testing

### Manual Testing Performed
- Multi-VIP activity creation ✓
- Search across all fields ✓
- Column sorting (all fields) ✓
- Filter tabs + search combination ✓
- Admin Tools data generation ✓
- Database migrations ✓

## Breaking Changes & Migration

**Database Schema Changes**
1. Run migrations: `npx prisma migrate deploy`
2. Reseed database: `npx prisma db seed`
3. Existing data incompatible (dev environment - safe to nuke)

**API Changes**
- POST /events now requires vipIds array (not vipId string)
- GET /events returns vipIds array
- GET /vips/:id/schedule updated for multi-VIP
- Removed /common-events/* endpoints

**Frontend Type Changes**
- ScheduleEvent.vipIds: string[] (was vipId: string)
- EventFormData updated accordingly
- All pages handle array-based VIP assignment

## File Changes Summary

**Added:**
- backend/prisma/migrations/20260131180000_drop_duplicate_event_tables/
- backend/src/events/dto/add-vips-to-event.dto.ts
- frontend/src/components/InlineDriverSelector.tsx
- frontend/e2e/ (Playwright test structure)
- Documentation: NAVIGATION_UX_IMPROVEMENTS.md, PLAYWRIGHT_GUIDE.md

**Modified:**
- 30+ backend files (schema, services, DTOs, abilities)
- 20+ frontend files (pages, components, types)
- Admin tools, seed data, navigation

**Removed:**
- Event/EventAttendance/EventTemplate database tables
- Common events frontend pages
- Obsolete event template DTOs

## Next Steps

**Pending (Phase 3):**
- Activity Templates for bulk event creation
- Operations Dashboard (today's activities + conflicts)
- Complete workflow testing with real users
- Additional E2E test coverage

## Notes
- Development environment - no production data affected
- Database can be reset anytime: `npx prisma migrate reset`
- All servers tested and running successfully
- HMR working correctly for frontend changes

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-31 16:35:24 +01:00

315 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from '@playwright/test';
/**
* iPad UI Optimization Test
*
* Tests the UI responsiveness on iPad viewport sizes:
* - Portrait iPad: 768x1024
* - Landscape iPad: 1024x768
*
* Verifies:
* 1. Mobile navigation drawer works on portrait iPad
* 2. Desktop navigation appears on landscape iPad
* 3. Tables convert to cards on portrait, show as tables on landscape
* 4. Touch targets are properly sized
* 5. Modals are properly sized for both orientations
*/
test.describe('iPad UI - Portrait Mode (768x1024)', () => {
test.use({ viewport: { width: 768, height: 1024 } });
test('should show mobile navigation drawer', async ({ page }) => {
test.setTimeout(120000); // 2 minutes
// Login first
await page.goto('/login');
await page.locator('button:has-text("Sign in with Auth0")').click();
try {
// Try automatic login
await page.locator('input[name="username"], input[type="email"]').first().fill('test@test.com');
await page.locator('input[name="password"], input[type="password"]').first().fill('P@ssw0rd!');
await page.locator('button[type="submit"], button:has-text("Continue"), button:has-text("Log in")').first().click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
} catch {
// Manual login fallback
await page.waitForURL('**/dashboard', { timeout: 180000 });
}
// Verify we're on dashboard
expect(page.url()).toContain('/dashboard');
// Hamburger menu button should be visible on portrait iPad
const hamburgerButton = page.locator('button[aria-label="Open menu"]');
await expect(hamburgerButton).toBeVisible();
// Desktop navigation should be hidden
const desktopNav = page.locator('nav a:has-text("VIPs")').first();
await expect(desktopNav).not.toBeVisible();
// Click hamburger to open drawer
await hamburgerButton.click();
// Drawer should appear
const drawer = page.locator('text=VIP Coordinator').nth(1); // Second instance (in drawer)
await expect(drawer).toBeVisible();
// Drawer should have navigation links
await expect(page.locator('a:has-text("Dashboard")').nth(1)).toBeVisible();
await expect(page.locator('a:has-text("VIPs")').nth(1)).toBeVisible();
// Close button should be visible and properly sized
const closeButton = page.locator('button[aria-label="Close menu"]');
await expect(closeButton).toBeVisible();
// Verify close button size (should be at least 44x44px)
const closeBox = await closeButton.boundingBox();
expect(closeBox?.width).toBeGreaterThanOrEqual(44);
expect(closeBox?.height).toBeGreaterThanOrEqual(44);
await page.screenshot({ path: 'test-results/ipad-portrait-drawer-open.png', fullPage: true });
});
test('should show card layout for VIP list', async ({ page }) => {
test.setTimeout(120000);
// Login and navigate
await page.goto('/login');
await page.locator('button:has-text("Sign in with Auth0")').click();
try {
await page.locator('input[name="username"], input[type="email"]').first().fill('test@test.com');
await page.locator('input[name="password"], input[type="password"]').first().fill('P@ssw0rd!');
await page.locator('button[type="submit"]').first().click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
} catch {
await page.waitForURL('**/dashboard', { timeout: 180000 });
}
// Open drawer and navigate to VIPs
await page.locator('button[aria-label="Open menu"]').click();
await page.locator('a:has-text("VIPs")').nth(1).click();
await page.waitForLoadState('networkidle');
// Desktop table should be hidden
const desktopTable = page.locator('table').first();
await expect(desktopTable).not.toBeVisible();
// Card layout should be visible
// Look for card-specific elements (cards have rounded-lg class and shadow)
const cards = page.locator('.bg-white.shadow.rounded-lg.p-4');
const cardCount = await cards.count();
if (cardCount > 0) {
console.log(`✓ Found ${cardCount} VIP cards in portrait mode`);
// Verify first card has proper touch targets
const firstCard = cards.first();
const editButton = firstCard.locator('button:has-text("Edit")');
const editBox = await editButton.boundingBox();
expect(editBox?.height).toBeGreaterThanOrEqual(44);
console.log(`✓ Edit button height: ${editBox?.height}px (minimum 44px)`);
} else {
console.log(' No VIPs in database - card layout not tested');
}
await page.screenshot({ path: 'test-results/ipad-portrait-vip-cards.png', fullPage: true });
});
test('should properly size modal forms', async ({ page }) => {
test.setTimeout(120000);
// Login and navigate
await page.goto('/login');
await page.locator('button:has-text("Sign in with Auth0")').click();
try {
await page.locator('input[name="username"], input[type="email"]').first().fill('test@test.com');
await page.locator('input[name="password"], input[type="password"]').first().fill('P@ssw0rd!');
await page.locator('button[type="submit"]').first().click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
} catch {
await page.waitForURL('**/dashboard', { timeout: 180000 });
}
// Navigate to VIPs page
await page.locator('button[aria-label="Open menu"]').click();
await page.locator('a:has-text("VIPs")').nth(1).click();
await page.waitForLoadState('networkidle');
// Click "Add VIP" button
await page.locator('button:has-text("Add VIP")').click();
await page.waitForTimeout(500);
// Modal should appear
const modal = page.locator('text=Add New VIP').first();
await expect(modal).toBeVisible();
// Verify form inputs have proper height
const nameInput = page.locator('input[name="name"]');
const inputBox = await nameInput.boundingBox();
expect(inputBox?.height).toBeGreaterThanOrEqual(44);
console.log(`✓ Input height: ${inputBox?.height}px (minimum 44px)`);
// Verify submit button has proper height
const submitButton = page.locator('button:has-text("Create VIP")');
const buttonBox = await submitButton.boundingBox();
expect(buttonBox?.height).toBeGreaterThanOrEqual(44);
console.log(`✓ Button height: ${buttonBox?.height}px (minimum 44px)`);
// Modal should not be wider than viewport
const modalContainer = page.locator('.bg-white.rounded-lg.shadow-xl').first();
const modalBox = await modalContainer.boundingBox();
expect(modalBox?.width).toBeLessThanOrEqual(768);
console.log(`✓ Modal width: ${modalBox?.width}px (viewport: 768px)`);
await page.screenshot({ path: 'test-results/ipad-portrait-modal.png', fullPage: true });
});
});
test.describe('iPad UI - Landscape Mode (1024x768)', () => {
test.use({ viewport: { width: 1024, height: 768 } });
test('should show desktop navigation', async ({ page }) => {
test.setTimeout(120000);
// Login
await page.goto('/login');
await page.locator('button:has-text("Sign in with Auth0")').click();
try {
await page.locator('input[name="username"], input[type="email"]').first().fill('test@test.com');
await page.locator('input[name="password"], input[type="password"]').first().fill('P@ssw0rd!');
await page.locator('button[type="submit"]').first().click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
} catch {
await page.waitForURL('**/dashboard', { timeout: 180000 });
}
// Desktop navigation should be visible
const desktopNav = page.locator('nav a:has-text("VIPs")').first();
await expect(desktopNav).toBeVisible();
// Hamburger menu should be hidden
const hamburgerButton = page.locator('button[aria-label="Open menu"]');
await expect(hamburgerButton).not.toBeVisible();
// Verify navigation has clickable links
await expect(page.locator('nav a:has-text("Dashboard")')).toBeVisible();
await expect(page.locator('nav a:has-text("War Room")')).toBeVisible();
await expect(page.locator('nav a:has-text("Drivers")')).toBeVisible();
await page.screenshot({ path: 'test-results/ipad-landscape-navigation.png', fullPage: true });
});
test('should show table layout for VIP list', async ({ page }) => {
test.setTimeout(120000);
// Login and navigate
await page.goto('/login');
await page.locator('button:has-text("Sign in with Auth0")').click();
try {
await page.locator('input[name="username"], input[type="email"]').first().fill('test@test.com');
await page.locator('input[name="password"], input[type="password"]').first().fill('P@ssw0rd!');
await page.locator('button[type="submit"]').first().click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
} catch {
await page.waitForURL('**/dashboard', { timeout: 180000 });
}
// Navigate to VIPs using desktop nav
await page.locator('nav a:has-text("VIPs")').first().click();
await page.waitForLoadState('networkidle');
// Desktop table should be visible
const desktopTable = page.locator('table').first();
await expect(desktopTable).toBeVisible();
// Table headers should be visible
await expect(page.locator('th:has-text("Name")')).toBeVisible();
await expect(page.locator('th:has-text("Organization")')).toBeVisible();
await expect(page.locator('th:has-text("Department")')).toBeVisible();
await page.screenshot({ path: 'test-results/ipad-landscape-vip-table.png', fullPage: true });
});
test('should show stats in 4-column grid', async ({ page }) => {
test.setTimeout(120000);
// Login
await page.goto('/login');
await page.locator('button:has-text("Sign in with Auth0")').click();
try {
await page.locator('input[name="username"], input[type="email"]').first().fill('test@test.com');
await page.locator('input[name="password"], input[type="password"]').first().fill('P@ssw0rd!');
await page.locator('button[type="submit"]').first().click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
} catch {
await page.waitForURL('**/dashboard', { timeout: 180000 });
}
// Should be on dashboard
expect(page.url()).toContain('/dashboard');
// Stats cards should be visible
await expect(page.locator('text=Total VIPs')).toBeVisible();
await expect(page.locator('text=Active Drivers')).toBeVisible();
await expect(page.locator('text=Events Today')).toBeVisible();
await expect(page.locator('text=Flights Today')).toBeVisible();
await page.screenshot({ path: 'test-results/ipad-landscape-dashboard.png', fullPage: true });
});
});
test.describe('iPad UI - Touch Target Verification', () => {
test.use({ viewport: { width: 768, height: 1024 } });
test('all interactive elements meet 44px minimum', async ({ page }) => {
test.setTimeout(120000);
// Login
await page.goto('/login');
await page.locator('button:has-text("Sign in with Auth0")').click();
try {
await page.locator('input[name="username"], input[type="email"]').first().fill('test@test.com');
await page.locator('input[name="password"], input[type="password"]').first().fill('P@ssw0rd!');
await page.locator('button[type="submit"]').first().click();
await page.waitForURL('**/dashboard', { timeout: 30000 });
} catch {
await page.waitForURL('**/dashboard', { timeout: 180000 });
}
const touchTargets = [
{ name: 'Hamburger Menu', selector: 'button[aria-label="Open menu"]' },
{ name: 'Sign Out Button', selector: 'button:has-text("Sign Out")' },
];
console.log('\n🎯 Verifying Touch Target Sizes:');
console.log('================================');
for (const target of touchTargets) {
const element = page.locator(target.selector).first();
if (await element.isVisible()) {
const box = await element.boundingBox();
if (box) {
const meetsMinimum = box.height >= 44 && box.width >= 44;
const status = meetsMinimum ? '✓' : '✗';
console.log(`${status} ${target.name}:`);
console.log(` Width: ${box.width}px, Height: ${box.height}px`);
expect(box.height).toBeGreaterThanOrEqual(44);
}
}
}
console.log('================================\n');
});
});