Plugin para incluir edição em linha para Grids

O gridColumnEditable é um plugin JavaScript leve e autônomo projetado para adicionar o recurso de edição in-line a tabelas HTML existentes. Ele suporta a inicialização de múltiplas tabelas simultaneamente, injeção de inputs customizados e rastreamento de alterações por linha e coluna.

Código fonte

  • commons.js:
function __deepMerge(target, ...sources) {
    sources.forEach(source => {
        if (source) {
            Object.keys(source).forEach(key => {
                if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
                    if (!target[key]) {
                        target[key] = {};
                    }
                    deepMerge(target[key], source[key]);  // Recursivamente mescla objetos
                } else {
                    target[key] = source[key];  // Cópia simples para valores não-objetos
                }
            });
        }
    });
    return target;
}

function __uuidv4() {
    return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, c =>
        (+c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> +c / 4).toString(16)
    );
}
  • gridColumnEditable.js:
/********************************* Column Grid Editable **********************************/
/*
    Control instances data change (Support for multiple grids)
    Expected structure of instancesDataMap:
    {
        '[instanceId]': {
            'gridName': 'myGrid',
            'indexId': 0, // Column index that holds the row ID
            'editableColumns': [{ index: 1 }, { index: 2, inputElement: customInputElement }],
            'DataChanged': {
                '[rowId]': {
                    [columnIndex]: 'new data value',
                    // ... other changed columns for this row
                },
                // ... other rows
            }
        }
    }
*/

