Working with forms

Form handling is a critical aspect of web applications. The Aurelia Fetch Client provides comprehensive support for various form submission methods, file uploads, and advanced form processing scenarios.

Form Data Submission

Basic Form Submission with FormData

import { IHttpClient } from '@aurelia/fetch-client';
import { resolve } from '@aurelia/kernel';

export class FormService {
  private http = resolve(IHttpClient);

  async submitForm(formElement: HTMLFormElement): Promise<any> {
    const formData = new FormData(formElement);
    
    try {
      const response = await this.http.post('/api/forms/submit', {
        body: formData
      });

      if (!response.ok) {
        throw new Error(`Form submission failed: ${response.statusText}`);
      }

      return await response.json();
    } catch (error) {
      console.error('Form submission error:', error);
      throw error;
    }
  }

  async submitFormData(data: Record<string, any>): Promise<any> {
    const formData = new FormData();
    
    // Add form fields
    Object.entries(data).forEach(([key, value]) => {
      if (value !== null && value !== undefined) {
        if (value instanceof File || value instanceof Blob) {
          formData.append(key, value);
        } else if (Array.isArray(value)) {
          value.forEach(item => formData.append(`${key}[]`, item));
        } else {
          formData.append(key, String(value));
        }
      }
    });

    const response = await this.http.post('/api/forms/data', {
      body: formData
    });

    if (!response.ok) {
      throw new Error(`Form submission failed: ${response.statusText}`);
    }

    return await response.json();
  }
}

URL-Encoded Form Submission

For traditional form submissions that don't involve file uploads:

export class UrlEncodedFormService {
  private http = resolve(IHttpClient);

  async submitUrlEncodedForm(data: Record<string, string | number | boolean>): Promise<any> {
    const params = new URLSearchParams();
    
    Object.entries(data).forEach(([key, value]) => {
      if (value !== null && value !== undefined) {
        params.append(key, String(value));
      }
    });

    const response = await this.http.post('/api/forms/urlencoded', {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      body: params.toString()
    });

    if (!response.ok) {
      throw new Error(`Form submission failed: ${response.statusText}`);
    }

    return await response.json();
  }

  async loginUser(username: string, password: string): Promise<{ token: string; user: any }> {
    return this.submitUrlEncodedForm({
      username,
      password,
      grant_type: 'password'
    });
  }
}

File Upload Operations

Single File Upload

export class FileUploadService {
  private http = resolve(IHttpClient);

  async uploadSingleFile(
    file: File, 
    additionalData?: Record<string, any>
  ): Promise<{ id: string; url: string; size: number }> {
    const formData = new FormData();
    formData.append('file', file);
    formData.append('originalName', file.name);
    formData.append('mimeType', file.type);
    
    // Add any additional form data
    if (additionalData) {
      Object.entries(additionalData).forEach(([key, value]) => {
        formData.append(key, String(value));
      });
    }

    const response = await this.http.post('/api/files/upload', {
      body: formData
    });

    if (!response.ok) {
      throw new Error(`Upload failed: ${response.statusText}`);
    }

    return await response.json();
  }

  async uploadProfileImage(file: File, userId: string): Promise<{ profileImageUrl: string }> {
    // Validate file type
    if (!file.type.startsWith('image/')) {
      throw new Error('Only image files are allowed');
    }

    // Validate file size (e.g., max 5MB)
    const maxSize = 5 * 1024 * 1024;
    if (file.size > maxSize) {
      throw new Error('File size must be less than 5MB');
    }

    return this.uploadSingleFile(file, { userId, type: 'profile' });
  }
}

Multiple File Upload

export class MultiFileUploadService {
  private http = resolve(IHttpClient);

  async uploadMultipleFiles(
    files: FileList | File[],
    onProgress?: (loaded: number, total: number) => void
  ): Promise<Array<{ id: string; name: string; url: string }>> {
    const formData = new FormData();
    const fileArray = Array.from(files);
    
    fileArray.forEach((file, index) => {
      formData.append(`files[${index}]`, file);
      formData.append(`names[${index}]`, file.name);
    });

    // Add metadata
    formData.append('fileCount', String(fileArray.length));
    formData.append('uploadTimestamp', new Date().toISOString());

    const response = await this.http.post('/api/files/upload-multiple', {
      body: formData
    });

    if (!response.ok) {
      throw new Error(`Multi-file upload failed: ${response.statusText}`);
    }

    return await response.json();
  }

  async uploadDocuments(files: FileList): Promise<any> {
    // Filter for document types
    const allowedTypes = [
      'application/pdf',
      'application/msword',
      'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
      'text/plain'
    ];

    const validFiles = Array.from(files).filter(file => 
      allowedTypes.includes(file.type)
    );

    if (validFiles.length === 0) {
      throw new Error('No valid document files selected');
    }

    return this.uploadMultipleFiles(validFiles);
  }
}

Upload with Progress Tracking

export class ProgressUploadService {
  private http = resolve(IHttpClient);

