refactor: Extract reusable UI components and add TDD documentation
- Add comprehensive TDD documentation to CLAUDE.md with coverage requirements and examples - Extract reusable UI components to frontend/src/components/ui/ (Modal, FormInput, Button, Alert, etc.) - Add shared constants (schedulePresets) and utility hooks (useCrudMutation, useFormValidation) - Update frontend/CLAUDE.md with component documentation and usage examples - Refactor CreateTaskModal to use shared components and constants - Fix test assertions to be more robust and accurate across all test files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -94,6 +94,13 @@ describe('useAppointments hooks', () => {
|
||||
durationMinutes: 60,
|
||||
status: 'SCHEDULED',
|
||||
notes: 'First appointment',
|
||||
depositAmount: null,
|
||||
depositTransactionId: '',
|
||||
finalChargeTransactionId: '',
|
||||
finalPrice: null,
|
||||
isVariablePricing: false,
|
||||
overpaidAmount: null,
|
||||
remainingBalance: null,
|
||||
});
|
||||
|
||||
// Verify second appointment transformation (with alternative field names and null resource)
|
||||
@@ -107,6 +114,13 @@ describe('useAppointments hooks', () => {
|
||||
durationMinutes: 30,
|
||||
status: 'COMPLETED',
|
||||
notes: '',
|
||||
depositAmount: null,
|
||||
depositTransactionId: '',
|
||||
finalChargeTransactionId: '',
|
||||
finalPrice: null,
|
||||
isVariablePricing: false,
|
||||
overpaidAmount: null,
|
||||
remainingBalance: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -274,6 +288,13 @@ describe('useAppointments hooks', () => {
|
||||
durationMinutes: 60,
|
||||
status: 'SCHEDULED',
|
||||
notes: 'Test note',
|
||||
depositAmount: null,
|
||||
depositTransactionId: '',
|
||||
finalChargeTransactionId: '',
|
||||
finalPrice: null,
|
||||
isVariablePricing: false,
|
||||
overpaidAmount: null,
|
||||
remainingBalance: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -805,7 +805,7 @@ describe('FEATURE_NAMES', () => {
|
||||
expect(FEATURE_NAMES.custom_domain).toBe('Custom Domain');
|
||||
expect(FEATURE_NAMES.white_label).toBe('White Label');
|
||||
expect(FEATURE_NAMES.custom_oauth).toBe('Custom OAuth');
|
||||
expect(FEATURE_NAMES.plugins).toBe('Custom Plugins');
|
||||
expect(FEATURE_NAMES.plugins).toBe('Plugins');
|
||||
expect(FEATURE_NAMES.tasks).toBe('Scheduled Tasks');
|
||||
expect(FEATURE_NAMES.export_data).toBe('Data Export');
|
||||
expect(FEATURE_NAMES.video_conferencing).toBe('Video Conferencing');
|
||||
|
||||
@@ -137,13 +137,12 @@ describe('useResources hooks', () => {
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/resources/', {
|
||||
name: 'New Room',
|
||||
type: 'ROOM',
|
||||
user: null,
|
||||
timezone: 'UTC',
|
||||
user_id: null,
|
||||
max_concurrent_events: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('converts userId to user integer', async () => {
|
||||
it('converts userId to user_id integer', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1 } });
|
||||
|
||||
const { result } = renderHook(() => useCreateResource(), {
|
||||
@@ -159,7 +158,7 @@ describe('useResources hooks', () => {
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/resources/', expect.objectContaining({
|
||||
user: 42,
|
||||
user_id: 42,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
126
frontend/src/hooks/useCrudMutation.ts
Normal file
126
frontend/src/hooks/useCrudMutation.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useMutation, useQueryClient, QueryKey, UseMutationOptions } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
|
||||
type HttpMethod = 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
|
||||
interface CrudMutationConfig<TData, TVariables, TResponse = TData> {
|
||||
/** The API endpoint (e.g., '/resources') */
|
||||
endpoint: string;
|
||||
/** HTTP method */
|
||||
method: HttpMethod;
|
||||
/** Query keys to invalidate on success */
|
||||
invalidateKeys?: QueryKey[];
|
||||
/** Transform response data */
|
||||
transformResponse?: (response: AxiosResponse<TResponse>) => TData;
|
||||
/** React Query mutation options */
|
||||
options?: Omit<
|
||||
UseMutationOptions<TData, AxiosError, TVariables>,
|
||||
'mutationFn'
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic CRUD mutation hook factory
|
||||
*
|
||||
* @example
|
||||
* // Create a resource
|
||||
* const useCreateResource = () => useCrudMutation<Resource, CreateResourceData>({
|
||||
* endpoint: '/resources',
|
||||
* method: 'POST',
|
||||
* invalidateKeys: [['resources']],
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* // Update a resource
|
||||
* const useUpdateResource = () => useCrudMutation<Resource, { id: string; data: Partial<Resource> }>({
|
||||
* endpoint: '/resources',
|
||||
* method: 'PATCH',
|
||||
* invalidateKeys: [['resources']],
|
||||
* });
|
||||
*/
|
||||
export function useCrudMutation<TData = unknown, TVariables = unknown, TResponse = TData>({
|
||||
endpoint,
|
||||
method,
|
||||
invalidateKeys = [],
|
||||
transformResponse,
|
||||
options = {},
|
||||
}: CrudMutationConfig<TData, TVariables, TResponse>) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<TData, AxiosError, TVariables>({
|
||||
mutationFn: async (variables: TVariables) => {
|
||||
let response: AxiosResponse<TResponse>;
|
||||
|
||||
// Handle different variable shapes for different methods
|
||||
if (method === 'DELETE') {
|
||||
// For DELETE, variables is typically just the ID
|
||||
const id = typeof variables === 'object' && variables !== null && 'id' in variables
|
||||
? (variables as { id: string | number }).id
|
||||
: variables;
|
||||
response = await apiClient.delete(`${endpoint}/${id}/`);
|
||||
} else if (method === 'PUT' || method === 'PATCH') {
|
||||
// For PUT/PATCH, variables should have id and data
|
||||
if (typeof variables === 'object' && variables !== null && 'id' in variables) {
|
||||
const { id, ...data } = variables as { id: string | number; [key: string]: unknown };
|
||||
response = await apiClient[method.toLowerCase() as 'put' | 'patch'](`${endpoint}/${id}/`, data);
|
||||
} else {
|
||||
// If no id, just send to the endpoint
|
||||
response = await apiClient[method.toLowerCase() as 'put' | 'patch'](`${endpoint}/`, variables);
|
||||
}
|
||||
} else {
|
||||
// POST - create new
|
||||
response = await apiClient.post(`${endpoint}/`, variables);
|
||||
}
|
||||
|
||||
return transformResponse ? transformResponse(response) : (response.data as unknown as TData);
|
||||
},
|
||||
onSuccess: (data, variables, context) => {
|
||||
// Invalidate specified query keys
|
||||
invalidateKeys.forEach((key) => {
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
});
|
||||
|
||||
// Call custom onSuccess if provided
|
||||
options.onSuccess?.(data, variables, context);
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create hook factory for a resource
|
||||
*/
|
||||
export function createCrudHooks<
|
||||
TResource,
|
||||
TCreateData = Partial<TResource>,
|
||||
TUpdateData = Partial<TResource>
|
||||
>(endpoint: string, queryKey: string) {
|
||||
return {
|
||||
useCreate: (options?: UseMutationOptions<TResource, AxiosError, TCreateData>) =>
|
||||
useCrudMutation<TResource, TCreateData>({
|
||||
endpoint,
|
||||
method: 'POST',
|
||||
invalidateKeys: [[queryKey]],
|
||||
options,
|
||||
}),
|
||||
|
||||
useUpdate: (options?: UseMutationOptions<TResource, AxiosError, { id: string | number } & TUpdateData>) =>
|
||||
useCrudMutation<TResource, { id: string | number } & TUpdateData>({
|
||||
endpoint,
|
||||
method: 'PATCH',
|
||||
invalidateKeys: [[queryKey]],
|
||||
options,
|
||||
}),
|
||||
|
||||
useDelete: (options?: UseMutationOptions<void, AxiosError, string | number>) =>
|
||||
useCrudMutation<void, string | number>({
|
||||
endpoint,
|
||||
method: 'DELETE',
|
||||
invalidateKeys: [[queryKey]],
|
||||
options: options as UseMutationOptions<void, AxiosError, string | number>,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export default useCrudMutation;
|
||||
251
frontend/src/hooks/useFormValidation.ts
Normal file
251
frontend/src/hooks/useFormValidation.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
|
||||
type ValidationRule<T> = (value: T, formData?: Record<string, unknown>) => string | undefined;
|
||||
|
||||
type ValidationSchema<T extends Record<string, unknown>> = {
|
||||
[K in keyof T]?: ValidationRule<T[K]>[];
|
||||
};
|
||||
|
||||
interface UseFormValidationResult<T extends Record<string, unknown>> {
|
||||
/** Current validation errors */
|
||||
errors: Partial<Record<keyof T, string>>;
|
||||
/** Whether the form is valid (no errors) */
|
||||
isValid: boolean;
|
||||
/** Validate a single field */
|
||||
validateField: (field: keyof T, value: T[keyof T]) => string | undefined;
|
||||
/** Validate all fields */
|
||||
validateForm: (data: T) => boolean;
|
||||
/** Set a specific error */
|
||||
setError: (field: keyof T, error: string) => void;
|
||||
/** Clear a specific error */
|
||||
clearError: (field: keyof T) => void;
|
||||
/** Clear all errors */
|
||||
clearAllErrors: () => void;
|
||||
/** Get error for a field */
|
||||
getError: (field: keyof T) => string | undefined;
|
||||
/** Check if a field has an error */
|
||||
hasError: (field: keyof T) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form validation hook with schema-based validation
|
||||
*
|
||||
* @example
|
||||
* const schema = {
|
||||
* email: [required('Email is required'), email('Invalid email')],
|
||||
* password: [required('Password is required'), minLength(8, 'Password must be at least 8 characters')],
|
||||
* };
|
||||
*
|
||||
* const { errors, validateForm, validateField, isValid } = useFormValidation(schema);
|
||||
*
|
||||
* const handleSubmit = () => {
|
||||
* if (validateForm(formData)) {
|
||||
* // Submit form
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
export function useFormValidation<T extends Record<string, unknown>>(
|
||||
schema: ValidationSchema<T>
|
||||
): UseFormValidationResult<T> {
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
|
||||
|
||||
const validateField = useCallback(
|
||||
(field: keyof T, value: T[keyof T], formData?: T): string | undefined => {
|
||||
const rules = schema[field];
|
||||
if (!rules) return undefined;
|
||||
|
||||
for (const rule of rules) {
|
||||
const error = rule(value, formData as Record<string, unknown>);
|
||||
if (error) return error;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[schema]
|
||||
);
|
||||
|
||||
const validateForm = useCallback(
|
||||
(data: T): boolean => {
|
||||
const newErrors: Partial<Record<keyof T, string>> = {};
|
||||
let isValid = true;
|
||||
|
||||
for (const field of Object.keys(schema) as (keyof T)[]) {
|
||||
const error = validateField(field, data[field], data);
|
||||
if (error) {
|
||||
newErrors[field] = error;
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
},
|
||||
[schema, validateField]
|
||||
);
|
||||
|
||||
const setError = useCallback((field: keyof T, error: string) => {
|
||||
setErrors((prev) => ({ ...prev, [field]: error }));
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback((field: keyof T) => {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[field];
|
||||
return newErrors;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearAllErrors = useCallback(() => {
|
||||
setErrors({});
|
||||
}, []);
|
||||
|
||||
const getError = useCallback(
|
||||
(field: keyof T): string | undefined => errors[field],
|
||||
[errors]
|
||||
);
|
||||
|
||||
const hasError = useCallback(
|
||||
(field: keyof T): boolean => !!errors[field],
|
||||
[errors]
|
||||
);
|
||||
|
||||
const isValid = useMemo(() => Object.keys(errors).length === 0, [errors]);
|
||||
|
||||
return {
|
||||
errors,
|
||||
isValid,
|
||||
validateField,
|
||||
validateForm,
|
||||
setError,
|
||||
clearError,
|
||||
clearAllErrors,
|
||||
getError,
|
||||
hasError,
|
||||
};
|
||||
}
|
||||
|
||||
// ============= Built-in validation rules =============
|
||||
|
||||
/**
|
||||
* Required field validation
|
||||
*/
|
||||
export const required = (message = 'This field is required'): ValidationRule<unknown> => {
|
||||
return (value) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return message;
|
||||
}
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return message;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Email validation
|
||||
*/
|
||||
export const email = (message = 'Invalid email address'): ValidationRule<string> => {
|
||||
return (value) => {
|
||||
if (!value) return undefined;
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(value) ? undefined : message;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimum length validation
|
||||
*/
|
||||
export const minLength = (min: number, message?: string): ValidationRule<string> => {
|
||||
return (value) => {
|
||||
if (!value) return undefined;
|
||||
return value.length >= min
|
||||
? undefined
|
||||
: message || `Must be at least ${min} characters`;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Maximum length validation
|
||||
*/
|
||||
export const maxLength = (max: number, message?: string): ValidationRule<string> => {
|
||||
return (value) => {
|
||||
if (!value) return undefined;
|
||||
return value.length <= max
|
||||
? undefined
|
||||
: message || `Must be at most ${max} characters`;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimum value validation (for numbers)
|
||||
*/
|
||||
export const minValue = (min: number, message?: string): ValidationRule<number> => {
|
||||
return (value) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return value >= min
|
||||
? undefined
|
||||
: message || `Must be at least ${min}`;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Maximum value validation (for numbers)
|
||||
*/
|
||||
export const maxValue = (max: number, message?: string): ValidationRule<number> => {
|
||||
return (value) => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return value <= max
|
||||
? undefined
|
||||
: message || `Must be at most ${max}`;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Pattern/regex validation
|
||||
*/
|
||||
export const pattern = (regex: RegExp, message = 'Invalid format'): ValidationRule<string> => {
|
||||
return (value) => {
|
||||
if (!value) return undefined;
|
||||
return regex.test(value) ? undefined : message;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* URL validation
|
||||
*/
|
||||
export const url = (message = 'Invalid URL'): ValidationRule<string> => {
|
||||
return (value) => {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
new URL(value);
|
||||
return undefined;
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Match another field (e.g., confirm password)
|
||||
*/
|
||||
export const matches = <T extends Record<string, unknown>>(
|
||||
fieldName: keyof T,
|
||||
message = 'Fields must match'
|
||||
): ValidationRule<unknown> => {
|
||||
return (value, formData) => {
|
||||
if (!formData) return undefined;
|
||||
return value === formData[fieldName as string] ? undefined : message;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Phone number validation (basic)
|
||||
*/
|
||||
export const phone = (message = 'Invalid phone number'): ValidationRule<string> => {
|
||||
return (value) => {
|
||||
if (!value) return undefined;
|
||||
const phoneRegex = /^[+]?[(]?[0-9]{1,4}[)]?[-\s./0-9]*$/;
|
||||
return phoneRegex.test(value) ? undefined : message;
|
||||
};
|
||||
};
|
||||
|
||||
export default useFormValidation;
|
||||
Reference in New Issue
Block a user