var gridColumnEditable = (function () {
    var instancesDataMap = {};
    var settings = {};
    var initialized = false;
    var lastIndexChanged;
    
    // 'me' is used to maintain a consistent reference to the plugin's active state
    var me = {
        element: undefined,
        activeCell: undefined // NEW: Tracks the logical cell being edited, regardless of DOM placement
    };

    function initialize(self, options) {
        settings = __deepMerge({
            editableGrids: [
                {
                    name: undefined,
                    wrapperElement: 'table',
                    contentElement: 'tbody',
                    columnsInfo: {
                        idIndex: 0,
                        valueDelegate: undefined,
                        editables: []
                    }
                }
            ],
            confirmKey: "Enter",
            onConfirm: undefined,
        }, options);

        if (!settings.editableGrids || !Array.isArray(settings.editableGrids) || settings.editableGrids.length === 0)
            return;

        _ensureSettingsDefaultValues();

        for (var gridIndex = 0; gridIndex < settings.editableGrids.length; gridIndex++) {
            var gridConfig = settings.editableGrids[gridIndex];
            var wrapperElement = _tryGetElement(gridConfig.wrapperElement);

            if (gridConfig.columnsInfo.editables && gridConfig.columnsInfo.editables.length > 0) {
                var instanceId = __uuidv4();

                wrapperElement.addEventListener("click", function (ev) {
                    var currentInstanceId = this.getAttribute("data-instance");
                    var cell;

                    if (ev.target.tagName.toLowerCase() === "td") 
                        cell = ev.target;
                    else if (ev.target.closest("td"))
                        cell = ev.target.closest("td");

                    if (!cell) return;

                    if (gridColumnEditable.getColumnsEditableIndex(currentInstanceId).indexOf(cell.cellIndex) === -1)
                        return;
                    
                    gridColumnEditable.show({ cell: cell });
                });

                instancesDataMap[instanceId] = {
                    gridName: gridConfig.name,
                    indexId: gridConfig.columnsInfo.idIndex,
                    editableColumns: gridConfig.columnsInfo.editables,
                    wrapperElement: gridConfig.wrapperElement,
                    DataChanged: {}
                };

                wrapperElement.setAttribute("data-instance", instanceId);
            }

            wrapperElement.setAttribute("data-la-editable-grid", "true");
        }

        initialized = true;
    }

    function _tryGetElement(elementOrSelector) {
        if (elementOrSelector instanceof HTMLElement)
            return elementOrSelector;
        return document.querySelector(elementOrSelector);
    }

    function _tryGetRowId(rowElement, idIndex, wrapperElement) {
        if (typeof idIndex === "number")
            return rowElement.querySelector(`td:nth-child(${idIndex + 1})`)?.textContent;
        else if (typeof idIndex === "function")
            return idIndex(wrapperElement, rowElement.rowIndex);
        
        return undefined;
    }

    function _tryGetValue(element, delegate) {
        if (typeof delegate === "function")
            return delegate();
        return element.value;
    }

    function _ensureInitialized() {
        if (!initialized)
            throw new Error("gridColumnEditable is not initialized. Please call initialize method before using it.");
    }

    function _ensureSettingsDefaultValues() {
        if (!settings.editableGrids || !Array.isArray(settings.editableGrids))
            settings.editableGrids = [{}];

        for (var editableGrid of settings.editableGrids) {
            if (!editableGrid.wrapperElement) editableGrid.wrapperElement = "table";
            if (!editableGrid.contentElement) editableGrid.contentElement = "tbody";
            if (!editableGrid.columnsInfo) editableGrid.columnsInfo = {};
            if (!editableGrid.columnsInfo.gridName) editableGrid.columnsInfo.name = undefined;
            if (!editableGrid.columnsInfo.idIndex) editableGrid.columnsInfo.idIndex = 0;
            if (!editableGrid.columnsInfo.editables || !Array.isArray(editableGrid.columnsInfo.editables))
                editableGrid.columnsInfo.editables = [];
        }
    }

    function renderByElement(parent) {
        if (!(parent instanceof HTMLElement))
            throw new Error("Element must be an instance of HTMLElement");

        if (me.element !== undefined) return; 
        
        if (parent.tagName.toLowerCase() !== "td")  // TODO: Considerar adicionar o suporte para N tipos de element.
            throw new Error(`I'm sorry but that resource is not implemented to tag '${parent.tagName}'.`);

        var wrapperElement = parent.closest("[data-la-editable-grid=true]");
        if (!wrapperElement)
            throw new Error("Parent element is not inside an editable grid wrapper.");

        var instanceId = wrapperElement.getAttribute("data-instance");
        var columnData = instancesDataMap[instanceId].editableColumns.find(function (e) { return e.index == parent.cellIndex; });

        if (columnData && columnData.inputElement) {
            me.element = columnData.inputElement;
        } else {
            me.element = document.createElement("input");
            me.element.id = `la-editable-input`;
            me.element.name = `la-editable-input`;
        }
        
        me.activeCell = parent; // Lock the logical parent reference

        var styleElement = document.head.querySelector('style#la-editable-input-style');
        if (!styleElement) {
            styleElement = document.createElement('style');
            styleElement.id = "la-editable-input-style";
            styleElement.innerHTML = `
            .la-editable-input input,
            /* LATROMI Support */
            .la-editable-input .fieldWrapper,
            .la-editable-input .fieldWrapper > div {
                position: absolute !important;
                left: 0 !important; 
                top: 0 !important;
                width: 100% !important; 
                height: 100% !important;
                font-size: inherit !important;
                font-family: inherit !important;
                padding-top: 0 !important; 
                padding-bottom: 0 !important;
                padding-left: inherit !important; 
                padding-right: inherit !important;
                margin: 0 !important; 
                box-sizing: border-box !important;
                display: inherit !important;
            }`;
            document.head.appendChild(styleElement);
        }

        _configureEvent();
        
        parent.classList.add("la-editable-input");
        parent.style.position = "relative"; 
        parent.appendChild(me.element);
        me.element.focus();
    }

    function renderByPosition(args) {
        if (args.x === undefined || args.y === undefined)
            throw new Error("Position must have x and y properties");

        if (!args.cell)
            throw new Error("A logical cell reference (args.cell) is required even when rendering by position.");

        if (me.element !== undefined) return;

        var style = {
            "position": "absolute",
            "left": args.x + "px",
            "top": args.y + "px"
        };

        if (args.width) style.width = args.width + "px";
        if (args.height) style.height = args.height + "px";

        // FIX: Replaced 'this.element' with 'me.element' to maintain scope
        me.element = document.createElement("input");
        me.element.name = `la-editable-input`;
        me.element.style = Object.entries(style).map(([key, value]) => `${key}: ${value};`).join(" ");

        me.activeCell = args.cell; // Lock the logical cell reference

        _configureEvent();

        document.body.appendChild(me.element);
        me.element.focus();
    }

    function _confirm() {
        // FIX: Now relies on me.activeCell instead of me.element's physical DOM parent
        var cell = me.activeCell;
        var row = cell?.closest("tr");
        var instanceId = cell?.closest("[data-la-editable-grid=true]")?.getAttribute("data-instance");

        if (!instanceId || !row || cell?.cellIndex === undefined) {
            console.error("Invalid operation: Cannot identify the grid or cell context.");
            return; 
        }

        var dataInfo = instancesDataMap[instanceId];
        
        if (!dataInfo) {
            console.warn(`No data info found for instance ID '${instanceId}'`);
            return; 
        }

        var editableColumn = dataInfo.editableColumns.find(e => e.index == cell.cellIndex);

        if (!editableColumn) {
            console.warn(`Column index '${cell.cellIndex}' is not editable for instance ID '${instanceId}'`);
            return; 
        }
        
        // FIX: Passing the correct gridConfig context
        var rowId = _tryGetRowId(row, dataInfo.indexId, dataInfo.wrapperElement);
        
        if (!(rowId in dataInfo.DataChanged))
            dataInfo.DataChanged[rowId] = {};
        
        lastIndexChanged = rowId;
        dataInfo.DataChanged[rowId][cell.cellIndex] = _tryGetValue(me.element, editableColumn.valueDelegate);
        
        if (settings.onConfirm) {
            settings.onConfirm({
                elementName: dataInfo.gridName,
                valueChanged: dataInfo.DataChanged[rowId][cell.cellIndex],
                parent: cell
            });
        }
    }

    function _configureEvent() {
        var clickListener = function (event) {
            if (me.element && me.element.contains(event.target)) {
                return; 
            }

            if (me.activeCell) {
                me.activeCell.classList.remove("la-editable-input");
                me.activeCell.style.position = ""; 
            }

            me.element?.remove();
            me.element = undefined;
            me.activeCell = undefined;

            document.removeEventListener("keydown", keyPressListener, { capture: true }); 
            document.removeEventListener("click", clickListener, { capture: true }); 
        }

        var keyPressListener = function (event) {
            if (event.key === "Escape" || event.key === "Tab" || event.key === settings.confirmKey) {
                event.preventDefault();

                if (event.key === settings.confirmKey) 
                    _confirm();

                if (me.activeCell) {
                    me.activeCell.classList.remove("la-editable-input");
                    me.activeCell.style.position = ""; 
                }

                me.element?.remove();
                me.element = undefined;
                me.activeCell = undefined;

                document.removeEventListener("keydown", keyPressListener, { capture: true }); 
                document.removeEventListener("click", clickListener, { capture: true }); 
            }
        }

        document.addEventListener("click", clickListener, { capture: true });
        document.addEventListener("keydown", keyPressListener, { capture: true });
    }

    function __reset() {
        settings = {};
        instancesDataMap = {};
        initialized = false;
        lastIndexChanged = undefined;
        me.element = undefined;
        me.activeCell = undefined;
    }

    return {
        initialize: function (options, forceInit) {
            if (forceInit) __reset();
            if (initialized) return;

            initialize(this, options);

            return this;
        },
        show: function (args) {
            _ensureInitialized();

            // The plugin now requires 'cell' to be passed in args in all scenarios
            if (args.x !== undefined && args.y !== undefined)
                renderByPosition(args);
            else if (args.cell)
                renderByElement(args.cell);
        },
        getDataChanged: function (instanceId) {
            _ensureInitialized();

            if (!instanceId) throw new Error("Instance ID is required to get data changed");

            return instancesDataMap[instanceId].DataChanged;
        },
        getColumnsEditableIndex: function (instanceId) {
            _ensureInitialized();

            if (!instanceId) 
                throw new Error("Instance ID is required to get columns editable index");
            
            if (!(instancesDataMap[instanceId].editableColumns) || instancesDataMap[instanceId].editableColumns?.length === 0)
                return [];

            return instancesDataMap[instanceId].editableColumns.map(function (data) { return data.index; });
        },
        getLastIndexChanged: function () {
            return lastIndexChanged;
        }
    };
})();

