Manual de Tablas Dinámicas con Filtros en TypeScript

Implementación moderna con TypeScript y tema Dracula

Introducción a TypeScript

TypeScript es un superset tipado de JavaScript que compila a JavaScript puro. Al usar TypeScript para nuestras tablas dinámicas, obtenemos:

Nota: Este manual asume que estás familiarizado con los conceptos básicos de TypeScript. Si no es así, te recomendamos revisar la documentación oficial primero.

Estructura TypeScript

Definamos primero nuestras interfaces y tipos para la tabla dinámica:

// Definición de tipos
interface TableData {
    nombre: string;
    edad: number;
    departamento: string;
    estado: 'Activo' | 'Inactivo';
}

type SortDirection = 'asc' | 'desc';

class DynamicTable {
    private table: HTMLTableElement;
    private data: TableData[];
    private filteredData: TableData[];
    private itemsPerPage: number;
    private currentPage: number;
    private sortColumn: number | null;
    private sortDirection: SortDirection;
    
    constructor(tableId: string, data: TableData[], itemsPerPage: number = 5) {
        const tableElement = document.getElementById(tableId);
        if (!tableElement || !(tableElement instanceof HTMLTableElement)) {
            throw new Error(`Elemento con ID ${tableId} no encontrado o no es una tabla`);
        }
        
        this.table = tableElement;
        this.data = data;
        this.filteredData = [...data];
        this.itemsPerPage = itemsPerPage;
        this.currentPage = 1;
        this.sortColumn = null;
        this.sortDirection = 'asc';
        
        this.init();
    }
    
    private init(): void {
        this.renderTable();
        this.setupEventListeners();
        this.renderPagination();
    }
    
    // ... otros métodos
}

Ventajas del enfoque TypeScript

Al definir nuestras interfaces y tipos:

  1. Validación de datos: TypeScript verificará que los datos cumplan con la estructura TableData
  2. Seguridad en tiempo de desarrollo: Errores como acceder a propiedades inexistentes serán detectados
  3. Documentación clara: La interfaz TableData documenta claramente qué estructura deben tener los datos

Implementación Completa en TypeScript

1. Configuración del proyecto

Primero, configura tu proyecto TypeScript (tsconfig.json):

