diff --git a/application/asset/js/resource-form.js b/application/asset/js/resource-form.js index 835630012f..b22648966a 100644 --- a/application/asset/js/resource-form.js +++ b/application/asset/js/resource-form.js @@ -2,6 +2,10 @@ $(document).ready( function() { + if ($('div#properties').data('default-data-types').split(',').length < 1) { + $('div#properties').data('default-data-types', 'literal,resource,uri'); + } + // Select property $('#property-selector li.selector-child').on('click', function(e) { e.stopPropagation(); @@ -25,7 +29,7 @@ $('a.value-language').on('click', function(e) { e.preventDefault(); var languageButton = $(this); - var languageInput = languageButton.next('input.value-language'); + var languageInput = languageButton.next('input.value-language'); languageButton.toggleClass('active'); languageInput.toggleClass('active'); if (languageInput.hasClass('active')) { @@ -37,7 +41,7 @@ if ('' === this.value || Omeka.langIsValid(this.value)) { this.setCustomValidity(''); } else { - this.setCustomValidity(Omeka.jsTranslate('Please enter a valid language tag')) + this.setCustomValidity(Omeka.jsTranslate('Please enter a valid language tag')); } }); @@ -46,7 +50,7 @@ e.preventDefault(); var typeButton = $(this); var field = typeButton.closest('.resource-values.field'); - var value = makeNewValue(field.data('property-term'), null, typeButton.data('type')) + var value = makeNewValue(field.data('property-term'), typeButton.data('type')); field.find('.values').append(value); }); @@ -94,7 +98,6 @@ $('#select-item a').on('o:resource-selected', function (e) { var value = $('.value.selecting-resource'); var valueObj = $('.resource-details').data('resource-values'); - $(document).trigger('o:prepare-value', ['resource', value, valueObj]); Omeka.closeSidebar($('#select-resource')); }); @@ -112,7 +115,7 @@ $('#item-results').find('.resource') .has('input.select-resource-checkbox:checked').each(function(index) { if (0 < index) { - value = makeNewValue(field.data('property-term'), null, 'resource'); + value = makeNewValue(field.data('property-term'), 'resource'); field.find('.values').append(value); } var valueObj = $(this).data('resource-values'); @@ -183,6 +186,7 @@ errors.push('The following field is required: ' + propLabel); } }); + thisForm.data('has-error', errors.length > 0); if (errors.length) { e.preventDefault(); alert(errors.join("\n")); @@ -212,7 +216,7 @@ valueData['is_public'] = value.find('input.is_public').val(); value.find(':input[data-value-key]').each(function () { var input = $(this); - valueKey = input.data('valueKey'); + var valueKey = input.data('valueKey'); if (!valueKey || input.prop('disabled')) { return; } @@ -221,23 +225,36 @@ propertyValues.push(valueData); }); if (propertyValues.length) { - values[propertyTerm] = propertyValues; + values[propertyTerm] = values.hasOwnProperty(propertyTerm) + ? values[propertyTerm].concat(propertyValues) + : propertyValues; } }); return values; }; + var makeDefaultValue = function (term, dataType) { + return makeNewValue(term, dataType) + .addClass('default-value') + .one('change', '*', function (event) { + $(event.delegateTarget).removeClass('default-value'); + }); + }; + /** * Make a new value. */ - var makeNewValue = function(term, valueObj, type) { + var makeNewValue = function(term, dataType, valueObj) { var field = $('.resource-values.field[data-property-term="' + term + '"]'); // Get the value node from the templates. - if (typeof type !== 'string') { - type = valueObj['type']; + if (!dataType || typeof dataType !== 'string') { + dataType = valueObj ? valueObj['type'] : field.find('.add-value:visible:first').data('type'); } - var value = $('.value.template[data-data-type="' + type + '"]').clone(true); + var fieldForDataType = field.filter(function() { return $.inArray(dataType, $(this).data('data-types').split(',')) > -1; }); + field = fieldForDataType.length ? fieldForDataType.first() : field.first(); + var value = $('.value.template[data-data-type="' + dataType + '"]').clone(true); value.removeClass('template'); + value.data('data-term', term); // Get and display the value's visibility. var isPublic = true; // values are public by default @@ -255,7 +272,6 @@ valueVisibilityButton.attr('title', Omeka.jsTranslate('Make public')); } // Prepare the value node. - var count = field.find('.value').length; var valueLabelID = 'property-' + field.data('property-id') + '-label'; value.find('input.is_public') .val(isPublic ? 1 : 0); @@ -264,7 +280,7 @@ value.find('textarea.input-value') .attr('aria-labelledby', valueLabelID); value.attr('aria-labelledby', valueLabelID); - $(document).trigger('o:prepare-value', [type, value, valueObj]); + $(document).trigger('o:prepare-value', [dataType, value, valueObj]); return value; }; @@ -272,7 +288,7 @@ /** * Prepare the markup for the default data types. */ - $(document).on('o:prepare-value', function(e, type, value, valueObj) { + $(document).on('o:prepare-value', function(e, dataType, value, valueObj) { // Prepare simple single-value form inputs using data-value-key value.find(':input').each(function () { var valueKey = $(this).data('valueKey'); @@ -290,7 +306,7 @@ 'resource:itemset', 'resource:media', ]; - if (valueObj && -1 !== resourceDataTypes.indexOf(type)) { + if (valueObj && -1 !== resourceDataTypes.indexOf(dataType)) { value.find('span.default').hide(); var resource = value.find('.selected-resource'); if (typeof valueObj['display_title'] === 'undefined') { @@ -308,12 +324,16 @@ }); /** - * Make a new property field. + * Make a new property field with data stored in the property selector. */ - var makeNewField = function(property) { - //sort out whether property is the LI that holds data, or the id - var propertyLi, propertyId; + var makeNewField = function(property, dataTypes) { + // Prepare data type name of the field. + if (!dataTypes || dataTypes.length < 1) { + dataTypes = $('#properties').data('default-data-types').split(','); + } + // Sort out whether property is the LI that holds data, or the id. + var propertyLi, propertyId; switch (typeof property) { case 'object': propertyLi = property; @@ -329,6 +349,9 @@ propertyLi = $('#property-selector').find("li[data-property-term='" + property + "']"); propertyId = propertyLi.data('property-id'); break; + + default: + return null; } var term = propertyLi.data('property-term'); @@ -339,10 +362,12 @@ field.find('.field-description').prepend(propertyLi.find('.field-comment').text()); field.data('property-term', term); field.data('property-id', propertyId); + field.data('data-types', dataTypes.join(',')); // Adding the attr because selectors need them to find the correct field // and count when adding more. field.attr('data-property-term', term); field.attr('data-property-id', propertyId); + field.attr('data-data-types', dataTypes.join(',')); field.attr('aria-labelledby', 'property-' + propertyId + '-label'); $('div#properties').append(field); @@ -356,163 +381,299 @@ }; /** - * Rewrite a property field following the rules defined by the selected - * resource template. + * Rewrite an existing property field, or create a new one. */ - var rewritePropertyField = function(template) { + var rewritePropertyField = function(templateProperty) { + var templateId = $('#resource-template-select').val(); var properties = $('div#properties'); - var propertyId = template['o:property']['o:id']; - var field = properties.find('[data-property-id="' + propertyId + '"]'); - if (field.length == 0) { - field = makeNewField(propertyId); + var propertyId = templateProperty['o:property']['o:id']; + var dataTypes = templateProperty['o:data_type'] && templateProperty['o:data_type'].length + ? templateProperty['o:data_type'] + : $('div#properties').data('default-data-types').split(','); + + // Check if an existing field exists in order to update it and to avoid duplication. + // Since fields can have the same property but only different data types, a filter is used. + var field = properties.find('[data-property-id="' + propertyId + '"]') + .filter(function() { return dataTypes.sort().join(',') === $(this).data('data-types').split(',').sort().join(','); }) + .first().data('data-types', dataTypes.join(',')); + if (!field.length) { + field = makeNewField(propertyId, dataTypes); } + var originalLabel = field.find('.field-label'); var originalDescription = field.find('.field-description'); - var singleSelector = field.find('div.single-selector'); var defaultSelector = field.find('div.default-selector'); + var multipleSelector = field.find('div.multiple-selector'); + var singleSelector = field.find('div.single-selector'); - if (template['o:is_required']) { + if (templateProperty['o:is_required']) { field.addClass('required'); } - if (template['o:is_private']) { + if (templateProperty['o:is_private']) { field.addClass('private'); } - if (template['o:alternate_label']) { + if (templateProperty['o:alternate_label']) { var altLabel = originalLabel.clone(); + var altLabelId = 'property-' + propertyId + '-' + dataTypes.join('-') + '-label'; altLabel.addClass('alternate'); - altLabel.text(template['o:alternate_label']); + altLabel.text(templateProperty['o:alternate_label']); altLabel.insertAfter(originalLabel); + altLabel.attr('id', altLabelId); + field.attr('aria-labelledby', altLabelId); originalLabel.hide(); } - if (template['o:alternate_comment']) { + if (templateProperty['o:alternate_comment']) { var altDescription = originalDescription.clone(); altDescription.addClass('alternate'); - altDescription.text(template['o:alternate_comment']); + altDescription.text(templateProperty['o:alternate_comment']); altDescription.insertAfter(originalDescription); originalDescription.hide(); } + // Store specific settings of this template property. + field.attr('data-template-id', templateId); + field.data('settings', templateProperty['o:settings'] ? templateProperty['o:settings'] : {}); + // Remove any unchanged default values for this property so we start fresh. field.find('.value.default-value').remove(); - // Change value selector and add empty value if needed. - if (template['o:data_type']) { - // Use the single selector if the property has a data type. + // Change value selector (multiple, single, or default). + if (templateProperty['o:data_type'].length > 1) { defaultSelector.hide(); - singleSelector.find('a.add-value.button').data('type', template['o:data_type']) - singleSelector.show(); - // Add an empty value if none already exist in the property. - if (!field.find('.value').length) { - field.find('.values').append(makeNewValue(field.data('property-term'), null, template['o:data_type'])); + singleSelector.hide(); + if (!multipleSelector.find('.add-value').length) { + multipleSelector.append(prepareMultipleSelector(templateProperty['o:data_type'])); } + multipleSelector.show(); + } else if (templateProperty['o:data_type'].length === 1) { + defaultSelector.hide(); + multipleSelector.hide(); + singleSelector.find('a.add-value.button').data('type', templateProperty['o:data_type'][0]); + singleSelector.show(); } else { - // Use the default selector if the property has no data type. + multipleSelector.hide(); singleSelector.hide(); defaultSelector.show(); - // Add an empty default value if none already exist in the property. - if (!field.find('.value').length) { - field.find('.values').append(makeDefaultValue(field.data('property-term'), 'literal')); - } } properties.prepend(field); }; + /** + * Prepare a selector (usualy a html list of buttons) from a list of data types. + * + * @see view/common/resource-form-templates.phtml + * + * @param array dataTypes + * @return string + */ + var prepareMultipleSelector = function(dataTypes) { + var html = ''; + dataTypes.forEach(function(dataType) { + var dataTypeTemplate = $('.template.value[data-data-type="' + dataType + '"]'); + var label = dataTypeTemplate.data('data-type-label') ? dataTypeTemplate.data('data-type-label') : Omeka.jsTranslate('Add value'); + var icon = dataTypeTemplate.data('data-type-icon') ? dataTypeTemplate.data('data-type-icon') : dataType.substring(0, (dataType + ':').indexOf(':')); + html += dataTypeTemplate.data('data-type-button') + ? dataTypeTemplate.data('data-type-button') + : '' + label + ''; + }); + return html; + }; + + var makeDefaultTemplate = function() { + var defaultDataType = $('div#properties').data('default-data-types').substring(0, ($('div#properties').data('default-data-types') + ',').indexOf(',')); + makeNewField('dcterms:title').find('.values') + .append(makeDefaultValue('dcterms:title', defaultDataType)); + makeNewField('dcterms:description').find('.values') + .append(makeDefaultValue('dcterms:description', defaultDataType)); + }; + /** * Apply the selected resource template to the form. * * @param bool changeClass Whether to change the suggested class */ var applyResourceTemplate = function(changeClass) { - - // Fieldsets may have been marked as required or private in a previous state. - $('.field').removeClass('required'); - $('.field').removeClass('private'); - var templateSelect = $('#resource-template-select'); var templateId = templateSelect.val(); var fields = $('#properties .resource-values'); - if (!templateId) { - // Using the default resource template, so all properties should use the default - // selector. - fields.find('div.single-selector').hide(); - fields.find('div.default-selector').show(); - return; - } - var url = templateSelect.data('api-base-url') + '/' + templateId; - return $.get(url) - .done(function(data) { - if (changeClass) { - // Change the resource class. - var classSelect = $('#resource-class-select'); - if (data['o:resource_class'] && classSelect.val() === '') { - classSelect.val(data['o:resource_class']['o:id']); - classSelect.trigger('chosen:updated'); - } - } - // Rewrite every property field defined by the template. We - // reverse the order so property fields on page that are not - // defined by the template are ultimately appended. - var templatePropertyIds = data['o:resource_template_property'] - .reverse().map(function(templateProperty) { - rewritePropertyField(templateProperty); - return templateProperty['o:property']['o:id']; - }); - // Property fields that are not defined by the template should - // use the default selector. - fields.each(function() { - var propertyId = $(this).data('property-id'); - if (templatePropertyIds.indexOf(propertyId) === -1) { - var field = $(this); - field.find('div.single-selector').hide(); - field.find('div.default-selector').show(); + // Reset settings of the previous template. + $('#resource-values').data('template-settings', {}); + fields.data('template-id', ''); + fields.attr('data-template-id', ''); + fields.data('settings', {}); + + // Fieldsets may have been marked as required or private in a previous state. + fields.removeClass('required'); + fields.removeClass('private'); + + // Using the default resource template, so all properties should use the default + // selector. + fields.find('div.multiple-selector').hide(); + fields.find('div.single-selector').hide(); + fields.find('div.default-selector').show(); + + // All properties should uses the default data types. + fields.data('data-types', $('div#properties').data('default-data-types')); + fields.attr('data-data-types', $('div#properties').data('default-data-types')); + + // Merge all duplicate properties, keeping order of values when possible. + fields.each(function() { + var propertyId = $(this).attr('data-property-id'); + // Deduplicate only first properties, that are not already processed. + if ($(this).prevAll('[data-property-id="' + propertyId + '"]').length < 1) { + var duplicatedFields = $('div#properties').find('[data-property-id="' + propertyId + '"]'); + var duplicatedFieldFirst = duplicatedFields.first(); + duplicatedFields.each(function(index) { + if (index > 0) { + duplicatedFieldFirst.find('.inputs .values').append($(this).find('.inputs .values > .value')); + $(this).remove(); } }); - }) - .fail(function() { - console.log('Failed loading resource template from API'); + } + }); + + if (templateId) { + var url = templateSelect.data('api-base-url') + '/' + templateId; + $.get(url) + .done(function(data) { + if (changeClass) { + // Change the resource class. + var classSelect = $('#resource-class-select'); + if (data['o:resource_class'] && classSelect.val() === '') { + classSelect.val(data['o:resource_class']['o:id']); + classSelect.trigger('chosen:updated'); + } + } + + // Store global settings of the template. + $('#resource-values').data('template-settings', data['o:settings'] ? data['o:settings'] : {}); + + // Rewrite every property field defined by the template. We + // reverse the order so property fields on page that are not + // defined by the template are ultimately appended. + data['o:resource_template_property'] + .reverse().map(function(templateProperty) { + rewritePropertyField(templateProperty); + }); + + // Furthermore, the values are moved to the property row according + // to their data type when there are multiple duplicate properties. + // @see \Omeka\Api\Representation\AbstractEntityRepresentation::values() + fields = $('#properties .resource-values'); + if (fields.length > 0) { + // Prepare the list of data types one time and make easier to fill specific rows first. + var dataTypesByProperty = {}; + fields.each(function() { + var fieldTemplateId = $(this).data('template-id'); + var propertyId = $(this).data('property-id'); + var dataTypes = $(this).data('data-types'); + if (!dataTypesByProperty.hasOwnProperty(propertyId)) { + dataTypesByProperty[propertyId] = {}; + } + // Manage exception for resource values. + if (fieldTemplateId && dataTypes.split(',').length) { + dataTypes.split(',').forEach(function(dataType) { + if (dataType === 'resource') { + dataTypesByProperty[propertyId]['resource'] = dataTypes; + dataTypesByProperty[propertyId]['resource:item'] = dataTypes; + dataTypesByProperty[propertyId]['resource:itemset'] = dataTypes; + dataTypesByProperty[propertyId]['resource:media'] = dataTypes; + } else { + dataTypesByProperty[propertyId][dataType] = dataTypes; + } + }); + } else { + dataTypesByProperty[propertyId]['default'] = $('div#properties').data('default-data-types'); + } + }); + fields.each(function() { + var propertyId = $(this).data('property-id'); + $(this).find('.inputs .values > .value').each(function() { + var valueDataType = $(this).data('data-type'); + if (!dataTypesByProperty[propertyId].hasOwnProperty(valueDataType)) { + if (!dataTypesByProperty[propertyId].hasOwnProperty('default')) { + return; + } + valueDataType = 'default'; + } + fields + .filter('[data-property-id="' + propertyId + '"][data-data-types="' + dataTypesByProperty[propertyId][valueDataType] + '"]') + .find('.inputs .values') + .append($(this)); + }); + }); + } + }) + .fail(function() { + console.log('Failed loading resource template from API'); + }) + .always(finalize); + } else { + finalize(); + } + + function finalize() { + // Remove empty properties, except the templates ones. + // TODO Keep properties selected by user (remove the "default-value"?). + fields = templateId ? $('#properties .resource-values[data-template-id!="' + templateId + '"]') : $('#properties .resource-values'); + fields.each(function() { + if ($(this).find('.inputs .values > .value').length === $(this).find('.inputs .values > .value.default-value').length) { + $(this).remove(); + } }); - } - var makeDefaultValue = function (term, type) { - return makeNewValue(term, null, type) - .addClass('default-value') - .one('change', '*', function (event) { - $(event.delegateTarget).removeClass('default-value'); + // Add default fields if none. + if (!$('#properties .resource-values').length) { + makeDefaultTemplate(); + } + + // Add a default value if none already exist in the property. + fields = $('#properties .resource-values'); + fields.each(function(index, field) { + field = $(field); + if (!field.find('.value').length) { + field.find('.inputs .values').append( + makeDefaultValue(field.data('property-term'), field.find('.add-value:visible:first').data('type')) + ); + } }); - }; + + fields.find('input.value-language').each(initValueLanguage); + + $('#properties').closest('form').trigger('o:template-applied'); + }; + } + + var initValueLanguage = function() { + var languageInput = $(this); + if (languageInput.val() !== '') { + languageInput.addClass('active'); + languageInput.prev('a.value-language').addClass('active'); + } + } /** * Initialize the page. */ var initPage = function() { - if (typeof valuesJson == 'undefined') { - makeNewField('dcterms:title').find('.values') - .append(makeDefaultValue('dcterms:title', 'literal')); - makeNewField('dcterms:description').find('.values') - .append(makeDefaultValue('dcterms:description', 'literal')); - } else { + // Prepare the form with values if any, else an empty template will be displayed. + if (typeof valuesJson !== 'undefined') { $.each(valuesJson, function(term, valueObj) { var field = makeNewField(term); $.each(valueObj.values, function(index, value) { - field.find('.values').append(makeNewValue(term, value)); + field.find('.values').append(makeNewValue(term, null, value)); }); }); } + // Adapt the form for the template, if any. var applyTemplateClass = $('body').hasClass('add'); - $.when(applyResourceTemplate(applyTemplateClass)).done(function () { - $('#properties').closest('form').trigger('o:form-loaded'); - }); - - $('input.value-language').each(function() { - var languageInput = $(this); - if (languageInput.val() !== "") { - languageInput.addClass('active'); - languageInput.prev('a.value-language').addClass('active'); - } - }); + $.when(applyResourceTemplate(applyTemplateClass)) + .done(function () { + $('#properties').closest('form').trigger('o:form-loaded'); + }); }; -})(jQuery); +})(jQuery); diff --git a/application/asset/js/resource-template-form.js b/application/asset/js/resource-template-form.js index d54c9dc33f..9ea16fea8a 100644 --- a/application/asset/js/resource-template-form.js +++ b/application/asset/js/resource-template-form.js @@ -21,13 +21,18 @@ new Sortable(propertyList[0], { $('#property-selector .selector-child').click(function(e) { e.preventDefault(); var propertyId = $(this).closest('li').data('property-id'); - if ($('#properties li[data-property-id="' + propertyId + '"]').length) { - // Resource templates cannot be assigned duplicate properties. - return; - } $.get(propertyList.data('addNewPropertyRowUrl'), {property_id: propertyId}) .done(function(data) { + // Check if the property is the template title or description. propertyList.append(data); + if (propertyId == titleProperty.val()) { + $('.title-property-cell').remove(); + $('#properties .property[data-property-id=' + propertyId + ']').find('.actions').before(titlePropertyTemplate); + } + if (propertyId == descriptionProperty.val()) { + $('.description-property-cell').remove(); + $('#properties .property[data-property-id=' + propertyId + ']').find('.actions').before(descriptionPropertyTemplate); + } }); }); @@ -53,50 +58,87 @@ propertyList.on('click', '.property-restore', function(e) { propertyList.on('click', '.property-edit', function(e) { e.preventDefault(); + // Get values stored in the row. var prop = $(this).closest('.property'); - var propId = prop.data('property-id'); + var propertyId = prop.data('property-id'); var oriLabel = prop.find('.original-label'); - var altLabel = prop.find('.alternate-label'); + var altLabel = prop.find('[data-property-key="o:alternate_label"]'); var oriComment = prop.find('.original-comment'); - var altComment = prop.find('.alternate-comment'); - var isRequired = prop.find('.is-required'); - var isPrivate = prop.find('.is-private'); - var dataType = prop.find('.data-type'); + var altComment = prop.find('[data-property-key="o:alternate_comment"]'); + var isRequired = prop.find('[data-property-key="o:is_required"]'); + var isPrivate = prop.find('[data-property-key="o:is_private"]'); + var dataTypes = prop.find('[data-property-key="o:data_type"]'); + var settings = {}; + prop.find('[data-setting-key]').each(function(index, hiddenElement) { + settings[index] = $(hiddenElement); + }); - $('#original-label').text(oriLabel.val()); - $('#alternate-label').val(altLabel.val()); - $('#original-comment').text(oriComment.val()); - $('#alternate-comment').val(altComment.val()); - $('#is-title-property').prop('checked', propId == titleProperty.val()); - $('#is-description-property').prop('checked', propId == descriptionProperty.val()); - $('#is-required').prop('checked', isRequired.val()); - $('#is-private').prop('checked', isPrivate.val()); - $('#data-type option[value="' + dataType.val() + '"]').prop('selected', true); - $('#data-type').trigger('chosen:updated'); + // Copy values into the sidebar. + $('#edit-sidebar #original-label').text(oriLabel.val()); + $('#edit-sidebar #alternate-label').val(altLabel.val()); + $('#edit-sidebar #original-comment').text(oriComment.val()); + $('#edit-sidebar #alternate-comment').val(altComment.val()); + $('#edit-sidebar #is-title-property').prop('checked', propertyId == titleProperty.val()); + $('#edit-sidebar #is-description-property').prop('checked', propertyId == descriptionProperty.val()); + $('#edit-sidebar #is-required').prop('checked', isRequired.val() === '1'); + $('#edit-sidebar #is-private').prop('checked', isPrivate.val() === '1'); + $('#edit-sidebar #data-type').val(dataTypes.val().replace(/^,+|,+$/g, '').split(',')); + $('#edit-sidebar #data-type').trigger('chosen:updated'); + $.each(settings, function(index, hiddenElement) { + var settingKey = hiddenElement.data('setting-key'); + var sidebarElement = $('#edit-sidebar [data-setting-key="' + settingKey + '"]'); + var sidebarElementType = sidebarElement.prop('type') ? sidebarElement.prop('type') : sidebarElement.prop('nodeName').toLowerCase(); + if (sidebarElementType === 'checkbox') { + sidebarElement.prop('checked', hiddenElement.prop('checked')); + } else if (sidebarElementType === 'radio') { + $('#edit-sidebar [data-setting-key="' + hiddenElement.data('setting-key') + '"]') + .val([prop.find('[data-setting-key="' + hiddenElement.data('setting-key') + '"]:checked').val()]); + } else if (sidebarElementType === 'select' || sidebarElementType === 'select-multiple' ) { + sidebarElement.val(hiddenElement.val()); + sidebarElement.trigger('chosen:updated'); + } else { // Text, textarea, number… + sidebarElement.val(hiddenElement.val()); + } + }); + // When the sidebar fieldset is applied, store new values in the row. $('#set-changes').off('click.setchanges').on('click.setchanges', function(e) { - altLabel.val($('#alternate-label').val()); - prop.find('.alternate-label-cell').text($('#alternate-label').val()); - altComment.val($('#alternate-comment').val()); - if ($('#is-title-property').prop('checked')) { - titleProperty.val(propId); + altLabel.val($('#edit-sidebar #alternate-label').val()); + prop.find('.alternate-label-cell').text($('#edit-sidebar #alternate-label').val()); + altComment.val($('#edit-sidebar #alternate-comment').val()); + if ($('#edit-sidebar #is-title-property').prop('checked')) { + titleProperty.val(propertyId); $('.title-property-cell').remove(); prop.find('.actions').before(titlePropertyTemplate); - } else if (propId == titleProperty.val()) { + } else if (propertyId == titleProperty.val()) { titleProperty.val(null); $('.title-property-cell').remove(); } - if ($('#is-description-property').prop('checked')) { - descriptionProperty.val(propId); + if ($('#edit-sidebar #is-description-property').prop('checked')) { + descriptionProperty.val(propertyId); $('.description-property-cell').remove(); prop.find('.actions').before(descriptionPropertyTemplate); - } else if (propId == descriptionProperty.val()) { + } else if (propertyId == descriptionProperty.val()) { descriptionProperty.val(null); $('.description-property-cell').remove(); } - $('#is-required').prop('checked') ? isRequired.val(1) : isRequired.val(null); - $('#is-private').prop('checked') ? isPrivate.val(1) : isPrivate.val(null); - dataType.val($('#data-type').val()); + isRequired.val($('#edit-sidebar #is-required').prop('checked') ? '1' : ''); + isPrivate.val($('#edit-sidebar #is-private').prop('checked') ? '1' : ''); + dataTypes.val($('#edit-sidebar #data-type').val()); + // New fields are not yet stored in the row. + $('#edit-sidebar [data-setting-key]').each(function(index, sidebarElement) { + sidebarElement = $(sidebarElement); + var sidebarElementType = sidebarElement.prop('type') ? sidebarElement.prop('type') : sidebarElement.prop('nodeName').toLowerCase(); + var hiddenElement = prop.find('[data-setting-key="' + sidebarElement.data('setting-key') + '"]'); + if (sidebarElementType === 'checkbox') { + hiddenElement.prop('checked', sidebarElement.prop('checked')); + } else if (sidebarElementType === 'radio') { + prop.find('[data-setting-key="' + sidebarElement.data('setting-key') + '"]') + .val([$('#edit-sidebar [data-setting-key="' + sidebarElement.data('setting-key') + '"]:checked').val()]); + } else { + hiddenElement.val(sidebarElement.val()); + } + }); Omeka.closeSidebar($('#edit-sidebar')); }); diff --git a/application/config/module.config.php b/application/config/module.config.php index 74c4277434..ed7cce6a30 100644 --- a/application/config/module.config.php +++ b/application/config/module.config.php @@ -475,6 +475,7 @@ 'Omeka\Form\SiteForm' => Service\Form\SiteFormFactory::class, 'Omeka\Form\SiteSettingsForm' => Service\Form\SiteSettingsFormFactory::class, 'Omeka\Form\UserBatchUpdateForm' => Service\Form\UserBatchUpdateFormFactory::class, + 'Omeka\Form\Element\DataTypeSelect' => Service\Form\Element\DataTypeSelectFactory::class, 'Omeka\Form\Element\ResourceSelect' => Service\Form\Element\ResourceSelectFactory::class, 'Omeka\Form\Element\ResourceClassSelect' => Service\Form\Element\ResourceClassSelectFactory::class, 'Omeka\Form\Element\ResourceTemplateSelect' => Service\Form\Element\ResourceTemplateSelectFactory::class, diff --git a/application/data/doctrine-proxies/__CG__OmekaEntityResourceTemplateProperty.php b/application/data/doctrine-proxies/__CG__OmekaEntityResourceTemplateProperty.php index e12349eb53..138dd4a0f7 100644 --- a/application/data/doctrine-proxies/__CG__OmekaEntityResourceTemplateProperty.php +++ b/application/data/doctrine-proxies/__CG__OmekaEntityResourceTemplateProperty.php @@ -304,7 +304,7 @@ public function setPosition($position) /** * {@inheritDoc} */ - public function setDataType($dataType) + public function setDataType(array $dataType = NULL) { $this->__initializer__ && $this->__initializer__->__invoke($this, 'setDataType', [$dataType]); diff --git a/application/data/install/schema.sql b/application/data/install/schema.sql index a7d63957b2..3e7719bc67 100644 --- a/application/data/install/schema.sql +++ b/application/data/install/schema.sql @@ -194,13 +194,13 @@ CREATE TABLE `resource_template_property` ( `alternate_label` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `alternate_comment` longtext COLLATE utf8mb4_unicode_ci, `position` int(11) DEFAULT NULL, - `data_type` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `data_type` longtext COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '(DC2Type:json_array)', `is_required` tinyint(1) NOT NULL, `is_private` tinyint(1) NOT NULL, PRIMARY KEY (`id`), - UNIQUE KEY `UNIQ_4689E2F116131EA549213EC` (`resource_template_id`,`property_id`), KEY `IDX_4689E2F116131EA` (`resource_template_id`), KEY `IDX_4689E2F1549213EC` (`property_id`), + KEY `IDX_4689E2F116131EA549213EC` (`resource_template_id`, `property_id`), CONSTRAINT `FK_4689E2F116131EA` FOREIGN KEY (`resource_template_id`) REFERENCES `resource_template` (`id`), CONSTRAINT `FK_4689E2F1549213EC` FOREIGN KEY (`property_id`) REFERENCES `property` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/application/data/migrations/20200831000000_MutlipleDataTypes.php b/application/data/migrations/20200831000000_MutlipleDataTypes.php new file mode 100644 index 0000000000..5087b0fb5f --- /dev/null +++ b/application/data/migrations/20200831000000_MutlipleDataTypes.php @@ -0,0 +1,54 @@ +fetchAll($sql)) { + $sql = <<exec($sql); + } + } + + $sql = <<exec($sql); + + $sql = <<exec($sql); + + $sql = <<exec($sql); + + $sql = <<exec($sql); + } +} diff --git a/application/src/Api/Adapter/ResourceTemplateAdapter.php b/application/src/Api/Adapter/ResourceTemplateAdapter.php index 2bc8c59fb6..9eab50ed0c 100644 --- a/application/src/Api/Adapter/ResourceTemplateAdapter.php +++ b/application/src/Api/Adapter/ResourceTemplateAdapter.php @@ -60,23 +60,45 @@ public function validateRequest(Request $request, ErrorStore $errorStore) { $data = $request->getContent(); - // A resource template may not have duplicate properties. + // A resource template must not have duplicate properties with the same + // data types. Each data type is checked separately for each property, + // so a data type cannot be used multiple times with the same property. if (isset($data['o:resource_template_property']) && is_array($data['o:resource_template_property']) ) { - $propertyIds = []; + $checkDataTypes = []; + $checkDataTypesByProperty = []; foreach ($data['o:resource_template_property'] as $resTemPropData) { - if (!isset($resTemPropData['o:property']['o:id'])) { - continue; // skip when no property ID + if (empty($resTemPropData['o:property']['o:id'])) { + // Skip when no property ID. + continue; } $propertyId = $resTemPropData['o:property']['o:id']; - if (in_array($propertyId, $propertyIds)) { + + $dataTypes = empty($resTemPropData['o:data_type']) ? [] : $resTemPropData['o:data_type']; + sort($dataTypes); + $dataTypes = array_unique(array_filter(array_map('trim', $dataTypes))); + $dataTypesString = implode('|', $dataTypes); + $check = $propertyId . '-' . $dataTypesString; + if (isset($checkDataTypes[$check])) { $errorStore->addError('o:property', new Message( - 'Attempting to add duplicate property with ID %s', // @translate - $propertyId + 'Attempting to add duplicate property "%s" (ID %s) with the same data types', // @translate + $resTemPropData['o:original_label'] ?? '', $propertyId )); } - $propertyIds[] = $propertyId; + $checkDataTypes[$check] = true; + + if (empty($checkDataTypesByProperty[$propertyId])) { + $checkDataTypesByProperty[$propertyId] = $dataTypes; + } elseif ($dataTypes) { + if (array_intersect($dataTypes, $checkDataTypesByProperty[$propertyId])) { + $errorStore->addError('o:property', new Message( + 'Attempting to add the same data types to the same property "%s" (ID %s)', // @translate + $resTemPropData['o:original_label'] ?? '', $propertyId + )); + } + $checkDataTypesByProperty[$propertyId] = array_merge($checkDataTypesByProperty[$propertyId], $dataTypes); + } } } } @@ -96,6 +118,7 @@ public function validateEntity(EntityInterface $entity, public function hydrate(Request $request, EntityInterface $entity, ErrorStore $errorStore ) { + /** @var \Omeka\Entity\ResourceTemplate $entity */ $data = $request->getContent(); $this->hydrateOwner($request, $entity); $this->hydrateResourceClass($request, $entity); @@ -128,27 +151,19 @@ public function hydrate(Request $request, EntityInterface $entity, && isset($data['o:resource_template_property']) && is_array($data['o:resource_template_property']) ) { - - // Get a resource template property by property ID. - $getResTemProp = function ($propertyId, $resTemProps) { - foreach ($resTemProps as $resTemProp) { - if ($propertyId == $resTemProp->getProperty()->getId()) { - return $resTemProp; - } - } - return null; - }; - $propertyAdapter = $this->getAdapter('properties'); $resTemProps = $entity->getResourceTemplateProperties(); - $resTemPropsToRetain = []; + $resTemProps->first(); + $totalExisting = count($resTemProps); + // Position is one-based. $position = 1; foreach ($data['o:resource_template_property'] as $resTemPropData) { - if (!isset($resTemPropData['o:property']['o:id'])) { + if (empty($resTemPropData['o:property']['o:id'])) { continue; // skip when no property ID } - $propertyId = $resTemPropData['o:property']['o:id']; + $propertyId = (int) $resTemPropData['o:property']['o:id']; + $altLabel = null; if (isset($resTemPropData['o:alternate_label']) && '' !== trim($resTemPropData['o:alternate_label']) @@ -161,11 +176,9 @@ public function hydrate(Request $request, EntityInterface $entity, ) { $altComment = $resTemPropData['o:alternate_comment']; } - $dataType = null; - if (isset($resTemPropData['o:data_type']) - && '' !== trim($resTemPropData['o:data_type']) - ) { - $dataType = $resTemPropData['o:data_type']; + $dataTypes = null; + if (!empty($resTemPropData['o:data_type'])) { + $dataTypes = array_values(array_unique(array_filter(array_map('trim', $resTemPropData['o:data_type'])))); } $isRequired = false; if (isset($resTemPropData['o:is_required'])) { @@ -176,36 +189,30 @@ public function hydrate(Request $request, EntityInterface $entity, $isPrivate = (bool) $resTemPropData['o:is_private']; } - // Check whether a passed property is already assigned to this - // resource template. - $resTemProp = $getResTemProp($propertyId, $resTemProps); - if (!$resTemProp) { - // It is not assigned. Add a new resource template property. - // No need to explicitly add it to the collection since it - // is added implicitly when setting the resource template. - $property = $propertyAdapter->findEntity($propertyId); + // Reuse existing records, because id has no meaning. + if ($position <= $totalExisting) { + $resTemProp = $resTemProps[$position - 1]; + } else { $resTemProp = new ResourceTemplateProperty; - $resTemProp->setResourceTemplate($entity); - $resTemProp->setProperty($property); - $entity->getResourceTemplateProperties()->add($resTemProp); + $resTemProps->add($resTemProp); } + + $resTemProp->setResourceTemplate($entity); + $resTemProp->setProperty($propertyAdapter->findEntity($propertyId)); $resTemProp->setAlternateLabel($altLabel); $resTemProp->setAlternateComment($altComment); - $resTemProp->setDataType($dataType); + $resTemProp->setDataType($dataTypes); $resTemProp->setIsRequired($isRequired); $resTemProp->setIsPrivate($isPrivate); // Set the position of the property to its intrinsic order // within the passed array. $resTemProp->setPosition($position++); - $resTemPropsToRetain[] = $resTemProp; } - // Remove resource template properties that were not included in the - // passed data. - foreach ($resTemProps as $resTemPropId => $resTemProp) { - if (!in_array($resTemProp, $resTemPropsToRetain)) { - $resTemProps->remove($resTemPropId); - } + // Remove remaining resource template properties that were not + // included in the passed data. + for (; $position <= $totalExisting; $position++) { + $resTemProps->remove($position - 1); } } } diff --git a/application/src/Api/Representation/AbstractResourceEntityRepresentation.php b/application/src/Api/Representation/AbstractResourceEntityRepresentation.php index 05ff57a9b4..a835e8af04 100644 --- a/application/src/Api/Representation/AbstractResourceEntityRepresentation.php +++ b/application/src/Api/Representation/AbstractResourceEntityRepresentation.php @@ -12,12 +12,14 @@ abstract class AbstractResourceEntityRepresentation extends AbstractEntityRepresentation { /** - * All value representations of this resource, organized by property. + * All value representations of this resource, organized by property term. * * * array( * {JSON-LD term} => array( * 'property' => {property representation}, + * 'alternate_label' => {label}, + * 'alternate_comment' => {comment}, * 'values' => { * {value representation}, * {value representation}, @@ -30,6 +32,7 @@ abstract class AbstractResourceEntityRepresentation extends AbstractEntityRepres * @var array */ protected $values; + protected $valuesByTemplateProperty; /** * Get the internal members of this resource entity. @@ -222,7 +225,11 @@ public function modified() } /** - * Get all value representations of this resource. + * Get all value representations of this resource, by term or template row. + * + * The two outputs are the same when there are no duplicated property in the + * template. The key for template row are "term" for the first property, then + * "term-ResourceTemplateProperty position" when the property is duplicated. * * * array( @@ -233,40 +240,73 @@ public function modified() * 'values' => array( * {ValueRepresentation}, * {ValueRepresentation}, + * {…}, * ), * ), * ) * * + * @param $byTemplateProperty * @return array */ - public function values() + public function values($byTemplateProperty = false) { if (isset($this->values)) { - return $this->values; + return $byTemplateProperty + ? $this->valuesByTemplateProperty + : $this->values; } - // Set the default template info. - $templateInfo = [ - 'dcterms:title' => [], - 'dcterms:description' => [], - ]; + $values = []; + $valuesByTemplateProperty = []; + $dataTypesByProperty = []; + $hasDuplicate = false; + // Set the default template info one time. $template = $this->resourceTemplate(); if ($template) { - // Set the custom template info. - $templateInfo = []; foreach ($template->resourceTemplateProperties() as $templateProperty) { - $term = $templateProperty->property()->term(); - $templateInfo[$term] = [ + $property = $templateProperty->property(); + $term = $property->term(); + $dataTypes = $templateProperty->dataTypes(); + // Manage an exception. + if (in_array('resource', $dataTypes)) { + $dataTypes = array_unique(array_merge($dataTypes, ['resource:item', 'resource:itemset', 'resource:media'])); + } + $keyTemplateProperty = $term . '-' . $templateProperty->position(); + // With duplicate properties, keep only the first label and + // comment. + if (isset($values[$term])) { + $hasDuplicate = true; + $valuesByTemplateProperty[$keyTemplateProperty] = [ + 'property' => $property, + 'alternate_label' => $templateProperty->alternateLabel(), + 'alternate_comment' => $templateProperty->alternateComment(), + ]; + $dataTypesByProperty[$term] += empty($dataTypes) + ? ['default' => $keyTemplateProperty] + : array_fill_keys($dataTypes, $keyTemplateProperty); + continue; + } + $values[$term] = [ + 'property' => $property, 'alternate_label' => $templateProperty->alternateLabel(), 'alternate_comment' => $templateProperty->alternateComment(), ]; + $valuesByTemplateProperty[$term] = $values[$term]; + $dataTypesByProperty[$term] = empty($dataTypes) + ? ['default' => $term] + : array_fill_keys($dataTypes, $term); } + } else { + // Force prepend title and description when there is no template. + $values = [ + 'dcterms:title' => [], + 'dcterms:description' => [], + ]; } // Get this resource's values. - $values = []; foreach ($this->resource->getValues() as $valueEntity) { $value = new ValueRepresentation($valueEntity, $this->getServiceLocator()); if ($value->isHidden()) { @@ -283,25 +323,50 @@ public function values() $values[$term]['values'][] = $value; } - // Order this resource's properties according to the template order. - $sortedValues = []; - foreach ($values as $term => $valueInfo) { - foreach ($templateInfo as $templateTerm => $templateAlternates) { - if (array_key_exists($templateTerm, $values)) { - $sortedValues[$templateTerm] = - array_merge($values[$templateTerm], $templateAlternates); - } - } - } - - $values = $sortedValues + $values; + // Remove terms without values. + $removeEmpty = function ($v) { + return !empty($v['values']); + }; + $values = array_filter($values, $removeEmpty); $eventManager = $this->getEventManager(); $args = $eventManager->prepareArgs(['values' => $values]); $eventManager->trigger('rep.resource.values', $this, $args); $this->values = $args['values']; - return $this->values; + + // Prepare the list for template with duplicated properties after the + // event above. + // Note: duplicated properties don't have duplicated data types, so + // values can be remapped directly. + if ($template && $hasDuplicate) { + foreach ($this->values as $term => $data) { + foreach ($data['values'] as $value) { + $dataType = $value->type(); + if (isset($dataTypesByProperty[$term][$dataType])) { + $keyTemplateProperty = $dataTypesByProperty[$term][$dataType]; + } elseif (isset($dataTypesByProperty[$term]['default'])) { + $keyTemplateProperty = $dataTypesByProperty[$term]['default']; + } else { + $keyTemplateProperty = $term; + } + if (!isset($valuesByTemplateProperty[$keyTemplateProperty]['property'])) { + $valuesByTemplateProperty[$keyTemplateProperty]['property'] = $value->property(); + $valuesByTemplateProperty[$keyTemplateProperty]['alternate_label'] = null; + $valuesByTemplateProperty[$keyTemplateProperty]['alternate_comment'] = null; + } + $valuesByTemplateProperty[$keyTemplateProperty]['values'][] = $value; + } + } + // Remove keys without values. + $this->valuesByTemplateProperty = array_filter($valuesByTemplateProperty, $removeEmpty); + } else { + $this->valuesByTemplateProperty = $this->values; + } + + return $byTemplateProperty + ? $this->valuesByTemplateProperty + : $this->values; } /** @@ -309,7 +374,7 @@ public function values() * * @param string $term The prefix:local_part * @param array $options - * - type: (null) Get values of this type only. Valid types are "literal", + * - type: (null) Get values of this type only. Default types are "literal", * "uri", and "resource". Returns all types by default. * - all: (false) If true, returns all values that match criteria. If false, * returns the first matching value. @@ -322,18 +387,12 @@ public function values() public function value($term, array $options = []) { // Set defaults. - if (!isset($options['type'])) { - $options['type'] = null; - } - if (!isset($options['all'])) { - $options['all'] = false; - } - if (!isset($options['default'])) { - $options['default'] = $options['all'] ? [] : null; - } - if (!isset($options['lang'])) { - $options['lang'] = null; - } + $options += [ + 'type' => null, + 'all' => false, + 'default' => isset($options['all']) ? [] : null, + 'lang' => null, + ]; if (!$this->getAdapter()->isTerm($term)) { return $options['default']; @@ -449,14 +508,14 @@ public function displayValues(array $options = []) $partial = $this->getViewHelper('partial'); $eventManager = $this->getEventManager(); - $args = $eventManager->prepareArgs(['values' => $this->values()]); + $args = $eventManager->prepareArgs(['values' => $this->values(true)]); $eventManager->trigger('rep.resource.display_values', $this, $args); $options['values'] = $args['values']; $template = $this->resourceTemplate(); - if ($template) { - $options['templateProperties'] = $template->resourceTemplateProperties(); - } + $options['templateProperties'] = $template + ? $template->resourceTemplateProperties() + : []; return $partial($options['viewName'], $options); } diff --git a/application/src/Api/Representation/ResourceTemplatePropertyRepresentation.php b/application/src/Api/Representation/ResourceTemplatePropertyRepresentation.php index 26f2cfdd59..13d9a78be1 100644 --- a/application/src/Api/Representation/ResourceTemplatePropertyRepresentation.php +++ b/application/src/Api/Representation/ResourceTemplatePropertyRepresentation.php @@ -2,7 +2,6 @@ namespace Omeka\Api\Representation; use Omeka\Entity\ResourceTemplateProperty; -use Laminas\ServiceManager\Exception\ServiceNotFoundException; use Laminas\ServiceManager\ServiceLocatorInterface; class ResourceTemplatePropertyRepresentation extends AbstractRepresentation @@ -31,7 +30,7 @@ public function jsonSerialize() 'o:property' => $this->property()->getReference(), 'o:alternate_label' => $this->alternateLabel(), 'o:alternate_comment' => $this->alternateComment(), - 'o:data_type' => $this->dataType(), + 'o:data_type' => $this->dataTypes(), 'o:is_required' => $this->isRequired(), 'o:is_private' => $this->isPrivate(), ]; @@ -80,22 +79,46 @@ public function position() } /** + * @deprecated Since version 3.0.0. Use dataTypes() instead. * @return string|null */ public function dataType() { // Check the data type against the list of registered data types. - $dataType = $this->templateProperty->getDataType(); - try { - $this->getServiceLocator()->get('Omeka\DataTypeManager')->get($dataType); - } catch (ServiceNotFoundException $e) { - // Treat an unknown data type as "Default" - $dataType = null; + $dataTypes = $this->templateProperty->getDataType(); + if (empty($dataTypes)) { + return null; } - return $dataType; + $dataType = reset($dataTypes); + // Treat an unknown data type as "Default". + return $this->getServiceLocator()->get('Omeka\DataTypeManager')->has($dataType) + ? $dataType + : null; } /** + * @return string[] + */ + public function dataTypes() + { + // Check the data type against the list of registered data types. + $dataTypes = $this->templateProperty->getDataType(); + if (empty($dataTypes)) { + return []; + } + $dataTypeManager = $this->getServiceLocator()->get('Omeka\DataTypeManager'); + $result = []; + foreach ($dataTypes as $dataType) { + // Treat an unknown data type as "Default". + if ($dataTypeManager->has($dataType)) { + $result[] = $dataType; + } + } + return $result; + } + + /** + * @deprecated Since version 3.0.0. Use dataTypeLabels() instead. * @return string */ public function dataTypeLabel() @@ -108,6 +131,22 @@ public function dataTypeLabel() ->get($dataType)->getLabel(); } + /** + * @return array List of data type names and labels. + */ + public function dataTypeLabels() + { + $result = []; + $dataTypeManager = $this->getServiceLocator()->get('Omeka\DataTypeManager'); + foreach ($this->dataTypes() as $dataType) { + $result[] = [ + 'name' => $dataType, + 'label' => $dataTypeManager->get($dataType)->getLabel(), + ]; + } + return $result; + } + /** * @return bool */ diff --git a/application/src/Api/Representation/ResourceTemplateRepresentation.php b/application/src/Api/Representation/ResourceTemplateRepresentation.php index c46ad91f43..3bf69d5244 100644 --- a/application/src/Api/Representation/ResourceTemplateRepresentation.php +++ b/application/src/Api/Representation/ResourceTemplateRepresentation.php @@ -76,7 +76,7 @@ public function resourceClass() /** * Return the title property of this resource template. * - * @return ResourceClassRepresentation + * @return PropertyRepresentation */ public function titleProperty() { @@ -87,7 +87,7 @@ public function titleProperty() /** * Return the description property of this resource template. * - * @return ResourceClassRepresentation + * @return PropertyRepresentation */ public function descriptionProperty() { @@ -98,14 +98,15 @@ public function descriptionProperty() /** * Return the properties assigned to this resource template. * - * @return array + * @return ResourceTemplatePropertyRepresentation[] */ public function resourceTemplateProperties() { $resTemProps = []; + $services = $this->getServiceLocator(); foreach ($this->resource->getResourceTemplateProperties() as $resTemProp) { $resTemProps[] = new ResourceTemplatePropertyRepresentation( - $resTemProp, $this->getServiceLocator()); + $resTemProp, $services); } return $resTemProps; } @@ -114,15 +115,44 @@ public function resourceTemplateProperties() * Return the specified template property or null if it doesn't exist. * * @param int $propertyId - * @mixed ResourceTemplatePropertyRepresentation + * @param string $dataType + * @param bool $all + * @mixed ResourceTemplatePropertyRepresentation|ResourceTemplatePropertyRepresentation[]|null */ - public function resourceTemplateProperty($propertyId) + public function resourceTemplateProperty($propertyId, $dataType = null, $all = false) { - $resTemProp = $this->resource->getResourceTemplateProperties()->get($propertyId); - if ($resTemProp) { - return new ResourceTemplatePropertyRepresentation($resTemProp, $this->getServiceLocator()); + $propertyId = (int) $propertyId; + $resTemProps = $this->resource->getResourceTemplateProperties() + ->filter(function (\Omeka\Entity\ResourceTemplateProperty $resTemProp) use ($propertyId, $dataType, $all) { + if ($resTemProp->getProperty()->getId() !== $propertyId) { + return false; + } + if (empty($dataType)) { + return true; + } + $dataTypes = $resTemProp->getDataType(); + return in_array($dataType, $dataTypes); + }); + if (!count($resTemProps)) { + return $all ? [] : null; + } + + $services = $this->getServiceLocator(); + if ($all) { + return array_map(function ($resTemProp) use ($services) { + return new ResourceTemplatePropertyRepresentation($resTemProp, $services); + }, $resTemProps); + } else { + // Return the template property without data type, if any. + if (empty($dataType) && count($resTemProps) > 1) { + foreach ($resTemProps as $resTemProp) { + if (!$resTemProp->getDataType()) { + return new ResourceTemplatePropertyRepresentation($resTemProp, $services); + } + } + } + return new ResourceTemplatePropertyRepresentation($resTemProps->first(), $services); } - return null; } /** diff --git a/application/src/Controller/Admin/ResourceTemplateController.php b/application/src/Controller/Admin/ResourceTemplateController.php index 318a696562..1be4ebe0e4 100644 --- a/application/src/Controller/Admin/ResourceTemplateController.php +++ b/application/src/Controller/Admin/ResourceTemplateController.php @@ -79,12 +79,14 @@ public function importAction() } } else { // Process review import form. - $import = json_decode($this->params()->fromPost('import'), true); - $dataTypes = $this->params()->fromPost('data_types', []); - + $import = json_decode($form->getData()['import'], true); $import['o:label'] = $this->params()->fromPost('label'); - foreach ($dataTypes as $key => $dataType) { - $import['o:resource_template_property'][$key]['o:data_type'] = $dataType; + + $dataTypes = $this->params()->fromPost('data_types'); + if ($dataTypes) { + foreach ($dataTypes as $key => $dataTypeList) { + $import['o:resource_template_property'][$key]['o:data_type'] = $dataTypeList; + } } $response = $this->api($form)->create('resource_templates', $import); @@ -121,6 +123,8 @@ public function importAction() * the property. By design, the API will only hydrate members and data types * that are flagged as valid. * + * @todo Manage direct import of data types from Value Suggest and other modules. + * * @param array $import * @return array */ @@ -163,6 +167,21 @@ protected function flagValid(array $import) } } + foreach (['o:title_property', 'o:description_property'] as $property) { + if (isset($import[$property])) { + if ($vocab = $getVocab($import[$property]['vocabulary_namespace_uri'])) { + $import[$property]['vocabulary_prefix'] = $vocab->prefix(); + $prop = $this->api()->searchOne('properties', [ + 'vocabulary_namespace_uri' => $import[$property]['vocabulary_namespace_uri'], + 'local_name' => $import[$property]['local_name'], + ])->getContent(); + if ($prop) { + $import[$property]['o:id'] = $prop->id(); + } + } + } + } + foreach ($import['o:resource_template_property'] as $key => $property) { if ($vocab = $getVocab($property['vocabulary_namespace_uri'])) { $import['o:resource_template_property'][$key]['vocabulary_prefix'] = $vocab->prefix(); @@ -172,8 +191,25 @@ protected function flagValid(array $import) ])->getContent(); if ($prop) { $import['o:resource_template_property'][$key]['o:property'] = ['o:id' => $prop->id()]; - if (in_array($import['o:resource_template_property'][$key]['data_type_name'], $dataTypes)) { - $import['o:resource_template_property'][$key]['o:data_type'] = $import['o:resource_template_property'][$key]['data_type_name']; + // Check the deprecated "data_type_name" if needed and + // normalize it. + if (!array_key_exists('data_types', $import['o:resource_template_property'][$key])) { + $import['o:resource_template_property'][$key]['data_types'] = [[ + 'name' => $import['o:resource_template_property'][$key]['data_type_name'], + 'label' => $import['o:resource_template_property'][$key]['data_type_label'], + ]]; + } + $importDataTypes = []; + foreach ($import['o:resource_template_property'][$key]['data_types'] as $dataType) { + $importDataTypes[$dataType['name']] = $dataType; + } + $import['o:resource_template_property'][$key]['data_types'] = $importDataTypes; + // Prepare the list of standard data types. + $import['o:resource_template_property'][$key]['o:data_type'] = []; + foreach ($importDataTypes as $name => $importDataType) { + if (in_array($name, $dataTypes)) { + $import['o:resource_template_property'][$key]['o:data_type'][] = $importDataType['name']; + } } } } @@ -224,6 +260,32 @@ protected function importIsValid($import) } } + // Validate title and description. + foreach (['o:title_property', 'o:description_property'] as $property) { + if (isset($import[$property])) { + if (!is_array($import[$property])) { + // Invalid property. + return false; + } + if (!array_key_exists('vocabulary_namespace_uri', $import[$property]) + || !array_key_exists('vocabulary_label', $import[$property]) + || !array_key_exists('local_name', $import[$property]) + || !array_key_exists('label', $import[$property]) + ) { + // Missing a property info. + return false; + } + if (!is_string($import[$property]['vocabulary_namespace_uri']) + || !is_string($import[$property]['vocabulary_label']) + || !is_string($import[$property]['local_name']) + || !is_string($import[$property]['label']) + ) { + // Invalid property info. + return false; + } + } + } + // Validate properties. if (!isset($import['o:resource_template_property']) || !is_array($import['o:resource_template_property'])) { // missing or invalid o:resource_template_property @@ -235,6 +297,11 @@ protected function importIsValid($import) // invalid o:resource_template_property format return false; } + + // Manage import from an export of Omeka < 3.0. + $oldExport = !array_key_exists('data_types', $property); + + // Check missing o:resource_template_property info. if (!array_key_exists('vocabulary_namespace_uri', $property) || !array_key_exists('vocabulary_label', $property) || !array_key_exists('local_name', $property) @@ -243,12 +310,17 @@ protected function importIsValid($import) || !array_key_exists('o:alternate_comment', $property) || !array_key_exists('o:is_required', $property) || !array_key_exists('o:is_private', $property) - || !array_key_exists('data_type_name', $property) - || !array_key_exists('data_type_label', $property) ) { - // missing o:resource_template_property info return false; } + if ($oldExport + && (!array_key_exists('data_type_name', $property) + || !array_key_exists('data_type_label', $property) + )) { + return false; + } + + // Check invalid o:resource_template_property info. if (!is_string($property['vocabulary_namespace_uri']) || !is_string($property['vocabulary_label']) || !is_string($property['local_name']) @@ -257,10 +329,16 @@ protected function importIsValid($import) || (!is_string($property['o:alternate_comment']) && !is_null($property['o:alternate_comment'])) || !is_bool($property['o:is_required']) || !is_bool($property['o:is_private']) - || (!is_string($property['data_type_name']) && !is_null($property['data_type_name'])) - || (!is_string($property['data_type_label']) && !is_null($property['data_type_label'])) ) { - // invalid o:resource_template_property info + return false; + } + if ($oldExport) { + if ((!is_string($property['data_type_name']) && !is_null($property['data_type_name'])) + || (!is_string($property['data_type_label']) && !is_null($property['data_type_label'])) + ) { + return false; + } + } elseif (!is_array($property['data_types']) && !is_null($property['data_types'])) { return false; } } @@ -269,8 +347,11 @@ protected function importIsValid($import) public function exportAction() { + /** @var \Omeka\Api\Representation\ResourceTemplateRepresentation $template */ $template = $this->api()->read('resource_templates', $this->params('id'))->getContent(); $templateClass = $template->resourceClass(); + $templateTitle = $template->titleProperty(); + $templateDescription = $template->descriptionProperty(); $templateProperties = $template->resourceTemplateProperties(); $export = [ @@ -288,25 +369,37 @@ public function exportAction() ]; } + if ($templateTitle) { + $vocab = $templateTitle->vocabulary(); + $export['o:title_property'] = [ + 'vocabulary_namespace_uri' => $vocab->namespaceUri(), + 'vocabulary_label' => $vocab->label(), + 'local_name' => $templateTitle->localName(), + 'label' => $templateTitle->label(), + ]; + } + + if ($templateDescription) { + $vocab = $templateDescription->vocabulary(); + $export['o:description_property'] = [ + 'vocabulary_namespace_uri' => $vocab->namespaceUri(), + 'vocabulary_label' => $vocab->label(), + 'local_name' => $templateDescription->localName(), + 'label' => $templateDescription->label(), + ]; + } + foreach ($templateProperties as $templateProperty) { $property = $templateProperty->property(); $vocab = $property->vocabulary(); - $dataTypeName = $templateProperty->dataType(); - $dataTypeLabel = null; - if ($dataTypeName) { - $dataType = $this->dataTypeManager->get($dataTypeName); - $dataTypeLabel = $dataType->getLabel(); - } - // Note that "position" is implied by array order. $export['o:resource_template_property'][] = [ 'o:alternate_label' => $templateProperty->alternateLabel(), 'o:alternate_comment' => $templateProperty->alternateComment(), 'o:is_required' => $templateProperty->isRequired(), 'o:is_private' => $templateProperty->isPrivate(), - 'data_type_name' => $dataTypeName, - 'data_type_label' => $dataTypeLabel, + 'data_types' => $templateProperty->dataTypeLabels(), 'vocabulary_namespace_uri' => $vocab->namespaceUri(), 'vocabulary_label' => $vocab->label(), 'local_name' => $property->localName(), @@ -361,25 +454,25 @@ public function deleteAction() public function addAction() { - return $this->getAddEditView(); + return $this->getAddEditView(false); } public function editAction() { - return $this->getAddEditView(); + return $this->getAddEditView(true); } /** * Get the add/edit view. * + * @param bool $isUpdate * @return ViewModel */ - protected function getAddEditView() + protected function getAddEditView($isUpdate = false) { - $action = $this->params('action'); $form = $this->getForm(ResourceTemplateForm::class); - if ('edit' == $action) { + if ($isUpdate) { $resourceTemplate = $this->api() ->read('resource_templates', $this->params('id')) ->getContent(); @@ -394,17 +487,28 @@ protected function getAddEditView() $data['o:description_property[o:id]'] = $data['o:description_property']->id(); } $form->setData($data); + } else { + $resourceTemplate = null; } if ($this->getRequest()->isPost()) { $data = $this->params()->fromPost(); $form->setData($data); if ($form->isValid()) { - $response = ('edit' === $action) + foreach ($data['o:resource_template_property'] as $key => $dataProperty) { + if (empty($dataProperty['o:data_type'])) { + $data['o:resource_template_property'][$key]['o:data_type'] = []; + } elseif (is_array($dataProperty['o:data_type'])) { + $data['o:resource_template_property'][$key]['o:data_type'] = $dataProperty['o:data_type']; + } else { + $data['o:resource_template_property'][$key]['o:data_type'] = explode(',', $dataProperty['o:data_type']); + } + } + $response = $isUpdate ? $this->api($form)->update('resource_templates', $resourceTemplate->id(), $data) : $this->api($form)->create('resource_templates', $data); if ($response) { - if ('edit' === $action) { + if ($isUpdate) { $successMessage = 'Resource template successfully updated'; // @translate } else { $successMessage = new Message( @@ -420,16 +524,15 @@ protected function getAddEditView() $this->messenger()->addSuccess($successMessage); return $this->redirect()->toUrl($response->getContent()->url()); } + $this->messenger()->addFormErrors($form); } else { $this->messenger()->addFormErrors($form); } } $view = new ViewModel; - if ('edit' === $action) { - $view->setVariable('resourceTemplate', $resourceTemplate); - } - $view->setVariable('propertyRows', $this->getPropertyRows()); + $view->setVariable('resourceTemplate', $resourceTemplate); + $view->setVariable('propertyRows', $this->getPropertyRows($isUpdate)); $view->setVariable('form', $form); return $view; } @@ -437,12 +540,11 @@ protected function getAddEditView() /** * Get the property rows for the add/edit form. * + * @param bool $isUpdate * @return array */ - protected function getPropertyRows() + protected function getPropertyRows($isUpdate) { - $action = $this->params('action'); - if ($this->getRequest()->isPost()) { // Set POSTed property rows $data = $this->params()->fromPost(); @@ -456,22 +558,23 @@ protected function getPropertyRows() $property = $this->api()->read( 'properties', $propertyRow['o:property']['o:id'] )->getContent(); - $propertyRows[$property->id()]['o:property'] = $property; + $propertyRows[$key]['o:property'] = $property; } } else { // Set default property rows $propertyRows = []; - if ('edit' == $action) { + if ($isUpdate) { + /** @var \Omeka\Api\Representation\ResourceTemplateRepresentation $resourceTemplate */ $resourceTemplate = $this->api() ->read('resource_templates', $this->params('id')) ->getContent(); $resTemProps = $resourceTemplate->resourceTemplateProperties(); - foreach ($resTemProps as $key => $resTemProp) { - $propertyRows[$key] = [ + foreach ($resTemProps as $resTemProp) { + $propertyRows[] = [ 'o:property' => $resTemProp->property(), 'o:alternate_label' => $resTemProp->alternateLabel(), 'o:alternate_comment' => $resTemProp->alternateComment(), - 'o:data_type' => $resTemProp->dataType(), + 'o:data_type' => $resTemProp->dataTypes(), 'o:is_required' => $resTemProp->isRequired(), 'o:is_private' => $resTemProp->isPrivate(), ]; @@ -490,7 +593,7 @@ protected function getPropertyRows() 'o:property' => $titleProperty, 'o:alternate_label' => null, 'o:alternate_comment' => null, - 'o:data_type' => null, + 'o:data_type' => [], 'o:is_required' => false, 'o:is_private' => false, ], @@ -498,7 +601,7 @@ protected function getPropertyRows() 'o:property' => $descriptionProperty, 'o:alternate_label' => null, 'o:alternate_comment' => null, - 'o:data_type' => null, + 'o:data_type' => [], 'o:is_required' => false, 'o:is_private' => false, ], @@ -525,15 +628,19 @@ public function addNewPropertyRowAction() 'o:property' => $property, 'o:alternate_label' => null, 'o:alternate_comment' => null, - 'o:data_type' => null, + 'o:data_type' => [], 'o:is_required' => false, 'o:is_private' => false, ]; + $namePrefix = 'o:resource_template_property[' . rand(PHP_INT_MAX / 1000000, PHP_INT_MAX) . ']'; + $view = new ViewModel; $view->setTerminal(true); $view->setTemplate('omeka/admin/resource-template/show-property-row'); + $view->setVariable('resourceTemplate', null); $view->setVariable('propertyRow', $propertyRow); + $view->setVariable('namePrefix', $namePrefix); return $view; } } diff --git a/application/src/Entity/ResourceTemplate.php b/application/src/Entity/ResourceTemplate.php index 12a126ae6c..872953cc6d 100644 --- a/application/src/Entity/ResourceTemplate.php +++ b/application/src/Entity/ResourceTemplate.php @@ -49,8 +49,7 @@ class ResourceTemplate extends AbstractEntity * targetEntity="ResourceTemplateProperty", * mappedBy="resourceTemplate", * orphanRemoval=true, - * cascade={"persist", "remove", "detach"}, - * indexBy="property_id" + * cascade={"persist", "remove", "detach"} * ) * @OrderBy({"position" = "ASC"}) */ diff --git a/application/src/Entity/ResourceTemplateProperty.php b/application/src/Entity/ResourceTemplateProperty.php index 4df4443f33..c0a1fb2588 100644 --- a/application/src/Entity/ResourceTemplateProperty.php +++ b/application/src/Entity/ResourceTemplateProperty.php @@ -4,8 +4,8 @@ /** * @Entity * @Table( - * uniqueConstraints={ - * @UniqueConstraint( + * indexes={ + * @Index( * columns={"resource_template_id", "property_id"} * ) * } @@ -48,7 +48,7 @@ class ResourceTemplateProperty extends AbstractEntity protected $position; /** - * @Column(nullable=true) + * @Column(type="json_array", nullable=true) */ protected $dataType; @@ -117,7 +117,7 @@ public function setPosition($position) $this->position = (int) $position; } - public function setDataType($dataType) + public function setDataType(array $dataType = null) { $this->dataType = $dataType; } diff --git a/application/src/Form/Element/DataTypeSelect.php b/application/src/Form/Element/DataTypeSelect.php new file mode 100644 index 0000000000..7d3f91f013 --- /dev/null +++ b/application/src/Form/Element/DataTypeSelect.php @@ -0,0 +1,74 @@ + 'select', + 'multiple' => false, + 'class' => 'chosen-select', + ]; + + /** + * @var DataTypeManager + */ + protected $dataTypeManager; + + /** + * @var array + */ + protected $dataTypes = []; + + public function getValueOptions() + { + $options = []; + $optgroupOptions = []; + foreach ($this->dataTypes as $dataTypeName) { + $dataType = $this->dataTypeManager->get($dataTypeName); + $label = $dataType->getLabel(); + if ($optgroupLabel = $dataType->getOptgroupLabel()) { + // Hash the optgroup key to avoid collisions when merging with + // data types without an optgroup. + $optgroupKey = md5($optgroupLabel); + // Put resource data types before ones added by modules. + $optionsVal = in_array($dataTypeName, ['resource', 'resource:item', 'resource:itemset', 'resource:media']) + ? 'options' + : 'optgroupOptions'; + if (!isset(${$optionsVal}[$optgroupKey])) { + ${$optionsVal}[$optgroupKey] = [ + 'label' => $optgroupLabel, + 'options' => [], + ]; + } + ${$optionsVal}[$optgroupKey]['options'][$dataTypeName] = $label; + } else { + $options[$dataTypeName] = $label; + } + } + // Always put data types not organized in option groups before data + // types organized within option groups. + return array_merge($options, $optgroupOptions); + } + + /** + * @param DataTypeManager $dataTypeManager + * @return self + */ + public function setDataTypeManager(DataTypeManager $dataTypeManager) + { + $this->dataTypeManager = $dataTypeManager; + $this->dataTypes = $dataTypeManager->getRegisteredNames(); + return $this; + } + + /** + * @return DataTypeManager + */ + public function getDataTypeManager() + { + return $this->dataTypeManager; + } +} diff --git a/application/src/Form/SettingForm.php b/application/src/Form/SettingForm.php index 8a9931e712..18855ded92 100644 --- a/application/src/Form/SettingForm.php +++ b/application/src/Form/SettingForm.php @@ -2,6 +2,7 @@ namespace Omeka\Form; use DateTimeZone; +use Omeka\Form\Element\DataTypeSelect; use Omeka\Form\Element\SiteSelect; use Omeka\Form\Element\RestoreTextarea; use Omeka\Settings\Settings; @@ -246,8 +247,8 @@ public function init() 'name' => 'default_to_private', 'type' => 'Checkbox', 'options' => [ - 'label' => 'Default content visibility to Private', // @translate - 'info' => 'If checked, all items, item sets and sites newly created will have their visibility set to private by default.', // @translate + 'label' => 'Default content visibility to Private', // @translate + 'info' => 'If checked, all items, item sets and sites newly created will have their visibility set to private by default.', // @translate ], 'attributes' => [ 'value' => $this->settings->get('default_to_private'), @@ -255,6 +256,18 @@ public function init() ], ]); + $generalFieldset->add([ + 'name' => 'resource_default_datatypes', + 'type' => DataTypeSelect::class, + 'options' => [ + 'label' => 'Default datatypes for properties in resources forms', // @translate + ], + 'attributes' => [ + 'value' => $this->settings->get('resource_default_datatypes', ['literal', 'resource', 'uri']), + 'id' => 'resource_default_datatypes', + ], + ]); + $generalFieldset->add([ 'name' => 'index_fulltext_search', 'type' => 'Checkbox', diff --git a/application/src/Service/Form/Element/DataTypeSelectFactory.php b/application/src/Service/Form/Element/DataTypeSelectFactory.php new file mode 100644 index 0000000000..f39d26d539 --- /dev/null +++ b/application/src/Service/Form/Element/DataTypeSelectFactory.php @@ -0,0 +1,16 @@ +setDataTypeManager($services->get('Omeka\DataTypeManager')); + } +} diff --git a/application/src/View/Helper/DataType.php b/application/src/View/Helper/DataType.php index d3c21e9f30..6e5cab5f7a 100644 --- a/application/src/View/Helper/DataType.php +++ b/application/src/View/Helper/DataType.php @@ -38,7 +38,7 @@ public function __construct(DataTypeManager $dataTypeManager) * - Data types organized in option groups * * @param string $name - * @param string $value + * @param string|array $value * @param array $attributes */ public function getSelect($name, $value = null, $attributes = []) @@ -74,6 +74,9 @@ public function getSelect($name, $value = null, $attributes = []) $element->setEmptyOption('Default') ->setValueOptions($options) ->setAttributes($attributes); + if (!$element->getAttribute('multiple') && is_array($value)) { + $value = reset($value); + } $element->setValue($value); return $this->getView()->formSelect($element); } @@ -82,10 +85,12 @@ public function getTemplates() { $view = $this->getView(); $templates = ''; + $resource = isset($view->resource) ? $view->resource : null; + $partial = $view->plugin('partial'); foreach ($this->dataTypes as $dataType) { - $templates .= $view->partial('common/data-type-wrapper', [ + $templates .= $partial('common/data-type-wrapper', [ 'dataType' => $dataType, - 'resource' => isset($view->resource) ? $view->resource : null, + 'resource' => $resource, ]); } return $templates; @@ -96,6 +101,22 @@ public function getTemplate($dataType) return $this->manager->get($dataType)->form($this->getView()); } + public function getLabel($dataType) + { + return $this->manager->get($dataType)->getLabel(); + } + + /** + * @param string $dataType + * @return \Omeka\DataType\DataTypeInterface|null + */ + public function getDataType($dataType) + { + return $this->manager->has($dataType) + ? $this->manager->get($dataType) + : null; + } + /** * Prepare the view to enable the data types. */ diff --git a/application/view/common/data-type-wrapper.phtml b/application/view/common/data-type-wrapper.phtml index 0498c4ac99..ffbaede872 100644 --- a/application/view/common/data-type-wrapper.phtml +++ b/application/view/common/data-type-wrapper.phtml @@ -1,17 +1,27 @@ plugin('translate'); $escape = $this->plugin('escapeHtml'); +$escapeAttr = $this->plugin('escapeHtmlAttr'); +$hyperlink = $this->plugin('hyperlink'); +$dataTypeHelper = $this->plugin('dataType'); + +$icons = [ + 'resource:item' => 'items', + 'resource:itemset' => 'item-sets', + 'resource:media' => 'media', +]; ?> -
+ +
-
dataType()->getTemplate($dataType); ?>
+
getTemplate($dataType); ?>
-
+
propertySelector(); ?> diff --git a/application/view/common/resource-form-templates.phtml b/application/view/common/resource-form-templates.phtml index f6b5a6b473..8359c067d9 100644 --- a/application/view/common/resource-form-templates.phtml +++ b/application/view/common/resource-form-templates.phtml @@ -2,9 +2,17 @@ $translate = $this->plugin('translate'); $escape = $this->plugin('escapeHtml'); $this->dataType()->prepareForm(); + +$defaultDataTypes = $this->setting('resource_default_datatypes') ?: ['literal', 'resource', 'uri']; + +$icons = [ + 'resource:item' => 'items', + 'resource:itemset' => 'item-sets', + 'resource:media' => 'media', +]; ?> -
+
@@ -19,9 +27,15 @@ $this->dataType()->prepareForm();
- - - + + dataType()->getDataType($dataType); ?> + + + getLabel()); ?> + +
+