Índice

  1. Inicialização Rápida
  2. Estrutura de Configuração
  3. Exemplos de Uso Avançado
  4. Métodos da API Pública
  5. Como Funciona o Rastreamento de Dados

Inicialização Rápida

Para iniciar o plugin, basta chamar o método initialize, passando um objeto de configuração detalhando quais tabelas devem receber o comportamento de edição e quais colunas serão afetadas.

Exemplo Básico de HTML

<table id="minhaTabela">
    <thead>
        <tr><th>ID</th><th>Nome</th><th>Idade (Editável)</th></tr>
    </thead>
    <tbody>
        <tr><td>101</td><td>João</td><td>25</td></tr>
        <tr><td>102</td><td>Maria</td><td>30</td></tr>
    </tbody>
</table>

Script de Inicialização

gridColumnEditable.initialize({
    editableGrids: [
        {
            name: "GridUsuarios",
            wrapperElement: "#minhaTabela", // Seletor CSS ou HTMLElement
            columnsInfo: {
                idIndex: 0, // A coluna 0 guarda o ID único da linha (101, 102...)
                editables: [
                    { index: 2 } // Torna a coluna 2 (Idade) editável
                ]
            }
        }
    ],
    confirmKey: "Enter",
    onConfirm: function(eventData) {
        console.log(`Grid: ${eventData.elementName} atualizada! Novo valor: ${eventData.valueChanged}`);
    }
});

Estrutura de Configuração

