HackTJ / live

A massively scalable application for realtime hackathon judging and mentoring.
MIT License
5 stars 2 forks source link

[judge] Filtering and sorting table #47

Open sumanthratna opened 3 years ago

sumanthratna commented 3 years ago

https://laravel-livewire.com/screencasts/s7-sorting

https://twitter.com/ormanclark/status/1356605867160969218

sumanthratna commented 2 years ago

made progress but introduced a bug where sorting then filtering resulted in wrong sort order (https://github.com/alpinejs/alpine/discussions/2660), so not merging

occurred with this

{% extends "base.html" %}

{% block content %}
<section>
    {% load compress %}
    {% compress css %}
    <style>
        [x-cloak] {
            display: none;
        }

        [type="checkbox"] {
            box-sizing: border-box;
            padding: 0;
        }

        .form-checkbox {
            -webkit-appearance: none;
            -moz-appearance: none;
            appearance: none;
            -webkit-print-color-adjust: exact;
            color-adjust: exact;
            display: inline-block;
            vertical-align: middle;
            background-origin: border-box;
            -webkit-user-select: none;
            -moz-user-select: none;
            -ms-user-select: none;
            user-select: none;
            flex-shrink: 0;
            color: currentColor;
            background-color: #fff;
            border-color: #e2e8f0;
            border-width: 1px;
            border-radius: 0.25rem;
            height: 1.2em;
            width: 1.2em;
        }

        .form-checkbox:checked {
            background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M5.707 7.293a1 1 0 0 0-1.414 1.414l2 2a1 1 0 0 0 1.414 0l4-4a1 1 0 0 0-1.414-1.414L7 8.586 5.707 7.293z'/%3e%3c/svg%3e");
            border-color: transparent;
            background-color: currentColor;
            background-size: 100% 100%;
            background-position: center;
            background-repeat: no-repeat;
        }
    </style>
    {% endcompress %}

    <div class="container mx-auto py-6 px-4 mb-4" x-data="datatable" x-cloak>
        <h1 class="text-3xl py-4 border-b mb-10 text-white">Live Rankings</h1>
        <div class="relative h-12 flex flex-row text-gray-600 focus-within:ring-2 bg-white rounded-lg text-sm pl-3">
          <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 my-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
          </svg>
          <input type="search" placeholder="Filter rows" x-on:input.debounce="filterRows($event.target.value)" class="w-full focus:ring-0 outline-transparent bg-transparent border-none h-100">
        </div>

        <div class="bg-blue-200 my-8 left-0 bottom-0 right-0 z-40 w-full shadow rounded">
            <div class="container mx-auto px-4 py-4">
                <div class="flex md:items-center">
                    <div class="mr-4 flex-shrink-0">
                        <svg class="fill-current h-8 w-8 text-blue-600" viewBox="0 0 20 20">
                            <path fill-rule="evenodd"
                                  d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
                                  clip-rule="evenodd"></path>
                        </svg>
                    </div>
                    <div x-text="selectedRows.length + ' rows are selected'" class="text-blue-800 text-lg mr-6"></div>
                    <button class="ml-auto bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded inline-flex items-center"
                            @click="downloadTableAsCsv">
                        <svg class="fill-current w-4 h-4 mr-2" viewBox="0 0 20 20">
                            <path fill-rule="evenodd"
                                  d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
                                  clip-rule="evenodd">
                            </path>
                        </svg>
                        <span class="uppercase">Download CSV</span>
                    </button>
                </div>
            </div>
        </div>

        <div x-text="filteredRows"></div>

        <div class="mb-4 flex justify-between items-center">
            <div>
                <div class="shadow rounded-lg flex">
                    <div class="relative">
                        <button @click.prevent="open = !open"
                                class="rounded-lg inline-flex items-center bg-white hover:text-blue-500 focus:outline-none focus:shadow-outline text-gray-500 font-semibold py-2 px-2 md:px-4">
                            <svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 md:hidden" viewBox="0 0 24 24"
                                 stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
                                 stroke-linejoin="round">
                                <rect x="0" y="0" width="24" height="24" stroke="none"></rect>
                                <path d="M5.5 5h13a1 1 0 0 1 0.5 1.5L14 12L14 19L10 16L10 12L5 6.5a1 1 0 0 1 0.5 -1.5"/>
                            </svg>
                            <span class="hidden md:block">Columns</span>
                            <svg class="fill-current w-5 h-5 ml-1" viewBox="0 0 20 20">
                                <path fill-rule="evenodd"
                                      d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
                                      clip-rule="evenodd"></path>
                            </svg>
                        </button>

                        <div x-show="open" @click.outside="open = false"
                             class="z-40 absolute top-0 right-0 w-40 bg-white rounded-lg shadow-lg mt-12 -mr-1 block py-1 overflow-hidden">
                            <template x-for="heading in headings">
                                <label class="flex justify-start items-center text-truncate hover:bg-gray-100 px-4 py-2">
                                    <div class="text-blue-600 mr-3">
                                        <input type="checkbox"
                                               class="form-checkbox focus:outline-none focus:shadow-outline" checked
                                               @click="toggleColumn(heading.key)">
                                    </div>
                                    <div class="select-none text-gray-700" x-text="heading.value"></div>
                                </label>
                            </template>
                        </div>
                    </div>
                </div>
            </div>
        </div>

        <div class="overflow-x-auto bg-white rounded-lg shadow overflow-y-auto relative">
            <table id="spreadsheet-table" class="border-collapse table-auto w-full whitespace-no-wrap bg-white table-striped relative">
                <thead>
                <tr class="text-left bg-gray-200 border-b border-gray-300">
                    <th class="py-2 px-3" data-sort-method='none'>
                        <label class="text-blue-500 inline-flex justify-between items-center hover:bg-gray-200 px-2 py-2 rounded-lg cursor-pointer">
                            <input id="selectAllCheckbox" type="checkbox"
                                   class="form-checkbox focus:outline-none focus:shadow-outline"
                                   @click="selectAllCheckboxes($event);">
                        </label>
                    </th>
                    <template x-for="heading in headings">
                        <th class="px-6 py-2 text-gray-600 font-bold tracking-wider uppercase text-xs text-center"
                            x-text="heading.value" :id="'heading-' + heading.key" :heading="heading.key"></th>
                    </template>
                </tr>
                </thead>
                <tbody>
                <template x-for="index in filteredRows">
                    <tr class="text-gray-700 odd:bg-gray-100" :id="'row-' + index">
                        <td class="border-dashed border-t border-gray-200 px-3">
                            <label class="text-blue-500 inline-flex justify-between items-center hover:bg-gray-200 px-2 py-2 rounded-lg cursor-pointer">
                                <input type="checkbox"
                                       class="form-checkbox rowCheckbox focus:outline-none focus:shadow-outline"
                                       :name="index" @click="getRowDetail($event, index)">
                            </label>
                        </td>
                        <template x-for="heading in headings" :key="heading.key">
                            <!-- <span>
                              <td x-show="typeof data[index][heading.key] === 'boolean'" class="border-dashed border-t border-gray-200" :heading="heading.key">
                                <svg class="w-6 h-6 mx-auto" x-show="data[index][heading.key]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                                  heroicon: check
                                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
                                </svg>
                                <svg class="w-6 h-6 mx-auto" x-show="!data[index][heading.key]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                                  heroicon: x
                                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
                                </svg>
                              </td>
                              <td x-show="typeof data[index][heading.key] !== 'boolean'" class="border-dashed border-t border-gray-200 px-6 py-3 items-center" x-text="data[index][heading.key]" :heading="heading.key"></td>
                            </span> -->
                            <td class="border-dashed border-t border-gray-200 px-6 py-3 items-center"
                                x-text="data[index][heading.key]" :heading="heading.key"></td>
                        </template>
                    </tr>
                </template>
                </tbody>
            </table>
        </div>
    </div>

    {% block headings %}
    <script>
        // must have an id setting
    </script>
    {% endblock %}

    {% block data %}
    <script>
        //
    </script>
    {% endblock %}

    {% load static %}
    <script src="//unpkg.com/web-streams-polyfill@3.2.0/dist/polyfill.min.js"></script>
    <script src="//cdn.jsdelivr.net/gh/eligrey/Blob.js/Blob.min.js"></script>
    <script src="//cdn.jsdelivr.net/npm/streamsaver@2.0.5/StreamSaver.min.js"></script>

    {% compress js %}
    <script>
        Object.freeze(headings);
        Object.freeze(data);

        document.addEventListener('alpine:init', () => {
            let getHeadingIdfromLabel = label => headings.find(heading => heading.value.toUpperCase() === label.toUpperCase())?.key;
            let getHeadingFromKey = key => headings.find(heading => heading.key.toUpperCase() === key.toUpperCase());

            // the first element of the output is '', because of the checkbox column:
            let getSelectedColumns = () => Array.from(document.querySelectorAll('thead > tr > th[id^=heading-]:not(.hidden)'), thDomElement => thDomElement.innerText);

            const fuse = new Fuse(data, {keys: headings.map(heading => heading.key)});

            const allRowIndices = [...Array(data.length).keys()];

            Alpine.data('datatable', () => ({
                headings: headings,
                data: data,
                filteredRows: allRowIndices, // the rows that should be shown
                selectedRows: [],

                open: false,

                filterRows(searchQuery) {
                    console.log(searchQuery);
                    console.log(fuse.search(searchQuery));
                    // unsort table

                    this.filteredRows = searchQuery === "" ? allRowIndices : fuse.search(searchQuery).map(result => result.refIndex);

                    // unselect rows that aren't in the search results:
                    for (var index = this.selectedRows.length - 1; index >= 0; index--) { // iterate backwards because we remove elements as we iterate
                        if (!this.filteredRows.includes(this.selectedRows[index])) {
                            this.selectedRows.splice(index, 1); // removes `this.selectedRows[index]`
                        }
                    }
                },

                toggleColumn(key) {
                    let rows = document.querySelectorAll('[heading="' + key + '"]');
                    if (document.getElementById("heading-" + key).classList.contains('hidden')) {
                        rows.forEach(row => {
                            row.classList.remove('hidden');
                        });
                    } else {
                        rows.forEach(row => {
                            row.classList.add('hidden');
                        });
                    }
                },

                getRowDetail($event, rowIndex) {
                    if (this.selectedRows.includes(rowIndex)) {
                        let index = this.selectedRows.indexOf(rowIndex); // index of `rowIndex` in `selectedRows`
                        this.selectedRows.splice(index, 1); // removes `this.selectedRows[index]`
                    } else {
                        this.selectedRows.push(rowIndex);
                    }

                    if (this.selectedRows.length === this.data.length) {
                        document.getElementById('selectAllCheckbox').checked = true;
                    }
                },

                selectAllCheckboxes($event) {
                    let columns = document.querySelectorAll('.rowCheckbox');

                    this.selectedRows = [];

                    if ($event.target.checked == true) {
                        columns.forEach(column => {
                            column.checked = true;
                            this.selectedRows.push(parseInt(column.name));
                        });
                    } else {
                        columns.forEach(column => {
                            column.checked = false;
                        });
                        this.selectedRows = [];
                    }
                },

                downloadTableAsCsv($event) {
                    var selectedRowsIndices = Array.from(document.querySelectorAll("tbody > tr[id^=row-]"), trElement => parseInt(trElement.id.replace("row-", "")));
                    if (this.selectedRows.length > 0) {
                      selectedRowsIndices = selectedRowsIndices.filter(index => this.selectedRows.includes(index));
                    }
                    var selectedRowsData = selectedRowsIndices.map(index => Object.assign({}, this.data[index])); // use `Object.assign` to clone the row so that `data` does not change

                    let selectedColumns = getSelectedColumns().map(getHeadingIdfromLabel);

                    let removeHeadings = (selectedRowData) => {
                        for (let key in selectedRowData) {
                            if (selectedRowData.hasOwnProperty(key)) {
                                if (!selectedColumns.includes(key)) {
                                    selectedRowData[key] = undefined;
                                    delete selectedRowData[key];
                                }
                            }
                        }
                        return selectedRowData;
                    }
                    let selectedData = selectedRowsData.map(removeHeadings);

                    const quoteString = (string) => '"' + string.replace(/['"]+/g, '') + '"';

                    // convert JS object to CSV string
                    // https://stackoverflow.com/a/31536517/7127932
                    const replacer = (key, value) => value === null ? '' : value;  // replace null values with ''
                    // TODO bug: id not in CSV when remove name
                    let output = selectedData.map(row => selectedColumns.map(fieldName => quoteString(JSON.stringify(row[fieldName], replacer))).join(','));
                    output.unshift(selectedColumns.join(',')); // add header column
                    output = output.join('\r\n');

                    const blob = new Blob([output]);
                    const fileName = 'export ' + new Date().toLocaleDateString() + '.csv';
                    const fileStream = streamSaver.createWriteStream(fileName, {
                        size: blob.size,
                        // writableStrategy: undefined, // (optional)
                        // readableStrategy: undefined  // (optional)
                    });
                    const readableStream = blob.stream();
                    // more optimized pipe version
                    // (Safari may have pipeTo but it's useless without the WritableStream)
                    if (window.WritableStream && readableStream.pipeTo) {
                        readableStream.pipeTo(fileStream).then(() => console.log('done downloading CSV data'))
                    } else {
                      // Write (pipe) manually
                      window.writer = fileStream.getWriter();

                      const reader = readableStream.getReader();
                      const pump = () => reader.read()
                          .then(res => res.done ?
                              writer.close() :
                              writer.write(res.value).then(pump));
                      pump();

                      // abort so it does not look stuck
                      window.onunload = () => {
                          // writableStream.abort();
                          // also possible to call abort on the writer you got from `getWriter()`
                          writer.abort();
                      }
                    }
                }
            }));
        });

        document.addEventListener("DOMContentLoaded", () => {
            new Tablesort(document.getElementById('spreadsheet-table'));
        });
    </script>
    {% endcompress %}
</section>
<script src="//unpkg.com/alpinejs@3.9.1" defer></script>
<script src='//unpkg.com/tablesort@5.3.0/dist/tablesort.min.js' defer></script>
<script src='//unpkg.com/tablesort@5.3.0/dist/sorts/tablesort.number.min.js' defer></script>
<link rel="stylesheet" href="//unpkg.com/tablesort@5.3.0/tablesort.css">
<script src="//unpkg.com/fuse.js@6.5.3/dist/fuse.basic.min.js"></script>
{% endblock %}

using list.js with alpine instead of tablesort + fuse might help?