Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[judge] Filtering and sorting table #47

Open
2 tasks
sumanthratna opened this issue Feb 1, 2021 · 1 comment
Open
2 tasks

[judge] Filtering and sorting table #47

sumanthratna opened this issue Feb 1, 2021 · 1 comment
Assignees
Labels
enhancement New feature or request

Comments

@sumanthratna sumanthratna added the enhancement New feature or request label Feb 1, 2021
@sumanthratna sumanthratna self-assigned this Feb 1, 2021
@sumanthratna
Copy link
Member Author

sumanthratna commented Apr 25, 2022

made progress but introduced a bug where sorting then filtering resulted in wrong sort order (alpinejs/alpine#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/[email protected]/dist/polyfill.min.js"></script>
    <script src="//cdn.jsdelivr.net/gh/eligrey/Blob.js/Blob.min.js"></script>
    <script src="//cdn.jsdelivr.net/npm/[email protected]/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/[email protected]" defer></script>
<script src='//unpkg.com/[email protected]/dist/tablesort.min.js' defer></script>
<script src='//unpkg.com/[email protected]/dist/sorts/tablesort.number.min.js' defer></script>
<link rel="stylesheet" href="//unpkg.com/[email protected]/tablesort.css">
<script src="//unpkg.com/[email protected]/dist/fuse.basic.min.js"></script>
{% endblock %}

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

1 participant