O método initialize espera um objeto contendo as seguintes propriedades globais:

Propriedade Tipo Padrão Descrição
confirmKey string "Enter" Tecla que, ao ser pressionada, confirma e salva a edição atual. Ex: "Enter", "Tab".
onConfirm function undefined Callback disparado imediatamente após uma edição ser confirmada. Recebe como parâmetro um objeto eventData.
editableGrids array [] Lista de configurações individuais para cada tabela que será gerenciada pelo plugin.

Configuração Interna de editableGrids

Cada objeto dentro do array editableGrids deve seguir esta estrutura:

Propriedade Tipo Obrigatório Descrição
name string Não Nome de identificação da Grid (útil para logs no callback onConfirm).
wrapperElement string ou HTMLElement Não (Padrão: "table") O elemento raiz da tabela onde o evento de clique será ancorado (Delegação de Eventos).
contentElement string ou HTMLElement Não (Padrão "tbody") Onde as células de conteúdo residem. (Atualmente não bloqueante, reservado para expansões).
columnsInfo object Sim Objeto definindo as regras das colunas (veja detalhes abaixo).

Objeto columnsInfo

  • idIndex (number ou function):
    Indica como o plugin deve encontrar a chave primária da linha que está sendo editada. Se for number, ele lerá o texto da coluna correspondente (ex: 0 para a primeira coluna). Se for function, permite extrair o ID via script.

  • valueDelegate (function):
    Função customizada para extrair o valor final a ser salvo (útil se você estiver usando componentes complexos em vez de um <input> simples).

  • editables (array de objetos):
    Define as colunas editáveis. Ex: [{ index: 1 }, { index: 2 }].

Exemplos de Uso Avançado

Exemplo A: Usando Inputs Customizados

Se você não quiser o <input type="text"> padrão gerado pelo plugin, você pode injetar seu próprio elemento (como um <select> ou um input com máscaras).

var meuSelect = document.createElement('select');
meuSelect.innerHTML = '<option value="A">Ativo</option><option value="I">Inativo</option>';

gridColumnEditable.initialize({
    editableGrids: [
        {
            wrapperElement: document.querySelector('#gridStatus'),
            columnsInfo: {
                idIndex: 0,
                editables: [
                    // Na coluna 3, o plugin usará o select criado acima
                    { index: 3, inputElement: meuSelect } 
                ]
            }
        }
    ]
});

Exemplo B: Extraindo ID da linha via Delegate (Função)

Caso o ID da sua linha não esteja visível em uma coluna (ex: ele fica em um atributo data-row-id="15" do <tr>), você pode usar uma função para ensiná-lo a capturar o ID.

columnsInfo: {
    // Recebe o wrapperElement e o rowIndex do <tr> clicado
    idIndex: function(wrapper, rowIndex) {
        var linha = wrapper.querySelectorAll("tr")[rowIndex];
        return linha.getAttribute("data-row-id");
    },
    editables: [{ index: 1 }]
}

Métodos da API Pública

O gridColumnEditable expõe uma API para que você interaja com ele externamente.

  • initialize(options, forceInit):
    Inicializa o plugin. Se forceInit for true, descarta configurações anteriores e recomeça o ciclo de vida.

  • show(args):
    Abre o modo de edição manualmente. O args exige a propriedade cell contendo o HTMLElement <td> alvo. Exemplo: gridColumnEditable.show({ cell: tdElement }). (Suporta também as propriedades x, y, width e height para renderização em coordenadas absolutas sobrepostas na tela).

  • getDataChanged(instanceId):
    Retorna o dicionário de dados alterados para uma grid específica. Requer o ID da instância da grid.

  • getColumnsEditableIndex(instanceId):
    Retorna um array com os índices de todas as colunas que estão configuradas como editáveis.

  • getLastIndexChanged():
    Retorna o ID da última linha editada no sistema.

Como Funciona o Rastreamento de Dados

O plugin não altera silenciosamente o valor visual do HTML (o innerText do <td>) após o “Enter”. A filosofia do plugin é armazenar temporariamente o estado das modificações na memória (no dicionário DataChanged).

Sempre que a tecla de confirmação é acionada, o plugin monta a seguinte estrutura, que você pode recuperar usando getDataChanged(instanceId):

{
  "101": { // ID da Linha (rowId)
    "2": "Novo valor da idade", // Chave é o Index da coluna, valor é o dado editado
    "3": "Ativo"
  },
  "105": {
    "2": "Nova idade"
  }
}

É responsabilidade do desenvolvedor capturar essas alterações (seja através do evento onConfirm ou lendo o mapa completo no momento de salvar o formulário) e processar a atualização no backend ou redesenhar a tabela na tela conforme necessário.