diff --git a/Gemfile b/Gemfile index 442ee9f533..9b92aa4bbc 100644 --- a/Gemfile +++ b/Gemfile @@ -38,7 +38,6 @@ gem "graphiql-rails", "1.4.10", group: :development gem "graphql", "1.8.2" gem "honeybadger" gem "hydra-access-controls" -gem "hydra-editor", "~> 6.0" gem "hydra-head" gem "hydra-role-management" gem "iiif_manifest", git: "https://github.com/samvera-labs/iiif_manifest" diff --git a/Gemfile.lock b/Gemfile.lock index 6be3654304..d6c8473e1c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -155,8 +155,6 @@ GEM afm (0.2.2) airbrussh (1.4.0) sshkit (>= 1.6.1, != 1.7.0) - almond-rails (0.3.0) - rails (>= 4.2) amazing_print (1.4.0) amq-protocol (2.3.2) arabic-letter-connector (0.1.1) @@ -185,10 +183,6 @@ GEM aws-sigv4 (~> 1.4) aws-sigv4 (1.5.0) aws-eventstream (~> 1, >= 1.0.2) - babel-source (5.8.35) - babel-transpiler (0.7.0) - babel-source (>= 4.0, < 6) - execjs (~> 2.0) backport (1.2.0) bagit (0.4.4) docopt (~> 0.5.0) @@ -495,15 +489,6 @@ GEM deprecation mime-types (> 2.0, < 4.0) mini_magick (>= 3.2, < 5) - hydra-editor (6.0.0) - active-fedora (>= 9.0.0) - activerecord (>= 5.2, < 6.1) - almond-rails (~> 0.1) - cancancan (~> 1.8) - rails (>= 5.2, < 6.1) - simple_form (>= 4.1.0, < 6.0) - sprockets (>= 3.7) - sprockets-es6 hydra-head (12.0.1) hydra-access-controls (= 12.0.1) hydra-core (= 12.0.1) @@ -899,10 +884,6 @@ GEM sprockets (4.1.1) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-es6 (0.9.2) - babel-source (>= 5.8.11) - babel-transpiler - sprockets (>= 3.0.0) sprockets-rails (3.4.2) actionpack (>= 5.2) activesupport (>= 5.2) @@ -1046,7 +1027,6 @@ DEPENDENCIES graphql (= 1.8.2) honeybadger hydra-access-controls - hydra-editor (~> 6.0) hydra-head hydra-role-management iiif_manifest! diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 707641cf07..d25a9f72a3 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -34,12 +34,6 @@ //= require openseadragon/openseadragon //= require openseadragon/jquery //= require bootstrap_select_dropdown -//= //require bootstrap/affix -//= require babel/polyfill -//= require hydra-editor/hydra-editor //= require cocoon //= require blacklight_range_limit //= require_tree . -$(document).ready(function() { - $('.multi_value.form-group').manage_fields(); -}); diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index b453f7e2d4..bc9f96ddb9 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -63,9 +63,9 @@ @import 'components/thumbnail'; @import 'components/vue_select'; @import 'components/facets'; +@import 'components/inputs'; @import 'blacklight/blacklight'; -@import "hydra-editor/hydra-editor"; @import "openseadragon/openseadragon"; @import 'components/pagination'; diff --git a/app/assets/stylesheets/components/inputs.scss b/app/assets/stylesheets/components/inputs.scss new file mode 100644 index 0000000000..a315c670a1 --- /dev/null +++ b/app/assets/stylesheets/components/inputs.scss @@ -0,0 +1,79 @@ +.multi_value { + .btn.remove { + color: $brand-danger; + } + + .btn.remove, .btn.add { + text-decoration: underline; + } + + .btn.add { + padding-left: 0; + } + + .field-wrapper { + list-style-type:none; + } + + .listing { + margin-left: 0; + max-width: 40em; + padding-left: 0px; + margin-bottom: 0; + } + + .field-controls { + margin-left: 2em; + } + + .remove .glyphicon-remove, + .add .glyphicon-plus { + margin-right: 0.3rem + } + + .message { + background-size: 40px 40px; + background-image: linear-gradient(135deg, rgba(255, 255, 255, .05) 25%, transparent 25%, + transparent 50%, rgba(255, 255, 255, .05) 50%, rgba(255, 255, 255, .05) 75%, + transparent 75%, transparent); + box-shadow: inset 0 -1px 0 rgba(255,255,255,.4); + width: 100%; + border: 1px solid; + color: #fff; + padding: 10px; + text-shadow: 0 1px 0 rgba(0,0,0,.5); + animation: animate-bg 5s linear infinite; + border-radius: $border-radius-base; + } + + .has-error { + background-color: #de4343; + border-color: #c43d3d; + } + + .has-warning{ + background-color: #eaaf51; + border-color: #d99a36; + } + + .listing li:not(:last-child) { + margin-bottom: 0.5rem; + } + + .listing li:first-of-type { + width: 83.3333333333%; + + input { + border-radius: 4px; + } + + .remove { + display: none; + } + } +} + +// The contributor listing needs some normalization +#contributors .listing { + max-width:20em; +} diff --git a/app/helpers/edit_field_helper.rb b/app/helpers/edit_field_helper.rb index 8c6f37c2c5..91f673cfb1 100644 --- a/app/helpers/edit_field_helper.rb +++ b/app/helpers/edit_field_helper.rb @@ -4,6 +4,11 @@ def reorder_languages(languages, top_languages) pull_to_front(languages) { |term| top_languages.include? term } end + def render_edit_field_partial(field_name, locals) + collection = locals[:f].object.model_name.collection + render_edit_field_partial_with_action(collection, field_name, locals) + end + private def pull_to_front(array, &block) @@ -11,4 +16,23 @@ def pull_to_front(array, &block) array.reject!(&block) temp + array end + + # This finds a partial based on the record_type and field_name + # if no partial exists for the record_type it tries using "records" as a default + def render_edit_field_partial_with_action(record_type, field_name, locals) + partial = find_edit_field_partial(record_type, field_name) + render partial, locals.merge(key: field_name) + end + + def find_edit_field_partial(record_type, field_name) + ["#{record_type}/edit_fields/_#{field_name}", "records/edit_fields/_#{field_name}", + "#{record_type}/edit_fields/_default", "records/edit_fields/_default"].find do |partial| + logger.debug "Looking for edit field partial #{partial}" + return partial.sub(/\/_/, "/") if partial_exists?(partial) + end + end + + def partial_exists?(partial) + lookup_context.find_all(partial).any? + end end diff --git a/app/inputs/multi_value_input.rb b/app/inputs/multi_value_input.rb new file mode 100644 index 0000000000..db5bb5a328 --- /dev/null +++ b/app/inputs/multi_value_input.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true +class MultiValueInput < SimpleForm::Inputs::CollectionInput + def input(_wrapper_options) + @rendered_first_element = false + input_html_classes.unshift("string") + input_html_options[:name] ||= "#{object_name}[#{attribute_name}][]" + + outer_wrapper do + buffer_each(collection) do |value, index| + inner_wrapper do + build_field(value, index) + end + end + end + end + + protected + + def buffer_each(collection) + collection.each_with_object("".dup).with_index do |(value, buffer), index| + buffer << yield(value, index) + end + end + + def outer_wrapper + " \n" + end + + def inner_wrapper + <<-HTML +
  • + #{yield} +
  • + HTML + end + + private + + # Although the 'index' parameter is not used in this implementation it is useful in an + # an overridden version of this method, especially when the field is a complex object and + # the override defines nested fields. + def build_field_options(value, _index) + options = input_html_options.dup + + options[:value] = value + if @rendered_first_element + options[:id] = nil + options[:required] = nil + else + options[:id] ||= input_dom_id + end + options[:class] ||= [] + options[:class] += ["#{input_dom_id} form-control multi-text-field"] + options[:'aria-labelledby'] = label_id + @rendered_first_element = true + + options + end + + def build_field(value, index) + options = build_field_options(value, index) + if options.delete(:type) == "textarea" + @builder.text_area(attribute_name, options) + else + @builder.text_field(attribute_name, options) + end + end + + def label_id + input_dom_id + "_label" + end + + def input_dom_id + input_html_options[:id] || "#{object_name}_#{attribute_name}" + end + + def collection + @collection ||= + Array(object.send(attribute_name)).reject { |v| v.to_s.strip.blank? } + [""] + end + + def multiple? + true + end +end diff --git a/app/javascript/figgy/field_manager.js b/app/javascript/figgy/field_manager.js new file mode 100644 index 0000000000..7ecfeeebd6 --- /dev/null +++ b/app/javascript/figgy/field_manager.js @@ -0,0 +1,176 @@ +export default class FieldManager { + constructor(element, options) { + this.element = $(element); + + this.options = options; + + this.options.label = this.getFieldLabel(this.element, options) + + this.addSelector = '.add' + this.removeSelector = '.remove' + + this.adder = this.createAddHtml(this.options) + this.remover = this.createRemoveHtml(this.options) + + this.controls = $(options.controlsHtml); + + this.inputTypeClass = options.inputTypeClass; + this.fieldWrapperClass = options.fieldWrapperClass; + this.warningClass = options.warningClass; + this.listClass = options.listClass; + + this.init(); + } + + init() { + this._addInitialClasses(); + this._addAriaLiveRegions() + this._appendControls(); + this._attachEvents(); + this._addCallbacks(); + } + + _addInitialClasses() { + this.element.addClass("managed"); + $(this.fieldWrapperClass, this.element).addClass("input-group input-append"); + } + + _addAriaLiveRegions() { + $(this.element).find('.listing').attr('aria-live', 'polite') + } + + // Add the "Add another" and "Remove" controls to the DOM + _appendControls() { + // We want to make these DOM additions idempotently, so exit if it's + // already set up. + if (!this._hasRemoveControl()) { + this._createRemoveWrapper() + this._createRemoveControl() + } + + if (!this._hasAddControl()) { + this._createAddControl() + } + } + + _createRemoveWrapper() { + $(this.fieldWrapperClass, this.element).append(this.controls); + } + + _createRemoveControl() { + $(this.fieldWrapperClass + ' .field-controls', this.element).append(this.remover) + } + + _createAddControl() { + this.element.find(this.listClass).after(this.adder) + } + + _hasRemoveControl() { + return this.element.find(this.removeSelector).length > 0 + } + + _hasAddControl() { + return this.element.find(this.addSelector).length > 0 + } + + _attachEvents() { + this.element.on('click', this.removeSelector, (e) => this.removeFromList(e)) + this.element.on('click', this.addSelector, (e) => this.addToList(e)) + } + + _addCallbacks() { + this.element.bind('managed_field:add', this.options.add); + this.element.bind('managed_field:remove', this.options.remove); + } + + _manageFocus() { + $(this.element).find(this.listClass).children('li').last().find('.form-control').focus(); + } + + addToList( event ) { + event.preventDefault(); + let $listing = $(event.target).closest(this.inputTypeClass).find(this.listClass) + let $activeField = $listing.children('li').last() + + if (this.inputIsEmpty($activeField)) { + this.displayEmptyWarning(); + } else { + this.clearEmptyWarning(); + $listing.append(this._newField($activeField)); + } + + this._manageFocus() + } + + inputIsEmpty($activeField) { + return $activeField.children('input.multi-text-field').val() === ''; + } + + _newField ($activeField) { + var $newField = this.createNewField($activeField); + return $newField; + } + + createNewField($activeField) { + let $newField = $activeField.clone(); + let $newChildren = this.createNewChildren($newField); + this.element.trigger("managed_field:add", $newChildren); + return $newField; + } + + clearEmptyWarning() { + let $listing = $(this.listClass, this.element) + $listing.children(this.warningClass).remove(); + } + + displayEmptyWarning() { + let $listing = $(this.listClass, this.element) + var $warningMessage = $("
    cannot add another with empty field
    "); + $listing.children(this.warningClass).remove(); + $listing.append($warningMessage); + } + + removeFromList( event ) { + event.preventDefault(); + var $field = $(event.target).parents(this.fieldWrapperClass).remove(); + this.element.trigger("managed_field:remove", $field); + + this._manageFocus(); + } + + destroy() { + $(this.fieldWrapperClass, this.element).removeClass("input-append"); + this.element.removeClass("managed"); + } + + getFieldLabel($element, options) { + var label = ''; + var $label = $element.find("label").first(); + if ($label.length && options.labelControls) { + var label = $label.data('label') || $.trim($label.contents().filter(function() { return this.nodeType === 3; }).text()); + label = ' ' + label; + } + + return label; + } + + createAddHtml(options) { + var $addHtml = $(options.addHtml); + $addHtml.find('.controls-add-text').html(options.addText + options.label); + return $addHtml; + } + + createRemoveHtml(options) { + var $removeHtml = $(options.removeHtml); + $removeHtml.find('.controls-remove-text').html(options.removeText); + $removeHtml.find('.controls-field-name-text').html(options.label); + return $removeHtml; + } + + createNewChildren(clone) { + let $newChildren = $(clone).children(this.inputTypeClass); + $newChildren.val('').removeAttr('required'); + $newChildren.first().focus(); + return $newChildren.first(); + } +} diff --git a/app/javascript/figgy/figgy_boot.js b/app/javascript/figgy/figgy_boot.js index bc0ad50ef3..2c4b413c47 100644 --- a/app/javascript/figgy/figgy_boot.js +++ b/app/javascript/figgy/figgy_boot.js @@ -14,6 +14,7 @@ import MemberResourcesTables from "figgy/relationships/member_resources_table" import ParentResourcesTables from "figgy/relationships/parent_resources_table" import BulkLabeler from "figgy/bulk_labeler/bulk_label" import BoundingBoxSelector from "figgy/bounding_box_selector" +import FieldManager from "figgy/field_manager" export default class Initializer { constructor() { @@ -31,6 +32,7 @@ export default class Initializer { this.auto_ingest_handler = new AutoIngestHandler this.bulk_labeler = new BulkLabeler this.sortable_placeholder() + this.initialize_multi_fields() // Incompatibility in Blacklight with newer versions of jQuery seem to be // causing this to not run. Manually calling it so facet more links work. @@ -161,4 +163,38 @@ export default class Initializer { ui.placeholder.height(found_element.height()) }) } + + initialize_multi_fields() { + const DEFAULTS = { + /* callback to run after add is called */ + add: null, + /* callback to run after remove is called */ + remove: null, + + controlsHtml: '', + fieldWrapperClass: '.field-wrapper', + warningClass: '.has-warning', + listClass: '.listing', + inputTypeClass: '.multi_value', + + addHtml: '', + addText: 'Add another', + + removeHtml: '', + removeText: 'Remove', + + labelControls: true, + } + + $.fn.manage_fields = function(option) { + return this.each(function() { + var $this = $(this); + var data = $this.data('manage_fields'); + var options = $.extend({}, DEFAULTS, $this.data(), typeof option == 'object' && option); + + if (!data) $this.data('manage_fields', (data = new FieldManager(this, options))); + }) + } + $('.multi_value.form-group').manage_fields(); + } } diff --git a/app/views/layouts/blacklight/base.html.erb b/app/views/layouts/blacklight/base.html.erb index cb73520dae..fbd8d6dd04 100644 --- a/app/views/layouts/blacklight/base.html.erb +++ b/app/views/layouts/blacklight/base.html.erb @@ -17,19 +17,11 @@ <%# There were errors when we tried to include this webpack %> <%# see comments culminating with https://github.com/pulibrary/figgy/pull/3754#issuecomment-598510375 %> - <%= javascript_tag do %> - oldDefine = define; - define = null; - <% end %> - - <%= javascript_tag do %> - define = oldDefine; - <% end %> <%= stylesheet_pack_tag 'application' %> <%= javascript_pack_tag "application" %> <%= content_for(:head) %> diff --git a/app/views/records/edit_fields/_default.html.erb b/app/views/records/edit_fields/_default.html.erb new file mode 100644 index 0000000000..12fb6b9618 --- /dev/null +++ b/app/views/records/edit_fields/_default.html.erb @@ -0,0 +1,5 @@ +<% if f.object.multiple? key %> + <%= f.input key, as: :multi_value, input_html: { class: 'form-control' }, required: f.object.required?(key) %> +<% else %> + <%= f.input key, required: f.object.required?(key) %> +<% end %>