Backup: 2025-06-07 19:48 - Script test
[Restore from backup: vip-coordinator-backup-2025-06-07-19-48-script-test]
This commit is contained in:
72
frontend/src/components/AsyncErrorBoundary.tsx
Normal file
72
frontend/src/components/AsyncErrorBoundary.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class AsyncErrorBoundary extends Component<Props, State> {
|
||||
state: State = {
|
||||
hasError: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
console.error('AsyncErrorBoundary caught an error:', error);
|
||||
this.props.onError?.(error);
|
||||
}
|
||||
|
||||
retry = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-400 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
Failed to load data
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-red-700">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={this.retry}
|
||||
className="mt-3 text-sm text-red-600 hover:text-red-500 underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Driver {
|
||||
id: string;
|
||||
@@ -35,7 +35,7 @@ const EditDriverForm: React.FC<EditDriverFormProps> = ({ driver, onSubmit, onCan
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name === 'lat' || name === 'lng') {
|
||||
setFormData(prev => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Flight {
|
||||
flightNumber: string;
|
||||
@@ -191,15 +191,6 @@ const EditVipForm: React.FC<EditVipFormProps> = ({ vip, onSubmit, onCancel }) =>
|
||||
}
|
||||
};
|
||||
|
||||
const formatFlightTime = (timeString: string) => {
|
||||
if (!timeString) return '';
|
||||
return new Date(timeString).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
|
||||
114
frontend/src/components/ErrorBoundary.tsx
Normal file
114
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
errorInfo: null
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
this.setState({
|
||||
errorInfo
|
||||
});
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return <>{this.props.fallback}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="bg-white p-8 rounded-lg shadow-md max-w-2xl w-full">
|
||||
<div className="flex items-center mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-red-500 mr-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<h1 className="text-2xl font-bold text-gray-800">Something went wrong</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
We're sorry, but something unexpected happened. Please try refreshing the page or contact support if the problem persists.
|
||||
</p>
|
||||
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<details className="mb-6">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||
Error details (development mode only)
|
||||
</summary>
|
||||
<div className="mt-2 p-4 bg-gray-100 rounded text-xs">
|
||||
<p className="font-mono text-red-600 mb-2">{this.state.error.toString()}</p>
|
||||
{this.state.errorInfo && (
|
||||
<pre className="text-gray-700 overflow-auto">
|
||||
{this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
53
frontend/src/components/ErrorMessage.tsx
Normal file
53
frontend/src/components/ErrorMessage.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ErrorMessageProps {
|
||||
message: string;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ErrorMessage: React.FC<ErrorMessageProps> = ({
|
||||
message,
|
||||
onDismiss,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`bg-red-50 border border-red-200 rounded-lg p-4 ${className}`}>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-red-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<p className="text-sm text-red-700">{message}</p>
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<div className="ml-auto pl-3">
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50"
|
||||
>
|
||||
<span className="sr-only">Dismiss</span>
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
interface GanttEvent {
|
||||
id: string;
|
||||
@@ -163,7 +162,7 @@ const GanttChart: React.FC<GanttChartProps> = ({ events, driverName }) => {
|
||||
|
||||
{/* Events */}
|
||||
<div style={{ padding: '1rem 0' }}>
|
||||
{events.map((event, index) => {
|
||||
{events.map((event) => {
|
||||
const position = calculateEventPosition(event, timeRange);
|
||||
return (
|
||||
<div
|
||||
|
||||
104
frontend/src/components/GoogleLogin.tsx
Normal file
104
frontend/src/components/GoogleLogin.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface GoogleLoginProps {
|
||||
onSuccess: (user: any, token: string) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
// Helper to decode JWT token
|
||||
function parseJwt(token: string) {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(''));
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google: any;
|
||||
}
|
||||
}
|
||||
|
||||
const GoogleLogin: React.FC<GoogleLoginProps> = ({ onSuccess, onError }) => {
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize Google Sign-In
|
||||
const initializeGoogleSignIn = () => {
|
||||
if (!window.google) {
|
||||
setTimeout(initializeGoogleSignIn, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: '308004695553-6k34bbq22frc4e76kejnkgq8mncepbbg.apps.googleusercontent.com',
|
||||
callback: handleCredentialResponse,
|
||||
auto_select: false,
|
||||
cancel_on_tap_outside: true,
|
||||
});
|
||||
|
||||
// Render the button
|
||||
if (buttonRef.current) {
|
||||
window.google.accounts.id.renderButton(
|
||||
buttonRef.current,
|
||||
{
|
||||
theme: 'outline',
|
||||
size: 'large',
|
||||
text: 'signin_with',
|
||||
shape: 'rectangular',
|
||||
logo_alignment: 'center',
|
||||
width: 300,
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCredentialResponse = async (response: any) => {
|
||||
try {
|
||||
// Send the Google credential to our backend
|
||||
const res = await fetch('/auth/google/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ credential: response.credential }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
if (data.error === 'pending_approval') {
|
||||
// User created but needs approval - still successful login
|
||||
onSuccess(data.user, data.token);
|
||||
} else {
|
||||
throw new Error(data.error || 'Authentication failed');
|
||||
}
|
||||
} else {
|
||||
onSuccess(data.user, data.token);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during authentication:', error);
|
||||
onError(error instanceof Error ? error.message : 'Failed to process authentication');
|
||||
}
|
||||
};
|
||||
|
||||
initializeGoogleSignIn();
|
||||
}, [onSuccess, onError]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div ref={buttonRef} className="google-signin-button"></div>
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Sign in with your Google account to continue
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleLogin;
|
||||
45
frontend/src/components/GoogleOAuthButton.tsx
Normal file
45
frontend/src/components/GoogleOAuthButton.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { apiCall } from '../utils/api';
|
||||
|
||||
interface GoogleOAuthButtonProps {
|
||||
onSuccess: (user: any, token: string) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
const GoogleOAuthButton: React.FC<GoogleOAuthButtonProps> = ({ onSuccess, onError }) => {
|
||||
const handleGoogleLogin = async () => {
|
||||
try {
|
||||
// Get the OAuth URL from backend
|
||||
const { data } = await apiCall('/auth/google/url');
|
||||
|
||||
if (data && data.url) {
|
||||
// Redirect to Google OAuth (no popup to avoid CORS issues)
|
||||
window.location.href = data.url;
|
||||
} else {
|
||||
onError('Failed to get authentication URL');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initiating Google login:', error);
|
||||
onError('Failed to start authentication');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleGoogleLogin}
|
||||
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-lg shadow-sm bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
|
||||
<path fill="#4285F4" d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"/>
|
||||
<path fill="#34A853" d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"/>
|
||||
<path fill="#FBBC05" d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"/>
|
||||
<path fill="#EA4335" d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700">Sign in with Google</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleOAuthButton;
|
||||
45
frontend/src/components/LoadingSpinner.tsx
Normal file
45
frontend/src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
size = 'md',
|
||||
message
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-4">
|
||||
<svg
|
||||
className={`animate-spin ${sizeClasses[size]} text-blue-500`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{message && (
|
||||
<p className="mt-2 text-sm text-gray-600">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -65,7 +65,12 @@ const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to get user info: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(user => {
|
||||
onLogin(user);
|
||||
// Clean up URL and redirect to dashboard
|
||||
@@ -73,6 +78,7 @@ const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error getting user info:', error);
|
||||
alert('Login failed. Please try again.');
|
||||
localStorage.removeItem('authToken');
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
|
||||
109
frontend/src/components/OAuthCallback.tsx
Normal file
109
frontend/src/components/OAuthCallback.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { apiCall } from '../utils/api';
|
||||
|
||||
const OAuthCallback: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [processing, setProcessing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
// Check for errors from OAuth provider
|
||||
const errorParam = searchParams.get('error');
|
||||
if (errorParam) {
|
||||
setError(`Authentication failed: ${errorParam}`);
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the authorization code
|
||||
const code = searchParams.get('code');
|
||||
if (!code) {
|
||||
setError('No authorization code received');
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange the code for a token
|
||||
const response = await apiCall('/auth/google/exchange', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
if (response.error === 'pending_approval') {
|
||||
// User needs approval
|
||||
localStorage.setItem('authToken', response.data.token);
|
||||
localStorage.setItem('user', JSON.stringify(response.data.user));
|
||||
navigate('/pending-approval');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data && response.data.token) {
|
||||
// Success! Store the token and user data
|
||||
localStorage.setItem('authToken', response.data.token);
|
||||
localStorage.setItem('user', JSON.stringify(response.data.user));
|
||||
|
||||
// Redirect to dashboard
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
setError('Failed to authenticate');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('OAuth callback error:', err);
|
||||
if (err.message?.includes('pending_approval')) {
|
||||
// This means the user was created but needs approval
|
||||
navigate('/');
|
||||
} else {
|
||||
setError(err.message || 'Authentication failed');
|
||||
}
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [navigate, searchParams]);
|
||||
|
||||
if (processing) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent mx-auto mb-4"></div>
|
||||
<p className="text-lg font-medium text-slate-700">Completing sign in...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-800 mb-2">Authentication Failed</h2>
|
||||
<p className="text-slate-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default OAuthCallback;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiCall } from '../config/api';
|
||||
import DriverSelector from './DriverSelector';
|
||||
|
||||
@@ -381,7 +381,7 @@ interface ScheduleEventFormProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const ScheduleEventForm: React.FC<ScheduleEventFormProps> = ({ vipId, event, onSubmit, onCancel }) => {
|
||||
const ScheduleEventForm: React.FC<ScheduleEventFormProps> = ({ event, onSubmit, onCancel }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
title: event?.title || '',
|
||||
location: event?.location || '',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { API_BASE_URL } from '../config/api';
|
||||
|
||||
interface User {
|
||||
@@ -131,7 +131,7 @@ const UserManagement: React.FC<UserManagementProps> = ({ currentUser }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const approveUser = async (userEmail: string, userName: string) => {
|
||||
const approveUser = async (userEmail: string) => {
|
||||
setUpdatingUser(userEmail);
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
@@ -424,7 +424,7 @@ const UserManagement: React.FC<UserManagementProps> = ({ currentUser }) => {
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => approveUser(user.email, user.name)}
|
||||
onClick={() => approveUser(user.email)}
|
||||
disabled={updatingUser === user.email}
|
||||
className={`px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-md transition-colors ${
|
||||
updatingUser === user.email ? 'opacity-50 cursor-not-allowed' : ''
|
||||
|
||||
257
frontend/src/components/UserOnboarding.tsx
Normal file
257
frontend/src/components/UserOnboarding.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiCall } from '../utils/api';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
|
||||
interface OnboardingData {
|
||||
requestedRole: 'coordinator' | 'driver' | 'viewer';
|
||||
phone: string;
|
||||
organization: string;
|
||||
reason: string;
|
||||
// Driver-specific fields
|
||||
vehicleType?: string;
|
||||
vehicleCapacity?: number;
|
||||
licensePlate?: string;
|
||||
}
|
||||
|
||||
const UserOnboarding: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState<OnboardingData>({
|
||||
requestedRole: 'viewer',
|
||||
phone: '',
|
||||
organization: '',
|
||||
reason: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await apiCall('/api/users/complete-onboarding', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
onboardingData: formData,
|
||||
phone: formData.phone,
|
||||
organization: formData.organization,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast('Onboarding completed! Your account is pending approval.', 'success');
|
||||
navigate('/pending-approval');
|
||||
} else {
|
||||
showToast('Failed to complete onboarding. Please try again.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('An error occurred. Please try again.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = (role: 'coordinator' | 'driver' | 'viewer') => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
requestedRole: role,
|
||||
// Clear driver fields if not driver
|
||||
vehicleType: role === 'driver' ? prev.vehicleType : undefined,
|
||||
vehicleCapacity: role === 'driver' ? prev.vehicleCapacity : undefined,
|
||||
licensePlate: role === 'driver' ? prev.licensePlate : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-slate-800 mb-2">Welcome to VIP Coordinator</h1>
|
||||
<p className="text-slate-600">Please complete your profile to request access</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Role Selection */}
|
||||
<div className="form-section">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">
|
||||
What type of access do you need?
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRoleChange('coordinator')}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
formData.requestedRole === 'coordinator'
|
||||
? 'border-amber-500 bg-amber-50'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">📋</div>
|
||||
<div className="font-semibold text-slate-800">Coordinator</div>
|
||||
<div className="text-xs text-slate-600 mt-1">Manage VIPs & schedules</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRoleChange('driver')}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
formData.requestedRole === 'driver'
|
||||
? 'border-amber-500 bg-amber-50'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">🚗</div>
|
||||
<div className="font-semibold text-slate-800">Driver</div>
|
||||
<div className="text-xs text-slate-600 mt-1">Transport VIPs</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRoleChange('viewer')}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
formData.requestedRole === 'viewer'
|
||||
? 'border-amber-500 bg-amber-50'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">👁️</div>
|
||||
<div className="font-semibold text-slate-800">Viewer</div>
|
||||
<div className="text-xs text-slate-600 mt-1">View-only access</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Common Fields */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Phone Number *
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
|
||||
className="form-input w-full"
|
||||
placeholder="+1 (555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Organization *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.organization}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, organization: e.target.value }))}
|
||||
className="form-input w-full"
|
||||
placeholder="Your company or department"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Driver-specific Fields */}
|
||||
{formData.requestedRole === 'driver' && (
|
||||
<div className="space-y-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h3 className="font-semibold text-slate-800 mb-3">Driver Information</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Vehicle Type *
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
value={formData.vehicleType || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, vehicleType: e.target.value }))}
|
||||
className="form-select w-full"
|
||||
>
|
||||
<option value="">Select vehicle type</option>
|
||||
<option value="sedan">Sedan</option>
|
||||
<option value="suv">SUV</option>
|
||||
<option value="van">Van</option>
|
||||
<option value="minibus">Minibus</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Passenger Capacity *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="20"
|
||||
value={formData.vehicleCapacity || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, vehicleCapacity: parseInt(e.target.value) }))}
|
||||
className="form-input w-full"
|
||||
placeholder="4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
License Plate *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.licensePlate || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, licensePlate: e.target.value }))}
|
||||
className="form-input w-full"
|
||||
placeholder="ABC-1234"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reason for Access */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Why do you need access? *
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
value={formData.reason}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, reason: e.target.value }))}
|
||||
className="form-textarea w-full"
|
||||
placeholder="Please explain your role and why you need access to the VIP Coordinator system..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/')}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? <LoadingSpinner size="sm" /> : 'Submit Request'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserOnboarding;
|
||||
168
frontend/src/components/__tests__/GoogleLogin.test.tsx
Normal file
168
frontend/src/components/__tests__/GoogleLogin.test.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '../../tests/test-utils';
|
||||
import GoogleLogin from '../GoogleLogin';
|
||||
|
||||
describe('GoogleLogin', () => {
|
||||
const mockOnSuccess = vi.fn();
|
||||
const mockOnError = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders login button', () => {
|
||||
render(
|
||||
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
|
||||
);
|
||||
|
||||
// Check if Google button container is rendered
|
||||
const buttonContainer = screen.getByTestId('google-signin-button');
|
||||
expect(buttonContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes Google Identity Services on mount', () => {
|
||||
render(
|
||||
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
|
||||
);
|
||||
|
||||
expect(google.accounts.id.initialize).toHaveBeenCalledWith({
|
||||
client_id: expect.any(String),
|
||||
callback: expect.any(Function),
|
||||
auto_select: true,
|
||||
cancel_on_tap_outside: false,
|
||||
});
|
||||
|
||||
expect(google.accounts.id.renderButton).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles successful login', async () => {
|
||||
// Get the callback function passed to initialize
|
||||
let googleCallback: any;
|
||||
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
|
||||
googleCallback = config.callback;
|
||||
});
|
||||
|
||||
render(
|
||||
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
|
||||
);
|
||||
|
||||
// Mock successful server response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
token: 'test-jwt-token',
|
||||
user: {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'coordinator',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Simulate Google credential response
|
||||
const mockCredential = { credential: 'mock-google-credential' };
|
||||
await googleCallback(mockCredential);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/auth/google/verify'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ credential: 'mock-google-credential' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSuccess).toHaveBeenCalledWith({
|
||||
token: 'test-jwt-token',
|
||||
user: {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'coordinator',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('handles login error', async () => {
|
||||
let googleCallback: any;
|
||||
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
|
||||
googleCallback = config.callback;
|
||||
});
|
||||
|
||||
render(
|
||||
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
|
||||
);
|
||||
|
||||
// Mock error response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: 'Invalid credential' }),
|
||||
});
|
||||
|
||||
const mockCredential = { credential: 'invalid-credential' };
|
||||
await googleCallback(mockCredential);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnError).toHaveBeenCalledWith('Authentication failed');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles network error', async () => {
|
||||
let googleCallback: any;
|
||||
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
|
||||
googleCallback = config.callback;
|
||||
});
|
||||
|
||||
render(
|
||||
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
|
||||
);
|
||||
|
||||
// Mock network error
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const mockCredential = { credential: 'mock-credential' };
|
||||
await googleCallback(mockCredential);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnError).toHaveBeenCalledWith('Network error. Please try again.');
|
||||
});
|
||||
});
|
||||
|
||||
it('displays loading state during authentication', async () => {
|
||||
let googleCallback: any;
|
||||
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
|
||||
googleCallback = config.callback;
|
||||
});
|
||||
|
||||
render(
|
||||
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
|
||||
);
|
||||
|
||||
// Mock a delayed response
|
||||
(global.fetch as any).mockImplementation(() =>
|
||||
new Promise(resolve => setTimeout(() => resolve({
|
||||
ok: true,
|
||||
json: async () => ({ token: 'test-token', user: {} }),
|
||||
}), 100))
|
||||
);
|
||||
|
||||
const mockCredential = { credential: 'mock-credential' };
|
||||
googleCallback(mockCredential);
|
||||
|
||||
// Check for loading state
|
||||
expect(screen.getByText('Authenticating...')).toBeInTheDocument();
|
||||
|
||||
// Wait for authentication to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Authenticating...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
196
frontend/src/components/__tests__/VipForm.test.tsx
Normal file
196
frontend/src/components/__tests__/VipForm.test.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '../../tests/test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import VipForm from '../VipForm';
|
||||
|
||||
describe('VipForm', () => {
|
||||
const mockOnSubmit = vi.fn();
|
||||
const mockOnCancel = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders all form fields', () => {
|
||||
render(
|
||||
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(/full name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/title/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/organization/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/contact information/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/arrival date/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/departure date/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/transportation mode/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/hotel/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/room number/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/additional notes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows flight-specific fields when flight mode is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
const transportSelect = screen.getByLabelText(/transportation mode/i);
|
||||
await user.selectOptions(transportSelect, 'flight');
|
||||
|
||||
expect(screen.getByLabelText(/airport/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/flight number/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides flight fields when self-driving mode is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
// First select flight to show fields
|
||||
const transportSelect = screen.getByLabelText(/transportation mode/i);
|
||||
await user.selectOptions(transportSelect, 'flight');
|
||||
|
||||
expect(screen.getByLabelText(/airport/i)).toBeInTheDocument();
|
||||
|
||||
// Then switch to self-driving
|
||||
await user.selectOptions(transportSelect, 'self_driving');
|
||||
|
||||
expect(screen.queryByLabelText(/airport/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/flight number/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits form with valid data', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
// Fill out the form
|
||||
await user.type(screen.getByLabelText(/full name/i), 'John Doe');
|
||||
await user.type(screen.getByLabelText(/title/i), 'CEO');
|
||||
await user.type(screen.getByLabelText(/organization/i), 'Test Corp');
|
||||
await user.type(screen.getByLabelText(/contact information/i), '+1234567890');
|
||||
await user.type(screen.getByLabelText(/arrival date/i), '2025-01-15T10:00');
|
||||
await user.type(screen.getByLabelText(/departure date/i), '2025-01-16T14:00');
|
||||
await user.selectOptions(screen.getByLabelText(/transportation mode/i), 'flight');
|
||||
await user.type(screen.getByLabelText(/airport/i), 'LAX');
|
||||
await user.type(screen.getByLabelText(/flight number/i), 'AA123');
|
||||
await user.type(screen.getByLabelText(/hotel/i), 'Hilton');
|
||||
await user.type(screen.getByLabelText(/room number/i), '1234');
|
||||
await user.type(screen.getByLabelText(/additional notes/i), 'VIP guest');
|
||||
|
||||
// Submit the form
|
||||
const submitButton = screen.getByRole('button', { name: /add vip/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith({
|
||||
name: 'John Doe',
|
||||
title: 'CEO',
|
||||
organization: 'Test Corp',
|
||||
contact_info: '+1234567890',
|
||||
arrival_datetime: '2025-01-15T10:00',
|
||||
departure_datetime: '2025-01-16T14:00',
|
||||
transportation_mode: 'flight',
|
||||
airport: 'LAX',
|
||||
flight_number: 'AA123',
|
||||
hotel: 'Hilton',
|
||||
room_number: '1234',
|
||||
notes: 'VIP guest',
|
||||
status: 'scheduled',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('validates required fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
// Try to submit empty form
|
||||
const submitButton = screen.getByRole('button', { name: /add vip/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
// Check that onSubmit was not called
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled();
|
||||
|
||||
// Check for HTML5 validation (browser will show validation messages)
|
||||
const nameInput = screen.getByLabelText(/full name/i) as HTMLInputElement;
|
||||
expect(nameInput.validity.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('calls onCancel when cancel button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pre-fills form when editing existing VIP', () => {
|
||||
const existingVip = {
|
||||
id: '123',
|
||||
name: 'Jane Smith',
|
||||
title: 'VP Sales',
|
||||
organization: 'Another Corp',
|
||||
contact_info: '+0987654321',
|
||||
arrival_datetime: '2025-01-15T14:00',
|
||||
departure_datetime: '2025-01-16T10:00',
|
||||
transportation_mode: 'self_driving' as const,
|
||||
hotel: 'Marriott',
|
||||
room_number: '567',
|
||||
status: 'scheduled' as const,
|
||||
notes: 'Arrives by car',
|
||||
};
|
||||
|
||||
render(
|
||||
<VipForm
|
||||
vip={existingVip}
|
||||
onSubmit={mockOnSubmit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('VP Sales')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Another Corp')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('+0987654321')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('2025-01-15T14:00')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('2025-01-16T10:00')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('self_driving')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Marriott')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('567')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Arrives by car')).toBeInTheDocument();
|
||||
|
||||
// Should show "Update VIP" instead of "Add VIP"
|
||||
expect(screen.getByRole('button', { name: /update vip/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates departure date is after arrival date', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
// Set departure before arrival
|
||||
await user.type(screen.getByLabelText(/arrival date/i), '2025-01-16T14:00');
|
||||
await user.type(screen.getByLabelText(/departure date/i), '2025-01-15T10:00');
|
||||
|
||||
// Fill other required fields
|
||||
await user.type(screen.getByLabelText(/full name/i), 'John Doe');
|
||||
await user.type(screen.getByLabelText(/title/i), 'CEO');
|
||||
await user.type(screen.getByLabelText(/organization/i), 'Test Corp');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /add vip/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
// Form should not submit
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user