From a81f7043fc44382775f9afac48e4c7a651e7ac6c Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 04 Jan 2026 18:29:10 +0000
Subject: [PATCH] =PopulateForm.js and ContentRoutes.php minor changes

---
 assets/js/concise/View.js |  552 +++++++++++++++++++++++++++++++++++++++++-------------
 1 files changed, 413 insertions(+), 139 deletions(-)

diff --git a/assets/js/concise/View.js b/assets/js/concise/View.js
index 70c8e2f..423d8a4 100644
--- a/assets/js/concise/View.js
+++ b/assets/js/concise/View.js
@@ -8,15 +8,20 @@
 
 		this.container = container;
 		this.initElements();
+		this.settings = window.jvbUserSettings;
 
 		this.store = store;
+
+		this.isTimeline = !!document.querySelector('[data-timeline]');
+
 		this.items = {
 			list: new Map(),
 			grid: new Map(),
 			table: new Map(),
 		}
-		this.currentView = 'grid';
+		this.currentView = this.container.dataset.view ?? 'grid';
 		this.selectedItems = new Set();
+		this.subscribers = new Set();
 
 		this.init();
 	}
@@ -25,8 +30,11 @@
 		this.selectors = {
 			grid: '.item-grid',
 			table: {
-				table: 'table',
+				table: 'form.table',
+				form: 'table',
 				body: 'table body',
+				header: 'table thead',
+				footer: 'table tfoot',
 				selectedColumns: '.all-filters .multi-select',
 				columns: 'thead th'
 			},
@@ -45,12 +53,11 @@
 		// Subscribe to store updates
 		this.store.subscribe((event, data) => {
 			switch(event) {
-				case 'data-loaded':
 				case 'items-saved':
-					this.handleDataUpdate(data);
+					// this.handleDataUpdate(data);
 					break;
-				case 'items-updated':
-					this.handleItemsUpdate(data.items);
+				case 'data-loaded':
+					this.handleItemsUpdate();
 					break;
 				case 'item-saved':
 					// this.updateItem(data.item);
@@ -140,44 +147,40 @@
 
 	setupViewSwitcher() {
 		document.querySelectorAll('[data-view]').forEach(btn => {
+			this.settings.addSetting(btn);
 			btn.addEventListener('click', () => {
 				this.currentView = btn.dataset.view;
 				this.render();
 			});
 		});
-	}
 
-	/**
-	 * Handle data updates from store
-	 */
-	handleDataUpdate(data) {
-		if (data.data && data.data.items) {
-			this.render(data.data.items);
+		const checkedView = document.querySelector('[data-view]:checked');
+		if (checkedView) {
+			this.currentView = checkedView.dataset.view;
 		}
 	}
 
 	/**
 	 * Handle items update
 	 */
-	handleItemsUpdate(items) {
-		this.render(items);
+	handleItemsUpdate() {
+		this.render();
 	}
 
-	render(items = null) {
+	render() {
 		if (!this.store) {
 			console.error('No store connected to renderer');
 			return;
 		}
+		const items = this.store.getFiltered();
 
-		// Get items from store if not provided
-		if (!items) {
-			const currentRequest = this.store.getCurrentRequest();
-			if (currentRequest && currentRequest.data && currentRequest.data.items) {
-				items = currentRequest.data.items;
-			} else {
-				return;
-			}
+		// Handle empty state
+		if (items.length === 0) {
+			console.log('Nothing to show');
+			this.renderEmpty();
+			return;
 		}
+
 		switch(this.currentView) {
 			case 'grid':
 				this.renderGrid(items);
@@ -193,6 +196,17 @@
 		this.updateSelectionUI();
 	}
 
+	renderEmpty() {
+		this.toggleTable(false);
+		window.removeChildren(this.ui.grid);
+
+		const empty = window.getTemplate('emptyState');
+		if (empty) {
+			this.ui.grid.appendChild(empty);
+			this.a11y?.announce('No items found');
+		}
+	}
+
 	renderGrid(items) {
 		this.toggleGrid();
 		this.toggleTable(false);
@@ -204,24 +218,15 @@
 		const fragment = document.createDocumentFragment();
 
 		items.forEach(item => {
-			let card;
-			if (this.store.renderOrRetrieve) {
-				card = this.store.renderOrRetrieve(item, 'grid', this.renderGridItem.bind(this));
-			} else {
-				// Fallback to local cache
-				if (this.items.grid.has(item.id)) {
-					card = this.items.grid.get(item.id);
-				} else {
-					card = this.renderGridItem(item);
-					this.items.grid.set(item.id, card);
-				}
-			}
-
+			let card = this.renderGridItem(item);
 			fragment.appendChild(card);
 		});
 		this.ui.grid.appendChild(fragment);
 	}
 	renderGridItem(item) {
+		if (this.items.grid.has(item.id)) {
+			return this.items.grid.get(item.id);
+		}
 		const card = window.getTemplate('gridView');
 		card.dataset.id = item.id;
 
@@ -246,8 +251,6 @@
 			checkbox.id,
 			checkbox.checked,
 			label.htmlFor,
-			img.src,
-			img.alt,
 			edit.dataset.id,
 			trash.dataset.id
 		] = [
@@ -255,28 +258,64 @@
 			`select-${item.id}`,
 			this.selectedItems.has(`${item.id}`),
 			`select-${item.id}`,
-			item.images[item.fields.post_thumbnail]?.medium??'',
-			item.images[item.fields.post_thumbnail]?.alt??'',
 			item.id,
 			item.id
 		];
+		// if (this.store.config.storeName === 'progress') {
+		// 	[
+		// 		img.src,
+		// 		img.alt,
+		// 	] = [
+		// 		item.images[item.fields['timeline'][0].post_thumbnail]?.medium??'',
+		// 		item.images[item.fields['timeline'][0].post_thumbnail]?.alt??'',
+		// 	];
+		// } else {
+			[
+				img.src,
+				img.alt,
+			] = [
+				item.images[item.fields.post_thumbnail]?.medium??'',
+				item.images[item.fields.post_thumbnail]?.alt??'',
+			];
+
+		// }
+
+		this.items.grid.set(item.id, card);
 		return card;
 	}
 
 	toggleTable(on) {
-		this.ui.table.selectedColumns.hidden = !on;
+		if (this.ui.table.selectedColumns) {
+			this.ui.table.selectedColumns.hidden = !on;
+		}
+
 		if (on && !this.ui.table.table) {
 			let table = window.getTemplate('contentTable');
 			this.container.append(table);
 			this.ui.table.table = this.container.querySelector('form.table');
-			this.ui.table.body = this.ui.table.table.querySelector('tbody');
+			this.ui.table.form  = this.ui.table.table.querySelector('table');
+			this.ui.table.header = this.ui.table.form.querySelector('thead');
+			this.ui.table.footer = this.ui.table.form.querySelector('tfoot');
+			this.ui.table.body = this.ui.table.form.querySelector('tbody');
 			this.ui.table.columns = this.container.querySelectorAll(this.selectors.table.columns);
 		}
+
 		if (this.ui.table.table) {
 			this.ui.table.table.hidden = !on;
-			window.removeChildren(this.ui.table.body);
+			if (on) {
+				this.notify('table-view', this.ui.table.table);
+			}else {
+				this.notify('not-table-view', this.ui.table.table);
+			}
+
+			if (this.ui.table.body){
+				window.removeChildren(this.ui.table.body);
+			}
 		}
-		this.ui.table.selectedColumns.hidden = !on;
+
+		if (this.ui.table.selectedColumns) {
+			this.ui.table.selectedColumns.hidden = !on;
+		}
 	}
 
 	toggleGrid() {
@@ -288,22 +327,29 @@
 		this.toggleGrid();
 
 		items.forEach(item => {
-			let row;
-			if (this.items.table.has(item.id)) {
-				row = this.items.table.get(item.id);
+
+			let row = (this.isTimeline) ? this.renderTimelineTableItem(item) : this.renderTableItem(item);
+
+			if (this.ui.table.body) {
+				this.ui.table.body.append(row);
 			} else {
-				row = this.store.renderOrRetrieve(item, 'table', this.renderTableItem.bind(this));
-				this.items.table.set(item.id, row);
+				if (!this.ui.table.footer) {
+					this.ui.table.footer = this.ui.table.table.querySelector('tfoot');
+				}
+				this.ui.table.form.insertBefore(row, this.ui.table.footer);
 			}
 
-			this.ui.table.body.append(row);
 		});
 
 		window.jvbSelector.scanExistingFields();
+
 	}
 
 	renderTableItem(item) {
-		let empty = ['',0];
+		if (this.items.table.has(item.id)) {
+			return this.items.table.get(item.id);
+		}
+
 		const row = window.getTemplate('tableView');
 		row.dataset.id = item.id;
 
@@ -312,96 +358,166 @@
 			row.querySelector('.select-item').value,
 			row.querySelector('.select-item').checked,
 			row.querySelector('.select-item + label').htmlFor,
-			row.querySelector(`input[name="post_status"][value="${item.status}"]`).checked
 		] = [
 			item.id,
 			item.id,
 			this.selectedItems.has(`${item.id}`),
 			item.id,
-			item.status
+		];
+		let status = row.querySelector(`input[name="post_status"][value="${item.status}"]`);
+		if (status) {
+			status.checked = true;
+		}
+
+		if (Object.hasOwn(this.ui.table.table.dataset, 'edit')) {
+			new window.jvbPopulate(row, item.fields, item.images);
+		} else {
+			for (let [key, value] of Object.entries(item)) {
+				let col = row.querySelector(`[data-field="${key}"]`);
+				if (col) {
+					let p = col.querySelector('p');
+					if (col.dataset.fieldType === 'date') {
+						value = window.formatTimeAgo(value);
+					}
+					p.textContent = value;
+				}
+			}
+		}
+
+
+		// Clean up after population
+		this.cleanupTableRow(row);
+
+		this.items.table.set(item.id, row);
+		return row;
+	}
+
+	renderTimelineTableItem(item) {
+		if (this.items.table.has(item.id)) {
+			return this.items.table.get(item.id);
+		}
+
+		const row = window.getTemplate('tableView');
+		row.dataset.id = item.id;
+
+		[
+			row.querySelector('.select-item').id,
+			row.querySelector('.select-item').value,
+			row.querySelector('.select-item').checked,
+			row.querySelector('.select-item + label').htmlFor,
+		] = [
+			item.id,
+			item.id,
+			this.selectedItems.has(`${item.id}`),
+			item.id,
 		];
 
+		let timelinePoint = row.querySelector('.timeline-point');
+		let tbody = row;
 
-		row.querySelectorAll('td[data-field]').forEach(field => {
-			let value = item.fields[field.dataset.field];
-			// field.querySelectorAll('label').forEach(label => {
-			// 	label.hidden = true;
-			// });
+		// Populate shared fields - NO prefixing!
+		let sharedRow = row.querySelector('tr.shared');
+		new window.jvbPopulate(sharedRow, item.fields, item.images);
+		this.prefixTimelineFieldNames(sharedRow, item.id);
+		this.cleanupTableRow(sharedRow);
 
-			let label = field.querySelector('label');
-			let isEmpty = (empty.includes(value));
-			let temp;
-			switch (field.dataset.fieldType) {
-				case 'text':
-				case 'number':
-				case 'url':
-				case 'tel':
-				case 'email':
-					if (!isEmpty) {
-						field.querySelector('input').value = value;
-					}
-					label.remove();
-					break;
-				case 'textarea':
-					if (!isEmpty) {
-						field.querySelector('textarea').value = value;
-					}
-					label.remove();
-					break;
-				case 'taxonomy':
-					label.remove();
-					if (!isEmpty) {
-						temp = field.querySelector('input[type=hidden]');
-						temp.value = value;
-					}
-					break;
-				case 'image':
-					if (!isEmpty) {
-						let image = window.getTemplate('uploadItem');
-						let img = image.querySelector('img');
-						[
-							img.src,
-							img.alt
-						] = [
-							item.images[value].medium??'',
-							item.images[value].alt??'',
-						];
-						field.querySelector('.item-grid').append(image);
-						field.querySelector('input[type=hidden]').value = value;
-					}
-					field.querySelectorAll('.progress,label,.upload-select,.status,details').forEach(item => {
-						item.remove();
-					});
-					break;
-				case 'true_false':
-					if (!isEmpty) {
-						field.querySelector('input').checked = parseInt(value) === 1;
-					}
-					field.querySelector('.toggle-label')?.remove();
-					break;
-				case 'select':
-					label.remove();
-				case 'radio':
-				case 'checkbox':
-					field.querySelector('.label')?.remove();
-					if (!isEmpty) {
-						value = value.split(',');
-						value.forEach(v => {
-							temp = field.querySelector(`[value="${v}"]`);
-							if (temp) {
-								temp.checked = true;
-							}
-						});
-					}
-					break;
-				default:
-					if (!isEmpty) {
-						console.log(value);
-					}
-					break;
+		// Handle timeline points - NO prefixing!
+		if (item.fields.timeline && typeof item.fields.timeline === 'object') {
+			const timelineArray = Object.entries(item.fields.timeline);
+
+			timelineArray.forEach(([imgId, timeline], index) => {
+
+				let point = timelinePoint.cloneNode(true);
+				point.dataset.index = index;
+				point.dataset.imageId = imgId;
+
+
+				new window.jvbPopulate(point, timeline, item.images);
+
+				this.cleanupTableRow(point);
+				let imgdata = item.images[timeline.post_thumbnail];
+				if (imgdata) {
+					point.querySelector('.field.upload').title = imgdata['image-title'];
+				}
+
+				this.prefixTimelineFieldNames(point,timeline.id);
+
+				tbody.insertBefore(point, timelinePoint);
+			});
+		}
+
+		timelinePoint.remove();
+
+		this.items.table.set(item.id, row);
+		return row;
+	}
+
+	/**
+	 * Timeline uses bracket notation: [postId]fieldName
+	 * This matches the collectTimeline() method in FormController
+	 */
+	prefixTimelineFieldNames(row, postId) {
+		row.querySelectorAll('input, textarea, select').forEach(field => {
+			const currentName = field.name;
+
+			if (!currentName || currentName.startsWith('[') ||
+				currentName === 'form-id' || currentName.startsWith('_')) {
+				return;
+			}
+
+			// Use bracket notation for timeline
+
+			let label = field.nextElementSibling;
+			field.name = `[${postId}]${currentName}`;
+			if (label && label.tagName === 'LABEL') {
+				field.id = `[${postId}]${field.id}`;
+				label.htmlFor = field.id;
 			}
 		});
-		return row;
+	}
+
+	cleanupTableRow(row) {
+		row.querySelectorAll('td[data-field]').forEach(field => {
+			// Remove labels (they're in the header)
+			field.querySelectorAll('label:not(.select-item-label,.radio-option,[for*="select-item"])').forEach(label => {
+				if (!label.closest('.radio-options')) {
+					label.remove();
+				}
+			});
+
+			// Special handling for image/upload fields
+			// if (field.dataset.fieldType === 'image' || field.dataset.fieldType === 'upload') {
+			// 	const itemGrid = field.querySelector('.item-grid');
+			// 	const uploadContainer = field.querySelector('.file-upload-container');
+			//
+			// 	// If grid has items (populated), just remove upload UI
+			// 	if (itemGrid && itemGrid.children.length > 0) {
+			// 		// Remove upload controls but keep the populated items
+			// 		field.querySelectorAll('.progress, .upload-select, .status, details:not(.item-grid details)').forEach(el => {
+			// 			el.remove();
+			// 		});
+			// 		// Keep upload container hidden if it was hidden
+			// 		if (uploadContainer && uploadContainer.hidden) {
+			// 			uploadContainer.hidden = true;
+			// 		}
+			// 	} else {
+			// 		// No items, remove all upload UI
+			// 		field.querySelectorAll('.file-upload-wrapper, .progress, .upload-select, .status, details').forEach(el => {
+			// 			el.remove();
+			// 		});
+			// 	}
+			// }
+
+			// Remove toggle labels for true_false fields
+			if (field.dataset.fieldType === 'true_false') {
+				field.querySelector('.toggle-label')?.remove();
+			}
+
+			// Remove field labels for checkbox/radio groups
+			if (['checkbox', 'radio', 'select'].includes(field.dataset.fieldType)) {
+				field.querySelector('.label')?.remove();
+			}
+		});
 	}
 
 	renderList(items) {
@@ -412,19 +528,15 @@
 		this.ui.grid.classList.add('list-view');
 
 		items.forEach(item => {
-			let row;
-			if (this.items.list.has(item.id)) {
-				row = this.items.list.get(item.id);
-			} else {
-				row = this.store.renderOrRetrieve(item, 'list', this.renderListItem.bind(this));
-				this.items.list.set(item.id, row);
-			}
-
+			let row = this.renderListItem(item);
 			this.ui.grid.appendChild(row);
 		});
 	}
 
 	renderListItem(item) {
+		if (this.items.list.has(item.id)) {
+			return this.items.list.get(item.id);
+		}
 		const row = window.getTemplate('listView');
 		row.dataset.id = item.id;
 
@@ -473,9 +585,158 @@
 				item.images[item.fields.post_thumbnail]?.alt??'',
 			]
 		}
+		this.items.list.set(item.id, row);
 		return row;
 	}
 
+	setupTimelineDragHandler() {
+		if (!this.isTimeline || this.currentView !== 'table') return;
+
+		// Clean up existing handler if any
+		if (this.timelineDragHandler) {
+			this.timelineDragHandler.destroy();
+		}
+
+		this.timelineDragHandler = new window.jvbDragHandler({
+			draggableSelector: '.timeline-point',
+			dropTargetSelector: '.timeline-point',
+			handleSelector: '.drag-handle',
+
+			getItemId: (element) => {
+				return element.dataset.imageId;
+			},
+
+			getSelectedItems: () => {
+				return [];
+			},
+
+			validateDrop: (itemIds, dropTarget) => {
+				const draggedRow = document.querySelector(`.timeline-point[data-image-id="${itemIds[0]}"]`);
+				if (!draggedRow) return false;
+
+				const draggedTbody = draggedRow.closest('tbody');
+				const targetTbody = dropTarget.closest('tbody');
+
+				return draggedTbody === targetTbody;
+			},
+
+			onDragStart: (itemIds, element) => {
+				element.classList.add('is-dragging');
+			},
+
+			onDrop: (itemIds, dropTarget) => {
+				const draggedRow = document.querySelector(`.timeline-point[data-image-id="${itemIds[0]}"]`);
+				if (!draggedRow) return;
+
+				// Remove all drop indicators
+				document.querySelectorAll('.drop-above, .drop-below').forEach(el => {
+					el.classList.remove('drop-above', 'drop-below');
+				});
+
+				const tbody = draggedRow.closest('tbody');
+				const dropPosition = dropTarget.dataset.dropPosition;
+
+				// Insert based on drop position
+				if (dropPosition === 'above') {
+					tbody.insertBefore(draggedRow, dropTarget);
+				} else {
+					tbody.insertBefore(draggedRow, dropTarget.nextSibling);
+				}
+
+				draggedRow.classList.remove('is-dragging');
+				this.updateTimelineOrder(tbody);
+			},
+
+			onDragEnd: (itemIds, success) => {
+				// Clean up all drag classes
+				document.querySelectorAll('.is-dragging, .drop-above, .drop-below').forEach(el => {
+					el.classList.remove('is-dragging', 'drop-above', 'drop-below');
+				});
+			},
+
+			previewElement: '.drag-handle',
+			previewOptions: {
+				offset: { x: -20, y: -20 },
+				showCount: false
+			}
+		});
+
+		// Add custom hover logic for better drop positioning
+		this.addTimelineDragHoverLogic();
+	}
+
+	addTimelineDragHoverLogic() {
+		let currentHover = null;
+
+		document.addEventListener('pointermove', (e) => {
+			if (!document.querySelector('.timeline-point.is-dragging')) return;
+
+			const target = e.target.closest('.timeline-point:not(.is-dragging)');
+			if (!target) {
+				if (currentHover) {
+					currentHover.classList.remove('drop-above', 'drop-below');
+					delete currentHover.dataset.dropPosition;
+					currentHover = null;
+				}
+				return;
+			}
+
+			// Determine if we're in the top or bottom half
+			const rect = target.getBoundingClientRect();
+			const midpoint = rect.top + (rect.height / 2);
+			const isTopHalf = e.clientY < midpoint;
+
+			// Update classes
+			if (currentHover && currentHover !== target) {
+				currentHover.classList.remove('drop-above', 'drop-below');
+				delete currentHover.dataset.dropPosition;
+			}
+
+			target.classList.remove('drop-above', 'drop-below');
+			target.classList.add(isTopHalf ? 'drop-above' : 'drop-below');
+			target.dataset.dropPosition = isTopHalf ? 'above' : 'below';
+
+			currentHover = target;
+		});
+	}
+	updateTimelineOrder(tbody) {
+		const postId = parseInt(tbody.dataset.id);
+		const rows = Array.from(tbody.querySelectorAll('.timeline-point'));
+
+		const item = this.store.get(postId);
+		if (!item) return;
+
+		let timeline = {};
+		// Update menu_order for each timeline point
+		rows.forEach((row, index) => {
+			const imgID = row.dataset.imageId;
+			timeline[imgID] = item.fields.timeline[imgID];
+		});
+		item.fields.timeline = timeline;
+
+		// Update store (triggers autosave)
+		this.store.save(item);
+		this.notify('order-changed', postId);
+		this.a11y?.announce(`Timeline order updated. ${rows.length} steps reordered.`);
+	}
+
+	extractRowFields(row) {
+		const fields = {};
+		row.querySelectorAll('[data-field]').forEach(cell => {
+			const fieldName = cell.dataset.field;
+			const input = cell.querySelector('input, textarea, select');
+
+			if (input) {
+				if (input.type === 'checkbox') {
+					fields[fieldName] = input.checked;
+				} else {
+					fields[fieldName] = input.value;
+				}
+			}
+		});
+		return fields;
+	}
+
 	toggleSelection(id) {
 		if (this.selectedItems.has(id)) {
 			this.selectedItems.delete(id);
@@ -520,6 +781,19 @@
 			this.ui.bulk.count.textContent = count === 0 ? '' : `${count} ${item} selected`;
 		}
 	}
+
+
+	/**
+	 * Event system
+	 */
+	subscribe(callback) {
+		this.subscribers.add(callback);
+		return () => this.subscribers.delete(callback);
+	}
+
+	notify(event, data) {
+		this.subscribers.forEach(cb => cb(event, data));
+	}
 }
 
 window.jvbViews = ViewController;

--
Gitblit v1.10.0