UNPKG

dbl-components

Version:

Framework based on bootstrap 5

762 lines (708 loc) 26.1 kB
import PropTypes from 'prop-types'; import React, { createRef } from "react"; import { eventHandler, deepMerge, t, formatValue, resolveRefs, splitAndFlat } from "dbl-utils"; import { ptClasses } from "../prop-types"; import fields from "../forms/fields"; import Icons from "../media/icons"; import Action from "../actions/action"; import JsonRender from "../json-render"; import Component from "../component"; import FloatingContainer from '../containers/floating-container/floating-container'; /** * @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, rawprops, data, jsonRender, colName) => { const props = resolveRefs(rawprops, { data }); if (props.type === 'boolean') { props.value = !!raw; } else { props.value = raw; } props.name = [props.name].flat().filter(Boolean).join('-') + '.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, params = {}) => typeof raw !== 'string' ? (params.empty || '') : formatValue(raw, Object.assign(params, { format: 'date' })), /** * 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, params = {}) => typeof raw !== 'string' ? (params.empty || '') : formatValue(raw, Object.assign(params, { format: 'datetime' })), time: (raw, params = {}) => typeof raw !== 'string' ? (params.empty || '') : formatValue(raw, Object.assign(params, { format: 'time' })), /** * 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, params = {}) => typeof raw !== 'number' ? (params.empty || '') : formatValue(raw, Object.assign(params, { format: 'currency' })), /** * 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, params = {}) => typeof raw !== 'number' ? (params.empty || '') : formatValue(raw, Object.assign(params, { format: 'number' })), /** * 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 propTypes = { col: PropTypes.any, icons: PropTypes.any, orderable: PropTypes.bool, classes: PropTypes.oneOfType([ PropTypes.string, PropTypes.object, PropTypes.arrayOf(PropTypes.string) ]), headerClasses: PropTypes.oneOfType([ PropTypes.string, PropTypes.object, PropTypes.arrayOf(PropTypes.string) ]), orderClasses: PropTypes.oneOfType([ PropTypes.string, PropTypes.object, PropTypes.arrayOf(PropTypes.string) ]), orderActiveClasses: PropTypes.oneOfType([ PropTypes.string, PropTypes.object, PropTypes.arrayOf(PropTypes.string) ]), dropFilters: PropTypes.object.isRequired, headerRefs: PropTypes.object.isRequired, tableName: PropTypes.string, vertical: PropTypes.bool } static jsClass = 'HeaderColumn'; static defaultProps = { } state = { searchActive: false }; constructor(props) { super(props); this.events = []; this.ref = createRef(); } /** * 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]); this.events.push(['update.' + col.filter.name, this.onUpdateFilter.bind(this)]); } 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); } onUpdateFilter({ value, reset }) { if (value !== undefined) { const searchActive = !!(Array.isArray(value) ? value.length : value); this.setState({ searchActive }); } if (reset) { this.setState({ searchActive: false }); } } /** * 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 }); const dispatchData = { [col.name]: newDir }; if (typeof onSort === 'function') onSort(newDir ? dispatchData : null); eventHandler.dispatch('order.' + this.props.tableName, dispatchData); } /** * Renderiza el componente. * @returns {React.Component} El componente renderizado. */ render() { const { col, classes, icons, orderable, vertical, headerClasses, orderClasses, orderActiveClasses } = this.props; const { sortDir, searchActive } = this.state; const showOrder = typeof col.orderable !== 'undefined' ? col.orderable : orderable; const style = { minWidth: col.width } const cn = [ 'header position-relative flex-grow-1', col.type, col.name + '-header', col.classes, classes ]; if (!vertical) cn.push('w-100', col.hClasses, col.hHeadClasses); else cn.push('my-1 pe-2 d-inline-block', col.vClasses, col.vHeadClasses); const cnSearch = ['search']; if (!!searchActive) cnSearch.push('active'); else cnSearch.push('opacity-75'); const oc = ['', orderClasses]; if (!sortDir) oc.push('opacity-50'); const hClasses = ["align-middle", col.name]; if (headerClasses) hClasses.push(headerClasses); const odescc = ['cursor-pointer']; const oascc = ['cursor-pointer']; if (sortDir === 'ASC') { odescc.push(orderActiveClasses); oascc.push('opacity-50'); } if (sortDir === 'DESC') { oascc.push(orderActiveClasses); odescc.push('opacity-50'); } this.props.headerRefs[col.name] = this.ref; if (col.filter?.name) { this.props.dropFilters[col.name] = React.createElement(FloatingContainer, { name: `${col.name}-${this.props.tableName}-floatingFilter`, floatAround: this.ref, active: false, placement: 'bottom-start', allowedPlacements: ['bottom-start', 'top-start', 'bottom-end', 'top-end'] }, (typeof col.filter.showClear === 'boolean' ? col.filter.showClear : true) && searchActive && React.createElement(Action, { name: col.name + 'Clear', classes: "btn-link btn-sm p-0", style: { top: 5, position: 'absolute', right: 8, zIndex: 4 } }, React.createElement(Icons, { icon: icons.clear, classes: "text-danger" }) ), React.createElement(fields[col.filter.component] || fields[col.filter.type] || fields.Field, col.filter) ); } const _actions = [ col.filter && React.createElement(Action, { tag: 'div', classButton: false, name: `${col.name}-${this.props.tableName}-triggerFilter`, open: `${col.name}-${this.props.tableName}-floatingFilter`, classes: cnSearch.flat().filter(Boolean).join(' '), icon: icons.search, style: { minWidth: '1.2rem' } }), showOrder && React.createElement('div', { className: oc.flat().filter(Boolean).join(' '), style: { fontSize: 10 } }, React.createElement('span', { onClick: (e) => this.sort('ASC', e) }, React.createElement(Icons, { icon: icons.caretUp, className: odescc.flat().filter(Boolean).join(' ') }) ), React.createElement('span', { onClick: (e) => this.sort('DESC', e) }, React.createElement(Icons, { icon: icons.caretDown, className: oascc.flat().filter(Boolean).join(' ') }) ) ) ]; return React.createElement('th', { className: splitAndFlat(hClasses, ' ').join(' '), scope: "col", ref: this.ref, colSpan: col.colSpan, rowSpan: col.rowSpan }, React.createElement('div', { className: (vertical ? '' : 'd-flex') + " align-items-center gap-3" }, React.createElement('div', { className: splitAndFlat(cn, ' ').join(' '), style }, col.label ), (col.filter || showOrder) && React.createElement('div', { className: 'd-flex align-items-center flex-shrink-1 justify-content-end gap-2 filter-order-container' + (vertical ? ' float-end my-2' : '') }, ..._actions) ) ) } } /** * 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 slots = ['headerCustom', 'columnsCustom', 'footerCustom']; static propTypes = { ...Component.propTypes, colClasses: ptClasses, headerClasses: ptClasses, tableClasses: ptClasses, orderClasses: ptClasses, orderActiveClasses: ptClasses, columns: PropTypes.any, data: PropTypes.any, hover: PropTypes.any, icons: PropTypes.any, mapCells: PropTypes.any, mapRows: PropTypes.any, mutations: PropTypes.any, onChange: PropTypes.any, orderable: PropTypes.any, striped: PropTypes.any, vertical: PropTypes.bool, headerCustom: PropTypes.any, columnsCustom: PropTypes.any, footerCustom: PropTypes.any, } static defaultProps = { ...Component.defaultProps, data: [], striped: true, hover: true, icons: { caretUp: 'caret-up', caretDown: 'caret-down', search: 'search', clear: 'x' }, vertical: false, orderClasses: '', orderActiveClasses: '', thead: {}, tbody: {} } constructor(props) { super(props); const { mutations, ...propsJ } = props; this.jsonRender = new JsonRender(propsJ, mutations); this.state.dropFilters = {}; this.state.headerRefs = {}; } /** * 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); } }); } /** * 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, vertical, orderClasses, orderActiveClasses } = 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, orderClasses, orderActiveClasses, dropFilters: this.state.dropFilters, headerRefs: this.state.headerRefs, vertical }; return React.createElement(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: splitAndFlat(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 className = ['cell', col.type, col.name + '-cell', col.classes, colClasses]; if (vertical) { className.push(col.vClasses, col.vCellClasses); } else { className.push(col.hClasses, col.hCellClasses); } const cellAttrs = { className, style: vertical ? { minWidth: col.width, ...col.style } : { paddingRight: `var(--${colName}-${name}-Table)`, ...col.style }, title: rowData[colName] !== undefined ? rowData[colName].toString() : null } const mutation = typeof mapCellsFunc === 'function' && mapCellsFunc(name, col.name, rowData, { cellAttrs, fullColumn: col }) || {}; let formatOptions; if (col.formatOpts) { deepMerge.setConfig({ fix: (target, source) => React.isValidElement(source) || React.isValidElement(target) ? source : undefined }); formatOptions = deepMerge({ ...col.formatOpts }, mutation); } else { if (mutation.classes) { const classes = mutation.classes; delete mutation.classes; cellAttrs.className.push(classes); } deepMerge.setConfig({ fix: (target, source) => React.isValidElement(source) || React.isValidElement(target) ? source : undefined }); deepMerge(cellAttrs, mutation); } cellAttrs.className = splitAndFlat(cellAttrs.className, ' ').join(' '); const formater = FORMATS[col.format] || ((raw, opts) => ((raw === '' || typeof raw !== 'string') && opts.empty) || t(raw, opts.context)); const cellData = typeof rowData[col.name] !== 'undefined' ? rowData[col.name] : true; const cell = React.createElement('div', { ...cellAttrs }, formater(cellData, formatOptions || col, rowData, this.jsonRender, colName)); return (colName === 'id' ? React.createElement('th', { key: i + '-' + colName, className: colName, scope: "row" }, cell ) : React.createElement('td', { key: i + '-' + colName, className: vertical ? splitAndFlat([colName, 'row-' + (i - 1)], ' ').join(' ') : colName }, cell ) ); } /** * 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, headerRows, hover, striped, vertical, disabled, headerCustom, columnsCustom, footerCustom, thead, tbody } = 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'); if (disabled) cn.push('disabled'); // 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 Object.entries(columns).forEach(([key, col], j) => { const column = col.name ? col : { name: key, ...col }; if (vertical) { if (!tableData[j]) tableData[j] = { cells: [], data: column }; tableData[j].cells[0] = this.mapHeaderCell([key, column], 0); } data.forEach((row, i) => { const k = vertical ? i + 1 : i; const [x, y] = vertical ? [k, j] : [j, k]; if (!tableData[y]) tableData[y] = { cells: [], data: row }; tableData[y].cells[x] = this.mapCell(row, column, x); }); }); const cssHeaderVars = Object.entries(this.state.headerRefs).reduce((chv, [colName, hRef]) => { const foc = hRef.current?.querySelector('.filter-order-container'); if (!foc) return chv; const colNameLabel = foc.parentElement.querySelector(`.${colName}-header`); const pTotal = foc.parentElement.clientWidth - colNameLabel.clientWidth; chv[`--${colName}-${this.props.name}-Table`] = pTotal + 'px'; return chv; }, {}); // Renderización de la tabla con las estructuras de encabezado, cuerpo y pie de página const finalTable = React.createElement('table', { className: splitAndFlat(cn, ' ').join(' '), style: { ...cssHeaderVars } }, React.createElement('thead', thead, header && ( React.createElement('tr', {}, React.createElement('td', { colSpan: "1000" }, React.createElement('div', {}, header) ) ) ), ...[headerCustom].flat().filter(Boolean), !vertical && ( !headerRows ? React.createElement('tr', {}, Object.entries(columns).map(this.mapHeaderCell) ) : headerRows.map((columnFix) => React.createElement('tr', { key: 'thead' + Object.keys(columnFix).join('.') }, Object.entries(columnFix).map(([cName, fix], icol) => { let renderColumn; const colF = Object.values(columns).find((c) => c.name === cName); if (typeof fix === 'string' || !colF) { renderColumn = <React.Fragment key={'custom' + icol}> {this.jsonRender.buildContent(fix)} </React.Fragment> } else { renderColumn = (fix === true ? this.mapHeaderCell([cName, colF], icol) : this.mapHeaderCell([cName, { ...colF, ...fix }], icol) ); } return renderColumn; }) ) ) ), ...[columnsCustom].flat().filter(Boolean), ), React.createElement('tbody', tbody, tableData.map(({ cells, data }, i) => React.createElement('tr', { ...this.rowProps(data, i) }, cells) ) ), (footer || footerCustom) && ( React.createElement('tfoot', {}, ...[footerCustom].flat().filter(Boolean), footer && React.createElement('tr', {}, React.createElement('td', { colSpan: "1000" }, React.createElement('div', {}, footer) ) ) ) ) ); return Object.keys(this.state.dropFilters).length ? React.createElement(React.Fragment, {}, finalTable, ...Object.values(this.state.dropFilters)) : finalTable } }