Как создать сортировку таблиц и разбиение на страницы в веб-компоненте

Как создать сортировку таблиц и разбиение на страницы в веб-компоненте

28 ноября 2022 г.

На прошлой неделе я записал в блоге о своем первом опыте создания простого веб-компонента. Как я уже сказал, я слышал об этом годы, но никогда не находил время поиграться. Если вы прочтете первую статью, то увидите, что для начала не потребовалось много усилий. Мне не нужен был процесс сборки или фреймворк, мне нужен был только файл JavaScript для определения моего пользовательского компонента. Если вы постоянный читатель, то знаете, что я несколько раз создавал одну и ту же демонстрацию — базовую таблицу с данными, загруженными с помощью Ajax, которые поддерживали сортировку и разбиение на страницы. Чтобы напомнить, вот эти предыдущие статьи:

* Построение сортировки таблиц и разбивки на страницы в Vue.js * Построение сортировки таблиц и нумерации страниц в JavaScript * Построение сортировки таблиц и разбивки на страницы в Alpine.js

В каждой из этих статей я упоминал серверную службу (https://www.raymondcamden.com/). netlify/functions/get-cats), который вернул массив котов. Каждый экземпляр массива имел значение имени, возраста, породы и пола. Для каждой из моих предыдущих демонстраций я начинал с демонстрации, которая просто загружала данные и отображала их. Затем я добавил сортировку. В качестве последней итерации я добавил нумерацию страниц.

Версия 1 – просто рендеринг

Итак, как мне создать веб-компонент? Я начал с файла JavaScript, datatable.js. Мой план относительно API компонента был довольно прост. Один обязательный атрибут указывает на API, а необязательный атрибут позволяет указать конкретные столбцы для вывода. Вот самый простой вариант использования:

<data-table src="https://www.raymondcamden.com/.netlify/functions/get-cats"></data-table>

А вот один с указанием столбцов:

<data-table src="https://www.raymondcamden.com/.netlify/functions/get-cats" cols="name,age"></data-table>

В моей первой итерации я просто сосредоточился на рендеринге:

class DataTable extends HTMLElement {

    constructor() {
        super();


        if(this.hasAttribute('src')) this.src = this.getAttribute('src');
        // If no source, do nothing
        if(!this.src) return;

        // attributes to do, datakey + cols
        if(this.hasAttribute('cols')) this.cols = this.getAttribute('cols').split(',');


        const shadow = this.attachShadow({
            mode: 'open'
        });

        const wrapper = document.createElement('table');
        const thead = document.createElement('thead');
        const tbody = document.createElement('tbody');
        wrapper.append(thead, tbody);
        shadow.appendChild(wrapper);

        const style = document.createElement('style');
        style.textContent = `
table { 
    border-collapse: collapse;
}

td, th {
  padding: 5px;
  border: 1px solid black;
}

th {
    cursor: pointer;
}
    `;

        // Attach the created elements to the shadow dom
        shadow.appendChild(style);

    }

    async load() {
        console.log('load', this.src);
        // error handling needs to be done :|
        let result = await fetch(this.src);
        this.data = await result.json();
        this.render();
    }

    render() {
        console.log('render time', this.data);
        if(!this.cols) this.cols = Object.keys(this.data[0]);

        this.renderHeader();
        this.renderBody();
    }

    renderBody() {

        let result = '';
        this.data.forEach(c => {
            let r = '<tr>';
            this.cols.forEach(col => {
                r += `<td>${c[col]}</td>`;
            });
            r += '</tr>';
            result += r;
        });

        let tbody = this.shadowRoot.querySelector('tbody');
        tbody.innerHTML = result;

    }

    renderHeader() {

        let header = '<tr>';
        this.cols.forEach(col => {
            header += `<th>${col}</th>`;
        });
        let thead = this.shadowRoot.querySelector('thead');
        thead.innerHTML = header;

    }

    static get observedAttributes() { return ['src']; }

    attributeChangedCallback(name, oldValue, newValue) {
        // even though we only listen to src, be sure
        if(name === 'src') {
            this.src = newValue;
            this.load();
        }
    }

}

// Define the new element
customElements.define('data-table', DataTable);

Сверху мой конструктор сначала проверяет атрибуты и гарантирует, что он имеет по крайней мере атрибут src и необязательный cols. Я не был точно уверен, что делать, если src не был передан, но в целом веб-страницы «ломаются» красиво, и я решил, что выход — это самое простое решение.

Затем я начинаю создавать свой DOM, в данном случае таблицу с головой и телом. Я также создаю таблицу стилей, чтобы добавить границы.

Логика рендеринга таблицы разбита на несколько методов. load обрабатывает выборку данных и по завершении вызывает render. Я разбил render еще на две функции: одну для заголовка и одну для тела. Я немного подумал и решил, что не хочу повторно отображать заголовок при сортировке или разбиении по страницам, а только тело. Наконец, обратите внимание, что attributeChangedCallback обрабатывает значения src и вызывает load. Это работает в моем «простом использовании html» и будет работать, если бы я использовал JavaScript для динамического изменения значения src. Проверьте эту версию здесь:

https://codepen.io/cfjedimaster/pen/MWQvRVw?embedable=true

Версия вторая — сортировка

Для сортировки я внес несколько изменений. Во-первых, в renderHeader я изменил его так:

renderHeader() {

    let header = '<tr>';
    this.cols.forEach(col => {
        header += `<th data-sort="${col}">${col}</th>`;
    });
    let thead = this.shadowRoot.querySelector('thead');
    thead.innerHTML = header;

    this.shadowRoot.querySelectorAll('thead tr th').forEach(t => {
        t.addEventListener('click', this.sort, false);
    });

}

Я использую атрибут данных, чтобы определить столбец для сортировки, а затем добавляю прослушиватель событий для каждого заголовка, чтобы прослушивать события кликов. Мое событие сортировки выглядит следующим образом:

async sort(e) {
    let thisSort = e.target.dataset.sort;
    console.log('sort by',thisSort);

    if(this.sortCol && this.sortCol === thisSort) this.sortAsc = !this.sortAsc;
    this.sortCol = thisSort;
    this.data.sort((a, b) => {
        if(a[this.sortCol] < b[this.sortCol]) return this.sortAsc?1:-1;
        if(a[this.sortCol] > b[this.sortCol]) return this.sortAsc?-1:1;
        return 0;
    });
    this.renderBody();  
}

Я получаю сортировку по столбцу, изучая атрибут данных элемента, который распознал событие клика. После этого это обычная функция сортировки JavaScript, и я запускаю renderBody.

Теперь, в этот момент, я столкнулся с проблемой. В sort значение this больше не указывает на основную область действия моего компонента. Я понятия не имел, почему. Я немного погуглил и наткнулся на это: Вот почему нам нужно связать обработчики событий в компонентах класса в React. Казалось, что это очень похожая проблема, и хотя я не могу обещать полностью понять проблему, но ее решение сработало для меня хорошо. В моем конструкторе я добавил это в конце:

this.sort = this.sort.bind(this);

И это сработало как шарм. Вы можете увидеть обновленную версию здесь:

https://codepen.io/cfjedimaster/pen/bGLrJxJ?embedable=true

Третья версия — пейджинг

В третьей и последней версии я добавил пейджинг. В моих предыдущих двух выпусках «корнем» моего компонента был тег таблицы. Поскольку я собирался добавить кнопки для навигации, в итоге я создал новый div для их размещения. Мне не приходило в голову использовать tfoot, и теперь мне как бы жаль, было, но я в порядке с этим. Вот обновленный конструктор с новыми элементами DOM, а также двумя новыми обработчиками событий для навигации. Я установил размер страницы равным 5, так как мой набор кошек не очень большой.

constructor() {
    super();

    if(this.hasAttribute('src')) this.src = this.getAttribute('src');
    // If no source, do nothing
    if(!this.src) return;

    // attributes to do, datakey 
    if(this.hasAttribute('cols')) this.cols = this.getAttribute('cols').split(',');

    this.pageSize = 5;
    if(this.hasAttribute('pagesize')) this.pageSize = this.getAttribute('pagesize');

    // helper values for sorting and paging
    this.sortAsc = false;
    this.curPage = 1;

    const shadow = this.attachShadow({
        mode: 'open'
    });

const table = document.createElement('table');
    const thead = document.createElement('thead');
    const tbody = document.createElement('tbody');
    table.append(thead, tbody);

    const nav = document.createElement('div');
    const prevButton = document.createElement('button');
    prevButton.innerHTML = 'Previous';
    const nextButton = document.createElement('button');
    nextButton.innerHTML = 'Next';
    nav.append(prevButton, nextButton);

    shadow.append(table,nav);

    const style = document.createElement('style');
    style.textContent = `
table { 
border-collapse: collapse;
}

td, th {
padding: 5px;
border: 1px solid black;
}

th {
cursor: pointer;
}

div {
padding-top: 10px;
}
`;

    // Attach the created elements to the shadow dom
    shadow.appendChild(style);

    // https://www.freecodecamp.org/news/this-is-why-we-need-to-bind-event-handlers-in-class-components-in-react-f7ea1a6f93eb/
    this.sort = this.sort.bind(this);

    this.nextPage = this.nextPage.bind(this);
    this.previousPage = this.previousPage.bind(this);

    nextButton.addEventListener('click', this.nextPage, false);
    prevButton.addEventListener('click', this.previousPage, false);

}

Обратите внимание, что я повторяю вызов bind для своих новых обработчиков событий. Пагинация делается так:

nextPage() {
    if((this.curPage * this.pageSize) < this.data.length) this.curPage++;
    this.renderBody();
}

previousPage() {
    if(this.curPage > 1) this.curPage--;
    this.renderBody();
}

А затем renderBody обновляется вызовом filter, чтобы просто получить «страницу» данных:

renderBody() {

    let result = '';
    this.data.filter((row, index) => {
        let start = (this.curPage-1)*this.pageSize;
        let end =this.curPage*this.pageSize;
        if(index >= start && index < end) return true;
    }).forEach(c => {
        let r = '<tr>';
        this.cols.forEach(col => {
            r += `<td>${c[col]}</td>`;
        });
        r += '</tr>';
        result += r;
    });

    let tbody = this.shadowRoot.querySelector('tbody');
    tbody.innerHTML = result;

}

Вы можете продемонстрировать эту версию здесь:

https://codepen.io/cfjedimaster/pen/OJQjGqq?embedable=true

Что осталось?

Итак, все, что я действительно сделал здесь, это создал самый минимум. Пока я занимался клиентской разработкой, существовали фреймворки с супер сложными таблицами данных. Я мог бы добавить поддержку таких вещей, как «мой API возвращает массив, но он находится в подэлементе с именем items». Я также мог видеть передачу размера страницы в качестве атрибута. Может быть, даже атрибут colLabels, позволяющий указать метки заголовков. Вы поняли идею. :) Если это будет полезно или у вас возникнут вопросы, дайте мне знать!

Первоначально опубликовано здесь. р>


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE