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

Remember checkbox state following page navigation #764

Merged
merged 5 commits into from
Feb 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions airlock/static/assets/file_browser/dir.js

This file was deleted.

132 changes: 132 additions & 0 deletions airlock/static/assets/file_browser/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,135 @@ function setTreeSelection(tree, event) {
.forEach((e) => (e.open = true));
}
}

//////////////////////
// Checkbox scripts //
//////////////////////

/**
* Get an array of all visible checkboxes. Does not return the "select all"
* checkbox
* @returns {HTMLInputElement[]} Array of visible checkboxes.
*/
function getVisibleCheckboxes() {
const form = document.getElementById("multiselect_form");
const selector = `input[type="checkbox"]:not(.selectall)`;

return form ? [...form.querySelectorAll(selector)] : [];
}

/**
* Retrieve from sessionStorage the currently checked checkboxes. Format
* is { "baseURI": {"checkbox_value": true/false}}
* @returns {{ [key: string]: { [key:string]: boolean } }}
*/
function getCheckboxSessionState() {
const stateStr = sessionStorage.getItem("checkbox-cache");

const state = stateStr ? JSON.parse(stateStr) : {};
return state;
}

/**
* Persist the state of the currently checked checkboxes to sessionStorage.
* This is scoped to the baseURI of the checkbox, which includes the workspace
* and therefore you avoid the situation where two identical files (apart from
* the workspace) start conflicting and share their state.
*/
function saveCheckboxSessionState() {
const currentState = getCheckboxSessionState();
const checkboxes = getVisibleCheckboxes();

checkboxes.forEach((checkbox) => {
if(!currentState[checkbox.baseURI]) currentState[checkbox.baseURI] = {};
currentState[checkbox.baseURI][checkbox.value] = checkbox.checked;
});

sessionStorage.setItem("checkbox-cache", JSON.stringify(currentState));
}

// implement select all checkbox
function toggleSelectAll(elem) {
const checkboxes = getVisibleCheckboxes();

checkboxes.forEach((checkbox) => {
checkbox.checked = elem.checked;
});

saveCheckboxSessionState();
}

// Update the state of the select all checkbox. Checked if
// all other checkboxes are checked, unchecked if none of the
// others are checked, and "intermediate" (visual only) if some
// of them are checked
function updateSelectAllCheckbox() {
const selectAllCheckboxEl = document.querySelector(".selectall");
if (!selectAllCheckboxEl) return;

const checkboxes = getVisibleCheckboxes();
const selected = checkboxes.filter((box) => box.checked);

const areAllChecked = selected.length === checkboxes.length;
const areNoneChecked = selected.length === 0;
selectAllCheckboxEl.checked = areAllChecked;
selectAllCheckboxEl.indeterminate = !(areAllChecked || areNoneChecked);
}


/**
* Update the UI so the selected checkboxes match the sessionStorage value
*/
function renderCheckboxStatus() {
const state = getCheckboxSessionState();
const checkboxes = getVisibleCheckboxes();

checkboxes.forEach((checkbox) => {
const savedValue = state[checkbox.baseURI]
? state[checkbox.baseURI][checkbox.value]
: false;
checkbox.checked = savedValue;
});
updateSelectAllCheckbox();
}

/**
* If the click on the form is for a checkbox (ignoring the selectall checkbox
* which has it's own logic) then we persist the state to sessionStorage, and
* check to see if the selectall checkbox needs updating.
*/
function fileBrowserClicked({ target }) {
if (target.type === "checkbox" && !target.classList.contains("selectall")) {
saveCheckboxSessionState();
updateSelectAllCheckbox();
}
}

/**
* Add click event listener on the form.
*/
function addCheckboxClickListener() {
document.getElementById("file-browser-panel").addEventListener("click", fileBrowserClicked);
}

// On first load of the page we need to wire up the event listener
// so that we can respond to checkbox changes.
if (document.readyState !== "loading") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this because of the htmx loads? So the document itself is already loaded but the checkbox listener and status have to be called again for htmx-refreshed content?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's actually just for the back button case. The event listener is put on an element far enough up the DOM so that any htmx changes within the file browser don't touch it. Once its enabled initially you therefore don't need to redo it. However, when the back button is clicked you get a whole page refresh so event listeners are disregarded - but you also don't get a DOMContentLoaded event (or at least it has already triggered before this code runs). I think this is because the page is cached by the browser in some way, but I'm not 100%.

// If the document is already loaded then we can add the event listener now
// and also ensure the checkboxes are in sync with the stored values
addCheckboxClickListener();
renderCheckboxStatus();
} else {
// If the document is not yet loaded we wait for the loaded event
document.addEventListener("DOMContentLoaded", () => {
addCheckboxClickListener();
// I'm pretty sure we never need to call renderCheckboxStatus here
// because in this scenario the "datatable-ready" event (see below)
// will fire. But calling it twice isn't an issue, so just in case...
renderCheckboxStatus();
});
}

// Every time a datatable is rendered we need to update the checkboxes
// so they match the saved state
document.body.addEventListener("datatable-ready", renderCheckboxStatus);
2 changes: 1 addition & 1 deletion airlock/templates/file_browser/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
{% block content %}{% endblock content %}

{% block full_width_content %}
<div class="grid grid-cols-4 gap-x-4 flex-1">
<div id="file-browser-panel" class="grid grid-cols-4 gap-x-4 flex-1">
<div class="border-r border-t bg-white overflow-auto" id="tree-container">
<ul
class="tree root tree__root"
Expand Down
1 change: 0 additions & 1 deletion airlock/templates/file_browser/repo/dir.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,3 @@

{% vite_hmr_client %}
{% vite_asset "assets/src/scripts/datatable.js" %}
<script src="{% static 'assets/file_browser/dir.js' %}"></script>
1 change: 0 additions & 1 deletion airlock/templates/file_browser/request/dir.html
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,3 @@

{% vite_hmr_client %}
{% vite_asset "assets/src/scripts/datatable.js" %}
<script src="{% static 'assets/file_browser/dir.js' %}"></script>
1 change: 0 additions & 1 deletion airlock/templates/file_browser/workspace/dir.html
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,3 @@

{% vite_hmr_client %}
{% vite_asset "assets/src/scripts/datatable.js" %}
<script src="{% static 'assets/file_browser/dir.js' %}"></script>
6 changes: 6 additions & 0 deletions assets/src/scripts/datatable.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ function buildTables() {
spinner?.classList.toggle("hidden");
wrapper.classList.toggle("hidden");
}

// For remembering the state of the checkboxes we need to update them
// from sessionStorage every time the table is redrawn. Rather than
// adding the checkbox logic here, we decouple it by just emitting an
// event when the table is ready, and the checkbox state logic can listen for it.
document.body.dispatchEvent(new Event("datatable-ready"));
});
});
}
Expand Down
Loading