Tales aplicaciones suelen tener varias páginas con una estructura similar - tablas emparejadas con barras de herramientas de filtrado - pero con contenido diferente. Por ejemplo:

Cada página puede contener tablas con diferentes columnas, tipos de valores y etiquetas.
Las barras de herramientas de filtrado de estas páginas pueden contener diferentes campos de formulario, tipos de campo (por ejemplo, texto, desplegable) y etiquetas.

El reto

En escenarios del mundo real, surge una complejidad adicional debido a requisitos como:

  • Lógica de validación personalizada para campos de formulario específicos.
  • Funcionalidad específica de columnas de tabla.
  • Manejo de eventos únicos o lógica condicional para campos y columnas.

Simplifiquemos este debate centrándonos en los retos fundamentales. Supongamos que tenemos cientos de páginas con una estructura similar pero con contenido y lógica únicos.

Inicialmente, podría diseñar componentes de página individuales para cada característica: sold-cars.component.ts, leased-cars.component.ts, cars-to-arrive.component.ts

Para cada página, podría crear además componentes específicos de tabla y barra de herramientas de filtrado:

sold-cars-filter-toolbar.component.ts, sold-cars-table.component.ts

leased-cars-filter-toolbar.component.ts, leased-cars-table.component.ts

Lo mismo para la función de llegada de vehículos.

Dentro de cada componente de la barra de herramientas de filtrado, se definiría un formulario reactivo adaptado a esa página específica. Del mismo modo, cada componente de tabla definiría columnas y lógica únicas para su página respectiva.

Aunque este enfoque funciona, introduce un inconveniente crítico: la duplicación de código. Cada componente específico de página contiene lógica repetitiva, lo que lleva a una base de código hinchada que es difícil de mantener y escalar.

CPU
1 vCPU
MEMORIA
1 GB
ALMACENAMIENTO
10 GB
TRANSFERENCIA
1 TB
PRECIO
$ 4 mes
Para obtener el servidor GRATIS debes de escribir el cupon "LEIFER"

La solución: Componentes genéricos y configurables


En lugar de crear componentes únicos para cada página, podemos construir componentes genéricos y reutilizables para tablas y barras de herramientas de filtrado. Estos componentes serán:

  • Dinámicos y flexibles: Configurables para satisfacer las necesidades de cualquier página.
  • Compactos y legibles: Se basan en matrices de configuración para definir los campos, columnas, etiquetas y lógica específicos de cada página.

Este enfoque se adhiere al principio DRY (Don't Repeat Yourself) eliminando el código redundante. También le permite:

  • Configurar tablas y filtros dinámicamente para cada página utilizando estructuras concisas similares a JSON.
  • Definir comportamientos específicos para cada página, tales como:
  • Visibilidad de campos y condiciones de activación/desactivación.

Reglas y lógica de validación.


Gestión de eventos y funciones específicas de las columnas.
Con esta arquitectura, en lugar de escribir código prolijo para cada página, sólo es necesario declarar una matriz de configuración que especifique qué columnas aparecen en la tabla, qué campos aparecen en la barra de herramientas de filtrado y cualquier lógica dinámica asociada.

Ventajas de una arquitectura configurable

Reutilización: Un único componente de tabla o filtro-barra de herramientas puede reutilizarse en todas las páginas con un esfuerzo mínimo.

Mantenimiento: La lógica centralizada de las funciones comunes facilita las actualizaciones y la corrección de errores.

Escalabilidad: Añadir una nueva página es tan sencillo como definir una matriz de configuración sin necesidad de duplicar o modificar los componentes existentes.

Personalización: Los comportamientos específicos de campos, columnas y validaciones pueden definirse fácilmente mediante la configuración.

Resumen de la implementación

La implementación implica tres pasos clave:

Definir tipos de configuración: Comience por crear tipos o interfaces TypeScript para representar la estructura de configuración para tablas y barras de herramientas de filtrado.

Crear componentes genéricos (a) Un componente de tabla genérico que representa dinámicamente las columnas basándose en la configuración; b) Un componente de barra de herramientas de filtrado genérico que genera formularios reactivos dinámicamente utilizando la configuración).

Configurar páginas: Utilice los componentes genéricos proporcionando una matriz de configuración adaptada a los requisitos de cada página.

