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:
poduck
2025-12-10 15:27:27 -05:00
parent 18c9a69d75
commit 8c52d6a275
48 changed files with 2780 additions and 444 deletions

View 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;