Refactor of tree stuff
This commit is contained in:
+46
-296
@@ -1,85 +1,39 @@
|
||||
import { CheckboxTree } from '../lib/checkbox-tree/checkbox-tree.js';
|
||||
import { getSortedKeys } from './sort.js';
|
||||
|
||||
const STORAGE_KEY = 'tree-state';
|
||||
|
||||
let treeContainer = null;
|
||||
let onSelectionChangeHandler = null;
|
||||
const STORAGE_KEY = 'tree-state-v2';
|
||||
let productTree = null;
|
||||
|
||||
export function initializeTree(containerId, onSelectionChange) {
|
||||
treeContainer = document.getElementById(containerId);
|
||||
onSelectionChangeHandler = onSelectionChange;
|
||||
}
|
||||
|
||||
export function renderProductTree(productsData) {
|
||||
if (!treeContainer) {
|
||||
throw new Error('Tree is not initialized. Call initializeTree first.');
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) {
|
||||
throw new Error(`Tree container not found: ${containerId}`);
|
||||
}
|
||||
|
||||
treeContainer.innerHTML = '';
|
||||
|
||||
getSortedKeys(productsData).forEach(product => {
|
||||
const productNode = createTreeNode(product, productsData[product], [product]);
|
||||
treeContainer.appendChild(productNode);
|
||||
productTree?.destroy();
|
||||
productTree = new CheckboxTree(container, {
|
||||
storageKey: STORAGE_KEY,
|
||||
initiallyCollapsed: true,
|
||||
onSelectionChange
|
||||
});
|
||||
}
|
||||
|
||||
export function renderProductTree(productsData) {
|
||||
requireTree().setData(toTreeNodes(productsData));
|
||||
}
|
||||
|
||||
export function getCheckedPaths() {
|
||||
if (!treeContainer) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const checkedBoxes = treeContainer.querySelectorAll('input[type="checkbox"]:checked');
|
||||
|
||||
return Array.from(checkedBoxes)
|
||||
.map(cb => cb.dataset.path)
|
||||
.filter(Boolean)
|
||||
.map(pathText => {
|
||||
try {
|
||||
return JSON.parse(pathText);
|
||||
} catch (error) {
|
||||
console.error('Invalid checkbox path data:', pathText, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(pathArray => Array.isArray(pathArray) && pathArray.length > 0);
|
||||
return productTree ? productTree.getSelectedNodes().map(node => node.metadata.path) : [];
|
||||
}
|
||||
|
||||
export function getCheckedFileRefs() {
|
||||
if (!treeContainer) {
|
||||
return {
|
||||
addressedFiles: [],
|
||||
knownFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
const addressedFiles = [];
|
||||
const knownFiles = [];
|
||||
const checkedBoxes = treeContainer.querySelectorAll('input[type="checkbox"]:checked[data-has-files="1"]');
|
||||
|
||||
checkedBoxes.forEach(cb => {
|
||||
const pathText = cb.dataset.path;
|
||||
if (!pathText) {
|
||||
return;
|
||||
}
|
||||
|
||||
let path;
|
||||
try {
|
||||
path = JSON.parse(pathText);
|
||||
} catch (error) {
|
||||
console.error('Invalid checkbox path data:', pathText, error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(path) || path.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
productTree?.getSelectedNodes().forEach(node => {
|
||||
const { path, addressed = [], known = [] } = node.metadata;
|
||||
const productKey = path[0];
|
||||
|
||||
const addressed = parseJsonArray(cb.dataset.addressed);
|
||||
addressed.forEach(fileName => addressedFiles.push({ productKey, fileName }));
|
||||
|
||||
const known = parseJsonArray(cb.dataset.known);
|
||||
known.forEach(fileName => knownFiles.push({ productKey, fileName }));
|
||||
});
|
||||
|
||||
@@ -89,255 +43,51 @@ export function getCheckedFileRefs() {
|
||||
};
|
||||
}
|
||||
|
||||
function createTreeNode(name, value, path) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'tree-item';
|
||||
// State restoration now happens when setData renders the generic tree.
|
||||
export function restoreTreeState() {}
|
||||
|
||||
const isObjectValue = value && typeof value === 'object';
|
||||
const hasFiles = isObjectValue && ('addressed' in value || 'known' in value);
|
||||
const childKeys = isObjectValue
|
||||
? getSortedKeys(value).filter(key => key !== 'addressed' && key !== 'known')
|
||||
: [];
|
||||
const hasChildren = childKeys.length > 0;
|
||||
|
||||
if (hasChildren) {
|
||||
container.classList.add('parent');
|
||||
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'tree-toggle';
|
||||
toggle.textContent = '▼';
|
||||
toggle.style.cursor = 'pointer';
|
||||
|
||||
const label = document.createElement('label');
|
||||
const checkbox = createCheckbox(path, hasFiles ? value : undefined);
|
||||
label.appendChild(checkbox);
|
||||
label.appendChild(document.createTextNode(name));
|
||||
|
||||
const childrenContainer = document.createElement('div');
|
||||
childrenContainer.className = 'tree-children';
|
||||
|
||||
childKeys.forEach(childKey => {
|
||||
const childNode = createTreeNode(childKey, value[childKey], [...path, childKey]);
|
||||
childrenContainer.appendChild(childNode);
|
||||
});
|
||||
|
||||
if (shouldStartCollapsed(value)) {
|
||||
childrenContainer.classList.add('collapsed');
|
||||
toggle.textContent = '▶';
|
||||
}
|
||||
|
||||
toggle.addEventListener('click', event => {
|
||||
event.stopPropagation();
|
||||
childrenContainer.classList.toggle('collapsed');
|
||||
toggle.textContent = childrenContainer.classList.contains('collapsed') ? '▶' : '▼';
|
||||
saveTreeState();
|
||||
});
|
||||
|
||||
container.appendChild(toggle);
|
||||
container.appendChild(label);
|
||||
container.appendChild(childrenContainer);
|
||||
return container;
|
||||
function requireTree() {
|
||||
if (!productTree) {
|
||||
throw new Error('Tree is not initialized. Call initializeTree first.');
|
||||
}
|
||||
|
||||
const label = document.createElement('label');
|
||||
if (hasFiles) {
|
||||
const checkbox = createCheckbox(path, value);
|
||||
label.appendChild(checkbox);
|
||||
}
|
||||
|
||||
label.appendChild(document.createTextNode(name));
|
||||
container.appendChild(label);
|
||||
return container;
|
||||
return productTree;
|
||||
}
|
||||
|
||||
function shouldStartCollapsed(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
function toTreeNodes(value, path = []) {
|
||||
return getSortedKeys(value)
|
||||
.filter(key => key !== 'addressed' && key !== 'known')
|
||||
.map(key => {
|
||||
const childValue = value[key];
|
||||
const childPath = [...path, key];
|
||||
const isObject = childValue && typeof childValue === 'object';
|
||||
const hasFiles = isObject && ('addressed' in childValue || 'known' in childValue);
|
||||
const children = isObject ? toTreeNodes(childValue, childPath) : [];
|
||||
|
||||
const childKeys = Object.keys(value).filter(key => key !== 'addressed' && key !== 'known');
|
||||
return childKeys.length > 0;
|
||||
return {
|
||||
id: JSON.stringify(childPath),
|
||||
label: key,
|
||||
selectable: hasFiles || children.length > 0,
|
||||
metadata: {
|
||||
path: childPath,
|
||||
addressed: hasFiles && Array.isArray(childValue.addressed) ? childValue.addressed : [],
|
||||
known: hasFiles && Array.isArray(childValue.known) ? childValue.known : []
|
||||
},
|
||||
...(children.length > 0 ? { children } : {})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeFileRefs(fileRefs) {
|
||||
const seen = new Set();
|
||||
return fileRefs.filter(ref => {
|
||||
const productKey = String(ref && ref.productKey ? ref.productKey : '');
|
||||
const fileName = String(ref && ref.fileName ? ref.fileName : '');
|
||||
return fileRefs.filter(({ productKey, fileName }) => {
|
||||
if (!productKey || !fileName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = `${productKey}::${fileName}`;
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function createCheckbox(path, value) {
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.value = path.join('/');
|
||||
checkbox.dataset.path = JSON.stringify(path);
|
||||
|
||||
if (value && typeof value === 'object' && ('addressed' in value || 'known' in value)) {
|
||||
checkbox.dataset.hasFiles = '1';
|
||||
checkbox.dataset.addressed = JSON.stringify(Array.isArray(value.addressed) ? value.addressed : []);
|
||||
checkbox.dataset.known = JSON.stringify(Array.isArray(value.known) ? value.known : []);
|
||||
}
|
||||
|
||||
checkbox.addEventListener('change', event => {
|
||||
handleCheckboxChange(event);
|
||||
});
|
||||
|
||||
return checkbox;
|
||||
}
|
||||
|
||||
function parseJsonArray(value) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (error) {
|
||||
console.error('Invalid checkbox file metadata:', value, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function handleCheckboxChange(event) {
|
||||
const checkbox = event.target;
|
||||
const container = checkbox.closest('.tree-item');
|
||||
const childrenContainer = container.querySelector('.tree-children');
|
||||
|
||||
if (childrenContainer) {
|
||||
const childCheckboxes = childrenContainer.querySelectorAll('input[type="checkbox"]');
|
||||
childCheckboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
cb.indeterminate = false;
|
||||
});
|
||||
}
|
||||
|
||||
checkbox.indeterminate = false;
|
||||
updateParentCheckboxes(checkbox);
|
||||
|
||||
if (typeof onSelectionChangeHandler === 'function') {
|
||||
onSelectionChangeHandler();
|
||||
}
|
||||
saveTreeState();
|
||||
}
|
||||
|
||||
function updateParentCheckboxes(checkbox) {
|
||||
let currentItem = checkbox.closest('.tree-item');
|
||||
let parentItem = currentItem.parentElement.closest('.tree-item');
|
||||
|
||||
while (parentItem) {
|
||||
const parentLabel = parentItem.querySelector(':scope > label');
|
||||
if (!parentLabel) {
|
||||
parentItem = parentItem.parentElement.closest('.tree-item');
|
||||
continue;
|
||||
}
|
||||
|
||||
const parentCheckbox = parentLabel.querySelector('input[type="checkbox"]');
|
||||
if (!parentCheckbox) {
|
||||
parentItem = parentItem.parentElement.closest('.tree-item');
|
||||
continue;
|
||||
}
|
||||
|
||||
const children = parentItem.querySelectorAll(':scope > .tree-children > .tree-item > label > input[type="checkbox"]');
|
||||
const checkedCount = Array.from(children).filter(cb => cb.checked || cb.indeterminate).length;
|
||||
|
||||
if (checkedCount === 0) {
|
||||
parentCheckbox.checked = false;
|
||||
parentCheckbox.indeterminate = false;
|
||||
} else if (checkedCount === children.length) {
|
||||
parentCheckbox.checked = true;
|
||||
parentCheckbox.indeterminate = false;
|
||||
} else {
|
||||
parentCheckbox.checked = false;
|
||||
parentCheckbox.indeterminate = true;
|
||||
}
|
||||
|
||||
currentItem = parentItem;
|
||||
parentItem = currentItem.parentElement.closest('.tree-item');
|
||||
}
|
||||
}
|
||||
|
||||
function saveTreeState() {
|
||||
if (!treeContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkedPaths = Array.from(
|
||||
treeContainer.querySelectorAll('input[type="checkbox"]:checked[data-has-files="1"]')
|
||||
).map(cb => cb.dataset.path).filter(Boolean);
|
||||
|
||||
const collapsedPaths = Array.from(
|
||||
treeContainer.querySelectorAll('.tree-children.collapsed')
|
||||
).map(children => {
|
||||
const cb = children.parentElement.querySelector(':scope > label > input[type="checkbox"]');
|
||||
return cb?.dataset.path ?? null;
|
||||
}).filter(Boolean);
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ checkedPaths, collapsedPaths }));
|
||||
} catch {
|
||||
// Storage unavailable or quota exceeded — silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreTreeState() {
|
||||
if (!treeContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
let state;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
state = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state || typeof state !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(state.checkedPaths) && state.checkedPaths.length > 0) {
|
||||
const checkedSet = new Set(state.checkedPaths);
|
||||
treeContainer.querySelectorAll('input[type="checkbox"][data-has-files="1"]').forEach(cb => {
|
||||
cb.checked = checkedSet.has(cb.dataset.path);
|
||||
});
|
||||
treeContainer.querySelectorAll('input[type="checkbox"][data-has-files="1"]:checked').forEach(cb => {
|
||||
updateParentCheckboxes(cb);
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(state.collapsedPaths)) {
|
||||
const collapsedSet = new Set(state.collapsedPaths);
|
||||
treeContainer.querySelectorAll('.tree-children').forEach(childrenDiv => {
|
||||
const cb = childrenDiv.parentElement.querySelector(':scope > label > input[type="checkbox"]');
|
||||
if (!cb) {
|
||||
return;
|
||||
}
|
||||
const toggle = childrenDiv.parentElement.querySelector(':scope > .tree-toggle');
|
||||
const shouldBeCollapsed = collapsedSet.has(cb.dataset.path);
|
||||
const isCollapsed = childrenDiv.classList.contains('collapsed');
|
||||
if (shouldBeCollapsed !== isCollapsed) {
|
||||
childrenDiv.classList.toggle('collapsed');
|
||||
if (toggle) {
|
||||
toggle.textContent = shouldBeCollapsed ? '▶' : '▼';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user