Este enfoque arquitectónico reduce significativamente la duplicación de código a la vez que hace que el proyecto sea más modular y adaptable a los cambios. Vamos a sumergirnos en la implementación, empezando por los tipos de configuración.

Empecemos con la implementación. Empezar por los tipos:

export type UIElementConfig = {
  id?: string;
  text?: string;
  tooltipText?: string;
  tabIndex?: number;
  conditions?: {
    visibleIf?: Function;
    disabledIf?: Function;
    displayAsHyperlinkIf?: Function;
    highlightedIf?: Function;
  };
  callbacks?: {
    click?: Function;
    change?: Function;
    keydownEnter?: Function;
  };
  properties?: {
    textInputUppercaseOnly?: boolean;
    icon?: string;
  };
};

export type SupportedColumnType = 'string' | 'button';
export type TableColumnConfig = UIElementConfig & {
    field: string;
    type?: SupportedColumnType;
    numberFormat?: NumberFormatConfig;
    editable?: boolean;
};

export type DropdownConfig = {
    options?: unknown[];
    optionLabel: string;
    optionValue: string;
    selectedOption?: unknown;
};

export type SupportedFormFieldType = 'text' | 'dropdown';
export type FormFieldConfig = UIElementConfig & Partial<DropdownConfig> & {
    name: string;
    label?: string;
    placeholder?: string;
    type: SupportedFormFieldType;
    defaultValue?: string | null;
    validations?: FormFieldValidator[];
};


filter-toolbar.component.html:

<div class="form_wrapper">
  <form [formGroup]="form">
    <div *ngFor="let field of formConfig; trackBy: trackById">
      <div
        *ngIf="!field.conditions?.visibleIf || field.conditions?.visibleIf(form)"
      >
        <span class="p-form-field">
          <label [htmlFor]="field.name">
            {{ field.label || ‘’ | translate }}</label
          >
          <ng-container [ngSwitch]="field.type">
            <input
              *ngSwitchCase="’text’"
              [formControlName]="field.name"
              type="text"
              [id]="field.name"
              [placeholder]="field.placeholder || ‘’ | translate"
              [attr.readonly]="field.conditions?.disabledIf(form) ? true : null"
              [tabindex]="field.tabIndex"
              (keydown.enter)="field.callbacks?.keydownEnter(form)"
            />
            <app-dropdown
              *ngSwitchCase="’dropdown’"
              [formControlName]="field.name"
              [options]="field.options"
              [optionLabel]="field.optionLabel"
              [optionValue]="field.optionValue"
              [id]="field.name"
              [placeholder]="field.placeholder || ‘’ | translate"
              [tabindex]="field.tabIndex"
              (keydown.enter)="field.callbacks?.keydownEnter(form)"
            >
              {{ fieldName?.[field?.optionLabel || ‘label’] | translate }}
            </app-dropdown>
            <!-- ... other field type cases are skipped -->
          </ng-container>
        </span>
        <app-form-control-errors
          *ngIf="formControls[field.name]?.dirty"
          [controlErrors]="formControls[field.name].errors"
          [validations]="field.validations"
        ></app-form-control-errors>
      </div>
    </div>

    <div *ngIf="buttonsConfig?.length">
      <app-button
        *ngFor="let button of buttonsConfig"
        size="small"
        [label]="button.text || ‘’ | translate"
        [disabled]="button.conditions?.disabledIf && button.conditions?.disabledIf(form)"
        [tabindex]="button.tabIndex"
        (click)="button.callbacks?.click(form)"
        [icon]="button.properties?.icon || ‘’"
      ></app-button>
    </div>
  </form>
</div>

A continuación, el código del componente (filter-toolbar.component.ts):

...
@Component({
  selector: 'app-filter-toolbar',
  templateUrl: './filter-toolbar.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [...],
})
export class FilterToolbarComponent implements OnInit {
  readonly trackById = trackById;

  private readonly fb = inject(FormBuilder);

  @Input({ required: true }) formConfig: FormFieldConfig[];
  @Input() set formValue(value: any) {
    if (this.form) {
      this.updateForm(value as FormValue);
    } else {
      this._initialFormValue = value as FormValue;
    }
  }
  @Input() buttonsConfig: UIElementConfig[];

