| | |
| | | /** |
| | | * TaxonomyCreator - Handles term creation for TaxonomySelector |
| | | * Simplified to focus only on creation logic |
| | | */ |
| | | class TaxonomyCreator { |
| | | |
| | | constructor(selector) { |
| | | this.selector = selector; |
| | | this.queue = window.jvbQueue; |
| | | |
| | | // Only initialize modal elements if modal exists |
| | | if (selector.modal) { |
| | | this.createNew = selector.modal.querySelector('.create-new-term'); |
| | | this.toggle = selector.modal.querySelector('.new-term-toggle'); |
| | | this.form = this.createNew?.querySelector('.create-new-term-section'); |
| | | } |
| | | |
| | | this.initElements(); |
| | | this.initListeners(); |
| | | } |
| | | |
| | | // Only init term creation UI if we have modal elements |
| | | if (this.form) { |
| | | this.initTermCreation(); |
| | | initElements() { |
| | | this.selectors = { |
| | | details: 'details.create-term', |
| | | parent: '#select_parent', |
| | | summary: '.create-term summary', |
| | | suggestion: '.term-suggestions', |
| | | name: '#term_name', |
| | | button: '.submit-term', |
| | | form: 'form.create-term', |
| | | label: { |
| | | name: '[for="term_name"]', |
| | | parent: '[for="select_parent"]' |
| | | }, |
| | | loading: '.loading-message.create-term' |
| | | }; |
| | | this.ui = window.uiFromSelectors(this.selectors, this.selector.container); |
| | | } |
| | | |
| | | handleOpen(field) { |
| | | this.field = field; |
| | | if (this.ui.details) { |
| | | this.ui.details.hidden = !field.canCreate; |
| | | |
| | | if (this.ui.summary) { |
| | | this.ui.summary.textContent = `Add new ${field.singular}`; |
| | | } |
| | | if (this.ui.label.name) { |
| | | this.ui.label.name.textContent = `Name this ${field.singular}`; |
| | | } |
| | | if (this.ui.label.parent) { |
| | | this.ui.label.parent.textContent = `Nest it under`; |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | initListeners() { |
| | | this.clickHandler = this.handleClick.bind(this); |
| | | document.addEventListener('click', this.clickHandler); |
| | | |
| | | if (this.ui.form) { |
| | | this.ui.form.addEventListener('change', (e) => { |
| | | console.log('Creator form change, prevents default'); |
| | | e.preventDefault(); |
| | | e.stopPropagation(); |
| | | }); |
| | | } |
| | | } |
| | | |
| | | /** |
| | |
| | | */ |
| | | handleClick(e) { |
| | | // Handle opening create term form |
| | | if (window.targetCheck(e, '.create-new-term summary')) { |
| | | if (this.createNew.open) { |
| | | this.createNew.querySelector('input[name="term_name"]').focus(); |
| | | if (window.targetCheck(e, this.selectors.summary)) { |
| | | if (this.ui.details.open) { |
| | | this.ui.name?.focus(); |
| | | } |
| | | this.resetParentOptions(); |
| | | } |
| | | |
| | | // Handle term creation submission |
| | | if (window.targetCheck(e, '.submit-term')) { |
| | | this.handleTermCreation(e).then(() => {}); |
| | | } |
| | | |
| | | // Handle autocomplete create button |
| | | if (window.targetCheck(e, '.create-term')) { |
| | | this.handleAutocompleteCreate(e).then(() => {}); |
| | | return; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle term creation from modal form |
| | | */ |
| | | async handleTermCreation(e) { |
| | | const taxonomy = this.selector.currentConfig?.taxonomy; |
| | | if (!taxonomy) return; |
| | | |
| | | const termName = this.form.querySelector('input[name="term_name"]').value.trim(); |
| | | const parentId = parseInt(this.form.querySelector('input#select_parent')?.value) || 0; |
| | | |
| | | if (!termName) return; |
| | | |
| | | const submitButton = this.form.querySelector('button'); |
| | | |
| | | async handleTermCreation(data) { |
| | | if (!data.name || data.name.length < 2) return false; |
| | | try { |
| | | if (submitButton) { |
| | | submitButton.disabled = true; |
| | | const response = await this.createTerm(data); |
| | | |
| | | if (!response.success) { |
| | | // Term already exists - still add it |
| | | if (response.term && response.term.id) { |
| | | this.selector.setMessage(true, `Using existing "${response.term.name}"`); |
| | | return response.term; |
| | | } |
| | | |
| | | // Other failure |
| | | this.selector.setMessage(true, response.message || 'Creation failed', false); |
| | | return false; |
| | | } |
| | | |
| | | const response = await this.createTerm(termName, parentId, taxonomy); |
| | | |
| | | if (response.term?.pending) { |
| | | // Term requires approval |
| | | this.selector.setMessage( |
| | | true, |
| | | `"${data.name}" submitted for approval`, |
| | | false |
| | | ); |
| | | // Don't add to selection since it's pending |
| | | return false; |
| | | } |
| | | if (response.success && response.term) { |
| | | await this.handleSuccessfulCreation(response.term, taxonomy, parentId); |
| | | await this.handleSuccessfulCreation(response.term, data); |
| | | this.clearForm(); |
| | | } |
| | | return response.term; |
| | | } catch (error) { |
| | | console.error('Error creating term:', error); |
| | | this.selector.handleError(error, 'handleTermCreation'); |
| | | } finally { |
| | | if (submitButton) { |
| | | submitButton.disabled = false; |
| | | } |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle successful term creation |
| | | */ |
| | | async handleSuccessfulCreation(term, taxonomy, parentId) { |
| | | const termPath = term.path || term.name; |
| | | |
| | | async handleSuccessfulCreation(term, data) { |
| | | // Close create form |
| | | this.createNew.open = false; |
| | | this.ui.details.open = false; |
| | | |
| | | // Clear cache to ensure fresh data |
| | | await this.selector.store.clearCache(); |
| | | |
| | | // Add to DataStore |
| | | this.selector.store.data.set(term.id, { |
| | | id: term.id, |
| | | name: term.name, |
| | | path: termPath, |
| | | taxonomy: taxonomy, |
| | | parent: parentId, |
| | | count: 0, |
| | | hasChildren: false, |
| | | slug: term.slug || termName.toLowerCase().replace(/\s+/g, '-') |
| | | }); |
| | | |
| | | // Add to modal selection |
| | | this.selector.addSelectedTermToModal(term.id, term.name, termPath); |
| | | |
| | | // Refresh current view if we're viewing the same parent |
| | | const currentParent = this.selector.store.filters.parent || 0; |
| | | if (currentParent === parentId) { |
| | | await this.selector.store.setFilters({ |
| | | taxonomy, |
| | | parent: parentId, |
| | | page: 1, |
| | | search: '' |
| | | }); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle autocomplete create button |
| | | */ |
| | | async handleAutocompleteCreate(e) { |
| | | const button = e.target.closest('.create-term'); |
| | | const fieldId = this.selector.getFieldId(button); |
| | | const field = this.selector.fields.get(fieldId); |
| | | |
| | | if (!field) return; |
| | | |
| | | const input = field.container.querySelector('input[data-autocomplete]'); |
| | | const termName = input?.value.trim() || button.dataset.query; |
| | | |
| | | if (!termName) return; |
| | | |
| | | const originalHTML = button.innerHTML; |
| | | |
| | | try { |
| | | button.disabled = true; |
| | | button.textContent = 'Creating...'; |
| | | |
| | | const response = await this.createTerm(termName, 0, field.taxonomy); |
| | | |
| | | if (response.success && response.term) { |
| | | await this.handleAutocompleteSuccess(response.term, field, input); |
| | | } else if (response.reason === 'exists' && response.term) { |
| | | this.handleExistingTerm(response.term, field, input); |
| | | } |
| | | } catch (error) { |
| | | console.error('Error creating term:', error); |
| | | this.selector.handleError(error, 'handleAutocompleteCreate'); |
| | | } finally { |
| | | button.innerHTML = originalHTML; |
| | | button.disabled = false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle successful autocomplete creation |
| | | */ |
| | | async handleAutocompleteSuccess(term, field, input) { |
| | | const termPath = term.path || term.name; |
| | | |
| | | // Add to field |
| | | field.selectedTerms.add(parseInt(term.id)); |
| | | |
| | | // Add to DataStore |
| | | this.selector.store.data.set(term.id, { |
| | | id: term.id, |
| | | name: term.name, |
| | | path: termPath, |
| | | taxonomy: field.taxonomy, |
| | | parent: 0, |
| | | count: 0, |
| | | hasChildren: false, |
| | | slug: term.slug || term.name.toLowerCase().replace(/\s+/g, '-') |
| | | }); |
| | | |
| | | // Update display |
| | | this.selector.addTermDisplay(term.id, term.name, termPath, 'field', field.id); |
| | | |
| | | // Update input and trigger change |
| | | field.input.value = Array.from(field.selectedTerms).join(','); |
| | | field.input.dispatchEvent(new Event('change', { bubbles: true })); |
| | | |
| | | // Clear and hide dropdown |
| | | field.autocompleteDropdown.hidden = true; |
| | | if (input) input.value = ''; |
| | | |
| | | // Clear cache for this taxonomy |
| | | await this.selector.store.clearCache(); |
| | | } |
| | | |
| | | /** |
| | | * Handle selecting existing term from autocomplete |
| | | */ |
| | | handleExistingTerm(term, field, input) { |
| | | field.selectedTerms.add(parseInt(term.id)); |
| | | this.selector.addTermDisplay(term.id, term.name, term.path || term.name, 'field', field.id); |
| | | |
| | | field.input.value = Array.from(field.selectedTerms).join(','); |
| | | field.input.dispatchEvent(new Event('change', { bubbles: true })); |
| | | |
| | | field.autocompleteDropdown.hidden = true; |
| | | if (input) input.value = ''; |
| | | } |
| | | |
| | | /** |
| | | * Initialize term creation form |
| | | */ |
| | | initTermCreation() { |
| | | if (!this.form) return; |
| | | |
| | | this.form.addEventListener('change', (e) => { |
| | | e.preventDefault(); |
| | | e.stopPropagation(); |
| | | }); |
| | | this.selector.store.clearCache(); |
| | | await this.selector.store.fetch(); |
| | | } |
| | | |
| | | /** |
| | | * Reset parent options in create form |
| | | */ |
| | | resetParentOptions() { |
| | | const taxonomy = this.selector.currentConfig?.taxonomy; |
| | | const field = this.selector.currentField(); |
| | | if (!field) return; |
| | | const taxonomy = field.taxonomy; |
| | | if (!taxonomy) return; |
| | | |
| | | let select = this.createNew.querySelector('#select_parent'); |
| | | if (!select) return; |
| | | if (!this.ui.parent) return; |
| | | |
| | | let defaultOption = select.querySelector('option'); |
| | | let defaultOption = this.ui.parent.querySelector('option'); |
| | | if (!defaultOption) return; |
| | | |
| | | // Clear existing options |
| | | window.removeChildren(select); |
| | | select.append(defaultOption.cloneNode(true)); |
| | | window.removeChildren(this.ui.parent); |
| | | this.ui.parent.append(defaultOption.cloneNode(true)); |
| | | |
| | | // Get current parent from store filters |
| | | const currentParent = this.selector.store.filters.parent || 0; |
| | | |
| | | // If we're in a sub-category, add the current parent as an option |
| | | if (currentParent !== 0) { |
| | | const parentTerm = this.selector.store.data.get(currentParent); |
| | | const parentTerm = this.selector.store.get(currentParent); |
| | | if (parentTerm) { |
| | | let parentOption = defaultOption.cloneNode(true); |
| | | parentOption.value = parentTerm.id; |
| | | parentOption.textContent = parentTerm.name; |
| | | select.append(parentOption); |
| | | this.ui.parent.append(parentOption); |
| | | } |
| | | } |
| | | |
| | | // Add all terms currently visible in the taxonomy |
| | | const visibleTerms = []; |
| | | this.selector.store.data.forEach(term => { |
| | | this.selector.store.getFiltered().forEach(term => { |
| | | if (term.taxonomy === taxonomy && term.parent === currentParent) { |
| | | visibleTerms.push(term); |
| | | } |
| | |
| | | option.id = `select-parent-${term.id}`; |
| | | option.value = term.id; |
| | | option.textContent = ' — ' + term.name; |
| | | select.append(option); |
| | | this.ui.parent.append(option); |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Create a new term |
| | | */ |
| | | async createTerm(name, parent = 0, taxonomy) { |
| | | async createTerm(data) { |
| | | if (!data.name || data.parent === undefined || !data.taxonomy) return; |
| | | try { |
| | | // Search to check for duplicates |
| | | await this.selector.store.setFilters({ |
| | | taxonomy: taxonomy, |
| | | search: name, |
| | | page: 1, |
| | | parent: 0 |
| | | }); |
| | | |
| | | // Wait for data to load |
| | | await new Promise(resolve => setTimeout(resolve, 100)); |
| | | |
| | | // Check if exact match exists |
| | | const exactMatch = Array.from(this.selector.store.data.values()) |
| | | .find(term => |
| | | term.taxonomy === taxonomy && |
| | | term.name.toLowerCase() === name.toLowerCase() |
| | | ); |
| | | |
| | | if (exactMatch) { |
| | | // For modal context, show suggestions |
| | | if (this.createNew) { |
| | | this.showTermSuggestions([exactMatch], true); |
| | | } |
| | | return { success: false, reason: 'exists', term: exactMatch }; |
| | | } |
| | | |
| | | // Term doesn't exist, create it |
| | | const response = await fetch(`${jvbSettings.api}terms`, { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | 'X-WP-Nonce': window.auth.getNonce() |
| | | }, |
| | | body: JSON.stringify({ |
| | | taxonomy: taxonomy, |
| | | name: name, |
| | | parent: parent |
| | | }) |
| | | body: JSON.stringify(data) |
| | | }); |
| | | |
| | | if (!response.ok) { |
| | |
| | | } |
| | | |
| | | /** |
| | | * Show term suggestions when similar terms exist |
| | | */ |
| | | showTermSuggestions(suggestions, isExact = false) { |
| | | const suggestionContainer = this.createNew.querySelector('.term-suggestions') || |
| | | this.createSuggestionContainer(); |
| | | |
| | | // Clear existing suggestions |
| | | window.removeChildren(suggestionContainer); |
| | | |
| | | // Add heading |
| | | const heading = document.createElement('h4'); |
| | | heading.textContent = isExact ? |
| | | 'This term already exists:' : |
| | | 'Similar terms already exist:'; |
| | | suggestionContainer.appendChild(heading); |
| | | |
| | | // Create list of suggestions |
| | | const list = document.createElement('ul'); |
| | | list.className = 'term-suggestion-list'; |
| | | |
| | | suggestions.forEach(term => { |
| | | const item = document.createElement('li'); |
| | | const button = this.createSuggestionButton(term); |
| | | item.appendChild(button); |
| | | list.appendChild(item); |
| | | }); |
| | | |
| | | suggestionContainer.appendChild(list); |
| | | suggestionContainer.hidden = false; |
| | | } |
| | | |
| | | /** |
| | | * Create suggestion button |
| | | */ |
| | | createSuggestionButton(term) { |
| | | const button = document.createElement('button'); |
| | | button.type = 'button'; |
| | | button.className = 'use-existing-term'; |
| | | button.dataset.id = term.id; |
| | | button.textContent = term.path || term.name; |
| | | |
| | | button.addEventListener('click', () => { |
| | | // Add this term to modal selection |
| | | this.selector.addSelectedTermToModal(term.id, term.name, term.path || term.name); |
| | | |
| | | // Close the create new section |
| | | this.createNew.open = false; |
| | | |
| | | // Clear suggestions and form |
| | | const suggestionContainer = this.createNew.querySelector('.term-suggestions'); |
| | | if (suggestionContainer) { |
| | | suggestionContainer.hidden = true; |
| | | } |
| | | |
| | | this.clearForm(); |
| | | }); |
| | | |
| | | return button; |
| | | } |
| | | |
| | | /** |
| | | * Create container for term suggestions |
| | | */ |
| | | createSuggestionContainer() { |
| | | const container = document.createElement('div'); |
| | | container.className = 'term-suggestions'; |
| | | container.hidden = true; |
| | | |
| | | // Insert after the form |
| | | this.createNew.querySelector('form').after(container); |
| | | return container; |
| | | } |
| | | |
| | | /** |
| | | * Clear the creation form |
| | | */ |
| | | clearForm() { |
| | | const nameInput = this.form.querySelector('input[name="term_name"]'); |
| | | if (nameInput) { |
| | | nameInput.value = ''; |
| | | if (this.ui.name) { |
| | | this.ui.name.value = ''; |
| | | } |
| | | |
| | | const suggestionContainer = this.createNew.querySelector('.term-suggestions'); |
| | | if (suggestionContainer) { |
| | | suggestionContainer.hidden = true; |
| | | if (this.selector.ui.search.input){ |
| | | this.selector.ui.search.input.value = ''; |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | // Clear any pending operations |
| | | const loadingMessage = this.createNew?.querySelector('.loading-message.create-term'); |
| | | if (loadingMessage) { |
| | | loadingMessage.hidden = true; |
| | | } |
| | | |
| | | // Clear suggestions |
| | | const suggestionContainer = this.createNew?.querySelector('.term-suggestions'); |
| | | if (suggestionContainer) { |
| | | suggestionContainer.hidden = true; |
| | | if (this.ui.loading) { |
| | | this.ui.loading.hidden = true; |
| | | } |
| | | } |
| | | } |