framework-entersol-web
Version:
Framework based on bootstrap 5
595 lines (550 loc) • 19.6 kB
JSX
import React from "react";
import moment from "moment";
import eventHandler from "../functions/event-handler";
import deepMerge from "../functions/deep-merge";
import Component from "../component";
import fields from "../forms/fields";
import Icons from "../media/icons";
import JsonRender from "../json-render";
import DropdownContainer from "../containers/dropdown-container";
import Action from "../actions/action";
import i18n from 'i18next';
import global_es from '../../translations/es/global.json'
import global_en from '../../translations/en/global.json'
/**
* @typedef {Object} FormatOptions
* @property {string} [format] - Formato para fechas y horas.
* @property {boolean} [locale] - Si se debe considerar la zona horaria local.
* @property {string} [currency] - Código de moneda para el formato de moneda.
* @property {string} ['true'] - Representación en cadena de un valor booleano `true`.
* @property {string} ['false'] - Representación en cadena de un valor booleano `false`.
*/
/**
* Objeto con funciones de formateo para diferentes tipos de datos.
*
* @namespace
* @property {function(any, Object, Object, Object, string): React.Component} component - Formatea los datos a un componente.
* @property {function(any, FormatOptions=): string} date - Formatea los datos a una fecha.
* @property {function(any, FormatOptions=): string} datetime - Formatea los datos a un datetime.
* @property {function(any, FormatOptions=): string} currency - Formatea los datos a una moneda.
* @property {function(any, FormatOptions=): string} number - Formatea los datos a un número.
* @property {function(any, Object): string} boolean - Formatea los datos a un booleano.
*/
export const FORMATS = {
/**
* Formatea los datos en crudo a un componente.
*
* @function
* @param {any} raw - Los datos en crudo que deben ser formateados.
* @param {Object} props - Propiedades del componente.
* @param {Object} data - Datos de la celda.
* @param {Object} jsonRender - Instancia de JsonRender.
* @param {String} colName - Nombre de la columna.
* @returns {React.Component} El componente formateado.
*/
component: (raw, props, data, jsonRender, colName) => {
if (props.type === 'boolean') {
props.value = !!raw;
} else {
props.value = raw;
}
props.name += '.cell';
props.id = data.id;
props.data = data;
props.columnName = colName;
return jsonRender.buildContent(props);
},
/**
* Formatea los datos en crudo a una fecha.
*
* @function
* @param {any} raw - Los datos en crudo que deben ser formateados.
* @param {Object} options - Opciones de formato de la fecha.
* @returns {String} La fecha formateada.
*/
date: (
raw,
{ format: f = 'DD/MM/YYYY', locale = false } = {}
) =>
raw
? locale
? moment.utc(raw).format(f) // Parse with UTC and format
: moment.utc(raw).format(f)
: '',
/**
* Formatea los datos en crudo a un datetime.
*
* @function
* @param {any} raw - Los datos en crudo que deben ser formateados.
* @param {Object} options - Opciones de formato del datetime.
* @returns {String} El datetime formateado.
*/
datetime: (
raw,
{ format: f = 'DD/MM/YYYY HH:mm', locale = false, options } = {}
) =>
raw
? locale
? moment.utc(raw, options).format(f) // Parse with UTC and format
: moment.utc(raw, options).format(f)
: '',
/**
* Formatea los datos en crudo a una moneda.
*
* @function
* @param {any} raw - Los datos en crudo que deben ser formateados.
* @param {Object} options - Opciones de formato de la moneda.
* @returns {String} La moneda formateada.
*/
currency: (raw, { locale = 'en-US', currency = 'USD' } = {}) =>
['string', 'number'].includes(typeof raw) ?
(new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 6
})).format(raw) : '',
/**
* Formatea los datos en crudo a un número.
*
* @function
* @param {any} raw - Los datos en crudo que deben ser formateados.
* @param {Object} options - Opciones de formato del número.
* @returns {String} El número formateado.
*/
number: (raw, { locale = 'en-US' } = {}) =>
['string', 'number'].includes(typeof raw) ?
(new Number(raw)).toLocaleString(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 6
}) : '',
/**
* Formatea los datos en crudo a un booleano.
*
* @function
* @param {any} raw - Los datos en crudo que deben ser formateados.
* @param {Object} options - Opciones de formato del booleano.
* @returns {String} El booleano formateado.
*/
boolean: (raw, { 'true': True = 'Yes', 'false': False = 'Not' }) => (raw ? True : False)
}
/**
* Agrega nuevas plantillas de formato al objeto FORMATS.
*
* Esta función utiliza `Object.assign` para fusionar las plantillas proporcionadas en el objeto FORMATS existente.
* Esto significa que si una plantilla con el mismo nombre ya existe en FORMATS, la nueva plantilla la sobrescribirá.
*
* @function
* @param {Object} newTemplates - Un objeto que contiene plantillas de formato a agregar. Las claves son los nombres de las plantillas y los valores son las funciones de formato.
* @returns {void}
* @example
*
* addFormatTemplates({
* newFormat: (raw, options) => { ... },
* anotherFormat: (raw, options) => { ... }
* });
*/
export const addFormatTemplates = (newTemplates = {}) => {
Object.assign(FORMATS, newTemplates);
}
/**
* Componente de celda de encabezado para una tabla.
*
* @class HeaderCell
* @extends {React.Component}
*/
export class HeaderCell extends React.Component {
static jsClass = 'HeaderColumn';
static defaultProps = {
filterPos: 'down'
}
state = {};
constructor(props) {
super(props);
this.events = [];
}
/**
* Método de ciclo de vida de React que se llama cuando el componente se ha montado.
* Se suscribe a eventos relevantes para este componente.
*/
componentDidMount() {
const { col } = this.props;
if (col.filter) {
this.events.push([col.filter.name, this.onChangeFilter]);
}
for (const event of this.events) {
eventHandler.subscribe(...event);
}
}
/**
* Método de ciclo de vida de React que se llama cuando el componente se actualiza.
* Restablece la dirección de la clasificación si la clasificación se ha eliminado.
* @param {Object} prevProps - Las propiedades anteriores del componente.
* @param {Object} prevState - El estado anterior del componente.
*/
componentDidUpdate(prevProps, prevState) {
if (!this.props.sort && this.state.sortDir) this.setState({ sortDir: null });
}
/**
* Método de ciclo de vida de React que se llama cuando el componente está a punto de desmontarse.
* Cancela la suscripción a todos los eventos a los que se suscribió en componentDidMount.
*/
componentWillUnmount() {
for (const event of this.events) {
eventHandler.unsubscribe(event[0]);
}
}
/**
* Manejador de eventos para cambios en el filtro.
* @param {Object} data - Los datos del evento.
*/
onChangeFilter = (data) => {
const { filter } = this.props.col;
const searchActive = !!(Array.isArray(data[filter.name]) ?
data[filter.name].length : data[filter.name]);
this.setState({ searchActive });
eventHandler.dispatch(this.props.tableName, data);
}
/**
* Realiza la acción de ordenar las celdas en el encabezado.
* @param {string} dir - La dirección en la que se va a realizar la ordenación.
*/
sort(dir) {
const { onSort, col } = this.props;
const { sortDir } = this.state;
let newDir = dir;
if (sortDir === newDir) newDir = null;
this.setState({ sortDir: newDir });
if (typeof onSort === 'function') onSort(newDir ? { [col.name]: newDir } : null);
}
/**
* Renderiza el componente.
* @returns {React.Component} El componente renderizado.
*/
render() {
const { col, classes, icons, orderable, filterPos, headerClasses, history } = this.props;
const { sortDir, searchActive } = this.state;
const showOrder = typeof col.orderable !== 'undefined' ? col.orderable : orderable;
const translatedLabel = history.type === 'proveedor' ? i18n.t(`${this.props.tableName}.${col.label?.props?.children || ''}`, { ns: 'global' }) : col.label;
const style = {
minWidth: col.width
}
const cn = [
'header position-relative w-100',
col.type, col.name + '-header',
col.classes, classes
];
const cnSearch = ['cursor-pointer'];
if (!searchActive) cnSearch.push('text-muted');
const hClasses = ["align-middle", col.name];
if (headerClasses) hClasses.push(headerClasses);
return <th className={hClasses.join(' ')} scope="col">
<div className="d-flex align-items-center">
<div className={cn.join(' ')} style={style}>
<span>{translatedLabel}</span>
</div>
<div className="d-flex">
{col.filter && <div className={"ps-2 mt-1 drop" + filterPos}>
<DropdownContainer
name={col.name + 'DropdownFilter'}
label={<Icons icon={icons.search} className={cnSearch.join(' ')} />}
dropdownClasses="dropdown-menu-end p-0"
dropdownClass={false}
>
{searchActive && <Action
name={col.name + 'Clear'}
classes="btn-link btn-sm p-0"
style={{ top: 5, position: 'absolute', right: 8, zIndex: 4 }}
>
<Icons icon={icons.clear} classes="text-danger" />
</Action>}
{React.createElement(fields[col.filter.type] || fields.Field, col.filter)}
</DropdownContainer>
</div>}
{showOrder && <div className="ps-2 text-muted" style={{ fontSize: 10 }}>
<span onClick={(e) => this.sort('DESC', e)}>
<Icons icon={icons.caretUp}
className={'cursor-pointer ' + (sortDir === 'DESC' ? 'text-body' : '')}
/>
</span>
<span onClick={(e) => this.sort('ASC', e)}>
<Icons icon={icons.caretDown}
className={'cursor-pointer ' + (sortDir === 'ASC' ? 'text-body' : '')}
/>
</span>
</div>}
</div>
</div>
</th>
}
}
/**
* Clase base para la tabla.
*
* Este componente es responsable de renderizar una tabla a partir de un conjunto de datos proporcionado.
* También proporciona funcionalidad para ordenar, filtrar y manejar eventos.
*
* @class Table
* @extends {Component}
*/
export default class Table extends Component {
static jsClass = 'Table';
static defaultProps = {
...Component.defaultProps,
data: [],
striped: true,
hover: true,
icons: {
caretUp: 'caret-up',
caretDown: 'caret-down',
search: 'search',
clear: 'close'
},
vertical: false
}
state = {};
constructor(props) {
super(props);
this.jsonRender = new JsonRender(props, props.mutations);
this.t = i18n.t.bind(i18n);
}
/**
* Subscribes to the events when the component is mounted.
*
* @method componentDidMount
* @memberof Table
*/
componentDidMount() {
this.events = [];
Object.entries(this.props.columns).forEach(([key, col]) => {
// TODO: mejorar esto, filtrar el componente dropdown
// buscar una forma de que solo los componentes field carguen evento
if (col.format === 'component' && col.formatOpts.component !== 'DropdownButtonContainer') {
const event = [col.formatOpts.name + '.cell', this.onEventCell];
this.events.push(event);
eventHandler.subscribe(...event);
}
});
// Initialize i18n
i18n.init({
lng: this.props.history.lang, // Set default language
resources: {
en: {
global: global_en
},
es: {
global: global_es
}
}
})
}
/**
* Unsubscribes from the events when the component is unmounted.
*
* @method componentWillUnmount
* @memberof Table
*/
componentWillUnmount() {
for (const [eventName] of this.events) {
eventHandler.unsubscribe(eventName);
}
}
// Events
/**
* Handles sorting event.
*
* @method onSort
* @param {Object} orderBy - An object representing the column to be sorted.
* @memberof Table
*/
onSort = (orderBy) => {
const { onChange } = this.props;
this.setState({ orderBy: orderBy && Object.keys(orderBy).pop() });
if (typeof onChange === 'function') onChange({ orderBy });
}
/**
* Handles cell events.
*
* @method onEventCell
* @param {Object} dataRaw - Raw data of the event.
* @memberof Table
*/
onEventCell = (dataRaw) => {
const data = {};
for (const event in dataRaw) {
data[event.replace(/\.cell$/, '')] = dataRaw[event];
}
eventHandler.dispatch(this.props.name, data);
}
//------
/**
* Maps HeaderCell components for each column.
*
* @method mapHeaderCell
* @param {Array} args - The column properties.
* @param {number} i - The index of the column.
* @returns {React.Component} - A HeaderCell component.
* @memberof Table
*/
mapHeaderCell = ([key, col], i) => {
const { colClasses, headerClasses, icons, orderable, name, history} = this.props;
const { orderBy } = this.state;
col.name = col.name || key;
col.label = this.jsonRender.buildContent(col.label);
const props = {
col,
orderable,
classes: colClasses,
headerClasses,
icons,
onSort: this.onSort,
sort: orderBy === col.name,
tableName: name,
history
};
return <HeaderCell key={i + '-' + col.name} {...props} />
}
/**
* Provides properties for a row.
*
* @method rowProps
* @param {Object} rowOrColumn - The data of the row or column.
* @param {number} i - The index of the row or column.
* @returns {Object} - Properties for the row.
* @memberof Table
*/
rowProps = (rowOrColumn, i) => {
const { name, mapRows: mapRowsFunc, vertical } = this.props;
const id = vertical ? rowOrColumn.name : rowOrColumn.id;
const rowKey = (!!id || id === 0) ? (i + '-' + id) : i;
const cnRow = ['row-' + this.props.name, 'row-' + ((!!id || id === 0) ? id : i)];
const { classes: rowClasses, ...rowProps } =
(typeof mapRowsFunc === 'function' && mapRowsFunc(name, rowOrColumn, i)) || {};
if (rowClasses) cnRow.push(rowClasses);
return {
key: rowKey,
...rowProps,
className: cnRow.join(' ')
};
};
/**
* Maps cell components for each cell in a row.
*
* @method mapCell
* @param {Object} rowData - The data of the row.
* @param {Object} col - The properties of the column.
* @param {number} i - The index of the cell.
* @returns {React.Component} - A cell component.
* @memberof Table
*/
mapCell = (rowData, col, i) => {
const { mapCells: mapCellsFunc, name, colClasses, vertical } = this.props;
const colName = col.name;
const cellAttrs = {
className: ['cell', col.type, col.name + '-cell', col.classes, colClasses],
style: vertical
? {
minWidth: col.width,
...col.style
}
: col.style,
title: typeof rowData[colName] !== 'object' ? rowData[colName] : undefined
}
const mutation = typeof mapCellsFunc === 'function' && mapCellsFunc(name, col.name, rowData, cellAttrs) || {};
let formatOptions;
if (col.formatOpts) {
formatOptions = deepMerge({ ...col.formatOpts }, mutation);
} else {
if (mutation.classes) {
const classes = mutation.classes;
delete mutation.classes;
cellAttrs.className.push(classes);
}
deepMerge(cellAttrs, mutation);
}
cellAttrs.className = cellAttrs.className.flat().join(' ');
const formater = FORMATS[col.format] || (raw => raw);
const cellData = typeof rowData[col.name] !== 'undefined' ? rowData[col.name] : true;
const cell = (<div {...cellAttrs}> {formater(cellData, formatOptions, rowData, this.jsonRender, colName)} </div>);
return (colName === 'id' ?
<th key={i + '-' + colName} className={colName} scope="row">{cell}</th> :
<td key={i + '-' + colName} className={vertical ? [colName, 'row-' + (i - 1)].join(' ') : colName} >{cell}</td>
);
}
/**
* Renders the table content.
*
* @method content
* @param {Array} children - Optional children to be rendered in the table.
* @returns {React.Component} - The rendered table.
* @memberof Table
*/
content(children = this.props.children) {
const { data, columns, tableClasses, hover, striped, vertical } = this.props;
// Definir encabezado y pie de página si están presentes
let header, footer;
if (Array.isArray(children))
([header, footer] = children);
// Construcción de las clases CSS para la tabla
const cn = ['table'];
if (striped) cn.push('table-striped');
if (hover) cn.push('table-hover');
if (tableClasses) cn.push(tableClasses);
if (vertical) cn.push('vertical');
// Inicialización de los datos de la tabla según la disposición (vertical/horizontal)
const tableData = [];
// Procesamiento de los datos para construir la estructura de la tabla
data.forEach((row, i) => {
Object.entries(columns).forEach(([key, col], j) => {
const column = col.name ? col : { name: key, ...col };
if (!vertical) {
// Si la tabla no es vertical (es decir, es horizontal)
// Si no se ha inicializado el registro de la tabla (fila) en la posición i, se inicializa
if (!tableData[i]) tableData[i] = { cells: [], data: row };
// Se mapea la celda en la posición j de la fila i, pasándole los datos de la fila y la columna
tableData[i].cells[j] = this.mapCell(row, column, j);
} else {
// Si la tabla es vertical
// Si no se ha inicializado el registro de la tabla (columna) en la posición j, se inicializa
if (!tableData[j]) tableData[j] = { cells: [], data: column };
// Para la primera fila de cada columna (i === 0), se mapea la celda de encabezado
if (i === 0) tableData[j].cells[0] = this.mapHeaderCell([key, column], 0);
// Para las filas restantes de cada columna, se mapea la celda correspondiente
const k = i + 1;
tableData[j].cells[k] = this.mapCell(row, column, k);
}
});
});
// Renderización de la tabla con las estructuras de encabezado, cuerpo y pie de página
return (
<table className={cn.join(' ')}>
<thead>
{header && (
<tr>
<td colSpan="1000">
<div>{header}</div>
</td>
</tr>
)}
{!vertical && (
<tr >
{Object.entries(columns).map(this.mapHeaderCell)}
</tr>
)}
</thead>
<tbody>
{tableData.map(({ cells, data }, i) => <tr {...this.rowProps(data, i)}>{cells}</tr>)}
</tbody>
{footer && (
<tfoot>
<tr>
<td colSpan="1000">
<div>{footer}</div>
</td>
</tr>
</tfoot>
)}
</table>
);
}
}