From 181938ea32cf2c11d10d56018df22586adff7860 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 11 Jan 2026 17:07:14 +0000
Subject: [PATCH] =CRUD.js cleanup, DataStore.js filtering optimization
---
assets/js/concise/CRUD.js | 1896 +++++++++++++++++++++++++++++++++++++++++-----------------
1 files changed, 1,331 insertions(+), 565 deletions(-)
diff --git a/assets/js/concise/CRUD.js b/assets/js/concise/CRUD.js
index 641a928..9e2e9bd 100644
--- a/assets/js/concise/CRUD.js
+++ b/assets/js/concise/CRUD.js
@@ -1,29 +1,182 @@
-/**
- * Main CRUD Manager - Coordinates everything
- */
class CRUDManager {
- constructor(config) {
+ constructor(){
+ this.container = document.querySelector('.crud[data-content]:not([data-ignore])');
+ if (!this.container) return;
+ this.content = this.container.dataset.content;
+ this.endpoint = this.container.dataset.endpoint??'content';
+ this.singular = this.container.dataset.singular;
+ this.plural = this.container.dataset.plural;
+
this.queue = window.jvbQueue;
- this.config = config;
- this.content = config.content || false;
- this.settings = window.jvbUserSettings;
this.a11y = window.jvbA11y;
- if (!this.content) {
- return;
- }
+ this.error = window.jvbError;
+ this.cache = new window.jvbCache(this.content);
+
+ this.activeItem = null;
this.isTimeline = false;
- this.currentItemID = null;
+ this.items = {
+ list: new Map(),
+ grid: new Map(),
+ table: new Map()
+ }; //DOM references
+ this.init();
+ }
+
+ init() {
this.initElements();
- this.updateBulkOptions();
+ this.initListeners();
+ let cached = this.initSettings();
+ this.initStore(cached);
+ this.checkHideFilters();
+ this.initIntegrations();
+ this.initUploader();
+ this.initModals();
+ }
- // Initialize components
+ initElements() {
+ this.allowedFilters = ['status', 'orderby', 'order', 'search', 'date-filter', 'dateFrom', 'dateTo'];
+ this.selectors = {
+ buttons: {
+ create: '.create-item',
+ clearFilters: '[data-action="clear-filters"]'
+ },
+ views: {
+ grid: 'input[data-view="grid"]',
+ list: 'input[data-view="list"]',
+ table: 'input[data-view="table"]'
+ },
+ modals: {
+ create: {
+ modal: 'dialog.create',
+ form: 'dialog.create form',
+ h2: 'dialog.create h2',
+ },
+ edit: {
+ modal: 'dialog.edit',
+ form: 'dialog.edit form',
+ h2: 'dialog.edit h2',
+ },
+ bulkEdit: {
+ modal: 'dialog.bulkEdit',
+ selected: 'dialog.bulkEdit .selected',
+ h2: 'dialog.bulkEdit h2 span',
+ form: 'dialog.bulkEdit form'
+ },
+ date: {
+ modal: 'dialog.date-range',
+ start: 'dialog.date-range .date-start',
+ end: 'dialog.date-range .date-end',
+ month: 'dialog.date-range .month-select',
+ }
+ },
+ grid: `.${this.content}.item-grid`,
+ table: {
+ nav: '#vertical',
+ form: 'form.table',
+ table: 'form.table table',
+ body: 'form.table body',
+ head: 'form.table thead',
+ foot: 'form.table tfoot',
+ selectedColumns: '.all-filters .multi-select',
+ columns: 'thead th',
+ },
+ bulk: {
+ action: '.bulk-action-select',
+ count: '.bulk-controls .selected-count',
+ control: '.bulk-controls .bulk-actions',
+ select: '.bulk-controls select',
+ selectAll: '.select-all'
+ },
+ filters: {
+ container: 'details.all-filters',
+ search: '.all-filters input[type="search"]',
+ status: {
+ all: '[name="status"]#all',
+ publish: '[name="status"]#publish',
+ draft: '[name="status"]#draft',
+ trash: '[name="status"]#trash',
+ },
+ orderby: {
+ date: '[name="orderby"]#date',
+ alphabetical: '[name="orderby"]#alphabetical',
+ },
+ order: {
+ asc: '[name="order"][value="asc"]',
+ desc: '[name="order"][value="desc"]'
+ },
+ date: '[data-filter="date"]'
+ },
+ uploader: 'details.uploader'
+ }
+
+ this.ui = window.uiFromSelectors(this.selectors);
+ const taxFilters = document.querySelectorAll('[data-filter="taxonomies"]');
+ if (taxFilters.length > 0) {
+ this.ui.filters.taxonomies = {};
+ taxFilters.forEach(tax => {
+ const taxonomy = tax.dataset.taxonomy;
+ this.ui.filters.taxonomies[taxonomy] = tax;
+ this.allowedFilters.push(`tax_${taxonomy}`);
+ });
+ }
+ this.isTimeline = !!document.querySelector('[data-timeline]');
+ }
+ initUploader() {
+ if (!this.ui.uploader) return;
+
+ window.jvbUploads.scanFields(this.ui.uploader);
+ window.jvbUploads.subscribe((event, data) => {
+ if (event === 'sent-to-queue') {
+ if (data === this.ui.uploader.dataset.uploader) {
+ window.debouncer.schedule('crud-complete', ()=> {
+ this.store.clearCache();
+ });
+ }
+ }
+ });
+ }
+ initModals() {
+ this.modals = {};
+ for (let [name, modal] of Object.entries(this.ui.modals)) {
+ if (!modal.modal) continue;
+ this.modals[name] = new window.jvbModal(modal.modal);
+
+ this.modals[name].subscribe((event, data) => {
+ switch (event) {
+ case 'modal-close':
+ this.activeItem = null;
+ const formId = this.ui.modals[name].form.dataset.formId;
+ if (formId) {
+ this.formController.cleanupForm(formId);
+ }
+
+ this.ui.modals[name].form.reset();
+
+ if (name === 'date') {
+ this.handleCustomDateSelection()
+ }
+ break;
+ case 'modal-open':
+
+ break;
+ }
+ })
+ }
+
+ }
+
+ initStore(cached) {
+ let filters = {
+ ... this.defaults,
+ ...cached
+ };
const store = window.jvbStore.register(
this.content,
{
storeName: this.content,
keyPath: 'id',
- endpoint: 'content',
+ endpoint: this.endpoint??'content', //for taxonomy stores
headers: {
'action_nonce': window.auth.getNonce('dash'),
},
@@ -34,52 +187,43 @@
{ name: 'modified', keyPath: 'modified'},
{ name: 'title', keyPath: 'title'}
],
- filters: {
- content: this.content,
- user: window.auth.getUser(),
- page: 1,
- status: 'all',
- orderby: 'modified', //or title
- order: 'desc'
- },
- TTL: 30 * 60 * 1000, //30 minutes cache
+ filters: filters,
+ ignore: ['content', 'user'],
+ TTL: 60 * 60 * 1000, //1 hour cache
showLoading: true,
- });
+ }
+ );
this.store = store[this.content];
- this.status = 'all';
- this.filterTimeout = null;
-
- this.viewController = new window.jvbViews(this.ui.container, this.store);
- this.tableForm = null;
- this.tableChanges = new Map();
-
-
- this.formController = (this.isTimeline) ? new window.jvbForm({collectFormData: () => this.collectTimelineData.bind(this)}) : new window.jvbForm();
- this.viewController.subscribe((event, form) => {
- if (event === 'table-view' && !this.tableForm) {
- if (!this.tableForm) {
- this.tableForm = this.formController.registerForm(form, {
- autosave: false,
- formStatus: false,
- isTable: true,
- });
- }
-
- } else if (event === 'not-table-view') {
- if (this.tableForm) {
-
- }
- } else if (event === 'order-changed') {
- let data = this.store.get(form);
- if (!data) {
- return;
- }
- let changes = {};
- changes[form] = data;
- this.savePosts(changes, `Updating progression order`);
+ this.store.subscribe((event, data) => {
+ switch (event) {
+ case 'data-loaded':
+ this.render();
+ this.selectionHandler.collectItems();
+ break;
+ }
+ })
+ }
+ initIntegrations() {
+ this.selected = new Set();
+ this.selectionHandler = new window.jvbHandleSelection(this.container, {
+ selectAll: {
+ checkbox: '.crud #select-all',
+ label: '.bulk-select label',
+ span: '.bulk-select label span'
+ },
+ wrapper: {
+ wrapper: '.wrap'
}
});
+ this.selectionHandler.subscribe((event, data) => {
+ this.selected = new Set([...data.selectedItems].map(id => parseInt(id)));
+ this.ui.bulk.control.hidden = this.selected.size === 0;
+ this.ui.bulk.count.hidden = this.selected.size === 0;
+ this.ui.bulk.count.textContent = `${this.selected.size} ${this.plural} selected`;
+ });
+
+ this.formController = new window.jvbForm();
this.formController.subscribe((event, data) => {
switch(event) {
@@ -91,406 +235,742 @@
});
this.queue.subscribe((event, data) => {
- if (!Object.hasOwn(data, 'endpoint') || !['content', 'uploads/groups'].includes(data.endpoint)) return;
- if (event === 'operation-completed') {
- this.handleQueueSuccess(event, data);
- } else if (event === 'operation-failed-permanent') {
- this.handleQueueFailure(event, data);
+ if (['image_upload', 'video_upload', 'document_upload'].includes(data.type)
+ && event === 'operation-status'
+ && data.status === 'completed') {
+ this.store.clearCache();
+ }
+ });
+ }
+
+ initSettings() {
+ this.defaults = {
+ content: this.content,
+ user: window.auth.getUser(),
+ page: 1,
+ status: 'all',
+ orderby: 'date',
+ order: 'desc',
+ search: '',
+ }
+
+ let updateFilters = {};
+ //current view (defaults to grid)
+ let defaultView = this.container.dataset.view??'grid'
+ this.view = this.cache.get('view')??defaultView;
+ if (this.view !== defaultView) {
+ this.ui.views[this.view].checked = true;
+ }
+ //current status (defaults to all)
+ this.status = this.cache.get('status')??this.defaults.status;
+ if (this.status !== this.defaults.status) {
+ this.ui.filters.status[this.status].checked = true;
+ updateFilters.status = this.status;
+ }
+ //orderby & order
+ this.orderby = this.cache.get('orderby')??this.defaults.orderby;
+ if (this.orderby !== this.defaults.orderby) {
+ this.ui.filters.orderby[this.orderby].checked = true;
+ updateFilters.orderBy = this.orderby;
+ }
+ this.order = this.cache.get('order')??this.defaults.order;
+ if (this.order !== this.defaults.order) {
+ this.ui.filters.order[this.order].checked = true;
+ updateFilters.order = this.order;
+ }
+
+ if (this.ui.filters.taxonomies) {
+ Object.entries(this.ui.filters.taxonomies).forEach(([taxonomy, element]) => {
+ const filterKey = `tax_${taxonomy}`;
+ const cached = this.cache.get(filterKey);
+ if (cached) {
+ element.value = cached;
+ updateFilters[filterKey] = cached;
+ }
+ });
+ }
+
+ let tabDirection = this.cache.get('tabNav')??'horizontal';
+ if (this.ui.table.nav && tabDirection === 'vertical') {
+ this.ui.table.nav.checked = true;
+ }
+
+
+
+ //Setup details open functionality
+ let details = {
+ showFilters: {
+ element: this.ui.filters.container,
+ default: 'closed',
+ },
+ showUploader: {
+ element: this.ui.uploader,
+ default: 'open'
+ }
+ };
+ for (let [name, conf] of Object.entries(details)) {
+ if (conf.element) {
+ let cached = this.cache.get(name)??conf.default;
+ conf.element.open = cached === 'open';
+ conf.element.addEventListener('toggle', ()=> {
+ this.cache.set(name, conf.element.open ? 'open' : 'closed');
+ });
+ }
+ }
+
+ return updateFilters;
+ }
+ /****************************************************************
+ EVENT LISTENERS
+ ****************************************************************/
+ initListeners() {
+ this.changeHandler = this.handleChange.bind(this);
+ this.clickHandler = this.handleClick.bind(this);
+ this.inputHandler = this.handleInput.bind(this);
+
+ document.addEventListener('change', this.changeHandler);
+ document.addEventListener('click', this.clickHandler);
+ if (this.ui.filters.search) {
+ this.ui.filters.search.addEventListener('input', this.inputHandler);
+ }
+ }
+ handleChange(e) {
+ const isSearch = window.targetCheck(e, this.selectors.filters.search);
+ if (isSearch) {
+ return;
+ }
+ const bulkAction = window.targetCheck(e, this.selectors.bulk.action);
+ if (bulkAction) {
+ this.handleBulkAction(bulkAction);
+ return;
+ }
+
+ let filter = window.targetCheck(e, '[data-filter]');
+ if (filter) {
+ this.handleFilterChange(filter);
+ return;
+ }
+
+ let view = window.targetCheck(e, 'input[data-view]');
+ if (view) {
+ this.handleViewChange(view);
+ return;
+ }
+
+ if (this.view === 'table') {
+ let target = window.targetCheck(e, '[data-id]');
+ if (target) {
+ this.handleTableChange(e);
+ return;
+ }
+
+ let multiSelect = window.targetCheck(e, 'details.multi-select');
+ if (multiSelect) {
+ this.toggleColumn(e.target.id, e.target.checked);
+ return;
+ }
+
+ let tabNav = window.targetCheck(e, this.selectors.table.nav);
+ if (tabNav) {
+ this.tabNav = tabNav.checked;
+ this.cache.set('tabNav', tabNav.checked ? 'vertical' : 'horizontal');
+ }
+ }
+ }
+ handleBulkAction(bulkAction) {
+ if (bulkAction.value.startsWith('tax-')) {
+ const selectedOption = bulkAction.options[bulkAction.selectedIndex];
+ const taxonomy = selectedOption.dataset.taxonomy;
+ const single = selectedOption.dataset.single;
+ const plural = selectedOption.dataset.plural;
+
+ window.jvbSelector.openEmpty(
+ taxonomy,
+ single,
+ plural,
+ (result) => this.handleBulkTaxonomy(result)
+ );
+ bulkAction.value = '';
+
+ return;
+ }
+ switch(bulkAction.value) {
+ case 'edit':
+ this.openBulkEditModal();
+ break;
+ case 'publish':
+ case 'trash':
+ case 'delete':
+ this.setBulkStatus(bulkAction.value);
+ break;
+ case 'draft':
+ case 'restore':
+ this.setBulkStatus('draft');
+ break;
+ }
+ }
+ handleBulkTaxonomy(result) {
+ if (!result.termIds.length || !this.selected.size) return;
+
+ const changes = {};
+ const taxonomyField = `tax_${result.taxonomy}`;
+
+ this.selected.forEach(itemID => {
+ const item = this.store.get(parseInt(itemID));
+ if (!item) return;
+
+ // Merge existing terms with new ones
+ const existingTerms = item.taxonomies?.[result.taxonomy] || [];
+ const existingIds = existingTerms.map(t => t.id);
+ const newIds = [...new Set([...existingIds, ...result.termIds])];
+
+ changes[itemID] = {
+ [taxonomyField]: newIds.join(','),
+ content: this.content
+ };
+ });
+
+ if (Object.keys(changes).length > 0) {
+ this.savePosts(
+ changes,
+ `Adding ${result.terms.length} ${result.taxonomy} to ${this.selected.size} ${this.plural}...`
+ );
+ }
+
+ this.selectionHandler.clearSelection();
+ }
+ handleFilterChange(target) {
+ let filter = target.dataset.filter;
+
+ if (filter === 'date' && target.value === 'custom') {
+ target.value = '';
+ this.modals.date.handleOpen();
+ return;
+ }
+
+ if (filter === 'date' && target.value !== '') {
+ this.setFilter('date-filter', target.value);
+ // Clear custom range
+ this.deleteFilter('dateFrom');
+ this.deleteFilter('dateTo');
+ this.checkHideFilters();
+ return;
+ }
+
+ if (filter === 'taxonomies') {
+ filter = `tax_${target.dataset.taxonomy}`;
+ }
+
+ this.setFilter(filter, target.value);
+ }
+ checkHideFilters() {
+ const filters = this.store.filters;
+ const hasActiveFilter = Object.entries(filters).some(([key, value]) => {
+ // Skip internal props
+ if (['content', 'user', 'page'].includes(key)) return false;
+ // Check if differs from default
+ return this.defaults[key] !== value && value !== '' && value !== null;
+ });
+
+ this.ui.buttons.clearFilters.hidden = !hasActiveFilter;
+ }
+ clearAllFilters() {
+ let currentFilters = this.store.filters;
+ this.store.clearFilters();
+ for (let [filter, value] of Object.entries(currentFilters)) {
+ this.cache.remove(filter);
+ this.deleteFilter(filter, value);
+ }
+ this.a11y.announce('All filters cleared');
+ }
+
+ handleCustomDateSelection() {
+
+ // Check if month select was used
+ if (this.ui.modals.date.month && this.ui.modals.date.month.value) {
+ const [year, month] = this.ui.modals.date.month.value.split('-');
+ const firstDay = `${year}-${month}-01`;
+ const lastDay = new Date(year, parseInt(month), 0).getDate();
+ const lastDayFormatted = `${year}-${month}-${String(lastDay).padStart(2, '0')}`;
+
+ this.setFilter('dateFrom', firstDay);
+ this.setFilter('dateTo', lastDayFormatted);
+
+ // Clear the regular date-filter
+ this.deleteFilter('date-filter');
+
+ // Reset month select for next time
+ this.ui.modals.date.month.value = '';
+ }
+ // Otherwise check custom range
+ else if (this.ui.modals.date.start && this.ui.modals.date.start.value && this.ui.modals.date.end && this.ui.modals.date.end.value) {
+ this.setFilter('dateFrom', this.ui.modals.date.start.value);
+ this.setFilter('dateTo', this.ui.modals.date.end.value);
+
+ // Clear the regular date-filter
+ this.deleteFilter('date-filter');
+
+ // Reset inputs for next time
+ this.ui.modals.date.start.value = '';
+ this.ui.modals.date.end.value = '';
+ }
+
+ this.checkHideFilters();
+ }
+ handleViewChange(view) {
+ this.view = view.dataset.view;
+ this.cache.set('view', this.view);
+ this.render();
+ }
+ handleClick(e) {
+ let clearSearch = window.targetCheck(e, '.clear-search');
+ if (clearSearch) {
+ this.deleteFilter('search', '');
+ }
+ let actionButton = window.targetCheck(e, '[data-action]');
+ if (actionButton) {
+ e.preventDefault();
+ let itemID = actionButton.dataset.id;
+
+ switch (actionButton.dataset.action) {
+ case 'edit':
+ this.openEditModal(itemID);
+ break;
+ case 'delete':
+ if (confirm('Delete this item? This cannot be undone')) {
+ let changes = {};
+ changes[itemID] = {
+ 'post_status': 'delete',
+ 'content': this.content
+ };
+ window.fade(actionButton.closest('.item'), false);
+ this.savePosts(changes, `Sending ${this.singular} to trash...`);
+ this.store.delete(itemID);
+ }
+ break;
+ case 'trash':
+ let changes = {};
+ changes[itemID] = {
+ 'post_status': 'trash',
+ 'content': this.content
+ };
+ window.fade(actionButton.closest('.item'), false);
+ this.savePosts(changes, `Sending ${this.singular} to trash...`);
+ break;
+ case 'bulk-edit':
+ if (this.selected.size > 0) {
+ this.openBulkEditModal();
+ }
+ break;
+ case 'bulk-delete':
+ if (this.selected.size > 0 && confirm(`Delete ${this.selected.size} items?`)) {
+ this.selected.forEach(id => this.store.delete(id));
+ this.selectionHandler.clearSelection();
+ }
+ break;
+ case 'refresh':
+ this.store.clearCache();
+ this.store.fetch();
+ break;
+ case 'clear-filters':
+ this.clearAllFilters();
+ break;
+ }
+ }
+
+ const applyDate = window.targetCheck(e, '.apply-date-filter');
+ if (applyDate) {
+ this.handleCustomDateSelection();
+ this.modals.date.handleClose();
+ return;
+ }
+
+ const createButton = window.targetCheck(e, this.selectors.buttons.create);
+ if (createButton) {
+ this.ui.modals.create.form.reset();
+ this.formController.registerForm(this.ui.modals.create.form);
+ this.modals.create.handleOpen();
+ }
+ }
+
+ handleInput(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ let query = e.target.value.trim();
+ let key = `${this.content}-search`;
+
+ console.log('Maybe search', query);
+ if (query.length === 0) {
+ this.deleteFilter('search', '');
+ return;
+ }
+
+ // Require minimum 2 characters
+ // if (query.length < 2) {
+ // return;
+ // }
+
+ window.debouncer.schedule(
+ key,
+ () => {
+ console.log('Searching for', query);
+ this.a11y.announce(`Searching for "${query}"...`);
+ this.store.setFilters({ search: query, page: 1 });
+ },
+ 300
+ );
+ }
+
+ handleKeys(e) {
+ if (!this.tabNav) return;
+
+ if (e.key === 'Tab') {
+ e.preventDefault();
+
+ const currentCell = e.target.closest('[data-field]');
+ const currentRow = e.target.closest('tr');
+
+ if (!currentCell || !currentRow) return;
+
+ const fieldName = currentCell.dataset.field;
+ const isShift = e.shiftKey;
+
+ // Find next editable row
+ let targetRow = this.findNextEditableRow(currentRow, isShift);
+
+ // If no target row found, wrap around
+ if (!targetRow) {
+ targetRow = this.wrapToRow(currentRow, isShift);
+ }
+
+ if (targetRow) {
+ this.focusFieldInRow(targetRow, fieldName, isShift);
+ }
+ }
+ }
+ findNextEditableRow(currentRow, goBackward = false) {
+ let row = goBackward ? currentRow.previousElementSibling : currentRow.nextElementSibling;
+
+ // For timeline tables, skip non-editable rows
+ while (row && !this.isEditableRow(row)) {
+ row = goBackward ? row.previousElementSibling : row.nextElementSibling;
+ }
+
+ return row;
+ }
+
+ wrapToRow(currentRow, goBackward = false) {
+ if (this.isTimeline) {
+ // For timeline, stay within the same tbody
+ const tbody = currentRow.closest('tbody');
+ if (!tbody) return null;
+
+ const rows = Array.from(tbody.querySelectorAll('tr'))
+ .filter(row => this.isEditableRow(row));
+
+ return goBackward ? rows[rows.length - 1] : rows[0];
+ } else {
+ // For regular tables, use all rows in tbody
+ if (!this.ui.table.body) return null;
+
+ const rows = Array.from(this.ui.table.body.querySelectorAll('tr'))
+ .filter(row => this.isEditableRow(row));
+
+ return goBackward ? rows[rows.length - 1] : rows[0];
+ }
+ }
+ isEditableRow(row) {
+ // Skip thead/tfoot
+ if (row.closest('thead') || row.closest('tfoot')) {
+ return false;
+ }
+
+ // For timeline, check for specific classes
+ if (this.isTimeline) {
+ return row.classList.contains('shared') || row.classList.contains('timeline-point');
+ }
+
+ // For regular tables, check for data-id
+ return !!row.dataset.id;
+ }
+
+ focusFieldInRow(row, fieldName, fromAbove = false) {
+ const targetCell = row.querySelector(`[data-field="${fieldName}"]`);
+ if (!targetCell) return;
+
+ const input = this.findFocusableInput(targetCell);
+ if (input) {
+ input.focus();
+
+ // Select text if it's a text input
+ if (input.select && input.type === 'text') {
+ input.select();
+ }
+
+ // Announce for accessibility
+ const direction = fromAbove ? 'next' : 'previous';
+ this.a11y?.announce(`Moved to ${fieldName} in ${direction} row`);
+ }
+ }
+
+ findFocusableInput(cell) {
+ const selectors = [
+ 'input:not([type="hidden"]):not([disabled])',
+ 'textarea:not([disabled])',
+ 'select:not([disabled])',
+ 'button:not([disabled])'
+ ];
+
+ for (const selector of selectors) {
+ const element = cell.querySelector(selector);
+ if (element) return element;
+ }
+
+ return null;
+ }
+
+ toggleTimelineListeners(on = true) {
+ if (!this.isTimeline || this.view !== 'table') return;
+
+ // Cleanup existing instances
+ if (this.timelineSortables) {
+ this.timelineSortables.forEach(sortable => sortable.destroy());
+ this.timelineSortables = [];
+ }
+
+ if (!on) return;
+
+ // Initialize sortable for each tbody
+ this.timelineSortables = [];
+ const tbodies = this.ui.table.form.querySelectorAll('tbody.item');
+
+ tbodies.forEach(tbody => {
+ const sortable = new Sortable(tbody, {
+ animation: 150,
+ handle: '.drag-handle',
+ draggable: 'tr.timeline-point',
+ ghostClass: 'sortable-ghost',
+ chosenClass: 'sortable-chosen',
+ dragClass: 'sortable-drag',
+
+ // Prevent dragging between different tbodies
+ group: {
+ name: `timeline-${tbody.dataset.id}`,
+ pull: false,
+ put: false
+ },
+
+ onEnd: (evt) => this.handleTimelineReorder(evt)
+ });
+
+ this.timelineSortables.push(sortable);
+ });
+ }
+
+ handleTimelineReorder(evt) {
+ const { item: draggedRow, oldIndex, newIndex, from: tbody } = evt;
+
+ // No change
+ if (oldIndex === newIndex) return;
+
+ const itemID = parseInt(tbody.dataset.id);
+ const item = this.store.get(itemID);
+ if (!item?.fields?.timeline) return;
+
+ // Get current order of image IDs from DOM
+ const newOrder = Array.from(tbody.querySelectorAll('tr.timeline-point'))
+ .map(row => row.dataset.imageId)
+ .filter(Boolean);
+
+ // Rebuild timeline object in new order
+ const reorderedTimeline = {};
+ newOrder.forEach((imgId, index) => {
+ if (item.fields.timeline[imgId]) {
+ reorderedTimeline[imgId] = {
+ ...item.fields.timeline[imgId],
+ order: index
+ };
}
});
+ item.fields.timeline = reorderedTimeline;
- // Track initialization
- this.initialized = false;
+ // Save to store and server
+ this.store.save(item);
+ this.savePosts(
+ { [itemID]: { timeline: reorderedTimeline, content: this.content } },
+ 'Reordering timeline...',
+ true
+ );
- this.init();
+ this.a11y?.announce(`Moved to position ${newIndex + 1}`);
}
+
+ /*******************************************************************
+ MODALS
+ *******************************************************************/
+ openEditModal(itemID) {
+ let item = this.store.get(parseInt(itemID));
+ if (!item) return;
+ this.activeItem = itemID;
+ this.ui.modals.edit.modal.dataset.itemId = itemID;
+ this.ui.modals.edit.modal.dataset.content = this.content;
+ this.ui.modals.edit.h2.textContent = `Editing ${item.fields.post_title === '' ? this.singular : item.fields.post_title}`;
+ this.ui.modals.edit.form.dataset.formId = `edit-${itemID}`;
+
+ this.ui.modals.edit.form.reset();
+ new window.jvbPopulate(this.ui.modals.edit.form, item);
+ this.formController.registerForm(this.ui.modals.edit.form);
+
+ this.modals.edit.handleOpen();
+ }
+ openBulkEditModal() {
+ window.removeChildren(this.ui.modals.bulkEdit.selected);
+ this.ui.modals.edit.form.reset();
+ this.modals.bulkEdit.handleOpen();
+ this.selected.forEach(itemId => {
+ let template = window.getTemplate('bulkItem');
+ if (!template) return;
+ let item = this.store.get(parseInt(itemId));
+ if (!item) return;
+ let [checkbox, img, label] = [template.querySelector('input'), template.querySelector('img'), template.querySelector('label')];
+ if (checkbox) {
+ checkbox.id = `bulk_${item.id}`;
+ checkbox.value = item.id;
+ checkbox.checked = true;
+ checkbox.name = 'selected[]';
+ }
+
+ let thumbnail = item.images[item.fields.post_thumbnail]??{};
+ if (img) {
+ img.src = thumbnail.medium??'';
+ img.alt = thumbnail.alt??''
+ }
+
+ label.title = item.fields['post_title']??'';
+ this.ui.modals.bulkEdit.selected.append(template);
+ });
+ if (this.ui.modals.bulkEdit.h2) {
+ this.ui.modals.bulkEdit.h2.textContent = this.selected.size;
+ }
+ this.formController.registerForm(this.ui.modals.bulkEdit.form);
+ }
+
+ /*****************************************************************
+ FIELD HANDLING
+ *****************************************************************/
handleFormChange(event, data) {
let title = data.fullData.post_title;
let changes = (Object.hasOwn(data, 'changes')) ? data.changes : data.fullData;
let theChanges = {};
if (this.isTimeline) {
- theChanges[this.currentItemID] = changes;
- this.savePosts(theChanges, title);
+ theChanges[this.activeItem] = changes;
+ if (changes.post_status && this.shouldRemoveItemUI(changes.post_status)) {
+ this.removeItems([this.activeItem]);
+ }
+ this.savePosts(theChanges, title, event === 'form-submit');
return;
}
- let itemsToRemove = [];
- console.log(data);
- switch (true) {
- case data.config.element === this.ui.forms.edit:
- theChanges[this.currentItemID] = changes;
- title = `Saving ${title} Changes`;
- // Check if status change requires removal
- if (changes.post_status && this.shouldRemoveItem(changes.post_status)) {
- itemsToRemove.push(this.currentItemID);
+ let remove = [];
+ let el = data.config.element;
+ if (el === this.ui.modals.edit.form) {
+ theChanges[this.activeItem] = changes;
+ title = `Saving ${title} Changes`;
+ if (changes.post_status && this.shouldRemoveItemUI(changes.post_status)) {
+ remove.push(this.activeItem);
+ }
+ } else if (el === this.ui.modals.bulkEdit.form) {
+ let num = 0;
+ el.querySelectorAll('.selected input:checked').forEach(selected => {
+ theChanges[selected.value] = changes;
+ if (changes.post_status && this.shouldRemoveItemUI(changes.post_status)) {
+ remove.push(selected.value);
}
- break;
- case data.config.element === this.ui.forms.bulkEdit:
- let selected = data.config.element.querySelectorAll('.selected input:checked');
- selected.forEach(sel => {
- theChanges[sel.value] = changes;
- // Check if status change requires removal
- if (changes.post_status && this.shouldRemoveItem(changes.post_status)) {
- itemsToRemove.push(sel.value);
- }
- });
-
- title = `Updating ${selected.length} ${this.config.plural??'posts'} Changes`;
- break;
- case data.config.element === this.ui.forms.create:
- if (event === 'form-submit') {
- theChanges[data.config.data['form-id']] = changes;
- title = `Saving ${title} Changes`;
- }
- break;
- }
-
- // Handle visual removal with stagger effect
- if (itemsToRemove.length > 0) {
- let delay = 0;
- itemsToRemove.forEach(itemId => {
- setTimeout(() => {
- const element = document.querySelector(`.item[data-id="${itemId}"]`);
- if (element) {
- window.fade(element, false);
- }
- }, delay);
- delay += 50; // Stagger by 50ms
+ num++;
});
-
- // Clear selection after bulk edit with staggered removal
- if (data.config.element === this.ui.forms.bulkEdit) {
- setTimeout(() => {
- this.viewController.clearSelection();
- }, delay + 100);
+ title = `Updating ${num} ${this.plural} Changes`;
+ } else if (el === this.ui.modals.create.form) {
+ if (event === 'form-submit') {
+ theChanges[el.dataset.formId] = changes;
+ title = `Saving ${title} Changes`;
}
}
-
+ if (remove.length > 0) {
+ this.removeItems(remove);
+ }
+ this.selectionHandler.clearSelection();
if (Object.keys(theChanges).length === 0) {
return;
}
-
- this.savePosts(theChanges, title);
+ this.savePosts(theChanges, title, event === 'form-submit');
}
+ savePosts(changes, title, delay = false) {
+ if (Object.keys(changes).length === 0) return;
- shouldRemoveItem(newStatus) {
- return (this.status === 'all' && !['publish', 'draft'].includes(newStatus)) ||
- (newStatus !== this.status);
- }
-
- savePosts(changes, title) {
- if (Object.keys(changes).length === 0) {
- return;
- }
-
- //ensure content is in each post
- for (let postId in changes) {
- if (!changes[postId]['content']) {
- changes[postId]['content'] = this.content;
+ let validChanges = {};
+ for (let itemId in changes) {
+ // Validate ID exists and is not null/undefined
+ if (!itemId || itemId === 'null' || itemId === 'undefined') {
+ console.warn('Skipping save for invalid ID:', itemId);
+ continue;
}
+ if (!changes[itemId]['content']) changes[itemId]['content'] = this.content;
+
+ validChanges[itemId] = changes[itemId];
}
+
let operation = {
- endpoint: 'content',
+ endpoint: this.endpoint,
headers: {
'action_nonce': window.auth.getNonce('dash'),
},
data: {
- posts: changes,
+ posts: validChanges,
},
- delay: true,
+ delay: delay,
popup: `Saving changes`,
title: title
};
-
this.queue.addToQueue(operation);
-
- }
- async handleQueueSuccess(event, data) {
- this.store.clearCache();
- this.store.fetch();
- }
- handleQueueFailure(event, data) {
- console.error('Operation failed permanently:', data);
- // Optionally show error notification to user
- this.a11y?.announce(`Operation failed: ${data.error_message || 'Unknown error'}`);
}
- initElements() {
- this.elements = {
- modals: {
- create: 'dialog.create',
- edit: 'dialog.edit',
- bulkEdit: 'dialog.bulkEdit'
- },
- container: '.crud[data-content]',
- grid: '.item-grid',
- bulkSelectActions: '.bulk-action-select',
- forms: {
- create: 'dialog.create form',
- edit: 'dialog.edit form',
- bulkEdit: 'dialog.bulkEdit form'
- },
- uploader: 'details.uploader'
- };
- this.ui = window.uiFromSelectors(this.elements);
- if (this.ui.uploader) {
- window.jvbUploads.scanFields(document.querySelector(this.elements.uploader));
-
- window.jvbUploads.subscribe((event, data) => {
- if (event === 'sent-to-queue') {
- console.log(data);
- if (data === this.ui.uploader.querySelector('[data-uploader]')?.dataset.uploader) {
- window.debouncer.schedule('crud-complete', ()=> {
- this.store.clearCache();
- });
- }
- }
- });
- }
- this.isTimeline = !!document.querySelector('[data-timeline]');
- }
- init() {
- if (this.ui.uploader){
- this.settings.addSetting(this.ui.uploader, 'open');
- this.ui.uploader.addEventListener('toggle', (e) =>{
- this.settings.saveSetting('open', this.ui.uploader.open ? 'on' : 'off');
- });
- }
-
- // Set up filter controls
- this.filterHandler = this.handleFilterChange.bind(this);
- this.changeHandler = this.handleChange.bind(this);
-
-
-
- this.modals = {};
- for (let [name, modal] of Object.entries(this.ui.modals)) {
- this.modals[name] = new window.jvbModal(modal);
-
- this.modals[name].subscribe((event, data) => {
- switch (event) {
- case 'modal-close':
- this.currentItemID = null;
- this.formController.cleanupForm(this.modals[name].modal.querySelector('form').dataset.formId);
- //double check we have finished saving
- break;
- case 'modal-open':
- //probably not needed in this class
- break;
- }
- });
- }
-
- // Set up global event delegation
- this.setupEventDelegation();
-
- this.setupFilters();
-
- this.initialized = true;
- }
-
- setupEventDelegation() {
- document.addEventListener('change', this.changeHandler);
- // Single event listener for all CRUD actions
- document.addEventListener('click', (e) => {
- // Check for action buttons
- const actionBtn = e.target.closest('[data-action]');
- if (actionBtn) {
- e.preventDefault();
- const action = actionBtn.dataset.action;
- const id = actionBtn.dataset.id;
-
- switch(action) {
- case 'edit':
- this.populateEditForm(id);
- this.modals.edit.handleOpen();
- break;
-
- case 'delete':
- if (confirm('Delete this item?')) {
- let changes = {};
- changes[actionBtn.dataset.id] = {
- 'post_status': 'delete',
- 'content': this.content
- };
- window.fade(actionBtn.closest('.item'), false);
- this.savePosts(changes, `Sending ${this.singular} to trash...`);
- this.store.delete(id);
- }
- break;
- case 'trash':
- let changes = {};
- changes[actionBtn.dataset.id] = {
- 'post_status': 'trash',
- 'content': this.content
- };
- window.fade(actionBtn.closest('.item'), false);
- this.savePosts(changes, `Sending ${this.singular} to trash...`);
- break;
-
- case 'create':
- this.modals.create.dataset.itemId = 'new';
- this.modals.create.dataset.content = this.content;
- this.modals.create.handleOpen();
- break;
-
- case 'bulk-edit':
- const selected = Array.from(this.viewController.selectedItems);
- if (selected.length > 0) {
-
- this.modals.bulkEdit.handleOpen();
- }
- break;
-
- case 'bulk-delete':
- const toDelete = Array.from(this.viewController.selectedItems);
- if (toDelete.length > 0 && confirm(`Delete ${toDelete.length} items?`)) {
- toDelete.forEach(id => this.store.delete(id));
- this.viewController.clearSelection();
- }
- break;
-
- case 'sync':
- // this.store.syncQueue();
- break;
-
- case 'refresh':
- this.store.fetch();
- break;
- }
- }
-
- let createButton = e.target.closest('.create-item');
- if (createButton) {
- this.formController.registerForm(this.ui.forms.create);
- this.modals.create.handleOpen();
- }
-
- let clearSelection = e.target.closest('.cancel-bulk');
- if (clearSelection) {
- this.viewController.selectAll(false);
- }
- });
-
- // Keyboard shortcuts
- document.addEventListener('keydown', (e) => {
- // Ctrl/Cmd + A to select all
- if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
- if (this.ui.container && this.ui.container.contains(document.activeElement)) {
- e.preventDefault();
- this.viewController.selectAll();
- }
- }
-
- // ESC to clear selection
- if (e.key === 'Escape' && this.viewController?.selectedItems.size > 0 && window.jvbModal.getAllModals().length === 0) {
- this.viewController.clearSelection();
- }
- });
- }
- handleChange(e) {
- if (e.target.closest('[data-id]')) {
- if (this.isTimeline) {
- this.handleTimelineTableChange(e);
- } else {
- this.handleTableChange(e);
- }
- return;
- }
- if (e.target.classList.contains('bulk-action-select')) {
- if (e.target.value.startsWith('tax-')) {
- const taxonomy = e.target.value.replace('tax-', '');
- this.openTaxonomyModal(taxonomy);
- e.target.value = '';
- return;
- }
-
- switch (e.target.value) {
- case 'edit':
- this.populateBulkEdit();
- this.modals.bulkEdit.handleOpen();
- break;
- case 'publish':
- this.setBulkStatus('publish');
- break;
- case 'draft':
- this.setBulkStatus('draft');
- break;
- case 'trash':
- this.setBulkStatus('trash');
- break;
- case 'restore':
- this.setBulkStatus('draft');
- break;
- case 'delete':
- this.setBulkStatus('delete');
- break;
- }
- }
- if (window.targetCheck(e, 'select[data-filter]')) {
- this.handleFilterChange(e);
- }
- }
handleTableChange(e) {
- const row = e.target.closest('tr[data-id]');
- if (!row) return;
+ const container = this.isTimeline
+ ? e.target.closest('tbody[data-id]')
+ : e.target.closest('tr[data-id]');
+ if (!container) return;
const input = e.target;
- const postID = parseInt(row.dataset.id);
- const fieldName = input.closest(['data-field'])?.dataset.field;
- if (!fieldName) return;
+ const field = input.closest('[data-field]')?.dataset.field;
+ if (!field) return;
- const item = this.store.get(postID);
- if (!item) return;
-
- item.fields[fieldName] = this.getInputValue(input);
-
- this.store.save(item);
-
- let post = {};
- post[postID] = item.fields;
- this.savePosts(post, `Saving changes to ${this.content}`);
- }
- handleTimelineTableChange(e) {
- const tbody = e.target.closest('tbody[data-id]');
- if (!tbody) return;
-
- const input = e.target;
- const fieldName = input.closest('[data-field]')?.dataset.field;
-
- if (!fieldName) return;
-
- const parentID = parseInt(tbody.dataset.id);
- const timelinePoint = input.closest('tr.timeline-point');
-
- const item = this.store.get(parentID);
+ const itemID = parseInt(container.dataset.id);
+ const item = this.store.get(itemID);
if (!item) return;
const value = this.getInputValue(input);
- // Check if this is a specific point, or a shared value
- if (timelinePoint) {
- const imgID = timelinePoint.dataset.imageId;
- if (!item.fields.timeline) {
- item.fields.timeline = {};
+ // Timeline-specific: check if it's a point or shared value
+ if (this.isTimeline) {
+ const timelinePoint = input.closest('tr.timeline-point');
+ if (timelinePoint) {
+ const imgID = timelinePoint.dataset.imageId;
+ item.fields.timeline ??= {};
+ item.fields.timeline[imgID] ??= {};
+ item.fields.timeline[imgID][field] = value;
+ } else {
+ item.fields[field] = value;
}
- if (!item.fields.timeline[imgID]) {
- item.fields.timeline[imgID] = {};
- }
- item.fields.timeline[imgID][fieldName] = value;
} else {
- item.fields[fieldName] = value;
+ item.fields[field] = value;
}
- //Update store directly
this.store.save(item);
-
- let changes = {};
- changes[parentID] = item.fields;
- this.savePosts(changes, 'Updating progress post');
+ this.savePosts({ [itemID]: item.fields }, `Saving changes...`, true);
}
+
getInputValue(input) {
if (input.type === 'checkbox') {
return input.checked ? (input.value || '1') : '';
@@ -500,54 +980,16 @@
}
return input.value;
}
-
- openTaxonomyModal(taxonomy) {
- // Check if jvbSelector exists
- if (!window.jvbSelector) {
- console.error('TaxonomySelector not initialized');
- return;
- }
-
- // Open the selector in filter mode
- window.jvbSelector.openForFilter(
- taxonomy,
- (selectedIds, taxonomy) => this.handleBulkTaxonomy(selectedIds, taxonomy)
- );
- }
- handleBulkTaxonomy(selectedIds, taxonomy) {
- // Callback when terms are selected
- if (selectedIds.length > 0) {
- selectedIds = selectedIds.join(',');
- let changes = {};
- let selected = Array.from(this.viewController.selectedItems);
-
-
- selected.forEach(sel => {
- changes[sel] = {
- content: this.content
- };
- changes[sel][taxonomy] = selectedIds;
- });
-
-
- let title = `Adding ${selected.length} ${this.config.plural??'posts'} to ${selectedIds.length} ${jvbSettings.labels[taxonomy].plural}`;
- this.viewController.clearSelection();
- this.savePosts(changes, title);
- }
- }
-
setBulkStatus(status) {
- if (!['publish', 'draft', 'trash', 'delete'].includes(status)){
- return;
- }
+ if (!['publish', 'draft', 'trash', 'delete'].includes(status)) return;
let changes = {};
- for (let selected of this.viewController.selectedItems) {
- changes[selected] = {
+ this.selected.forEach(itemID => {
+ changes[itemID] = {
post_status: status,
content: this.content
};
- }
+ });
let title;
switch (status) {
case 'delete':
@@ -556,150 +998,474 @@
default:
title = window.uppercaseFirst(status)+'ing';
}
-
- if ((this.status === 'all' && !['publish', 'draft'].includes(status)) || status !== this.status) {
- let delay = 0;
- for (let selected of this.viewController.selectedItems) {
- setTimeout(() => {
- const element = document.querySelector(`.item[data-id="${selected}"]`);
- if (element) {
- window.fade(element, false);
- }
- }, delay);
- delay += 50; // Increment delay for staggered effect
- }
+ if (this.shouldRemoveItemUI(status)) {
+ this.removeItems(Object.keys(changes));
}
- // Clear selection even if items aren't being removed
- this.viewController.clearSelection();
-
-
+ this.selectionHandler.clearSelection();
if (Object.keys(changes).length !== 0) {
- this.savePosts(changes, `${title} ${this.viewController.selectedItems.size} ${this.plural}...`);
+ this.savePosts(changes, `${title} ${Object.keys(changes).length} ${this.plural}...`);
+ }
+ }
+ /***************************************************************
+ VIEW
+ ***************************************************************/
+ render() {
+ const items = this.store.getFiltered();
+ if (items.length === 0) {
+ this.renderEmpty();
+ return;
+ }
+
+ switch (this.view) {
+ case 'grid':
+ this.renderGrid(items);
+ break;
+ case 'table':
+ this.renderTable(items);
+ break;
+ case 'list':
+ this.renderList(items);
+ break;
+ }
+ this.updateUI();
+ }
+ updateUI() {
+ if (this.ui.bulk.action) {
+ let options = false;
+ let hasEdit =this.ui.bulk.action.querySelector('[value="edit"]');
+ if (this.status === 'trash' && hasEdit) {
+ window.removeChildren(this.ui.bulk.action);
+ options = window.getTemplate('trashOptions');
+ } else if (this.status !== 'trash' && !hasEdit) {
+ window.removeChildren(this.ui.bulk.action);
+ options = window.getTemplate('notTrashOptions');
+ }
+ if (options) {
+ options.querySelectorAll('option').forEach((option, index)=> {
+ if (index === 0) option.checked = true;
+ this.ui.bulk.action.append(option);
+ });
+ }
+ this.ui.bulk.action.value = '';
+ }
+ if (this.selected.size > 0) {
+ this.selectionHandler.updateSelectionUI();
}
}
- handleFilterChange(e) {
- let target = e.target;
- let filter = target.dataset.filter;
- if (filter === 'taxonomies') {
- let taxonomy = target.dataset.taxonomy;
- this.store.setFilter(`tax_${taxonomy}`, target.value);
- } else {
- this[target.dataset.filter] = target.value;
- this.store.setFilter(target.dataset.filter, target.value);
- if (target.dataset.filter === 'status') {
- this.updateBulkOptions(target.value);
- }
+ renderEmpty() {
+ this.toggleTable(false);
+ window.removeChildren(this.ui.grid);
+ const empty = window.getTemplate('emptyState');
+ if (empty) {
+ this.ui.grid.append(empty);
+ this.a11y.announceItems(0,false,false);
}
}
- updateBulkOptions(status = 'all') {
- if (status === 'trash') {
- if (this.ui.bulkSelectActions?.querySelector('[value="edit"]')) {
- window.removeChildren(this.ui.bulkSelectActions);
- let options = window.getTemplate('trashOptions');
- options.querySelectorAll('option').forEach((option, index) => {
- if (index === 0) {
- option.checked = true;
+
+ toggleTable(on = true) {
+ if (this.ui.table.selectedColumns) this.ui.table.selectedColumns.hidden = !on;
+
+ if (on && !this.ui.table.form) {
+ let table = window.getTemplate('contentTable');
+ this.container.append(table);
+ this.ui.table = window.uiFromSelectors(this.selectors.table);
+ this.ui.table.columns = this.container.querySelectorAll(this.selectors.table.columns);
+ }
+
+ if (this.ui.table.form) {
+ this.ui.table.form.hidden = !on;
+ if (on) {
+ this.formController.registerForm(this.ui.table.form, {
+ autosave: false,
+ formStatus: false,
+ isTable: true
+ });
+ } else {
+ this.formController.cleanupForm(this.ui.table.form.dataset.formId)
+ }
+ if (this.ui.table.body) {
+ window.removeChildren(this.ui.table.body);
+ }
+ }
+ this.keyHandler = this.handleKeys.bind(this);
+ if (on) {
+ document.addEventListener('keydown', this.keyHandler);
+ } else {
+ document.removeEventListener('keydown', this.keyHandler);
+ }
+ if (this.isTimeline && !on) this.toggleTimelineListeners(on);
+ }
+
+ renderGrid(items) {
+ window.removeChildren(this.ui.grid);
+ this.toggleTable(false);
+
+ this.ui.grid.classList.remove('list-view');
+ this.ui.grid.classList.add('grid-view');
+
+ const fragment = document.createDocumentFragment();
+ items.forEach(item => {
+ let card = this.renderGridItem(item);
+ fragment.appendChild(card);
+ });
+ this.ui.grid.appendChild(fragment);
+ }
+
+ renderList(items) {
+ window.removeChildren(this.ui.grid);
+ this.toggleTable(false);
+
+ this.ui.grid.classList.remove('grid-view');
+ this.ui.grid.classList.add('list-view');
+ const fragment = document.createDocumentFragment();
+ items.forEach(item => {
+ let row = this.renderListItem(item);
+ fragment.append(row);
+ });
+ this.ui.grid.append(fragment);
+ }
+
+ renderTable(items) {
+ this.toggleTable();
+ window.removeChildren(this.ui.grid);
+
+ let fragment = document.createDocumentFragment();
+ items.forEach(item => {
+ let row = this.renderTableItem(item);
+ if (row) fragment.append(row);
+ });
+
+ if (this.ui.table.body) {
+ this.ui.table.body.append(fragment);
+ } else {
+ this.ui.table.table.insertBefore(fragment, this.ui.table.foot);
+ }
+
+ if (this.isTimeline) {
+ this.toggleTimelineListeners(true);
+ }
+ }
+
+ /***************************************************************
+ RENDER HELPERS
+ ***************************************************************/
+ setupItemElement(element, item, templateName) {
+ if (this.items[this.view].has(item.id)) {
+ return this.items[this.view].get(item.id);
+ }
+
+ element.dataset.id = item.id;
+ if (item._pending) element.classList.add('pending');
+
+ // Setup selection checkbox/label
+ this.setupItemSelection(element, item);
+
+ // Setup thumbnail
+ this.setupItemThumbnail(element, item);
+
+ // Setup action buttons
+ this.setupItemActions(element, item);
+
+ this.items[this.view].set(item.id, element);
+ return element;
+ }
+
+ setupItemSelection(element, item) {
+ const checkbox = element.querySelector('.select-item');
+ const label = element.querySelector('.select-item + label, .select-item-label');
+ if (!checkbox) return;
+
+ checkbox.id = `select-${item.id}`;
+ checkbox.value = item.id;
+ checkbox.checked = this.selected.has(parseInt(item.id));
+
+ if (label) {
+ label.htmlFor = `select-${item.id}`;
+ }
+ }
+
+ setupItemThumbnail(element, item) {
+ const img = element.querySelector('img');
+ if (!img) return;
+
+ const thumbnail = item.images?.[item.fields?.post_thumbnail] ?? {};
+ img.src = thumbnail.medium ?? '';
+ img.alt = thumbnail.alt ?? '';
+ }
+
+ setupItemActions(element, item) {
+ element.querySelectorAll('[data-action]').forEach(btn => {
+ btn.dataset.id = item.id;
+ });
+ }
+
+ renderGridItem(item) {
+ if (this.items.grid.has(item.id)) {
+ return this.items.grid.get(item.id);
+ }
+ const card = window.getTemplate('gridView');
+ if (!card) return null;
+ return this.setupItemElement(card, item, 'grid');
+ }
+
+ renderListItem(item) {
+ if (this.items.list.has(item.id)) {
+ return this.items.list.get(item.id);
+ }
+ let row = window.getTemplate('listView');
+ if (!row) return null;
+
+ row = this.setupItemElement(row, item, 'list');
+
+ // List-specific: populate data attributes
+ row.querySelectorAll('[data-attr]').forEach(el => {
+ const value = item[el.dataset.attr];
+ if (value && value !== '') {
+ el.textContent = value;
+ } else {
+ el.remove();
+ }
+ });
+
+ row.querySelectorAll('[data-field]').forEach(el => {
+ const value = item.fields?.[el.dataset.field];
+ if (value && value !== '') {
+ el.tagName === 'DIV' ? el.innerHTML = value : el.textContent = value;
+ } else {
+ el.remove();
+ }
+ });
+ this.items.list.set(item.id, row);
+
+ return row;
+ }
+
+ renderTableItem(item) {
+ if (this.items.table.has(item.id)) {
+ return this.items.table.get(item.id);
+ }
+ let row = window.getTemplate('tableView');
+ if (!row) return null;
+
+ row = this.setupItemElement(row, item, 'table');
+
+ const status = row.querySelector(`input[name="post_status"][value="${item.status}"]`);
+ if (status) status.checked = true;
+
+ if (this.isTimeline) {
+ // Timeline: populate shared row, clone points
+ const sharedRow = row.querySelector('tr.shared');
+ if (sharedRow) {
+ new window.jvbPopulate(sharedRow, item);
+ this.cleanupTableRow(sharedRow);
+ }
+
+ const pointTemplate = row.querySelector('tr.timeline-point');
+ if (pointTemplate && item.fields?.timeline) {
+ Object.entries(item.fields.timeline).forEach(([imgId, timeline], index) => {
+ const point = pointTemplate.cloneNode(true);
+ point.dataset.index = index;
+ point.dataset.imageId = imgId;
+
+ new window.jvbPopulate(point, {
+ fields: timeline,
+ images: item.images,
+ taxonomies: item.taxonomies
+ });
+ this.cleanupTableRow(point);
+
+ const imgData = item.images?.[timeline.post_thumbnail];
+ if (imgData) {
+ point.querySelector('.field.upload')?.setAttribute('title', imgData['image-title'] || '');
}
- this.ui.bulkSelectActions.append(option);
+
+ row.insertBefore(point, pointTemplate);
});
+ pointTemplate.remove();
}
} else {
- if (this.ui.bulkSelectActions && !this.ui.bulkSelectActions.querySelector('[value="edit"]')) {
- window.removeChildren(this.ui.bulkSelectActions);
-
- let options = window.getTemplate('notTrashOptions');
- options.querySelectorAll('option').forEach((option, index) => {
- this.ui.bulkSelectActions.append(option);
- });
+ // Standard table row
+ if (this.ui.table.form?.dataset.edit !== undefined) {
+ new window.jvbPopulate(row, item);
+ } else {
+ this.populateTableReadOnly(row, item);
}
+ this.cleanupTableRow(row);
}
- if (this.ui.bulkSelectActions) {
- this.ui.bulkSelectActions.value = '';
+
+ this.items.table.set(item.id, row);
+
+ return row;
+ }
+
+ populateTableReadOnly(row, item) {
+ for (const [key, value] of Object.entries(item)) {
+ const col = row.querySelector(`[data-field="${key}"]`);
+ if (!col) continue;
+
+ const p = col.querySelector('p');
+ if (p) {
+ p.textContent = col.dataset.fieldType === 'date'
+ ? window.formatTimeAgo(value)
+ : value;
+ }
}
}
- populateBulkEdit() {
- const container = this.modals.bulkEdit.modal.querySelector('form .selected');
- if (!container) return;
-
- window.removeChildren(container);
- for (let selected of this.viewController.selectedItems) {
- let item = this.store.get(selected);
-
- const img = window.getTemplate('bulkItem');
- if (!img) return;
-
- const checkbox = img.querySelector('input[type=checkbox]');
- const image = img.querySelector('img');
-
- if (checkbox) {
- checkbox.id = `bulk_${item.id}`;
- checkbox.value = item.id;
- checkbox.checked = true;
- }
-
- if (image && item.thumbnail) {
- image.src = item.thumbnail;
- image.alt = item.alt || '';
- }
-
- container.append(img);
- }
- let modal = this.modals.bulkEdit.modal;
- [
- modal.querySelector('h2 span').textContent
- ] = [
- this.viewController.selectedItems.size
- ];
-
- this.formController.registerForm(this.ui.forms.bulkEdit);
- }
-
- populateEditForm(itemID) {
- this.currentItemID = itemID;
-
- let item = this.store.get(parseInt(itemID));
- if (item) {
- this.ui.modals.edit.dataset.itemId = itemID;
- this.ui.modals.edit.dataset.content = this.content;
-
- let form = this.ui.modals.edit.querySelector('form');
- this.ui.modals.edit.querySelector('h2').textContent = `Editing ${item.fields.post_title}`;
- form.dataset.formId = `edit-${itemID}`;
-
- new window.jvbPopulate(form, item);
-
- this.formController.registerForm(this.ui.forms.edit);
- }
- }
-
- setupFilters() {
- // Search
- const searchInput = document.querySelector('.all-filters input[type="search"]');
- if (searchInput) {
- let searchTimeout;
- searchInput.addEventListener('input', () => {
- if (searchInput.value.length > 3) {
- clearTimeout(searchTimeout);
- searchTimeout = setTimeout(() => {
- this.store.setFilter('search', searchInput.value);
- }, 300);
- } else if (searchInput.value.length === 0) {
- this.store.removeFilter('search');
+ cleanupTableRow(row) {
+ row.querySelectorAll('td[data-field]').forEach(field => {
+ field.querySelectorAll('label:not(.select-item-label,.radio-option,[for*="select-item"])').forEach(label => {
+ if (!label.closest('.radio-options')) {
+ label.remove();
}
});
- }
+
+ //Remove toggle labels for true_false fields
+ if (field.dataset.fieldType === 'true_false') {
+ field.querySelector('.toggle-label')?.remove();
+ }
+
+ //Remove field label for checkbox/radio groups
+ if (['checkbox','radio','select'].includes(field.dataset.fieldType)) {
+ field.querySelector('.label')?.remove();
+ }
+
+ });
+ }
+ toggleColumn(column, show) {
+ this.ui.table.table.querySelectorAll(`.${column}`).forEach(el =>{
+ el.hidden = !show;
+ });
+ }
+ /***************************************************************
+ UTILITY
+ ***************************************************************/
+ shouldRemoveItemUI(newStatus) {
+ return (this.status === 'all' && !['publish', 'draft'].includes(newStatus))
+ || newStatus !== this.store.filters.status;
+ }
+ removeItems(items) {
+ let delay = 0;
+ items.forEach(itemId => {
+ setTimeout(() => {
+ let item = this.items[this.view].get(itemId);
+ if (item) {
+ window.fade(item, false);
+ }
+ }, delay);
+ delay += 50;
+ });
}
+ setFilters(filters) {
+ for (let [key, value] of Object.entries(filters)) {
+ if (!this.allowedFilters.includes(key)) {
+ delete filters[key];
+ continue;
+ }
+ this.cache.set(key, value);
+
+ let el = this.findFilterEl(key);
+ this.setElValue(el, value);
+ }
+ this.store.setFilters(filters);
+ }
+ setFilter(name, value) {
+ if (!this.allowedFilters.includes(name)) return;
+ this.cache.set(name, value);
+ let el = this.findFilterEl(name, value);
+ this.setElValue(el, value);
+ //TODO: If we set the element to checked, does that automatically call the change listener, which then also sets the store filter and cache?
+ this.store.setFilter(name, value);
+ }
+
+ deleteFilter(name, value) {
+ if (!this.allowedFilters.includes(name)) return;
+ if (Object.hasOwn(this.defaults, name)) {
+ this.setFilter(name, this.defaults[name]);
+ return;
+ }
+ let el = this.findFilterEl(name, value);
+ this.setElValue(el, false);
+ this.cache.remove(name);
+ this.setFilter(name, '');
+ }
+ setElValue(element, value) {
+ if (!element) return;
+ if (!value) {
+ if (['SELECT','TEXTAREA'].includes(element.tagName)) element.value = '';
+ if (['text', 'search'].includes(element.type)) element.value = '';
+ if (element.type === 'radio') element.checked = false;
+ return;
+ }
+
+ if (['SELECT','TEXTAREA'].includes(element.tagName)) element.value = value;
+ if (['text', 'search'].includes(element.type)) element.value = value;
+ if (element.type === 'radio') element.checked = true;
+ }
+ findFilterEl(name, value) {
+ //Handle exceptions first (custom date elements)
+ if (['date-filter', 'dateFrom', 'dateTo'].includes(name)) {
+ switch (name) {
+ case 'date-filter':
+ name = 'month';
+ break;
+ case 'dateFrom':
+ name = 'start';
+ break;
+ case 'dateTo':
+ name = 'end';
+ break;
+ }
+ return this.ui.modals.date[name];
+ }
+ // Handle taxonomy filters
+ if (name.includes('tax_')) {
+ const taxonomy = name.replace('tax_', '');
+ const element = this.ui.filters.taxonomies?.[taxonomy];
+ if (element) {
+ return element;
+ }
+ console.warn('Taxonomy filter element not found:', taxonomy);
+ return null;
+ }
+
+ if (!Object.hasOwn(this.ui.filters, name)) {
+ console.warn('Filter el not found: ', name);
+ return false;
+ }
+
+ let el = this.ui.filters[name];
+ if (typeof el === 'object') {
+ if (!Object.hasOwn(this.ui.filters[name], value)) {
+ console.log('Sub filter el not found: ', value);
+ console.log(name);
+ return false;
+ }
+ el = this.ui.filters[name][value];
+ }
+ return el;
+ }
+ /***************************************************************
+ CLEANUP
+ ***************************************************************/
destroy() {
- document.querySelectorAll('[data-filter]').forEach(filter => {
- filter.removeEventListener('change', this.filterHandler);
- });
+ if (this.timelineSortables) {
+ this.timelineSortables.forEach(sortable => sortable.destroy());
+ this.timelineSortables = [];
+ }
+ document.removeEventListener('click', this.clickHandler);
+ document.removeEventListener('change', this.changeHandler);
+ if (this.ui.filters.search) {
+ this.ui.filters.search.removeEventListener('input', this.handleInput);
+ }
+ for (let map of Object.values(this.items)) {
+ map.clear();
+ }
}
}
-// Initialize when ready
document.addEventListener('DOMContentLoaded', async function() {
window.auth.subscribe((event) => {
if (event === 'auth-loaded') {
--
Gitblit v1.10.0