From 39d9968091a58b6632f657f8e47e1bc2fe619acd Mon Sep 17 00:00:00 2001 From: Tom O'Dwyer Date: Tue, 10 Sep 2024 11:33:10 +0100 Subject: [PATCH] wip --- airlock/static/assets/activity.js | 2 +- airlock/static/assets/datatable-loader.js | 149 ++++++++++++++- airlock/static/assets/file_browser/dir.js | 14 +- airlock/templates/activity.html | 5 +- .../file_browser/file_content/csv.html | 7 +- assets/src/scripts/datatable.js | 178 ++++++++++++++++++ assets/src/styles/_datatable.css | 122 ++++++++++++ package-lock.json | 25 ++- package.json | 3 +- vite.config.js | 1 + 10 files changed, 488 insertions(+), 18 deletions(-) create mode 100644 assets/src/scripts/datatable.js create mode 100644 assets/src/styles/_datatable.css diff --git a/airlock/static/assets/activity.js b/airlock/static/assets/activity.js index 2eb2e375..e6c00b45 100644 --- a/airlock/static/assets/activity.js +++ b/airlock/static/assets/activity.js @@ -1 +1 @@ -window.initCustomTable(10); +window.initCustomTable({ paging: true, perPage: 10 }); diff --git a/airlock/static/assets/datatable-loader.js b/airlock/static/assets/datatable-loader.js index d6ac3262..79fbc76e 100644 --- a/airlock/static/assets/datatable-loader.js +++ b/airlock/static/assets/datatable-loader.js @@ -1,3 +1,146 @@ +import "simple-datatables"; + +/** + * @param {Element | null} el + * @param {number | null} state + * @param {{ page: (arg0: any) => any; }} table + */ +function pageButtonState(el, state, table) { + if (el) { + if (state) { + el.classList.remove("hidden"); + el.addEventListener("click", () => table.page(state)); + } else { + el.classList.add("hidden"); + } + } +} + +function getPageButtons() { + let nextPageBtn = document.querySelector( + `[data-table-pagination="next-page"]`, + ); + nextPageBtn?.replaceWith(nextPageBtn.cloneNode(true)); + nextPageBtn = document.querySelector(`[data-table-pagination="next-page"]`); + + let previousPageBtn = document.querySelector( + `[data-table-pagination="previous-page"]`, + ); + previousPageBtn?.replaceWith(previousPageBtn.cloneNode(true)); + previousPageBtn = document.querySelector( + `[data-table-pagination="previous-page"]`, + ); + + return { nextPageBtn, previousPageBtn }; +} + +/** + * @param {number} currentPage + * @param {import("simple-datatables").DataTable} table + */ +function setPaginationButtons(currentPage, table) { + const pageNumberEl = document.querySelector( + `[data-table-pagination="page-number"]`, + ); + const totalPagesEl = document.querySelector( + `[data-table-pagination="total-pages"]`, + ); + + const { nextPageBtn, previousPageBtn } = getPageButtons(); + const totalPages = table.pages.length; + const pagination = { + currentPage, + totalPages, + nextPage: currentPage < totalPages ? currentPage + 1 : null, + previousPage: currentPage > 1 ? currentPage - 1 : null, + }; + + if (pageNumberEl) { + pageNumberEl.innerHTML = JSON.stringify(pagination.currentPage); + } + if (totalPagesEl) { + totalPagesEl.innerHTML = JSON.stringify(pagination.totalPages); + } + + pageButtonState(nextPageBtn, pagination.nextPage, table); + pageButtonState(previousPageBtn, pagination.previousPage, table); +} + +const initCustomTable = async (perPage) => { + /** @type {HTMLTableElement | null} */ + const tableEl = document.querySelector("table#customTable"); + const paginationPerPage = perPage || 25; + + if (tableEl) { + const { DataTable } = await import("simple-datatables"); + // @ts-ignore + await import("../styles/_datatable.css"); + + const dataTable = new DataTable(tableEl, { + paging: false, + perPage: paginationPerPage, + searchable: true, + sortable: true, + tableRender: (_data, table) => { + const tHead = table.childNodes?.[0]; + const filterHeaders = { + nodeName: "TR", + childNodes: tHead?.childNodes?.[0].childNodes?.map((_th, index) => { + const showSearch = _th.attributes["data-searchable"] !== "false"; + + return { + nodeName: "TH", + childNodes: showSearch + ? [ + { + nodeName: "INPUT", + attributes: { + class: "datatable-input", + "data-columns": `[${index}]`, + // @ts-ignore + placeholder: `Filter ${_data.headings[index].text + .trim() + .toLowerCase()}`, + type: "search", + }, + }, + ] + : [], + }; + }), + }; + tHead?.childNodes?.push(filterHeaders); + return table; + }, + template: (options) => `
`, + }); + + /** @type {NodeListOf} */ + const filters = document.querySelectorAll("input.datatable-input"); + [...filters].map((filter) => { + filter.addEventListener("input", () => { + if (filter?.value === "") { + return setTimeout(() => setPaginationButtons(1, dataTable), 0); + } + return null; + }); + return null; + }); + + dataTable.on("datatable.init", () => setPaginationButtons(1, dataTable)); + dataTable.on("datatable.page", (/** @type {number} */ page) => + setPaginationButtons(page, dataTable), + ); + dataTable.on("datatable.sort", () => setPaginationButtons(1, dataTable)); + dataTable.on("datatable.search", () => setPaginationButtons(1, dataTable)); + } +}; + +initCustomTable(); +// expose to external callers, useful when partially loading a datatable over HTMX +window.initCustomTable = initCustomTable; + + var observer = new MutationObserver((mutations, obs) => { const sorterButton = document.querySelector( "button.datatable-sorter" @@ -8,9 +151,9 @@ var observer = new MutationObserver((mutations, obs) => { document.querySelector("#airlock-table table.datatable").style.display = "table"; // If we have paginationEl, display it // The upstream code hides the pagination until the page numbers have been populated - if (paginationEl !== null) { - document.querySelector("#pagination-nav").classList.remove("hidden") - }; + // if (paginationEl !== null) { + // document.querySelector("#pagination-nav").classList.remove("hidden") + // }; obs.disconnect(); clearTimeout(); return; diff --git a/airlock/static/assets/file_browser/dir.js b/airlock/static/assets/file_browser/dir.js index 3d4f8578..a7878e8c 100644 --- a/airlock/static/assets/file_browser/dir.js +++ b/airlock/static/assets/file_browser/dir.js @@ -1,13 +1,15 @@ // ensure datatable is initialised when loading over HTMX -window.initCustomTable ? window.initCustomTable() : null; +window.initCustomTable + ? window.initCustomTable({ paging: false, perPage: Infinity }) + : null; // implement select all checkbox function toggleSelectAll(elem, event) { - const form = document.querySelector("#multiselect_form"); + const form = document.querySelector("#multiselect_form"); - const checkboxes = form.querySelectorAll('input[type="checkbox"]'); + const checkboxes = form.querySelectorAll('input[type="checkbox"]'); - checkboxes.forEach(function(checkbox) { - checkbox.checked = elem.checked; - }); + checkboxes.forEach(function (checkbox) { + checkbox.checked = elem.checked; + }); } diff --git a/airlock/templates/activity.html b/airlock/templates/activity.html index 2b8da77c..d864173e 100644 --- a/airlock/templates/activity.html +++ b/airlock/templates/activity.html @@ -83,6 +83,5 @@ {% endif %} {% /card %} -{% vite_asset "assets/src/scripts/components.js" app="job_server" %} - - +{% vite_asset "assets/src/scripts/main.js" %} +{% vite_asset "assets/src/scripts/datatable.js" %} diff --git a/airlock/templates/file_browser/file_content/csv.html b/airlock/templates/file_browser/file_content/csv.html index 8492cf51..e407fc2f 100644 --- a/airlock/templates/file_browser/file_content/csv.html +++ b/airlock/templates/file_browser/file_content/csv.html @@ -6,8 +6,9 @@ {% vite_hmr_client %} - {% vite_asset "assets/src/scripts/base.js" app="job_server" %} - {% vite_asset "assets/src/scripts/components.js" app="job_server" %} + {% vite_asset "assets/src/scripts/main.js" %} + {% vite_asset "assets/src/scripts/datatable.js" %} + {% comment %} {% vite_asset "assets/src/scripts/components.js" app="job_server" %} {% endcomment %} @@ -48,7 +49,7 @@ - + {% comment %} {% endcomment %} diff --git a/assets/src/scripts/datatable.js b/assets/src/scripts/datatable.js new file mode 100644 index 00000000..73c64dad --- /dev/null +++ b/assets/src/scripts/datatable.js @@ -0,0 +1,178 @@ +import "../styles/_datatable.css"; + +/** + * @param {Element | null} el + * @param {number | null} state + * @param {{ page: (arg0: any) => any; }} table + */ +function pageButtonState(el, state, table) { + if (el) { + if (state) { + el.classList.remove("hidden"); + el.addEventListener("click", () => table.page(state)); + } else { + el.classList.add("hidden"); + } + } +} + +function getPageButtons() { + let nextPageBtn = document.querySelector( + `[data-table-pagination="next-page"]` + ); + nextPageBtn?.replaceWith(nextPageBtn.cloneNode(true)); + nextPageBtn = document.querySelector(`[data-table-pagination="next-page"]`); + + let previousPageBtn = document.querySelector( + `[data-table-pagination="previous-page"]` + ); + previousPageBtn?.replaceWith(previousPageBtn.cloneNode(true)); + previousPageBtn = document.querySelector( + `[data-table-pagination="previous-page"]` + ); + + return { nextPageBtn, previousPageBtn }; +} + +/** + * @param {number} currentPage + * @param {import("simple-datatables").DataTable} table + */ +function setPaginationButtons(currentPage, table) { + const pageNumberEl = document.querySelector( + `[data-table-pagination="page-number"]` + ); + const totalPagesEl = document.querySelector( + `[data-table-pagination="total-pages"]` + ); + + const { nextPageBtn, previousPageBtn } = getPageButtons(); + const totalPages = table.pages.length; + const pagination = { + currentPage, + totalPages, + nextPage: currentPage < totalPages ? currentPage + 1 : null, + previousPage: currentPage > 1 ? currentPage - 1 : null, + }; + + if (pageNumberEl) { + pageNumberEl.innerHTML = JSON.stringify(pagination.currentPage); + } + if (totalPagesEl) { + totalPagesEl.innerHTML = JSON.stringify(pagination.totalPages); + } + + pageButtonState(nextPageBtn, pagination.nextPage, table); + pageButtonState(previousPageBtn, pagination.previousPage, table); +} + +const initCustomTable = async ({ paging = true, perPage = 25 }) => { + /** @type {HTMLTableElement | null} */ + const tableEl = document.querySelector("table#customTable"); + + if (tableEl) { + const { DataTable } = await import("simple-datatables"); + + const dataTable = new DataTable(tableEl, { + paging, + perPage, + searchable: true, + sortable: true, + tableRender: (_data, table) => { + const tHead = table.childNodes?.[0]; + const filterHeaders = { + nodeName: "TR", + childNodes: tHead?.childNodes?.[0].childNodes?.map((_th, index) => { + const showSearch = _th.attributes["data-searchable"] !== "false"; + + return { + nodeName: "TH", + childNodes: showSearch + ? [ + { + nodeName: "INPUT", + attributes: { + class: "datatable-input", + "data-columns": `[${index}]`, + // @ts-ignore + placeholder: `Filter ${_data.headings[index].text + .trim() + .toLowerCase()}`, + type: "search", + }, + }, + ] + : [], + }; + }), + }; + tHead?.childNodes?.push(filterHeaders); + return table; + }, + template: (options) => `
`, + }); + + /** @type {NodeListOf} */ + const filters = document.querySelectorAll("input.datatable-input"); + [...filters].map((filter) => { + filter.addEventListener("input", () => { + if (filter?.value === "") { + return setTimeout(() => setPaginationButtons(1, dataTable), 0); + } + return null; + }); + return null; + }); + + dataTable.on("datatable.init", () => setPaginationButtons(1, dataTable)); + dataTable.on("datatable.page", (/** @type {number} */ page) => + setPaginationButtons(page, dataTable) + ); + dataTable.on("datatable.sort", () => setPaginationButtons(1, dataTable)); + dataTable.on("datatable.search", () => setPaginationButtons(1, dataTable)); + } +}; + +// initCustomTable(); +// expose to external callers, useful when partially loading a datatable over HTMX +window.initCustomTable = initCustomTable; + +var observer = new MutationObserver((mutations, obs) => { + const sorterButton = document.querySelector("button.datatable-sorter"); + const paginationEl = document.querySelector("#pagination-nav"); + if (sorterButton) { + document.querySelector("#airlock-table p.spinner").style.display = "none"; + document.querySelector("#airlock-table table.datatable").style.display = + "table"; + // If we have paginationEl, display it + // The upstream code hides the pagination until the page numbers have been populated + // if (paginationEl !== null) { + // document.querySelector("#pagination-nav").classList.remove("hidden") + // }; + obs.disconnect(); + clearTimeout(); + return; + } +}); + +observer.observe(document, { + childList: true, + subtree: true, +}); + +// If the datatable hasn't loaded within 5 seconds, it's likely something's gone +// wrong; unhide the table to show the non-datatable table +// Also hide the datatable sort icons as they'll be unformatted without the +// datatable, and they won't work anyway +setTimeout(() => { + const sorterButton = document.querySelector("button.datatable-sorter"); + if (!sorterButton) { + document.querySelector("#airlock-table p.spinner").style.display = "none"; + document.querySelector("#airlock-table table.datatable").style.display = + "table"; + const sortIcons = document.getElementsByClassName("sort-icon"); + for (let i = 0; i < sortIcons.length; i++) { + sortIcons.item(i).style.display = "none"; + } + } +}, 5000); diff --git a/assets/src/styles/_datatable.css b/assets/src/styles/_datatable.css new file mode 100644 index 00000000..dc1d1337 --- /dev/null +++ b/assets/src/styles/_datatable.css @@ -0,0 +1,122 @@ +.datatable-table#customTable { + min-width: 100%; + + & thead { + background-color: var(--color-slate-200); + } + + & th { + color: var(--color-slate-900); + font-size: 0.875rem; + font-weight: 600; + line-height: 1.25rem; + text-align: left; + white-space: nowrap; + width: auto !important; + + & button { + padding: 0.5rem; + position: relative; + text-align: left; + width: 100%; + } + + &:has(input) { + padding: 0.5rem; + } + + & input.datatable-input { + display: block; + width: 100%; + border-radius: 0.375rem; + border-color: var(--color-slate-300); + font-weight: 400; + box-shadow: + 0 0 rgba(0, 0, 0, 0), + 0 0 rgba(0, 0, 0, 0), + 0 1px 2px 0 rgba(0, 0, 0, 0.05); + + @media (min-width: 640px) { + font-size: 0.875rem; + line-height: 1.25rem; + } + + &:focus { + border-color: var(--color-oxford-500); + outline-color: var(--color-oxford-500); + outline-offset: -1px; + } + } + + & .datatable-icon--descending, + & .datatable-icon--ascending { + display: none; + } + + & .datatable-icon--no-sort { + display: block; + } + + &.datatable-ascending { + & .datatable-icon--no-sort, + & .datatable-icon--descending { + display: none; + } + + & .datatable-icon--ascending { + display: block; + } + } + + &.datatable-descending { + & .datatable-icon--no-sort, + & .datatable-icon--ascending { + display: none; + } + + & .datatable-icon--descending { + display: block; + } + } + } + + & tbody > tr ~ tr { + background-color: var(--color-white); + } + + & tbody > tr ~ tr:not(:last-child) { + border-block: 1px solid var(--color-slate-200); + } + + & tr:nth-child(even) { + background-color: var(--color-slate-50); + } + + & td { + color: var(--color-slate-700); + font-size: 0.875rem; + line-height: 1.25rem; + padding: 0.5rem; + } + + & a { + color: var(--color-oxford-600); + font-weight: 600; + text-decoration: underline #69afff; + text-underline-offset: 2px; + transition-duration: 200ms; + transition-property: color, background-color, border-color, + text-decoration-color; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + + &:hover, + &:focus { + color: var(--color-oxford-800); + text-decoration-color: transparent; + } + + &:focus { + background-color: var(--color-bn-sun-300); + } + } +} diff --git a/package-lock.json b/package-lock.json index 23c45707..efad8ef8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "dependencies": { "@fontsource-variable/public-sans": "^5.0.19", - "htmx.org": "^1.9.12" + "htmx.org": "^1.9.12", + "simple-datatables": "^9.1.0" }, "devDependencies": { "@tailwindcss/forms": "^0.5.7", @@ -1119,6 +1120,12 @@ "node": ">=4" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -1126,6 +1133,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/diff-dom": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/diff-dom/-/diff-dom-5.1.4.tgz", + "integrity": "sha512-TSEaVdVGictY1KHg7VpVw2nuM02YKC9C8/qBkGiCnkiAybVbu1zQTMj2/dnVLRO7Z62UsqzHGpXweiOj5/jaZg==", + "license": "LGPL-3.0" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -2066,6 +2079,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-datatables": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/simple-datatables/-/simple-datatables-9.1.0.tgz", + "integrity": "sha512-0k5x8+71P1Hnn37VcdM/FpRxKLqBM+T32jqRgJiezbijUg6LbpepufkIDEpf8RtGmA6ftoz+FnAcn0Wj9yOQuA==", + "license": "LGPL-3.0", + "dependencies": { + "dayjs": "^1.11.10", + "diff-dom": "^5.1.3" + } + }, "node_modules/source-map-js": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", diff --git a/package.json b/package.json index 4d203fc1..59e27881 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@fontsource-variable/public-sans": "^5.0.19", - "htmx.org": "^1.9.12" + "htmx.org": "^1.9.12", + "simple-datatables": "^9.1.0" } } diff --git a/vite.config.js b/vite.config.js index c2b6c5d8..7ebb8122 100644 --- a/vite.config.js +++ b/vite.config.js @@ -7,6 +7,7 @@ export default defineConfig({ outDir: "./assets/out", rollupOptions: { input: { + datatable: "assets/src/scripts/datatable.js", htmx: "assets/src/scripts/htmx.js", main: "assets/src/scripts/main.js", resizer: "assets/src/scripts/resizer.js",