  form: FormGroup;
  formControls: { [key: string]: FormControl } = {};
  private _initialFormValue: FormValue = null;

  ngOnInit(): void {
    this.createForm();

    if (this._initialFormValue) {
      this.updateForm(this._initialFormValue);
    }
  }

  private createForm(): void {
    this.form = this.fb.group({});
    this.formControls = {};

    // Iteration through the form columns
    this.formConfig.forEach((field: FormFieldConfig) => {
      const control = this.createField(field);
      this.addFieldToForm(control, field);
    });
  }

  private updateForm(formValue: FormValue): void {
    if (formValue) {
      this.form.patchValue(formValue, { emitEvent: false });
    }
  }

  private createField(field: FormFieldConfig): FormControl {
    const fieldValidators =
      field?.validations?.map((val) => {
        return val.validator;
      }) || [];

    return this.fb.control(
      field.defaultValue,
      Validators.compose(fieldValidators)
    );
  }

  private addFieldToForm(
    control: FormControl<string | number | null>,
    field: FormFieldConfig
  ): void {
    this.formControls[field.name] = control;
    this.form.addControl(field.name, control);
  }

  isFieldRequired(field: FormFieldConfig): boolean {
    // field require logic
  }
}

Ahora table-component.html:

...
@Component({
  selector: 'app-table',
  templateUrl: './table.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
  standalone: true,
  imports: [...],
})
export class TableComponent {
  readonly trackById = trackById;
  readonly SortOrder = SortOrder;

  first = 0;
  rows = 10;

  private _tableData: any[];
  @Input() set tableData(value: any[]) {
    this.modifiedCells = {};
    this._tableData = value.map((x) => ({ ...x }));
  }
  get tableData() {
    return this._tableData;
  }

  private _tableColumns: TableColumnConfig[];
  @Input() set tableColumns(value: TableColumnConfig[]) {
    this._tableColumns = value;
    this.selectedColumns = value
      .slice(0)
      .filter((column) => column.visible || !Object.hasOwn(column, 'visible'));
  }
  get tableColumns(): TableColumnConfig[] {
    return this._tableColumns;
  }

  @Input() count: number;
  @Input() loading: boolean;
  @Input() dataKey = 'id';

  sortField = '';
  sortOrder = SortOrder.ASCENDING;

  paginatedItems: any[] = [];

  customSort(event: SortEvent): void {
    // sorting logic
  }

  onPageChange(state: PaginatorState): void {
    // pagination logic
  }
}

Ahora reutilizamos los componentes tabla y filtro-barra de herramientas en sold-cars.component.html:

<ng-container *ngrxLet="data$ as data">
  <app-filter-toolbar
    [formConfig]="filterFormConfig"
    [buttonsConfig]="filterButtonsConfig"
    [formValue]="filterValue"
  ></app-filter-toolbar>
  <div class="p-mt-2">
    <app-table
      [tableData]="data ?? []"
      [tableColumns]="tableColumnsConfig || []"
      [loading]="(loading$ | async) ?? false"
    ></app-table>
  </div>
</ng-container>

Declarar matriz de configuración para componentes de tabla y filtro-barra de herramientas en sold-cars.component.ts:

