| | |
| | | this.error = window.jvbError; |
| | | this.populate = window.jvbPopulate; |
| | | this.cache = new window.jvbCache(this.content); |
| | | this.uploadedFields = new Set(); //tracks which upload fields are currently uploading; so don't send any of these changes to server |
| | | |
| | | this.activeItem = null; |
| | | this.isTimeline = false; |
| | |
| | | |
| | | const baseSetup = (el, refs, data) => { |
| | | el.dataset.itemId = data.id; |
| | | |
| | | window.prefixInput(refs.checkbox, `select-${data.id}`, true); |
| | | let wrapper = refs.checkbox.closest('.preview'); |
| | | window.prefixInput(refs.checkbox, `select-${data.id}`, wrapper, true); |
| | | refs.checkbox.value = data.id; |
| | | refs.checkbox.checked = crud.selected.has(parseInt(data.id)); |
| | | if (refs.selectLabel) refs.selectLabel.htmlFor = `select-${data.id}`; |
| | |
| | | baseSetup(el, refs, data); |
| | | |
| | | manyRefs?.inputs?.forEach(el => { |
| | | window.prefixInput(el, `${data.id}-`); |
| | | let wrapper = el.closest('[data-field]'); |
| | | window.prefixInput(el, `${data.id}-`, wrapper); |
| | | }); |
| | | |
| | | manyRefs?.status?.forEach(el => { |
| | |
| | | if (crud.isTimeline) { |
| | | if (refs.sharedRow) { |
| | | refs.sharedRow.querySelectorAll('input,select,textarea').forEach(input => { |
| | | window.prefixInput(input, `${data.id}-`); |
| | | let wrapper = input.closest('[data-field]'); |
| | | window.prefixInput(input, `${data.id}-`, wrapper); |
| | | }); |
| | | |
| | | crud.populate.populate(refs.sharedRow, data); |
| | |
| | | point.dataset.itemId = timeline.id; |
| | | |
| | | point.querySelectorAll('input,select,textarea').forEach(input => { |
| | | window.prefixInput(input, `${timeline.id}-`); |
| | | let wrapper = input.closest('[data-field]'); |
| | | window.prefixInput(input, `${timeline.id}-`, wrapper); |
| | | }); |
| | | |
| | | crud.populate.populate(point, { |
| | |
| | | if (crud.ui.table.form?.dataset.edit !== undefined) { |
| | | // Non-timeline: prefix all inputs normally |
| | | manyRefs?.inputs?.forEach(input => { |
| | | window.prefixInput(input, `${data.id}-`); |
| | | let wrapper = input.closest('[data-field]'); |
| | | window.prefixInput(input, `${data.id}-`, wrapper); |
| | | }); |
| | | |
| | | manyRefs?.status?.forEach(el => { |
| | |
| | | }); |
| | | } |
| | | } |
| | | |
| | | if (event === 'sent-to-queue' && data.field) { |
| | | const fieldName = data.field.config.name; |
| | | const itemId = data.field.config.itemID; |
| | | if (itemId && fieldName) { |
| | | this.uploadedFields.add(`${itemId}_${fieldName}`); |
| | | if (this.changes.has(itemId)) { |
| | | delete this.changes.get(itemId)[fieldName]; |
| | | } |
| | | } |
| | | } |
| | | |
| | | if (event === 'upload_complete') { |
| | | this.uploadedFields.delete(`${data['item_id']}_${data['field']}`); |
| | | } |
| | | }); |
| | | } |
| | | initModals() { |
| | |
| | | if (name === 'date') { |
| | | this.handleCustomDateSelection() |
| | | } |
| | | if (['edit','bulkEdit','create'].includes(name)) { |
| | | //handle escapes (not form submits) |
| | | if (window.debouncer.timeouts.has(`save-${this.content}`)) { |
| | | this.scheduleSave(0); |
| | | } |
| | | } |
| | | break; |
| | | case 'modal-open': |
| | | |
| | |
| | | keyPath: 'id', |
| | | endpoint: this.endpoint??'content', //for taxonomy stores |
| | | headers: { |
| | | 'action_nonce': window.auth.getNonce('dash'), |
| | | 'X-Action-Nonce': window.auth.getNonce('dash'), |
| | | }, |
| | | indexes: [ |
| | | {name: 'id', keyPath: 'id'}, |
| | |
| | | // } |
| | | // }); |
| | | |
| | | if (window.jvbUploads) { |
| | | window.jvbUploads.subscribe((event, data) => { |
| | | if (event === 'groups_uploaded' && data.content === this.content) { |
| | | this.handleGroupsUploaded(data); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | this.queue.subscribe((event, data) => { |
| | | if (['image_upload', 'video_upload', 'document_upload'].includes(data.type) |
| | | && event === 'operation-status' |
| | | && data.status === 'completed') { |
| | | this.store.clearCache(); |
| | | } |
| | | |
| | | |
| | | if (event === 'operation-status' |
| | | && data.status === 'completed' |
| | | && data.endpoint === 'content' |
| | | && Object.keys(data.data?.posts??{}).length > 0) { |
| | | |
| | | && data.endpoint === 'uploads/groups') { |
| | | if (data.result && data.result.group_mappings) { |
| | | this.handleGroupMappings(data.result.group_mappings); |
| | | } |
| | | this.store.clearCache(); |
| | | let ids = Object.keys(data.data.posts); |
| | | let storedChanges = this.changesStore.getMany(ids); |
| | | } |
| | | if (event === 'operation-status' |
| | | && data.status === 'completed' |
| | | && data.type === 'content_update') { |
| | | this.store.clearCache(); |
| | | |
| | | this.changesStore.deleteMany(ids); |
| | | |
| | | for (let id of ids) { |
| | | let stored = storedChanges.filter(change => change.id === id)[0]??false; |
| | | |
| | | let sentChanges = data.data.posts[id]; |
| | | let remainingChanges = {}; |
| | | |
| | | for (let [key, value] of Object.entries(sentChanges)) { |
| | | if (stored && !Object.hasOwn(stored, key)) continue; |
| | | if (stored[key] === value) { |
| | | delete stored[key]; |
| | | } |
| | | remainingChanges[key] = value; |
| | | } |
| | | if (Object.keys(remainingChanges).length > 0) { |
| | | remainingChanges['id'] = id; |
| | | remainingChanges['content'] = this.content; |
| | | this.changes.set(id, remainingChanges); |
| | | } |
| | | // Check for result data (from ContentExecutor) |
| | | if (!data.result || !data.result.posts) { |
| | | console.warn('Content update completed but no result.posts', data); |
| | | return; |
| | | } |
| | | if (Object.values(this.changes).length > 0) { |
| | | this.scheduleBackup(); |
| | | |
| | | // Get successfully processed post IDs |
| | | const successfulIds = Object.keys(data.result.posts); |
| | | |
| | | if (successfulIds.length === 0) { |
| | | return; |
| | | } |
| | | |
| | | // Clear from both persistent and in-memory storage |
| | | this.changesStore.deleteMany(successfulIds); |
| | | successfulIds.forEach(id => this.changes.delete(id)); |
| | | } |
| | | |
| | | }); |
| | |
| | | const form = e.target; |
| | | const modal = form.closest('dialog'); |
| | | if (!modal) return; |
| | | let title = `Saving changes for multiple ${this.plural}`; |
| | | if (modal.classList.contains('edit')) { |
| | | title = 'Saving your edits...'; |
| | | } else if (modal.classList.contains('create')) { |
| | | title = `Creating your new ${this.singular}`; |
| | | |
| | | if (modal.classList.contains('create')) { |
| | | this.handleCreateSubmit(modal); |
| | | return; |
| | | } |
| | | this.cancelBackup(); |
| | | this.handleBackup().then(()=>{}); |
| | | this.savePosts(title,false).then(()=>{}); |
| | | |
| | | let title = `Saving changes for multiple ${this.plural}`; |
| | | |
| | | this.scheduleSave(0); |
| | | } |
| | | |
| | | async handleCreateSubmit(modal) { |
| | | const itemId = modal.dataset.itemId; |
| | | |
| | | // 1. Flush changes to store |
| | | if (this.changes.size > 0) { |
| | | this.cancelBackup(); |
| | | await this.handleBackup(); |
| | | } |
| | | |
| | | const changes = await this.changesStore.getAll(); |
| | | if (changes.length === 0) return; |
| | | |
| | | let allChanges = {}; |
| | | changes.forEach(change => { |
| | | const { id, ...rest } = change; |
| | | allChanges[id] = rest; |
| | | }); |
| | | |
| | | // 2. Queue content creation, get operationId |
| | | let contentOpId = this.queue.addToQueue({ |
| | | endpoint: this.endpoint, |
| | | headers: { |
| | | 'X-Action-Nonce': window.auth.getNonce('dash'), |
| | | }, |
| | | data: { |
| | | posts: allChanges, |
| | | }, |
| | | popup: `Creating your new ${this.singular}`, |
| | | title: `Creating your new ${this.singular}`, |
| | | }); |
| | | |
| | | if (!contentOpId) return; |
| | | |
| | | // 3. Queue any pending uploads with dependency on content creation |
| | | const uploadFields = modal.querySelectorAll('[data-upload-field]'); |
| | | for (const fieldEl of uploadFields) { |
| | | const fieldId = fieldEl.dataset.uploader; |
| | | if (!fieldId) continue; |
| | | |
| | | const uploads = window.jvbUploads.stores.uploads.filterByIndex({ field: fieldId }); |
| | | if (uploads.length === 0) continue; |
| | | |
| | | await window.jvbUploads.queueUploads('uploads', fieldId, contentOpId); |
| | | } |
| | | } |
| | | handleChange(e) { |
| | | // Early bailout - target must be in an item or be a filter |
| | |
| | | if (!item) return; |
| | | item.dataset.itemId.split(',').forEach(itemId => { |
| | | let field = this.forms.getField(e.target); |
| | | if (['repeater', 'tag-list'].includes(field.dataset.fieldType)) { |
| | | return; |
| | | } |
| | | let name = field.dataset.field; |
| | | let value = this.forms.getFieldValue(e.target); |
| | | this.updateItem(itemId, name, value); |
| | | }); |
| | | this.savePosts('', true).then(()=>{}); |
| | | } |
| | | updateItem(itemId, name, value) { |
| | | if (!this.changes.has(itemId)) { |
| | |
| | | } |
| | | this.changes.get(itemId)[name] = value; |
| | | |
| | | for (const key of this.uploadedFields) { |
| | | const [itemId, fieldName] = key.split('_'); |
| | | if (this.changes.has(itemId)) { |
| | | delete this.changes.get(itemId)[fieldName]; |
| | | } |
| | | } |
| | | |
| | | // Don't schedule if only base keys remain |
| | | const change = this.changes.get(itemId); |
| | | const realKeys = Object.keys(change).filter(k => k !== 'id' && k !== 'content'); |
| | | if (realKeys.length === 0) { |
| | | this.changes.delete(itemId); |
| | | return; |
| | | } |
| | | |
| | | this.scheduleBackup(); |
| | | //Only send actual itemIds to server. If this is a recently uploaded item, just store changes for now |
| | | if (typeof itemId === 'number' || !itemId.includes('group')) { |
| | | this.scheduleSave(); |
| | | } |
| | | } |
| | | scheduleBackup() { |
| | | window.debouncer.schedule( |
| | |
| | | 2000 |
| | | ); |
| | | } |
| | | |
| | | cancelBackup() { |
| | | window.debouncer.cancel(`changes-${this.content}`); |
| | | } |
| | | async handleBackup() { |
| | | await this.changesStore.saveMany(this.changes); |
| | | const changesArray = Array.from(this.changes.values()); |
| | | this.changes.clear(); |
| | | |
| | | const ids = changesArray.map(c => c.id); |
| | | const existing = await Promise.all( |
| | | ids.map(id => this.changesStore.get(id)) |
| | | ); |
| | | |
| | | const changes = changesArray.map((change, i) => |
| | | existing[i] ? window.deepMerge(existing[i], change) : change |
| | | ); |
| | | |
| | | await this.changesStore.saveMany(changes); |
| | | } |
| | | |
| | | scheduleSave(delay = 10000) { |
| | | window.debouncer.schedule( |
| | | `save-${this.content}`, |
| | | async () => { |
| | | // Ensure latest changes are in IndexedDB |
| | | if (this.changes.size > 0) { |
| | | this.cancelBackup(); |
| | | await this.handleBackup(); |
| | | } |
| | | |
| | | await this.savePosts('', false); |
| | | }, |
| | | delay |
| | | ); |
| | | } |
| | | handleFilterChange(target) { |
| | | let filter = target.dataset.filter; |
| | |
| | | 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.forms.registerForm(this.ui.modals.edit.form, {cache: false}); |
| | | this.forms.registerForm(this.ui.modals.edit.form, {cache: false, |
| | | autoUpload: true,}); |
| | | |
| | | this.isPopulating = true; |
| | | this.populate.populate(this.ui.modals.edit.form, item); |
| | |
| | | await this.handleBackup(); |
| | | } |
| | | const changes = await this.changesStore.getAll(); |
| | | |
| | | if (changes.length === 0) return; |
| | | |
| | | if (title === '') { |
| | |
| | | |
| | | changes.forEach(change => { |
| | | let itemId = change.id; |
| | | const { id, content, ...fields } = change; |
| | | |
| | | // Create a new object without the id field (don't mutate original!) |
| | | const { id, ...changeWithoutId } = change; |
| | | allChanges[itemId] = changeWithoutId; |
| | | // Filter out uploaded fields |
| | | for (const key of this.uploadedFields) { |
| | | const [uid, fieldName] = key.split('_'); |
| | | if (uid === itemId) delete fields[fieldName]; |
| | | } |
| | | |
| | | if (Object.keys(fields).length > 0) { |
| | | allChanges[itemId] = { content, ...fields }; |
| | | } |
| | | |
| | | if (change.post_status && this.shouldRemoveItemUI(change.post_status)) { |
| | | remove.push(itemId); |
| | |
| | | let operation = { |
| | | endpoint: this.endpoint, |
| | | headers: { |
| | | 'action_nonce': window.auth.getNonce('dash'), |
| | | 'X-Action-Nonce': window.auth.getNonce('dash'), |
| | | }, |
| | | data: { |
| | | posts: allChanges, |
| | |
| | | }); |
| | | } |
| | | /*************************************************************** |
| | | UPLOAD GROUP SUPPORT |
| | | Handles: |
| | | - immediate UI feedback once the uploaded groups are sent to server |
| | | ***************************************************************/ |
| | | handleGroupsUploaded(data) { |
| | | const { posts, fieldId } = data; |
| | | let uploader = window.jvbUploads; |
| | | let field = uploader.fields.get(fieldId); |
| | | |
| | | let added = []; |
| | | posts.forEach(post => { |
| | | const placeholderPost = { |
| | | id: post.groupId, |
| | | title: post.fields.post_title || `New ${this.singular}`, |
| | | status: 'draft', |
| | | date: new Date().toISOString(), |
| | | modified: new Date().toISOString(), |
| | | thumbnail: null, |
| | | icon: this.content, |
| | | taxonomies: {}, |
| | | fields: post.fields, |
| | | images: {}, |
| | | }; |
| | | |
| | | post.images.forEach((uploadId, index) => { |
| | | let id = uploadId['upload_id']; |
| | | if (index === 0) { |
| | | placeholderPost.fields['post_thumbnail'] = uploadId; |
| | | } |
| | | let upload = uploader.stores.uploads.get(id); |
| | | if (upload) { |
| | | placeholderPost.images[id] = { |
| | | 'image-alt-text': '', |
| | | 'image-caption': '', |
| | | 'image-title': upload.fields.originalName, |
| | | medium: uploader.createPreviewUrl(uploader.formatFile(upload)) |
| | | }; |
| | | } |
| | | |
| | | }); |
| | | // |
| | | // // Add to store (won't persist since it's a fake ID) |
| | | // this.store.data.set(post.groupId, placeholderPost); |
| | | // |
| | | // |
| | | // // Render immediately |
| | | // let element; |
| | | // switch (this.view) { |
| | | // case 'grid': |
| | | // element = this.renderGridItem(placeholderPost); |
| | | // this.ui.grid.prepend(element); |
| | | // break; |
| | | // case 'list': |
| | | // element = this.renderListItem(placeholderPost); |
| | | // this.ui.grid.prepend(element); |
| | | // break; |
| | | // case 'table': |
| | | // element = this.renderTableItem(placeholderPost); |
| | | // if (this.ui.table.body) { |
| | | // this.ui.table.body.prepend(element); |
| | | // } |
| | | // break; |
| | | // } |
| | | // element.classList.add('uploading'); |
| | | added.push(placeholderPost); |
| | | }); |
| | | this.store.saveMany(added).then(() => this.render()); |
| | | |
| | | |
| | | this.a11y.announce(`${posts.length} ${posts.length === 1 ? this.singular : this.plural} created. Waiting for server confirmation...`); |
| | | } |
| | | |
| | | handleGroupMappings(mappings) { |
| | | // mappings = { "group_abc123": 456, "group_def456": 789 } |
| | | |
| | | for (const [groupId, postId] of Object.entries(mappings)) { |
| | | // Get any pending changes for this temp item |
| | | let changes = {}; |
| | | if (this.changes.has(groupId)) { |
| | | changes = this.changes.get(groupId); |
| | | this.changes.delete(groupId); |
| | | } |
| | | let storedChanges = this.changesStore.get(groupId)??{}; |
| | | if (changes.size > 0 || storedChanges.size > 0) { |
| | | changes = window.deepMerge(storedChanges, changes); |
| | | this.changes.set(postId, changes); |
| | | this.scheduleBackup(); |
| | | } |
| | | } |
| | | } |
| | | /*************************************************************** |
| | | UTILITY |
| | | ***************************************************************/ |
| | | shouldRemoveItemUI(newStatus) { |