{
  "compilerOptions": {
    "target": "ES6",
    "module": "ESNext",
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

2. Clase DynamicTable completa

Aquí está la implementación completa con TypeScript:

// src/dynamic-table.ts
interface TableData {
    nombre: string;
    edad: number;
    departamento: string;
    estado: 'Activo' | 'Inactivo';
}

type SortDirection = 'asc' | 'desc';
type ColumnKey = keyof TableData;

export class DynamicTable {
    private table: HTMLTableElement;
    private data: TableData[];
    private filteredData: TableData[];
    private itemsPerPage: number;
    private currentPage: number;
    private sortColumn: number | null;
    private sortDirection: SortDirection;
    private columnKeys: ColumnKey[];
    
    constructor(tableId: string, data: TableData[], itemsPerPage: number = 5) {
        const tableElement = document.getElementById(tableId);
        if (!tableElement || !(tableElement instanceof HTMLTableElement)) {
            throw new Error(`Elemento con ID ${tableId} no encontrado o no es una tabla`);
        }
        
        this.table = tableElement;
        this.data = this.validateData(data);
        this.filteredData = [...this.data];
        this.itemsPerPage = itemsPerPage;
        this.currentPage = 1;
        this.sortColumn = null;
        this.sortDirection = 'asc';
        this.columnKeys = ['nombre', 'edad', 'departamento', 'estado'];
        
        this.init();
    }
    
    private validateData(data: any[]): TableData[] {
        return data.map((item, index) => {
            if (typeof item.nombre !== 'string') {
                console.warn(`Ítem ${index}: nombre no es string`);
            }
            if (typeof item.edad !== 'number') {
                console.warn(`Ítem ${index}: edad no es número`);
            }
            if (typeof item.departamento !== 'string') {
                console.warn(`Ítem ${index}: departamento no es string`);
            }
            if (item.estado !== 'Activo' && item.estado !== 'Inactivo') {
                console.warn(`Ítem ${index}: estado no es 'Activo' o 'Inactivo'`);
            }
            
            return {
                nombre: String(item.nombre),
                edad: Number(item.edad),
                departamento: String(item.departamento),
                estado: item.estado === 'Activo' ? 'Activo' : 'Inactivo'
            };
        });
    }
    
    private init(): void {
        this.renderTable();
        this.setupEventListeners();
        this.renderPagination();
    }
    
    private renderTable(): void {
        const tbody = this.table.querySelector('tbody');
        if (!tbody) return;
        
        tbody.innerHTML = '';
        
        const startIndex = (this.currentPage - 1) * this.itemsPerPage;
        const endIndex = startIndex + this.itemsPerPage;
        const paginatedData = this.filteredData.slice(startIndex, endIndex);
        
        paginatedData.forEach(item => {
            const row = document.createElement('tr');
            
            row.innerHTML = `
                <td>${item.nombre}</td>
                <td>${item.edad}</td>
                <td>${item.departamento}</td>
                <td><span class="status-badge ${item.estado.toLowerCase()}">${item.estado}</span></td>
            `;
            
            tbody.appendChild(row);
        });
        
        this.updateTableInfo();
    }
    
    private setupEventListeners(): void {
        const globalSearch = document.getElementById('global-search');
        const columnFilter = document.getElementById('column-filter');
        const valueFilter = document.getElementById('value-filter');
        const resetFilters = document.getElementById('reset-filters');
        const prevPage = document.getElementById('prev-page');
        const nextPage = document.getElementById('next-page');
        
        if (globalSearch instanceof HTMLInputElement) {
            globalSearch.addEventListener('input', () => this.applyFilters());
        }
        
        if (columnFilter instanceof HTMLSelectElement) {
            columnFilter.addEventListener('change', () => this.applyFilters());
        }
        
        if (valueFilter instanceof HTMLInputElement) {
            valueFilter.addEventListener('input', () => this.applyFilters());
        }
        
        if (resetFilters instanceof HTMLButtonElement) {
            resetFilters.addEventListener('click', () => this.resetFilters());
        }
        
        this.table.querySelectorAll('th').forEach((th, index) => {
            th.addEventListener('click', () => this.sortTable(index));
        });
        
        if (prevPage instanceof HTMLButtonElement) {
            prevPage.addEventListener('click', () => this.prevPage());
        }
        
        if (nextPage instanceof HTMLButtonElement) {
            nextPage.addEventListener('click', () => this.nextPage());
        }
    }
    
    private applyFilters(): void {
        const globalSearch = document.getElementById('global-search');
        const columnFilter = document.getElementById('column-filter');
        const valueFilter = document.getElementById('value-filter');
        
        const globalSearchValue = globalSearch instanceof HTMLInputElement ? globalSearch.value.toLowerCase() : '';
        const columnFilterValue = columnFilter instanceof HTMLSelectElement ? columnFilter.value : '';
        const valueFilterValue = valueFilter instanceof HTMLInputElement ? valueFilter.value.toLowerCase() : '';
        
        this.filteredData = this.data.filter(item => {
            // Filtro global
            if (globalSearchValue) {
                const matches = Object.values(item).some(val => 
                    String(val).toLowerCase().includes(globalSearchValue)
                );
                if (!matches) return false;
            }
            
            // Filtro por columna específica
            if (columnFilterValue && valueFilterValue) {
                const columnIndex = parseInt(columnFilterValue);
                if (isNaN(columnIndex) return true;
                
                const columnKey = this.columnKeys[columnIndex];
                if (!columnKey) return true;
                
                if (!String(item[columnKey]).toLowerCase().includes(valueFilterValue)) {
                    return false;
                }
            }
            
            return true;
        });
        
        this.currentPage = 1;
        this.renderTable();
        this.renderPagination();
    }
    
    private resetFilters(): void {
        const globalSearch = document.getElementById('global-search');
        const columnFilter = document.getElementById('column-filter');
        const valueFilter = document.getElementById('value-filter');
        
        if (globalSearch instanceof HTMLInputElement) globalSearch.value = '';
        if (columnFilter instanceof HTMLSelectElement) columnFilter.value = '';
        if (valueFilter instanceof HTMLInputElement) valueFilter.value = '';
        
        this.applyFilters();
    }
    
    private sortTable(columnIndex: number): void {
        const columnKey = this.columnKeys[columnIndex];
        if (!columnKey) return;
        
        // Determinar dirección de ordenamiento
        if (this.sortColumn === columnIndex) {
            this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
        } else {
            this.sortColumn = columnIndex;
            this.sortDirection = 'asc';
        }
        
        // Ordenar los datos
        this.filteredData.sort((a, b) => {
            const valueA = a[columnKey];
            const valueB = b[columnKey];
            
            // Para ordenamiento numérico si la columna es edad
            if (columnKey === 'edad') {
                return this.sortDirection === 'asc' 
                    ? valueA - valueB 
                    : valueB - valueA;
            }
            
            // Ordenamiento alfabético para otras columnas
            if (valueA < valueB) return this.sortDirection === 'asc' ? -1 : 1;
            if (valueA > valueB) return this.sortDirection === 'asc' ? 1 : -1;
            return 0;
        });
        
        // Actualizar iconos de ordenamiento
        this.table.querySelectorAll('th').forEach((th, index) => {
            const icon = th.querySelector('.sort-icon');
            if (!icon) return;
            
            if (index === columnIndex) {
                icon.textContent = this.sortDirection === 'asc' ? '↑' : '↓';
            } else {
                icon.textContent = '↕';
            }
        });
        
        this.renderTable();
    }
    
    private renderPagination(): void {
        const pageNumbers = document.getElementById('page-numbers');
        const prevPage = document.getElementById('prev-page');
        const nextPage = document.getElementById('next-page');
        
        if (!pageNumbers || !prevPage || !nextPage) return;
        
        pageNumbers.innerHTML = '';
        
        const totalPages = Math.ceil(this.filteredData.length / this.itemsPerPage);
        
        for (let i = 1; i <= totalPages; i++) {
            const button = document.createElement('button');
            button.textContent = i.toString();
            button.className = 'btn btn-secondary';
            
            if (i === this.currentPage) {
                button.classList.add('active-page');
            }
            
            button.addEventListener('click', () => {
                this.currentPage = i;
                this.renderTable();
                this.renderPagination();
            });
            
            pageNumbers.appendChild(button);
        }
        
        if (prevPage instanceof HTMLButtonElement) {
            prevPage.disabled = this.currentPage === 1;
        }
        
        if (nextPage instanceof HTMLButtonElement) {
            nextPage.disabled = this.currentPage === totalPages || totalPages === 0;
        }
    }
    
    private prevPage(): void {
        if (this.currentPage > 1) {
            this.currentPage--;
            this.renderTable();
            this.renderPagination();
        }
    }
    
    private nextPage(): void {
        const totalPages = Math.ceil(this.filteredData.length / this.itemsPerPage);
        if (this.currentPage < totalPages) {
            this.currentPage++;
            this.renderTable();
            this.renderPagination();
        }
    }
    
    private updateTableInfo(): void {
        // Implementación opcional para mostrar información sobre los datos mostrados
        const startItem = (this.currentPage - 1) * this.itemsPerPage + 1;
        const endItem = Math.min(startItem + this.itemsPerPage - 1, this.filteredData.length);
        console.log(`Mostrando ${startItem}-${endItem} de ${this.filteredData.length} registros`);
    }
    
    // Métodos públicos para manipulación externa
    public updateData(newData: TableData[]): void {
        this.data = this.validateData(newData);
        this.filteredData = [...this.data];
        this.currentPage = 1;
        this.renderTable();
        this.renderPagination();
    }
    
    public setItemsPerPage(count: number): void {
        this.itemsPerPage = Math.max(1, count);
        this.currentPage = 1;
        this.renderTable();
        this.renderPagination();
    }
}

3. Uso de la clase

Para usar la tabla dinámica en tu aplicación:

// src/main.ts
import { DynamicTable } from './dynamic-table';

// Datos de ejemplo con tipo TableData
const sampleData: TableData[] = [
    { nombre: "Ana López", edad: 28, departamento: "Ventas", estado: "Activo" },
    { nombre: "Carlos Ruiz", edad: 35, departamento: "TI", estado: "Activo" },
    { nombre: "Beatriz García", edad: 42, departamento: "RRHH", estado: "Inactivo" },
    // ... más datos
];

document.addEventListener('DOMContentLoaded', () => {
    try {
        const dynamicTable = new DynamicTable('dynamic-table', sampleData);
        
        // Opcional: exponer para depuración
        (window as any).dynamicTable = dynamicTable;
    } catch (error) {
        console.error('Error al inicializar la tabla:', error);
        // Mostrar mensaje de error al usuario
    }
});

Demostración Funcional

Implementación completa con TypeScript compilado a JavaScript:

Nombre Edad Departamento Estado

Ventajas de TypeScript en Tablas Dinámicas

1. Validación de Datos

Con TypeScript podemos asegurar que los datos cumplan con la estructura esperada:

interface TableData {
    nombre: string;
    edad: number;
    departamento: string;
    estado: 'Activo' | 'Inactivo';
}

// El compilador TypeScript mostrará errores si los datos no coinciden
const badData: TableData[] = [
    { nombre: "Test", edad: "30", departamento: "TI", estado: "Activo" }, // Error: edad debe ser number
    { nombre: "Test 2", edad: 30, departamento: "TI", estado: "Pendiente" } // Error: estado no válido
];

2. Autocompletado Inteligente

Los editores modernos proporcionan autocompletado basado en los tipos:

// Al escribir "item." el editor sugerirá: nombre, edad, departamento, estado
this.filteredData.forEach(item => {
    console.log(item.nombre); // El editor sabe que nombre es string
    console.log(item.edad.toFixed(2)); // Sabe que edad es number
});

3. Refactorización Segura

Si cambias la estructura de los datos, TypeScript te indicará todos los lugares que necesitan actualización:

// Si añadimos un nuevo campo a TableData:
interface TableData {
    // ...
    fechaIngreso: Date; // Nuevo campo
}

// TypeScript mostrará errores en todos los lugares donde se usan TableData
// hasta que se proporcione el nuevo campo

Consejo Profesional: Para proyectos grandes, considera usar typescript-eslint con reglas estrictas para mantener una alta calidad de código.

Conclusión

La implementación de tablas dinámicas con TypeScript ofrece numerosas ventajas sobre JavaScript puro:

  1. Mayor seguridad: Detecta errores en tiempo de compilación
  2. Mejor documentación: Los tipos sirven como documentación viva
  3. Experiencia de desarrollo mejorada: Autocompletado y refactorización segura
  4. Mantenibilidad: Código más fácil de entender y modificar

El ejemplo presentado en este manual proporciona una base sólida que puedes adaptar a tus necesidades específicas, añadiendo más características como:

Recurso Adicional: Para llevar esta implementación al siguiente nivel, considera usar librerías como: