diff --git a/ckanext/scheming/assets/js/scheming-suggestions.js b/ckanext/scheming/assets/js/scheming-suggestions.js new file mode 100644 index 000000000..351df3bff --- /dev/null +++ b/ckanext/scheming/assets/js/scheming-suggestions.js @@ -0,0 +1,606 @@ +if (typeof window._schemingSuggestionsGlobalState === 'undefined') { + window._schemingSuggestionsGlobalState = { + datasetId: null, + globalInitDone: false, + pollAttempts: 0, + isPolling: false, + isInitialLoadWithExistingSuggestions: false + }; +} + +ckan.module('scheming-suggestions', function($) { + var esc = function(str) { return typeof str === 'string' ? $('
').text(str).html() : str; }; + var globalState = window._schemingSuggestionsGlobalState; + + return { + options: { + pollingInterval: 2500, + maxPollAttempts: 40, + initialButtonTitle: 'Suggestion available (loading...)', + noSuggestionTitle: 'No suggestion currently available', + suggestionReadyTitle: 'Suggestion Available', + suggestionErrorTitle: 'Error in suggestion', + processingMessage: ' Processing dataset, suggestions will appear shortly...', + statusProcessingTextPrefix: ' Status: ', + statusDoneText: ' Suggestions processed. Fields updated.', + statusErrorText: ' Error processing suggestions. Status: ', + timeoutMessage: ' Suggestions are taking longer than usual to process.', + errorMessage: ' Could not retrieve suggestions at this time.', + errorPrefix: '#ERROR!:', // User confirmed prefix + terminalStatuses: ['DONE', 'ERROR', 'FAILED'] + }, + _popoverDivs: {}, + _originalButtonTitles: {}, + + initialize: function() { + var self = this; + var el = this.el; + var $form = $(el).closest('form'); + var foundDatasetId = null; + + // Dataset ID acquisition (run only if global ID not set) + if (!globalState.datasetId) { + if ($form.length && $form.data('dataset-id')) foundDatasetId = $form.data('dataset-id'); + else if ($form.length && $form.find('input[name="id"]').val()) foundDatasetId = $form.find('input[name="id"]').val(); + else if ($form.length && $form.find('input[name="pkg_name"]').val()) foundDatasetId = $form.find('input[name="pkg_name"]').val(); + else if ($('body').data('dataset-id')) foundDatasetId = $('body').data('dataset-id'); + else { + var pathArray = window.location.pathname.split('/'); + var datasetIndex = pathArray.indexOf('dataset'); + var editIndex = pathArray.indexOf('edit'); + if (datasetIndex !== -1 && editIndex !== -1 && editIndex === datasetIndex + 1 && pathArray.length > editIndex + 1) { + var potentialId = pathArray[editIndex + 1]; + if (potentialId && potentialId.length > 5) foundDatasetId = potentialId; + } + } + if (foundDatasetId) { + globalState.datasetId = foundDatasetId; + } + } + + var fieldName = $(el).data('field-name'); + if (!fieldName) return; + + this._originalButtonTitles[fieldName] = $(el).attr('title'); + $(el).attr('title', this.options.initialButtonTitle); + var popoverId = 'custom-suggestion-popover-' + fieldName + '-' + Date.now(); + this._popoverDivs[fieldName] = $('').appendTo('body'); + $(el).hide(); + this._showFieldLoadingIndicator(el); + + if (!globalState.globalInitDone) { + globalState.globalInitDone = true; + if (!globalState.isPolling) this._pollForSuggestions(); // Start polling if not already + } + this._attachBaseEventHandlers(el, this._popoverDivs[fieldName], fieldName); + }, + + _showAllButtonsAsNoSuggestion: function() { + var self = this; + $('button[data-module="scheming-suggestions"]').each(function() { + var currentButton = $(this); + currentButton.attr('title', self.options.noSuggestionTitle).show(); + self._hideFieldLoadingIndicator(currentButton); + }); + }, + _showFieldLoadingIndicator: function(buttonEl) { + var $controlGroup = $(buttonEl).closest('.control-group.has-suggestion'); + if ($controlGroup.length === 0) $controlGroup = $(buttonEl).closest('.form-group.has-suggestion'); + var $label = $controlGroup.find('.control-label, .form-label').first(); + if ($label.length && $label.find('.suggestion-field-loader').length === 0) { + $label.append(' '); + } + }, + _hideFieldLoadingIndicator: function(buttonEl) { + var $controlGroup = $(buttonEl).closest('.control-group.has-suggestion'); + if ($controlGroup.length === 0) $controlGroup = $(buttonEl).closest('.form-group.has-suggestion'); + $controlGroup.find('.suggestion-field-loader').remove(); + }, + _showProcessingBanner: function() { + var self = this; + if ($('#scheming-processing-banner').length === 0) { + var bannerHtml = '
' + + self.options.processingMessage + + '
'; + var $formContainer = $('.primary.span9').first(); + if ($formContainer.length === 0) $formContainer = $('form.dataset-form, form#dataset-edit').first(); + if ($formContainer.length === 0) $formContainer = $('main .container, #content .container').first(); + if ($formContainer.length) $formContainer.prepend(bannerHtml); + else $('body').prepend(bannerHtml); + } + }, + _updateProcessingBanner: function(message, alertClass) { + var $banner = $('#scheming-processing-banner'); + if ($banner.length === 0 && message) { // If banner doesn't exist, create it + this._showProcessingBanner(); + $banner = $('#scheming-processing-banner'); + } + if ($banner.length) { + var timestamp = new Date().toLocaleTimeString(); + $banner.html(message + ' (as of ' + timestamp + ')') + .removeClass('scheming-alert-info scheming-alert-warning scheming-alert-danger scheming-alert-success') + .addClass(alertClass || 'scheming-alert-info'); + } + }, + _removeProcessingBanner: function() { + $('#scheming-processing-banner').fadeOut(function() { $(this).remove(); }); + }, + + _processDppButtonSuggestions: function(dppPackageSuggestions) { + var self = this; + if (!dppPackageSuggestions) { + return; + } + $('button[data-module="scheming-suggestions"]').each(function() { + var $buttonEl = $(this); + var fieldName = $buttonEl.data('field-name'); + var fieldSchemaJson = $buttonEl.data('field-schema'); + if (!fieldSchemaJson) { + self._hideFieldLoadingIndicator($buttonEl); + $buttonEl.attr('title', self.options.noSuggestionTitle).show(); + return; + } + var fieldSchema = typeof fieldSchemaJson === 'string' ? JSON.parse(fieldSchemaJson) : fieldSchemaJson; + + if (dppPackageSuggestions.hasOwnProperty(fieldName)) { + var suggestionValue = dppPackageSuggestions[fieldName]; + var isErrorSuggestion = typeof suggestionValue === 'string' && suggestionValue.startsWith(self.options.errorPrefix); + var suggestionLabel = fieldSchema.suggestion_label || fieldSchema.label || 'Suggestion'; + var suggestionFormula = fieldSchema.suggestion_formula || 'N/A'; + var isSelect = fieldSchema.is_select; + var isValidSuggestion = dppPackageSuggestions[fieldName + '_is_valid']; + if (isValidSuggestion === undefined) { + isValidSuggestion = true; + if (isSelect && fieldSchema.choices && fieldSchema.choices.length > 0) { + isValidSuggestion = fieldSchema.choices.some(function(choice){ return String(choice.value) === String(suggestionValue); }); + } + } + + if (isErrorSuggestion) { + $buttonEl.addClass('suggestion-btn-error'); $buttonEl.attr('title', self.options.suggestionErrorTitle); suggestionLabel = 'Suggestion Error'; + } else { + $buttonEl.removeClass('suggestion-btn-error'); $buttonEl.attr('title', self.options.suggestionReadyTitle); + } + + if (!self._popoverDivs[fieldName]) { + var popoverId = 'custom-suggestion-popover-' + fieldName + '-' + Date.now(); + self._popoverDivs[fieldName] = $('').appendTo('body'); + self._attachBaseEventHandlers($buttonEl, self._popoverDivs[fieldName], fieldName); + } + + self._populatePopoverContent($buttonEl, self._popoverDivs[fieldName], { + value: suggestionValue, label: suggestionLabel, formula: suggestionFormula, + is_select: isSelect, is_valid: isErrorSuggestion ? false : isValidSuggestion, + field_name: fieldName, is_error: isErrorSuggestion + }); + $buttonEl.show(); + + } else { + $buttonEl.attr('title', self.options.noSuggestionTitle).show(); + } + self._hideFieldLoadingIndicator($buttonEl); + }); + }, + + _setFieldValue: function($target, value, fieldNameForLog) { + var self = this; + var fieldId = fieldNameForLog || $target.data('scheming-field-name') || $target.attr('name') || $target.attr('id'); + var success = false; + + if ($target.is('textarea, input[type="text"], input[type="number"], input[type="email"], input[type="url"], input[type="hidden"], input:not([type="button"], [type="submit"], [type="reset"], [type="image"], [type="file"], [type="radio"], [type="checkbox"])')) { + var currentValue = $target.val(); + var newValueString = (value === null || value === undefined) ? "" : String(value); + if (currentValue !== newValueString) { + $target.val(newValueString).trigger('change'); + } + success = true; + } else if ($target.is('select')) { + var originalValueStr = (value === null || value === undefined) ? "" : String(value); + var $optionToSelect = null; + var $optionByValueExact = $target.find("option[value='" + originalValueStr.replace(/'/g, "'") + "']"); + if ($optionByValueExact.length > 0) $optionToSelect = $optionByValueExact.first(); + else { + var foundLoose = false; + $target.find("option").each(function() { + if ($(this).val() == value) { $optionToSelect = $(this); foundLoose = true; return false; } + }); + if (!foundLoose) { + var $optionByText = $target.find("option").filter(function() { return $(this).text() === originalValueStr; }); + if ($optionByText.length > 0) $optionToSelect = $optionByText.first(); + } + } + if ($optionToSelect) { + if ($target.val() !== $optionToSelect.val()) $target.val($optionToSelect.val()).trigger('change'); + if (typeof $target.chosen === 'function') $target.trigger("chosen:updated"); + if (typeof $target.select2 === 'function') $target.trigger('change.select2'); + success = true; + } else { + console.warn("SchemingSuggestions: Value '" + esc(value) + "' not valid for select:", fieldId); + } + } + return success; + }, + + _updateLiveDatasetAndFormulaFields: function(datasetObject, dppSuggestionsObject) { + var self = this; + var updatedFieldsLog = []; + + if (!datasetObject || typeof datasetObject !== 'object') { + console.warn("SchemingSuggestions: _updateLiveDatasetAndFormulaFields called with invalid datasetObject."); + return; + } + + var dppFieldName = 'dpp_suggestions'; + var $dppTextarea = $('#field-' + dppFieldName); + if ($dppTextarea.length === 0) $dppTextarea = $('textarea[data-scheming-field-name="' + dppFieldName + '"], textarea[name="' + dppFieldName + '"]'); + if ($dppTextarea.length && dppSuggestionsObject && typeof dppSuggestionsObject === 'object') { + try { + var prettyJson = JSON.stringify(dppSuggestionsObject, null, 2); + if (self._setFieldValue($dppTextarea, prettyJson, dppFieldName)) { + $dppTextarea.addClass('formula-auto-applied'); + setTimeout(function() { $dppTextarea.removeClass('formula-auto-applied'); }, 1200); + updatedFieldsLog.push(dppFieldName + " (JSON content)"); + } + } catch (e) { console.error("SchemingSuggestions: Error handling '"+dppFieldName+"' field:", e); } + } + + $('form.dataset-form [data-scheming-field-name][data-is-formula-field="true"]').each(function() { + var $target = $(this); + var fieldName = $target.data('scheming-field-name'); + + if ($target.closest('.scheming-resource-fields, .resource-item, .repeating-template').length > 0) return; // Skip resource fields here + + if (datasetObject.hasOwnProperty(fieldName)) { + var newValue = datasetObject[fieldName]; + var isError = typeof newValue === 'string' && newValue.startsWith(self.options.errorPrefix); + $target.removeClass('formula-auto-applied formula-apply-error'); + if (!isError) { + if (self._setFieldValue($target, newValue, fieldName)) { + $target.addClass('formula-auto-applied'); + setTimeout(function() { $target.removeClass('formula-auto-applied'); }, 1200); + updatedFieldsLog.push("Dataset Formula: " + fieldName); + } else $target.addClass('formula-apply-error'); + } else { + self._setFieldValue($target, newValue, fieldName); $target.addClass('formula-apply-error'); + updatedFieldsLog.push("Dataset Formula (error set): " + fieldName); + } + } else { + } + }); + + if (datasetObject.resources && Array.isArray(datasetObject.resources)) { + datasetObject.resources.forEach(function(resource, resourceIndex) { + // Try to find the DOM element for this resource + var $resourceForm = null; + if (resource.id) { + $resourceForm = $('.resource-item input[name$="].id"][value="'+resource.id+'"]').closest('.resource-item'); + if ($resourceForm.length === 0) { // Try another common selector + $resourceForm = $('.scheming-resource-fields input[name$="].id"][value="'+resource.id+'"]').closest('.scheming-resource-fields'); + } + } + if (!$resourceForm || $resourceForm.length === 0) { + $resourceForm = $('.resource-item, .scheming-resource-fields').eq(resourceIndex); + } + + if ($resourceForm && $resourceForm.length) { + $resourceForm.find('[data-scheming-field-name][data-is-formula-field="true"]').each(function() { + var $target = $(this); + var fieldName = $target.data('scheming-field-name'); + if (resource.hasOwnProperty(fieldName)) { + var newValue = resource[fieldName]; + var isError = typeof newValue === 'string' && newValue.startsWith(self.options.errorPrefix); + $target.removeClass('formula-auto-applied formula-apply-error'); + if (!isError) { + if (self._setFieldValue($target, newValue, fieldName)) { + $target.addClass('formula-auto-applied'); + setTimeout(function() { $target.removeClass('formula-auto-applied'); }, 1200); + updatedFieldsLog.push("Resource[" + (resource.id || resourceIndex) + "] Formula: " + fieldName); + } else $target.addClass('formula-apply-error'); + } else { + self._setFieldValue($target, newValue, fieldName); $target.addClass('formula-apply-error'); + updatedFieldsLog.push("Resource[" + (resource.id || resourceIndex) + "] Formula (error set): " + fieldName); + } + } + }); + } else { + // console.warn("SchemingSuggestions: Could not find DOM for resource index " + resourceIndex + " (ID: " + (resource.id || 'N/A') + ")"); + } + }); + } + + if (updatedFieldsLog.length > 0) console.log("SchemingSuggestions: _updateLiveDatasetAndFormulaFields updated:", updatedFieldsLog); + }, + + _pollForSuggestions: function() { + var self = this; + + if (!globalState.datasetId) { + if (globalState.pollAttempts === 0) { + globalState.pollAttempts++; + setTimeout(function() { self._pollForSuggestions(); }, self.options.pollingInterval + 1000); + return; + } + self._updateProcessingBanner(self.options.errorMessage + " (Dataset ID missing)", 'scheming-alert-danger'); + self._showAllButtonsAsNoSuggestion(); globalState.isPolling = false; return; + } + + if (globalState.isPolling && globalState.pollAttempts > 0 && !this._isFirstPollerInstance) { + } + + + if (globalState.pollAttempts >= self.options.maxPollAttempts) { + if (!$('#scheming-processing-banner').hasClass('scheming-alert-success') && !$('#scheming-processing-banner').hasClass('scheming-alert-danger')) { + self._updateProcessingBanner(self.options.timeoutMessage, 'scheming-alert-warning'); + } + self._showAllButtonsAsNoSuggestion(); globalState.isPolling = false; return; + } + + if (globalState.pollAttempts === 0) { // First actual poll attempt for a dataset ID + if (globalState.isPolling) { // Already polling from another instance, this one should not start another. + return; + } + globalState.isPolling = true; + this._isFirstPollerInstance = true; // This instance is managing the poll loop. + } else if (!globalState.isPolling) { // Should not happen if logic is correct, but as a safeguard + globalState.isPolling = true; + } + if (globalState.pollAttempts === 0 && $('#scheming-processing-banner').length === 0) { + var self = this; + // Quick check to see if we should show the banner + $.ajax({ + url: (ckan.SITE_ROOT || '') + '/api/3/action/package_show', + data: { id: globalState.datasetId, include_tracking: false }, + dataType: 'json', + cache: false, + async: false, // Make synchronous just for this initial check + success: function(response) { + if (response.success && response.result && response.result.dpp_suggestions) { + var status = response.result.dpp_suggestions.STATUS; + if (!status || !self.options.terminalStatuses.includes(status.toUpperCase())) { + self._showProcessingBanner(); + } + } else { + self._showProcessingBanner(); + } + }, + error: function() { + self._showProcessingBanner(); + } + }); + } + + $.ajax({ + url: (ckan.SITE_ROOT || '') + '/api/3/action/package_show', + data: { id: globalState.datasetId, include_tracking: false }, + dataType: 'json', + cache: false, + success: function(response) { + globalState.pollAttempts++; + + if (response.success && response.result) { + var datasetObject = response.result; + var dppSuggestionsData = datasetObject.dpp_suggestions; // This is the direct JSON object + + if (dppSuggestionsData && dppSuggestionsData.package) { + self._processDppButtonSuggestions(dppSuggestionsData.package); + } else { + self._processDppButtonSuggestions(null); + } + var currentDppStatus = (dppSuggestionsData && dppSuggestionsData.STATUS) ? dppSuggestionsData.STATUS.toUpperCase() : null; + + if (currentDppStatus === 'DONE') { + console.log("SchemingSuggestions: STATUS is DONE. Applying final updates to formula fields from main dataset object."); + self._updateLiveDatasetAndFormulaFields(datasetObject, dppSuggestionsData); + globalState.isInitialLoadWithExistingSuggestions = (globalState.pollAttempts === 1); + } else if (dppSuggestionsData) { // If dpp_suggestions exists, update its textarea + self._updateLiveDatasetAndFormulaFields(null, dppSuggestionsData); // Only update dpp_suggestions field + } + + + if (currentDppStatus) { + if (self.options.terminalStatuses.includes(currentDppStatus)) { + if (currentDppStatus === 'DONE') { + if (!globalState.isInitialLoadWithExistingSuggestions) { + self._updateProcessingBanner(self.options.statusDoneText, 'scheming-alert-success'); + setTimeout(function() { self._removeProcessingBanner(); }, 5000); + } + } else { // ERROR, FAILED + if ($('#scheming-processing-banner').length > 0) { + self._updateProcessingBanner(self.options.statusErrorText + esc(dppSuggestionsData.STATUS) + '
', 'scheming-alert-danger'); + } + } + globalState.isPolling = false; return; + } else { // Ongoing status + self._updateProcessingBanner(self.options.statusProcessingTextPrefix + esc(dppSuggestionsData.STATUS) + '
', 'scheming-alert-info'); + setTimeout(function() { self._pollForSuggestions(); }, self.options.pollingInterval); + } + } else { // No STATUS in dpp_suggestions + console.warn("SchemingSuggestions: Poll " + globalState.pollAttempts + ": dpp_suggestions object has no STATUS field."); + if (globalState.pollAttempts < self.options.maxPollAttempts) { + setTimeout(function() { self._pollForSuggestions(); }, self.options.pollingInterval); + } else { // Max attempts reached with no status + if (!$('#scheming-processing-banner').hasClass('scheming-alert-success') && !$('#scheming-processing-banner').hasClass('scheming-alert-danger')) { + self._updateProcessingBanner(' Processing status unclear. Max attempts reached.', 'scheming-alert-warning'); + } + globalState.isPolling = false; + } + } + } else { // API success:false or no result + console.error("SchemingSuggestions: Poll " + globalState.pollAttempts + ": API error/unexpected structure.", response); + if (globalState.pollAttempts < self.options.maxPollAttempts) { + setTimeout(function() { self._pollForSuggestions(); }, self.options.pollingInterval * 1.5); + } else { + self._updateProcessingBanner(self.options.errorMessage + " (API response error)", 'scheming-alert-danger'); + self._showAllButtonsAsNoSuggestion(); globalState.isPolling = false; + } + } + }, + error: function(jqXHR, textStatus, errorThrown) { + console.error("SchemingSuggestions: Poll " + (globalState.pollAttempts +1) + ": AJAX Error:", textStatus, errorThrown, jqXHR.status, jqXHR.responseText); + globalState.pollAttempts++; + if (globalState.pollAttempts < self.options.maxPollAttempts) { + var nextPollDelay = self.options.pollingInterval * Math.pow(1.2, Math.min(globalState.pollAttempts, 7)); + setTimeout(function() { self._pollForSuggestions(); }, nextPollDelay); + } else { + if (!$('#scheming-processing-banner').hasClass('scheming-alert-success')) { // Don't overwrite if it somehow succeeded then errored + self._updateProcessingBanner(self.options.errorMessage + " (API connection error)", 'scheming-alert-danger'); + } + self._showAllButtonsAsNoSuggestion(); globalState.isPolling = false; + } + } + }); + }, + _populatePopoverContent: function($buttonEl, $popoverDiv, suggestionData) { + var popoverContentHtml = ` +
+ ${esc(suggestionData.label)} + ${suggestionData.is_error ? ` +
+ + + + The suggestion could not be generated correctly: +
` : ''} + ${suggestionData.is_select && !suggestionData.is_valid && !suggestionData.is_error ? ` +
+ + + + This value is not a valid choice for this field. +
` : ''} +
${esc(suggestionData.value)}
+ ${!suggestionData.is_error ? ` +
+ +
+ ` : ''} + +
`; + $popoverDiv.html(popoverContentHtml); + this._attachActionHandlers($popoverDiv, suggestionData.field_name); + }, + _attachBaseEventHandlers: function(el, $popoverDiv, fieldName) { + var self = this; + $(el).on('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + if ($popoverDiv.is(':empty') && !$popoverDiv.html().trim()) { return; } // Check if truly empty + $('.custom-suggestion-popover').not($popoverDiv).hide(); + var buttonPos = $(el).offset(); + var windowWidth = $(window).width(); + var popoverCalculatedWidth = Math.min(380, windowWidth - 40); + var leftPos = buttonPos.left; + if (buttonPos.left + popoverCalculatedWidth > windowWidth - 20) { + leftPos = Math.max(20, windowWidth - popoverCalculatedWidth - 20); + } + var topPos = buttonPos.top + $(el).outerHeight() + 10; + if (topPos < $(window).scrollTop()){ topPos = $(window).scrollTop() + 10; } + $popoverDiv.css({ position: 'absolute', top: topPos, left: leftPos, width: popoverCalculatedWidth + 'px', zIndex: 1050 }).toggle(); + }); + }, + _attachActionHandlers: function($popoverDiv, fieldName) { + var self = this; + $(document).off('click.schemingSuggestionsGlobal.' + fieldName).on('click.schemingSuggestionsGlobal.' + fieldName, function(e) { + if (!$(e.target).closest($popoverDiv).length && !$(e.target).closest('button[data-field-name="'+fieldName+'"]').length) { + $popoverDiv.hide(); + } + }); + $popoverDiv.off('click.formulaToggle').on('click.formulaToggle', '.formula-toggle-btn', function(e) { + e.preventDefault(); e.stopPropagation(); + var $formulaSection = $(this).closest('.suggestion-popover-content').find('.suggestion-formula'); + var $toggleIcon = $(this).find('.formula-toggle-icon'); + var $toggleText = $(this).find('.formula-toggle-text'); + $formulaSection.slideToggle(200, function() { + if ($formulaSection.is(':visible')) { + $toggleIcon.html('▲'); $toggleText.text('Hide formula'); $(this).closest('.formula-toggle').addClass('formula-toggle-active'); + } else { + $toggleIcon.html('▼'); $toggleText.text('Show formula'); $(this).closest('.formula-toggle').removeClass('formula-toggle-active'); + } + }); + }); + $popoverDiv.off('click.copyFormula').on('click.copyFormula', '.copy-formula-btn', function(e) { + e.preventDefault(); e.stopPropagation(); + var formula = $(this).data('formula'); + var $copyBtn = $(this); + navigator.clipboard.writeText(formula).then(function() { + var $iconContainer = $copyBtn.find('svg').parent(); + var originalIcon = $iconContainer.html(); + $iconContainer.html(''); + $copyBtn.addClass('copy-success'); + setTimeout(function() { $copyBtn.removeClass('copy-success'); $iconContainer.html(originalIcon); }, 2000); + }).catch(function(err) { + console.error('Could not copy formula: ', err); + self._showTemporaryMessage(null, "Could not copy formula.", 'suggestion-warning-message', '#e67e22'); + }); + }); + $popoverDiv.off('click.applySugg').on('click.applySugg', '.suggestion-apply-btn', function(e) { + e.preventDefault(); e.stopPropagation(); + if ($(this).hasClass('suggestion-apply-btn-disabled')) return; + var targetId = $(this).data('target'); + var suggestionValue = $(this).data('value'); + var isValid = $(this).data('is-valid') !== false; + var $target = $('#' + targetId); + + if (!$target.length) { console.error("Scheming Popover Apply: Target not found:", targetId); return; } + + var applySuccess = false; + if (self._setFieldValue($target, suggestionValue, targetId.substring(6))) { + applySuccess = true; + } else if (!$target.is('select')) { + self._showTemporaryMessage($target, "Could not apply suggestion to this field type.", 'suggestion-warning-message', '#e67e22'); + } + + if (applySuccess) { + $target.addClass('suggestion-applied'); + setTimeout(function() { $target.removeClass('suggestion-applied'); }, 1200); + self._showTemporaryMessage($target, "Suggestion applied!", 'suggestion-success-message', 'rgba(40, 167, 69, 0.95)'); + } else if ($target.is('select') && !isValid) { + self._showTemporaryMessage($target, "The suggested value is not a valid option.", 'suggestion-warning-message', '#e67e22'); + $target.addClass('suggestion-invalid'); + setTimeout(function() { $target.removeClass('suggestion-invalid'); }, 3000); + } + $popoverDiv.hide(); + }); + }, + _showTemporaryMessage: function($targetElement, message, cssClass, bgColor) { + $('.scheming-temp-message').remove(); + var $msg = $('
').addClass('scheming-temp-message ' + cssClass).text(message).css({ + position: 'fixed', bottom: '20px', left: '50%', transform: 'translateX(-50%)', + backgroundColor: bgColor || '#333', color: 'white', padding: '10px 20px', + borderRadius: '6px', fontSize: '14px', fontWeight: '500', zIndex: 2000, + opacity: 0, boxShadow: '0 5px 15px rgba(0,0,0,0.2)' + }); + $('body').append($msg); + $msg.animate({opacity: 1, bottom: '30px'}, 300, 'swing') + .delay(2500) + .animate({opacity: 0, bottom: '20px'}, 300, 'swing', function() { $(this).remove(); }); + }, + finalize: function() { + var fieldName = this.el && $(this.el).data('field-name'); + if (fieldName) { + $(document).off('click.schemingSuggestionsGlobal.' + fieldName); + if (this._popoverDivs[fieldName]) { + this._popoverDivs[fieldName].remove(); + delete this._popoverDivs[fieldName]; + } + } + } + }; +}); diff --git a/ckanext/scheming/assets/resource.config b/ckanext/scheming/assets/resource.config index 50d1622f5..38996f515 100644 --- a/ckanext/scheming/assets/resource.config +++ b/ckanext/scheming/assets/resource.config @@ -5,3 +5,4 @@ main = base/main main = js/scheming-multiple-text.js js/scheming-repeating-subfields.js + js/scheming-suggestions.js \ No newline at end of file diff --git a/ckanext/scheming/assets/styles/scheming.css b/ckanext/scheming/assets/styles/scheming.css index 9e30967a8..af450f031 100644 --- a/ckanext/scheming/assets/styles/scheming.css +++ b/ckanext/scheming/assets/styles/scheming.css @@ -39,3 +39,302 @@ a.btn.btn-multiple-remove { .radio-group label { font-weight: normal; } + + +.form-label label::after { + content: none !important; +} +.control-group.has-suggestion .control-label { + position: relative; /* For potential loader positioning */ + display: flex; /* To align label text and button nicely */ + align-items: center; /* Vertical alignment */ +} + + +.suggestion-field-loader { + margin-left: 5px; /* Space from label text */ +} + +.suggestion-field-loader i.fa { + color: #007bff; /* CKAN blue */ + font-size: 0.9em; +} + +/* Processing Banner & Temporary Messages */ +.scheming-alert { + padding: 15px 20px; + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 6px; + display: flex; + align-items: center; + font-size: 14px; + box-shadow: 0 3px 10px rgba(0,0,0,0.08); + transition: background-color 0.3s ease, color 0.3s ease; /* Smooth transitions */ +} +.scheming-alert i.fa { margin-right: 12px; font-size: 1.3em; } + +.scheming-alert-info { background-color: #e7f3fe; border-color: #d0eaff; color: #0c5460; } +.scheming-alert-success { background-color: #d4edda; border-color: #c3e6cb; color: #155724; } +.scheming-alert-warning { background-color: #fff3cd; border-color: #ffeeba; color: #856404; } +.scheming-alert-danger { background-color: #f8d7da; border-color: #f5c6cb; color: #721c24; } + + +.suggestion-btn { + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + border: none; + background-color: transparent; + color: #2a9134; /* darker green */ + width: 20px; + height: 20px; + padding: 0; + cursor: pointer; + transition: all 0.2s ease; + position: relative; + top: -1px; +} + +.suggestion-btn:hover, .suggestion-btn:focus { + background-color: rgba(0, 123, 255, 0.1); + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(0,123,255,0.2); + outline: none; +} + +.suggestion-btn svg { + width: 20px; + height: 20px; + fill: #2a9134; /* darker green */ + transition: stroke 0.25s ease-in-out; /* Smooth color transition for icon */ +} +/* Suggestion Button Error State */ +.suggestion-btn.suggestion-btn-error { + color: #dc3545; /* Bootstrap danger red */ +} +.suggestion-btn.suggestion-btn-error svg { + stroke: #dc3545; /* Red icon stroke */ +} +.suggestion-btn.suggestion-btn-error:hover, +.suggestion-btn.suggestion-btn-error:focus { + background-color: rgba(220, 53, 69, 0.1); /* Light red background on hover */ + box-shadow: 0 2px 8px rgba(220, 53, 69, 0.2); +} + + +.custom-suggestion-popover { + max-width: 380px; + width: auto; + background-color: #ffffff; + border: 1px solid #ddd; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0,0,0,0.12), 0 4px 10px rgba(0,0,0,0.08); + padding: 18px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + line-height: 1.5; +} +.custom-suggestion-popover:before { display: none; } + +.suggestion-popover-content strong { + display: block; + margin-bottom: 12px; + color: #212529; + font-size: 17px; + font-weight: 600; +} + +.suggestion-value { + margin: 15px 0; + padding: 12px; + background-color: #f8f9fa; + border-radius: 6px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; + word-break: break-all; + border-left: 4px solid #007bff; + font-size: 14px; + color: #343a40; + box-shadow: inset 0 1px 3px rgba(0,0,0,0.04); + max-height: 150px; + overflow-y: auto; +} + +.formula-toggle { margin: 10px 0 5px 0; } +.formula-toggle-btn { + background: none; border: none; color: #007bff; font-size: 13px; + font-weight: 500; cursor: pointer; padding: 5px 0; display: flex; align-items: center; + opacity: 0.9; transition: opacity 0.2s ease; +} +.formula-toggle-btn:hover { opacity: 1; text-decoration: underline; } +.formula-toggle-active { opacity: 1; } /* Class for active (expanded) state */ +.formula-toggle-icon { font-size: 11px; margin-right: 6px; transition: transform 0.2s ease; } +.formula-toggle-active .formula-toggle-icon { transform: rotate(180deg); } /* Icon rotation */ + + +.suggestion-formula { + margin: 8px 0 15px 0; padding: 12px; background-color: #e9ecef; + border-radius: 4px; font-size: 12px; line-height: 1.5; color: #495057; + border-left: 3px solid #28a745; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; +} +.formula-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 8px; font-weight: 500; color: #333; font-size: 12px; + text-transform: uppercase; letter-spacing: 0.5px; +} +.copy-formula-btn { + background: none; border: none; color: #007bff; cursor: pointer; padding: 4px; + display: flex; align-items: center; justify-content: center; opacity: 0.7; + transition: all 0.2s ease; border-radius: 4px; +} +.copy-formula-btn:hover { opacity: 1; background-color: rgba(0, 123, 255, 0.08); } +.copy-formula-btn.copy-success { color: #28a745; opacity: 1; background-color: rgba(40, 167, 69, 0.1); } +.copy-formula-btn svg { width: 16px; height: 16px; } +.suggestion-formula code { white-space: pre-wrap; word-break: break-all; display: block; } + +/* Apply Button */ +.suggestion-apply-btn { + margin-top: 15px; padding: 10px 15px; font-size: 14px; + background-color: #28a745; color: white; border: none; border-radius: 5px; + cursor: pointer; display: block; width: 100%; font-weight: 500; + transition: all 0.2s ease; text-transform: none; + letter-spacing: 0; box-shadow: 0 2px 5px rgba(40, 167, 69, 0.25); +} +.suggestion-apply-btn:hover:not(.suggestion-apply-btn-disabled) { + background-color: #218838; transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(40, 167, 69, 0.3); +} +.suggestion-apply-btn:active:not(.suggestion-apply-btn-disabled) { + transform: translateY(0); box-shadow: 0 1px 3px rgba(40, 167, 69, 0.3); +} +.suggestion-apply-btn-disabled { + background-color: #adb5bd !important; color: #495057 !important; /* Darker text on grey */ + cursor: not-allowed !important; box-shadow: none !important; opacity: 0.65; +} + +/* Popover Error State Styling */ +.suggestion-popover-error strong { + color: #721c24; /* Darker red for title in error popover */ +} +.suggestion-error-text { + display: flex; + align-items: flex-start; /* Align icon and text nicely */ + margin-top: 8px; + margin-bottom: 8px; /* Consistent margin */ + padding: 10px; + background-color: #f8d7da; + border-radius: 6px; + border-left: 3px solid #dc3545; + font-size: 13px; + line-height: 1.4; + color: #721c24; +} +.suggestion-error-text svg { + flex-shrink: 0; + margin-right: 8px; + stroke: #dc3545; + margin-top: 2px; +} +.suggestion-value-error { + border-left-color: #dc3545 !important; + background-color: #f8d7da !important; + color: #721c24 !important; +} + + +/* Warning for invalid select */ +.suggestion-warning { + display: flex; /* For icon alignment */ + align-items: flex-start; + padding: 10px; background-color: #fff3cd; border-left: 3px solid #ffc107; + color: #856404; border-radius: 4px; margin: 10px 0; font-size: 13px; +} +.suggestion-warning svg { stroke: #ffc107; margin-right: 8px; flex-shrink: 0; margin-top: 2px;} + + + +/* Applied field animation (for manual suggestion apply) */ +@keyframes highlightSuccessPulse { + 0% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0.4); } + 70% { box-shadow: 0 0 0 10px rgba(40, 167, 69, 0); } + 100% { box-shadow: 0 0 0 0 rgba(40, 167, 69, 0); } +} +input.suggestion-applied, textarea.suggestion-applied, select.suggestion-applied { + animation: highlightSuccessPulse 1s ease-out; + border-color: #28a745 !important; +} + +/* NEW: Styles for auto-applied formula fields */ +@keyframes highlightAutoApplied { + 0% { background-color: rgba(0, 123, 255, 0); } /* Blueish highlight */ + 50% { background-color: rgba(0, 123, 255, 0.12); } + 100% { background-color: rgba(0, 123, 255, 0); } +} +input.formula-auto-applied, textarea.formula-auto-applied, select.formula-auto-applied { + animation: highlightAutoApplied 1.2s ease-in-out; +} + +/* NEW: Style for fields where auto-apply formula resulted in an error */ +@keyframes highlightFormulaError { + 0% { background-color: rgba(220, 53, 69, 0); } /* Reddish highlight */ + 50% { background-color: rgba(220, 53, 69, 0.12); } + 100% { background-color: rgba(220, 53, 69, 0); } +} +input.formula-apply-error, textarea.formula-apply-error, select.formula-apply-error { + animation: highlightFormulaError 1.5s ease-in-out; + border-color: #dc3545 !important; /* Persistent red border for a bit if needed */ +} + + + +/* Responsive adjustments */ +@media (max-width: 767px) { + .custom-suggestion-popover { + width: calc(100% - 30px) !important; + max-width: none; + left: 15px !important; + right: 15px !important; + padding: 15px; + } + .suggestion-btn { width: 30px; height: 30px; } + .suggestion-btn svg { width: 18px; height: 18px; } + .control-group.has-suggestion .control-label { + flex-wrap: wrap; /* Allow button to wrap if label is very long */ + } + .suggestion-btn { + margin-top: 4px; /* Add some space if it wraps */ + margin-left: 0; /* Align to left if wrapped */ + } +} + +/* Styles for preformulated fields (from original CSS, ensure they are still relevant) */ +.preformulated-field { + position: relative; + background-color: #f9f9f9; + border-left: 3px solid #2a9134; + padding-left: 10px; +} +.preformulated-field input, +.preformulated-field textarea, +.preformulated-field select { + background-color: #f9f9f9 !important; + border-color: #e0e0e0 !important; + color: #555 !important; +} +.preformulated-field-notice { + display: block; + margin-top: 5px; + font-size: 12px; + color: #555; + font-style: italic; + background-color: #f3f3f3; + padding: 5px 10px; + border-radius: 3px; +} + + +#dataset-edit div[class*="control-group"] > label > span.preformulated-icon > svg { + vertical-align: middle; + margin-left: 5px; +} \ No newline at end of file diff --git a/ckanext/scheming/assets/webassets.yml b/ckanext/scheming/assets/webassets.yml index b3710959c..a6f0450be 100644 --- a/ckanext/scheming/assets/webassets.yml +++ b/ckanext/scheming/assets/webassets.yml @@ -20,3 +20,12 @@ multiple_text: - base/main contents: - js/scheming-multiple-text.js + +suggestions: + filters: rjsmin + output: ckanext-scheming/%(version)s_scheming_suggestions.js + extra: + preload: + - base/main + contents: + - js/scheming-suggestions.js \ No newline at end of file diff --git a/ckanext/scheming/helpers.py b/ckanext/scheming/helpers.py index 80166d24f..b9fd08c7c 100644 --- a/ckanext/scheming/helpers.py +++ b/ckanext/scheming/helpers.py @@ -5,6 +5,7 @@ import pytz import json import six +import logging from jinja2 import Environment from ckan.plugins.toolkit import config, _, h @@ -12,6 +13,7 @@ from ckanapi import LocalCKAN, NotFound, NotAuthorized all_helpers = {} +logger = logging.getLogger(__name__) def helper(fn): """ @@ -459,3 +461,79 @@ def scheming_missing_required_fields(pages, data=None, package_id=None): if f.get('required') and not data.get(f['field_name']) ]) return missing + +@helper +def scheming_field_suggestion(field): + """ + Returns suggestion data for a field if it exists + """ + suggestion_label = field.get('suggestion_label', field.get('label', '')) + suggestion_formula = field.get('suggestion_formula', field.get('suggest_jinja2', None)) + + if suggestion_formula: + return { + 'label': suggestion_label, + 'formula': suggestion_formula + } + return None + + + +@helper +def scheming_get_suggestion_value(field_name, data=None, errors=None, lang=None): + if not data: + return '' + + try: + # Log the field name + logger.info(f"Field name extracted: {field_name}") + + # Get package data (where dpp_suggestions is stored) + package_data = data + logger.info(f"Data passed to scheming_get_suggestion_value: {data}") + + # Check if dpp_suggestions exists and has the package section + if (package_data and 'dpp_suggestions' in package_data and + isinstance(package_data['dpp_suggestions'], dict) and + 'package' in package_data['dpp_suggestions']): + + # Get the suggestion value if it exists + if field_name in package_data['dpp_suggestions']['package']: + logger.info(f"Suggestion value found for field '{field_name}': {package_data['dpp_suggestions']['package'][field_name]}") + return package_data['dpp_suggestions']['package'][field_name] + + # No suggestion value found + return '' + except Exception as e: + # Log the error but don't crash + logger.warning(f"Error getting suggestion value: {e}") + return '' + +@helper +def scheming_is_valid_suggestion(field, value): + """ + Check if a suggested value is valid for a field, particularly for select fields + """ + # If not a select/choice field, always valid + if not field.get('choices') and not field.get('choices_helper'): + return True + + # Get all valid choices for this field + choices = scheming_field_choices(field) + if not choices: + return True + + # Check if the value is in the list of valid choices + for choice in choices: + if choice['value'] == value: + return True + + return False + +@helper +def is_preformulated_field(field): + """ + Check if a field is preformulated (has formula attribute) + This helper returns True only if the field has a 'formula' key with a non-empty value + """ + return bool(field.get('formula', False)) diff --git a/ckanext/scheming/suggestion_test_schema.yaml b/ckanext/scheming/suggestion_test_schema.yaml new file mode 100644 index 000000000..02737103b --- /dev/null +++ b/ckanext/scheming/suggestion_test_schema.yaml @@ -0,0 +1,123 @@ +scheming_version: 2 +dataset_type: dataset +about: A reimplementation of the default CKAN dataset schema +about_url: http://github.com/ckan/ckanext-scheming + + +dataset_fields: + +- field_name: title + label: Title + preset: title + form_placeholder: eg. A descriptive title Custom + +- field_name: name + label: URL + preset: dataset_slug + form_placeholder: eg. my-dataset + +- field_name: notes + label: Description + form_snippet: markdown.html + form_placeholder: eg. Some useful notes/blurb about the data + suggestion_formula: Latitudinal range {{ (dpps[dpp.LAT_FIELD].stats.max|float) - (dpps[dpp.LAT_FIELD].stats.min|float) }} {{"the quick brown fox"|truncate_with_ellipsis(5)}} + +- field_name: spatial_extent + label: Spatial Extent + form_snippet: markdown.html + suggestion_formula: '{{ spatial_extent_wkt(resource.LONGITUDE.stats.min, resource.LATITUDE.stats.min, resource.LONGITUDE.stats.max, resource.LATITUDE.stats.max) }}' + +- field_name: test_field + label: Test Field + form_snippet: markdown.html + formula: 'Test field value:{{package.author}}: {{package.author_email}}' + +- field_name: frequency_info + label: Frequency Information + form_snippet: markdown.html + form_placeholder: eg. Daily, Weekly, Monthly, Annually, etc. + formula: '{{ get_frequency_top_values("LATITUDE") }}' + +- field_name: tag_string + label: Tags + preset: tag_string_autocomplete + form_placeholder: eg. economy, mental health, government + +- field_name: license_id + label: License + form_snippet: license.html + help_text: License definitions and additional information can be found at http://opendefinition.org/ + +- field_name: owner_org + label: Organization + preset: dataset_organization + +- field_name: url + label: Source + form_placeholder: http://example.com/dataset.json + display_property: foaf:homepage + display_snippet: link.html + +- field_name: version + label: Version + validators: ignore_missing unicode_safe package_version_validator + form_placeholder: '1.0' + +- field_name: author + label: Author + form_placeholder: Joe Bloggs + display_property: dc:creator + +- field_name: author_email + label: Author Email + form_placeholder: joe@example.com + display_property: dc:creator + display_snippet: email.html + display_email_name_field: author + +- field_name: maintainer + label: Maintainer + form_placeholder: Joe Bloggs + display_property: dc:contributor + +- field_name: maintainer_email + label: Maintainer Email + form_placeholder: joe@example.com + display_property: dc:contributor + display_snippet: email.html + display_email_name_field: maintainer + +- field_name: dpp_suggestions + label: DPP Suggestions + preset: json_object + +resource_fields: + +- field_name: url + label: URL + preset: resource_url_upload + +- field_name: name + label: Name + form_placeholder: eg. January 2011 Gold Prices + +- field_name: description + label: Description + form_snippet: markdown.html + form_placeholder: Some useful notes about the data + +- field_name: spatial_extent + label: Spatial Extent + form_snippet: markdown.html + +- field_name: spatial_extent_fc_inferred + label: Spatial Extent Feature Collection Inferred + form_snippet: markdown.html + +- field_name: spatial_extent_wkt_inferred + label: Spatial Extent WKT Inferred + form_snippet: markdown.html + +- field_name: format + label: Format + preset: resource_format_autocomplete \ No newline at end of file diff --git a/ckanext/scheming/templates/scheming/form_snippets/markdown.html b/ckanext/scheming/templates/scheming/form_snippets/markdown.html index 798ae96ea..3228fd0df 100644 --- a/ckanext/scheming/templates/scheming/form_snippets/markdown.html +++ b/ckanext/scheming/templates/scheming/form_snippets/markdown.html @@ -1,15 +1,53 @@ {% import 'macros/form.html' as form %} +{% include 'scheming/snippets/suggestions_asset.html' %} + +{# Check if this is a preformulated field #} +{% set is_preformulated = h.is_preformulated_field(field) %} + +{# Create a custom label with suggestion button(s) and icons #} +{% set suggestion = h.scheming_field_suggestion(field) %} +{% set label_text = h.scheming_language_text(field.label) %} +{% set label_with_extras %} + {{ label_text }}: + {%- if suggestion -%} + {%- snippet 'scheming/snippets/suggestion_button.html', field=field, data=data -%} + {%- endif -%} + {%- if is_preformulated -%} + + + + {%- endif -%} +{% endset %} + +{# Prepare attributes for the form macro #} +{% set current_attrs = {} %} +{% if supports_ai_suggestion %} + {% set _ = current_attrs.update({'data-field-supports-ai-suggestion': 'true'}) %} +{% endif %} +{% if is_preformulated %} {# is_preformulated means it has a 'formula' #} + {% set _ = current_attrs.update({'data-is-formula-field': 'true'}) %} +{% endif %} +{# Always add data-scheming-field-name for easier targeting by JS #} +{% set _ = current_attrs.update({'data-scheming-field-name': field.field_name}) %} + {% call form.markdown( field.field_name, id='field-' + field.field_name, - label=h.scheming_language_text(field.label), + label=label_with_extras|safe, placeholder=h.scheming_language_text(field.form_placeholder), value=data[field.field_name], error=errors[field.field_name], - attrs=dict({"class": "form-control"}, **(field.get('form_attrs', {}))), + attrs=current_attrs, is_required=h.scheming_field_required(field) ) %} - {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} -{% endcall %} +
+ {%- if is_preformulated -%} +
+ Automated field: This field is automatically populated. +
+ {%- endif -%} + {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} +
+{% endcall %} \ No newline at end of file diff --git a/ckanext/scheming/templates/scheming/form_snippets/select.html b/ckanext/scheming/templates/scheming/form_snippets/select.html index 619e8fec2..cae25aea4 100644 --- a/ckanext/scheming/templates/scheming/form_snippets/select.html +++ b/ckanext/scheming/templates/scheming/form_snippets/select.html @@ -1,4 +1,5 @@ {% import 'macros/form.html' as form %} +{% include 'scheming/snippets/suggestions_asset.html' %} {%- set options=[] -%} {%- set form_restrict_choices_to=field.get('form_restrict_choices_to') -%} @@ -16,18 +17,25 @@ {%- if field.get('sorted_choices') -%} {%- set options = options|sort(case_sensitive=false, attribute='text') -%} {%- endif -%} -{%- if data[field.field_name] is defined -%} +{%- if data[field.field_name] -%} {%- set option_selected = data[field.field_name]|string -%} -{%- elif field.default is defined -%} - {%- set option_selected = field.default|string -%} {%- else -%} {%- set option_selected = None -%} {%- endif -%} +{% set suggestion = h.scheming_field_suggestion(field) %} +{% set label_text = h.scheming_language_text(field.label) %} +{% set label_with_suggestion %} + {{ label_text }}: + {%- if suggestion -%} + {%- snippet 'scheming/snippets/suggestion_button.html', field=field, data=data -%} + {%- endif -%} +{% endset %} + {% call form.select( field.field_name, id='field-' + field.field_name, - label=h.scheming_language_text(field.label), + label=label_with_suggestion|safe, options=options, selected=option_selected, error=errors[field.field_name], @@ -36,5 +44,7 @@ is_required=h.scheming_field_required(field) ) %} - {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} -{% endcall %} +
+ {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} +
+{% endcall %} \ No newline at end of file diff --git a/ckanext/scheming/templates/scheming/form_snippets/text.html b/ckanext/scheming/templates/scheming/form_snippets/text.html index 9d276da09..c78b75fe9 100644 --- a/ckanext/scheming/templates/scheming/form_snippets/text.html +++ b/ckanext/scheming/templates/scheming/form_snippets/text.html @@ -1,9 +1,29 @@ {% import 'macros/form.html' as form %} +{% include 'scheming/snippets/suggestions_asset.html' %} + +{# Check if this is a preformulated field (i.e., has a 'formula') #} +{% set is_preformulated = h.is_preformulated_field(field) %} + +{# Create a custom label with suggestion button(s) and icons #} +{% set suggestion = h.scheming_field_suggestion(field) %} +{% set label_text = h.scheming_language_text(field.label) %} +{% set label_with_extras %} + {{ label_text }}: + {%- if suggestion -%} + {%- snippet 'scheming/snippets/suggestion_button.html', field=field, data=data -%} + {%- endif -%} + {%- if is_preformulated -%} + + + + {%- endif -%} +{% endset %} + {% call form.input( field.field_name, id='field-' + field.field_name, - label=h.scheming_language_text(field.label), + label=label_with_extras|safe, placeholder=h.scheming_language_text(field.form_placeholder), value=data[field.field_name], error=errors[field.field_name], @@ -12,5 +32,12 @@ is_required=h.scheming_field_required(field) ) %} - {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} +
+ {%- if is_preformulated -%} +
+ Automated field: This field is automatically populated. +
+ {%- endif -%} + {%- snippet 'scheming/form_snippets/help_text.html', field=field -%} +
{% endcall %} diff --git a/ckanext/scheming/templates/scheming/snippets/suggestion_button.html b/ckanext/scheming/templates/scheming/snippets/suggestion_button.html new file mode 100644 index 000000000..9433e0d4a --- /dev/null +++ b/ckanext/scheming/templates/scheming/snippets/suggestion_button.html @@ -0,0 +1,30 @@ +{% set suggestion_data_for_js = h.scheming_field_suggestion(field) %} +{% if suggestion_data_for_js %} + {% set field_id = 'field-' + field.field_name %} {# Used for targetting by apply button #} + + {% set field_schema_dict = { + "field_name": field.field_name, + "label": h.scheming_language_text(field.label), + "suggestion_label": h.scheming_language_text(suggestion_data_for_js.label or field.label), + "suggestion_formula": suggestion_data_for_js.formula, + "choices": h.scheming_field_choices(field) if (field.choices or field.choices_helper) else none, + "is_select": True if (field.choices or field.choices_helper) else False + } %} + {# Use h.scheming_display_json_value helper instead of |json filter #} + {% set field_schema_for_js_json_string = h.scheming_display_json_value(field_schema_dict) %} + + +{% endif %} diff --git a/ckanext/scheming/templates/scheming/snippets/suggestions_asset.html b/ckanext/scheming/templates/scheming/snippets/suggestions_asset.html new file mode 100644 index 000000000..6254967b5 --- /dev/null +++ b/ckanext/scheming/templates/scheming/snippets/suggestions_asset.html @@ -0,0 +1 @@ +{% asset 'ckanext-scheming/suggestions' %} \ No newline at end of file