class CRUDManager {
|
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.a11y = window.jvbA11y;
|
this.error = window.jvbError;
|
this.cache = new window.jvbCache(this.content);
|
|
this.activeItem = null;
|
this.isTimeline = false;
|
this.items = {
|
list: new Map(),
|
grid: new Map(),
|
table: new Map()
|
}; //DOM references
|
|
this.init();
|
}
|
|
init() {
|
this.initElements();
|
this.initListeners();
|
let cached = this.initSettings();
|
this.initStore(cached);
|
this.checkHideFilters();
|
this.initIntegrations();
|
this.initUploader();
|
this.initModals();
|
}
|
|
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: this.endpoint??'content', //for taxonomy stores
|
headers: {
|
'action_nonce': window.auth.getNonce('dash'),
|
},
|
indexes: [
|
{name: 'id', keyPath: 'id'},
|
{ name: 'status', keyPath: 'status'},
|
{ name: 'date', keyPath: 'date'},
|
{ name: 'modified', keyPath: 'modified'},
|
{ name: 'title', keyPath: 'title'}
|
],
|
filters: filters,
|
ignore: ['content', 'user'],
|
TTL: 60 * 60 * 1000, //1 hour cache
|
showLoading: true,
|
}
|
);
|
this.store = store[this.content];
|
|
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) {
|
case 'form-submit':
|
case 'form-autosave':
|
this.handleFormChange(event,data);
|
break;
|
}
|
});
|
|
this.queue.subscribe((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;
|
|
// Save to store and server
|
this.store.save(item);
|
this.savePosts(
|
{ [itemID]: { timeline: reorderedTimeline, content: this.content } },
|
'Reordering timeline...',
|
true
|
);
|
|
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.activeItem] = changes;
|
if (changes.post_status && this.shouldRemoveItemUI(changes.post_status)) {
|
this.removeItems([this.activeItem]);
|
}
|
this.savePosts(theChanges, title, event === 'form-submit');
|
return;
|
}
|
|
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);
|
}
|
num++;
|
});
|
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, event === 'form-submit');
|
}
|
savePosts(changes, title, delay = false) {
|
if (Object.keys(changes).length === 0) return;
|
|
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: this.endpoint,
|
headers: {
|
'action_nonce': window.auth.getNonce('dash'),
|
},
|
data: {
|
posts: validChanges,
|
},
|
delay: delay,
|
popup: `Saving changes`,
|
title: title
|
};
|
this.queue.addToQueue(operation);
|
}
|
|
handleTableChange(e) {
|
const container = this.isTimeline
|
? e.target.closest('tbody[data-id]')
|
: e.target.closest('tr[data-id]');
|
if (!container) return;
|
|
const input = e.target;
|
const field = input.closest('[data-field]')?.dataset.field;
|
if (!field) return;
|
|
const itemID = parseInt(container.dataset.id);
|
const item = this.store.get(itemID);
|
if (!item) return;
|
|
const value = this.getInputValue(input);
|
|
// 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;
|
}
|
} else {
|
item.fields[field] = value;
|
}
|
|
this.store.save(item);
|
this.savePosts({ [itemID]: item.fields }, `Saving changes...`, true);
|
}
|
|
getInputValue(input) {
|
if (input.type === 'checkbox') {
|
return input.checked ? (input.value || '1') : '';
|
}
|
if (input.type === 'radio') {
|
return input.checked ? input.value : null;
|
}
|
return input.value;
|
}
|
setBulkStatus(status) {
|
if (!['publish', 'draft', 'trash', 'delete'].includes(status)) return;
|
|
let changes = {};
|
this.selected.forEach(itemID => {
|
changes[itemID] = {
|
post_status: status,
|
content: this.content
|
};
|
});
|
let title;
|
switch (status) {
|
case 'delete':
|
title = 'Deleting';
|
break;
|
default:
|
title = window.uppercaseFirst(status)+'ing';
|
}
|
if (this.shouldRemoveItemUI(status)) {
|
this.removeItems(Object.keys(changes));
|
}
|
this.selectionHandler.clearSelection();
|
if (Object.keys(changes).length !== 0) {
|
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();
|
}
|
}
|
|
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);
|
}
|
}
|
|
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'] || '');
|
}
|
|
row.insertBefore(point, pointTemplate);
|
});
|
pointTemplate.remove();
|
}
|
} else {
|
// Standard table row
|
if (this.ui.table.form?.dataset.edit !== undefined) {
|
new window.jvbPopulate(row, item);
|
} else {
|
this.populateTableReadOnly(row, item);
|
}
|
this.cleanupTableRow(row);
|
}
|
|
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;
|
}
|
}
|
}
|
|
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() {
|
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();
|
}
|
}
|
}
|
|
document.addEventListener('DOMContentLoaded', async function() {
|
window.auth.subscribe((event) => {
|
if (event === 'auth-loaded') {
|
let container = document.querySelector('[data-content]');
|
if (container && !Object.hasOwn(container.dataset, 'ignore')) {
|
window.crudManager = new CRUDManager({
|
content: container.dataset.content,
|
});
|
}
|
}
|
});
|
});
|