  async uploadWithProgress(
    file: File,
    onProgress: (percentage: number, loaded: number, total: number) => void,
    onComplete: (result: any) => void,
    onError: (error: Error) => void
  ): Promise<void> {
    const formData = new FormData();
    formData.append('file', file);
    formData.append('filename', file.name);

    try {
      // Note: Native fetch doesn't support upload progress directly
      // This example shows the pattern, but you might need XMLHttpRequest for true upload progress
      const xhr = new XMLHttpRequest();

      xhr.upload.addEventListener('progress', (event) => {
        if (event.lengthComputable) {
          const percentage = Math.round((event.loaded / event.total) * 100);
          onProgress(percentage, event.loaded, event.total);
        }
      });

      xhr.addEventListener('load', () => {
        if (xhr.status >= 200 && xhr.status < 300) {
          const result = JSON.parse(xhr.responseText);
          onComplete(result);
        } else {
          onError(new Error(`Upload failed: ${xhr.statusText}`));
        }
      });

      xhr.addEventListener('error', () => {
        onError(new Error('Upload failed due to network error'));
      });

      xhr.open('POST', '/api/files/upload-progress');
      xhr.send(formData);

    } catch (error) {
      onError(error as Error);
    }
  }

  // Alternative approach using fetch with chunked upload for progress
  async uploadFileChunked(
    file: File,
    onProgress: (percentage: number) => void
  ): Promise<any> {
    const chunkSize = 1024 * 1024; // 1MB chunks
    const totalChunks = Math.ceil(file.size / chunkSize);
    const uploadId = this.generateUploadId();

    try {
      // Initialize upload
      await this.http.post('/api/files/upload/init', {
        body: JSON.stringify({
          uploadId,
          filename: file.name,
          fileSize: file.size,
          totalChunks
        }),
        headers: { 'Content-Type': 'application/json' }
      });

      // Upload chunks
      for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
        const start = chunkIndex * chunkSize;
        const end = Math.min(start + chunkSize, file.size);
        const chunk = file.slice(start, end);

        const formData = new FormData();
        formData.append('uploadId', uploadId);
        formData.append('chunkIndex', String(chunkIndex));
        formData.append('chunk', chunk);

        await this.http.post('/api/files/upload/chunk', {
          body: formData
        });

        const percentage = Math.round(((chunkIndex + 1) / totalChunks) * 100);
        onProgress(percentage);
      }

      // Finalize upload
      const response = await this.http.post('/api/files/upload/finalize', {
        body: JSON.stringify({ uploadId }),
        headers: { 'Content-Type': 'application/json' }
      });

      return await response.json();

    } catch (error) {
      // Cleanup on error
      await this.http.delete(`/api/files/upload/${uploadId}`).catch(() => {});
      throw error;
    }
  }

  private generateUploadId(): string {
    return `upload-${Date.now()}-${Math.random().toString(36).substring(2)}`;
  }
}

Advanced Form Handling

Form Validation Integration

interface ValidationError {
  field: string;
  message: string;
  code: string;
}

export class ValidatedFormService {
  private http = resolve(IHttpClient);

  async submitWithValidation(formData: FormData): Promise<any> {
    try {
      const response = await this.http.post('/api/forms/validated', {
        body: formData
      });

      if (!response.ok) {
        if (response.status === 422) {
          // Validation errors
          const validationErrors: ValidationError[] = await response.json();
          throw new ValidationError('Form validation failed', validationErrors);
        }
        throw new Error(`Form submission failed: ${response.statusText}`);
      }

      return await response.json();
    } catch (error) {
      if (error instanceof ValidationError) {
        this.handleValidationErrors(error.errors);
      }
      throw error;
    }
  }

  private handleValidationErrors(errors: ValidationError[]): void {
    errors.forEach(error => {
      const field = document.querySelector(`[name="${error.field}"]`);
      if (field) {
        field.classList.add('error');
        
        // Add error message
        const errorElement = document.createElement('div');
        errorElement.className = 'error-message';
        errorElement.textContent = error.message;
        field.parentElement?.appendChild(errorElement);
      }
    });
  }
}

class ValidationError extends Error {
  constructor(message: string, public errors: ValidationError[]) {
    super(message);
  }
}

Dynamic Form Building

export class DynamicFormService {
  private http = resolve(IHttpClient);

  async submitDynamicForm(formConfig: any, values: Record<string, any>): Promise<any> {
    const formData = new FormData();
    
    // Process form fields based on configuration
    formConfig.fields.forEach((field: any) => {
      const value = values[field.name];
      
      if (value !== null && value !== undefined) {
        switch (field.type) {
          case 'file':
            if (value instanceof File) {
              formData.append(field.name, value);
            }
            break;
          case 'array':
            if (Array.isArray(value)) {
              value.forEach(item => formData.append(`${field.name}[]`, item));
            }
            break;
          case 'json':
            formData.append(field.name, JSON.stringify(value));
            break;
          default:
            formData.append(field.name, String(value));
        }
      }
    });

    // Add form metadata
    formData.append('_formType', formConfig.type);
    formData.append('_version', formConfig.version);

    const response = await this.http.post('/api/forms/dynamic', {
      body: formData
    });

    if (!response.ok) {
      throw new Error(`Dynamic form submission failed: ${response.statusText}`);
    }

    return await response.json();
  }
}