...
@Component({
  selector: 'app-sold-cars',
  templateUrl: 'sold-cars.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SoldCarsComponent {
  readonly filterFormConfig: FormFieldConfig[] = [
    {
      name: 'name',
      label: 'SOLD_CARS.LABELS.NAME',
      type: 'text',
      validations: [maxLengthStringValidator(16)],
      properties: {
        textInputUppercaseOnly: true,
      },
      tabIndex: 1,
      callbacks: {
        keydownEnter: (form: FormGroup) => {
          if (form.valid) {
            this.applyFilters(form.value);
          }
        },
      },
    },
    {
      name: 'dealerLocationArea',
      label: 'SOLD_CARS.LABELS.DEALER_LOCATION_AREA',
      type: 'dropdown',
      optionLabel: 'value',
      optionValue: 'key',
      defaultValue: '*',
      options: [
        { key: '*', value: '*' },
        { key: 'A', value: 'A' },
        { key: 'B', value: 'B' },
      ],
      tabIndex: 2,
      conditions: {
        disabledIf: (form: FormGroup) => form.value.name,
      },
      callbacks: {
        keydownEnter: (form: FormGroup) => {
          if (form.valid) {
            this.applyFilters(form.value);
          }
        },
      },
    },
    {
      name: 'dealerId',
      label: 'SOLD_CARS.LABELS.DEALER_ID',
      type: 'text',
      validations: [maxLengthStringValidator(10)],
      tabIndex: 3,
      conditions: {
        visibleIf: (form: FormGroup) => form.value.dealerLocationArea,
      },
      callbacks: {
        keydownEnter: (form: FormGroup) => {
          if (form.valid) {
            this.applyFilters(form.value);
          }
        },
      },
    },
    // ... all the other fields
  ];

  readonly filterButtonsConfig: UIElementConfig[] = [
    {
      text: 'SHARED.BUTTONS.SEARCH',
      conditions: {
        disabledIf: (form: FormGroup) => form.invalid,
      },
      callbacks: {
        click: (form: FormGroup) => {
          this.applyFilters(form.value);
        },
      },
      tabIndex: 3,
    },
    {
      text: 'SHARED.BUTTONS.CANCEL',
      conditions: {},
      callbacks: {
        click: (form: FormGroup) => {
          this.resetFilter();
          this.currentSearchPayload = form.value;
        },
      },
      tabIndex: 4,
    },
  ];

readonly tableColumnsConfig: TableColumnConfig[] = [
  {
    field: 'log',
    type: 'button',
    tooltipText: 'SOLD_CARS.TOOLTIPS.LOG',
    callbacks: {
      click: (data: Car) => // click handling
    },
    conditions: {
      visibleIf: (data: Car) => {
        return data.logEnabled;
      },
    },
  },
  {
    field: 'id',
    text: 'SOLD_CARS.LABELS.ID',
    type: 'string',
    tooltipText:
      'SOLD_CARS.TOOLTIPS.DIFFERENCES',
    conditions: {
      displayAsHyperlinkIf: (data: Car) => {
        return data.isLink;
      },
    },
  },
  {
    field: 'dialerId',
    text: 'SOLD_CARS.LABELS.DEALER_ID',
    tooltipText:
      'SOLD_CARS.TOOLTIPS.DEALER_ID',
    type: 'string',
  },
  // ... other columns
];

  data$: Observable<SoldCar[] | null> = this.store.select(
    soldCarsFeature.selectSoldCars
  );
  loading$ = this.store.select(soldCarsFeature.selectSearchLoading);

  filterValue = {};
  page = 0;
  rows = 10;

  applyFilters(formValue: SearchSoldCarsParams): void {
    this.search({
      ...formValue,
      page: 1,
      size: 10,
    });
  }

  private search(requestBody: SearchSoldCarsParams): void {
    this.store.dispatch(actions.searchSoldCars(requestBody));
  }

  private resetFilter(): void {
    // reset filter logic
  }
}

Preparamos matrices de configuración similares para leased-cars.component.ts , cars-to-arrive.component.ts.

Conclusión

Adoptar una arquitectura basada en configuración para los componentes de Angular ofrece una forma potente de manejar estructuras repetitivas como tablas y barras de herramientas de filtrado de una forma escalable, mantenible y reutilizable.

Al abstraer la funcionalidad común en componentes genéricos y aprovechar las matrices de configuración, puedes reducir la duplicación de código, adherirte a las mejores prácticas como el principio DRY y hacer que tu proyecto sea más adaptable a futuros requisitos.

Este enfoque no sólo simplifica el proceso de desarrollo, sino que también garantiza que su aplicación pueda escalar sin problemas a medida que se añaden nuevas páginas o funciones.

En lugar de centrarse en la creación repetitiva de componentes, los desarrolladores pueden dedicar sus esfuerzos a mejorar la experiencia del usuario y a implementar funcionalidades avanzadas.

A medida que su proyecto crezca, esta arquitectura resultará inestimable para mantener un código limpio, conciso y modular, al tiempo que agiliza los flujos de trabajo de desarrollo y mantenimiento.

Así que, ¿por qué no dejar que tus componentes trabajen de forma más inteligente con el poder de la configuración? Empiece hoy mismo a crear su proyecto Angular escalable y eficiente.

Fuente