File Upload with AbortController

export class AbortableUploadService {
  private http = resolve(IHttpClient);

  async uploadWithCancellation(
    file: File,
    onProgress?: (percentage: number) => void
  ): Promise<{ result: any; abort: () => void }> {
    const controller = new AbortController();
    const formData = new FormData();
    formData.append('file', file);

    const uploadPromise = this.http.post('/api/files/upload', {
      body: formData,
      signal: controller.signal
    }).then(response => {
      if (!response.ok) {
        throw new Error(`Upload failed: ${response.statusText}`);
      }
      return response.json();
    });

    return {
      result: uploadPromise,
      abort: () => controller.abort()
    };
  }

  async uploadMultipleWithCancellation(
    files: File[]
  ): Promise<{ results: Promise<any>[]; abortAll: () => void }> {
    const controllers = files.map(() => new AbortController());
    
    const uploadPromises = files.map((file, index) => {
      const formData = new FormData();
      formData.append('file', file);
      
      return this.http.post('/api/files/upload', {
        body: formData,
        signal: controllers[index].signal
      }).then(response => {
        if (!response.ok) {
          throw new Error(`Upload failed for ${file.name}: ${response.statusText}`);
        }
        return response.json();
      });
    });

    return {
      results: uploadPromises,
      abortAll: () => controllers.forEach(controller => controller.abort())
    };
  }
}

Form Data Processing

Complex Data Structures

export class ComplexFormService {
  private http = resolve(IHttpClient);

  async submitComplexForm(data: {
    user: {
      name: string;
      email: string;
      avatar?: File;
    };
    preferences: Record<string, any>;
    documents: File[];
    metadata: any;
  }): Promise<any> {
    const formData = new FormData();

    // User data
    formData.append('user[name]', data.user.name);
    formData.append('user[email]', data.user.email);
    if (data.user.avatar) {
      formData.append('user[avatar]', data.user.avatar);
    }

    // Preferences (nested object)
    Object.entries(data.preferences).forEach(([key, value]) => {
      formData.append(`preferences[${key}]`, JSON.stringify(value));
    });

    // Multiple files
    data.documents.forEach((file, index) => {
      formData.append(`documents[${index}]`, file);
    });

    // Metadata as JSON
    formData.append('metadata', JSON.stringify(data.metadata));

    const response = await this.http.post('/api/forms/complex', {
      body: formData
    });

    if (!response.ok) {
      throw new Error(`Complex form submission failed: ${response.statusText}`);
    }

    return await response.json();
  }
}

Best Practices

File Type and Size Validation

export class FileValidationService {
  validateFile(file: File, options: {
    maxSize?: number;
    allowedTypes?: string[];
    allowedExtensions?: string[];
  }): { valid: boolean; errors: string[] } {
    const errors: string[] = [];

    // Size validation
    if (options.maxSize && file.size > options.maxSize) {
      errors.push(`File size must be less than ${this.formatFileSize(options.maxSize)}`);
    }

    // Type validation
    if (options.allowedTypes && !options.allowedTypes.includes(file.type)) {
      errors.push(`File type ${file.type} is not allowed`);
    }

    // Extension validation
    if (options.allowedExtensions) {
      const extension = file.name.split('.').pop()?.toLowerCase();
      if (!extension || !options.allowedExtensions.includes(extension)) {
        errors.push(`File extension .${extension} is not allowed`);
      }
    }

    return {
      valid: errors.length === 0,
      errors
    };
  }

  private formatFileSize(bytes: number): string {
    if (bytes === 0) return '0 Bytes';
    const k = 1024;
    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  }
}

Error Handling and Recovery

export class RobustFormService {
  private http = resolve(IHttpClient);

  async submitWithRetry(
    formData: FormData,
    maxRetries: number = 3
  ): Promise<any> {
    let lastError: Error;

    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        const response = await this.http.post('/api/forms/submit', {
          body: formData
        });

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }

        return await response.json();
      } catch (error) {
        lastError = error as Error;
        
        if (attempt < maxRetries) {
          // Wait before retry (exponential backoff)
          const delay = Math.pow(2, attempt) * 1000;
          await new Promise(resolve => setTimeout(resolve, delay));
        }
      }
    }

    throw new Error(`Form submission failed after ${maxRetries} attempts: ${lastError!.message}`);
  }
}

Memory Management

Always clean up object URLs and large FormData objects when done:

// Clean up blob URLs
const blobUrl = URL.createObjectURL(file);
// ... use the URL
URL.revokeObjectURL(blobUrl);

// For large forms, consider clearing FormData references
let formData: FormData | null = new FormData();
// ... use formData
formData = null; // Help garbage collection

The Aurelia Fetch Client provides a robust foundation for handling all types of form submissions and file uploads. By following these patterns, you can build reliable, user-friendly form handling in your applications.

Last updated

Was this helpful?