diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 501c73d89..15f766ff6 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -6,6 +6,7 @@ #= require nprogress-turbolinks #= require typeahead.bundle.min #= require autocomplete +#= require jquery.nested_attributes #= require app NProgress.configure diff --git a/app/controllers/admin/domains_controller.rb b/app/controllers/admin/domains_controller.rb index a2019519a..06f4419bf 100644 --- a/app/controllers/admin/domains_controller.rb +++ b/app/controllers/admin/domains_controller.rb @@ -6,6 +6,8 @@ class Admin::DomainsController < ApplicationController owner_contact = Contact.find(params[:owner_contact_id]) if params[:owner_contact_id] @domain = Domain.new(owner_contact: owner_contact) params[:domain_owner_contact] = owner_contact + + @domain.nameservers.build end def create @@ -59,7 +61,7 @@ class Admin::DomainsController < ApplicationController end def domain_params - params.require(:domain).permit(:name, :period, :period_unit, :registrar_id, :owner_contact_id) + params.require(:domain).permit(:name, :period, :period_unit, :registrar_id, :owner_contact_id, nameservers_attributes: [:id, :hostname, :ipv4, :ipv6, :_destroy]) end def verify_deletion diff --git a/app/models/domain.rb b/app/models/domain.rb index 1036313f2..faabe2058 100644 --- a/app/models/domain.rb +++ b/app/models/domain.rb @@ -14,7 +14,8 @@ class Domain < ActiveRecord::Base where(domain_contacts: { contact_type: DomainContact::ADMIN }) end, through: :domain_contacts, source: :contact - has_many :nameservers, dependent: :delete_all, autosave: true + has_many :nameservers, dependent: :delete_all + accepts_nested_attributes_for :nameservers, allow_destroy: true has_many :domain_statuses, dependent: :delete_all diff --git a/app/views/admin/domains/_form.haml b/app/views/admin/domains/_form.haml index f858ec4aa..99a256d40 100644 --- a/app/views/admin/domains/_form.haml +++ b/app/views/admin/domains/_form.haml @@ -7,30 +7,33 @@ %hr .row - .col-md-6 - .form-group - = f.label :name - = f.text_field(:name, class: 'form-control') - .form-group - = f.label :period - .row - .col-md-6 - = f.text_field(:period, class: 'form-control') - .col-md-6 - = f.select :period_unit, options_for_select(['y', 'm', 'd'], @domain.period_unit), {}, {class: 'form-control'} - .col-md-6 - .form-group.has-feedback - = f.label :registrar - = text_field_tag(:domain_registrar, params[:registrar], class: 'form-control js-registrar-typeahead', placeholder: t('shared.registrar_name'), autocomplete: 'off') - %span.glyphicon.glyphicon-ok.form-control-feedback.js-typeahead-ok.hidden - %span.glyphicon.glyphicon-remove.form-control-feedback.js-typeahead-remove - = f.hidden_field(:registrar_id, class: 'js-registrar-id') - .form-group.has-feedback - = f.label :owner_contact - = text_field_tag(:domain_owner_contact, params[:domain_owner_contact], class: 'form-control js-contact-typeahead', placeholder: t('shared.contact_code'), autocomplete: 'off') - %span.glyphicon.glyphicon-ok.form-control-feedback.js-typeahead-ok.hidden - %span.glyphicon.glyphicon-remove.form-control-feedback.js-typeahead-remove - = f.hidden_field(:owner_contact_id, class: 'js-contact-id') + .col-md-12 + / Nav tabs + %ul.nav.nav-tabs{:role => "tablist", id: 'tabs'} + %li.active + %a{"data-toggle" => "tab", :href => "#general-tab", :role => "tab"} general-tab + %li + %a{"data-toggle" => "tab", :href => "#nameservers-tab", :role => "tab"} nameservers-tab + %li + %a{"data-toggle" => "tab", :href => "#tech-contacts-tab", :role => "tab"} tech-contacts-tab + %li + %a{"data-toggle" => "tab", :href => "#admin-contacts", :role => "tab"} admin-contacts-tab + %li + %a{"data-toggle" => "tab", :href => "#statuses-tab", :role => "tab"} statuses-tab + / Tab panes + .tab-content{style:'margin-top: 20px;'} + #general-tab.tab-pane.active + = render 'admin/domains/form_partials/general', f: f + #nameservers-tab.tab-pane + = render 'admin/domains/form_partials/nameservers', f: f + #tech-contacts-tab.tab-pane + #admin-contacts-tab.tab-pane ... + #statuses-tab.tab-pane ... .row .col-md-12.text-right = button_tag(t('shared.save'), class: 'btn btn-primary') + +:javascript + $(function () { + $('#tabs a:first').tab('show') + }) diff --git a/app/views/admin/domains/form_partials/_general.haml b/app/views/admin/domains/form_partials/_general.haml new file mode 100644 index 000000000..aca861c68 --- /dev/null +++ b/app/views/admin/domains/form_partials/_general.haml @@ -0,0 +1,25 @@ +.row + .col-md-6 + .form-group + = f.label :name + = f.text_field(:name, class: 'form-control') + .form-group + = f.label :period + .row + .col-md-6 + = f.text_field(:period, class: 'form-control') + .col-md-6 + = f.select :period_unit, options_for_select(['y', 'm', 'd'], @domain.period_unit), {}, {class: 'form-control'} + .col-md-6 + .form-group.has-feedback + = f.label :registrar + = text_field_tag(:domain_registrar, params[:registrar], class: 'form-control js-registrar-typeahead', placeholder: t('shared.registrar_name'), autocomplete: 'off') + %span.glyphicon.glyphicon-ok.form-control-feedback.js-typeahead-ok.hidden + %span.glyphicon.glyphicon-remove.form-control-feedback.js-typeahead-remove + = f.hidden_field(:registrar_id, class: 'js-registrar-id') + .form-group.has-feedback + = f.label :owner_contact + = text_field_tag(:domain_owner_contact, params[:domain_owner_contact], class: 'form-control js-contact-typeahead', placeholder: t('shared.contact_code'), autocomplete: 'off') + %span.glyphicon.glyphicon-ok.form-control-feedback.js-typeahead-ok.hidden + %span.glyphicon.glyphicon-remove.form-control-feedback.js-typeahead-remove + = f.hidden_field(:owner_contact_id, class: 'js-contact-id') diff --git a/app/views/admin/domains/form_partials/_nameservers.haml b/app/views/admin/domains/form_partials/_nameservers.haml new file mode 100644 index 000000000..1a75fe2b4 --- /dev/null +++ b/app/views/admin/domains/form_partials/_nameservers.haml @@ -0,0 +1,30 @@ +#nameservers + = f.fields_for :nameservers do |ns_fields| + .panel.panel-default + .panel-heading.text-right + = link_to(t('shared.add_another'), '#', class: 'btn btn-primary btn-xs add-nameserver') + = link_to(t('shared.delete'), '#', class: 'btn btn-danger btn-xs destroy') + .panel-body + .errors + = render 'admin/shared/errors', object: ns_fields.object + .row + .col-md-12 + .form-group + = ns_fields.label :hostname + = ns_fields.text_field :hostname, class: 'form-control' + .row + .col-md-6 + .form-group + = ns_fields.label :ipv4 + = ns_fields.text_field :ipv4, class: 'form-control' + .col-md-6 + .form-group + = ns_fields.label :ipv6 + = ns_fields.text_field :ipv6, class: 'form-control' +:javascript + $("#nameservers").nestedAttributes({ + bindAddTo: $(".add-nameserver"), + afterAdd: function(item) { + item.find('.errors').html(''); + } + }); diff --git a/config/locales/en.yml b/config/locales/en.yml index e59873f3a..895b7af3e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -259,3 +259,4 @@ en: failed_to_add_domain: 'Failed to add domain!' domain_added: 'Domain added!' new_contact: 'New contact' + add_another: 'Add another' diff --git a/vendor/assets/javascripts/jquery.nested_attributes.coffee b/vendor/assets/javascripts/jquery.nested_attributes.coffee new file mode 100644 index 000000000..f3d11cf97 --- /dev/null +++ b/vendor/assets/javascripts/jquery.nested_attributes.coffee @@ -0,0 +1,293 @@ +### +Authors: Nick Giancola (@patbenatar), Brendan Loudermilk (@bloudermilk) +Homepage: https://github.com/patbenatar/jquery-nested_attributes +### +$ = jQuery + +methods = + init: (options) -> + $el = $(@) + throw "Can't initialize more than one item at a time" if $el.length > 1 + if $el.data("nestedAttributes") + throw "Can't initialize on this element more than once" + instance = new NestedAttributes($el, options) + $el.data("nestedAttributes", instance) + return $el + add: -> + $el = $(@) + unless $el.data("nestedAttributes")? + throw "You are trying to call instance methods without initializing first" + $el.data("nestedAttributes").addItem() + return $el + +$.fn.nestedAttributes = (method) -> + if methods[method]? + return methods[method].apply @, Array.prototype.slice.call(arguments, 1) + else if typeof method == 'object' || !method + return methods.init.apply(@, arguments) + else + $.error("Method #{method} does not exist on jQuery.nestedAttributes") + +class NestedAttributes + + RELEVANT_INPUTS_SELECTOR: ":input[name][name!=\"\"]" + + settings: + collectionName: false # If not provided, we will autodetect + bindAddTo: false # Required + removeOnLoadIf: false + collectIdAttributes: true + beforeAdd: false + afterAdd: false + beforeMove: false + afterMove: false + beforeDestroy: false + afterDestroy: false + destroySelector: '.destroy' + deepClone: true + $clone: null + + ###################### + ## ## + ## Initialization ## + ## ## + ###################### + + constructor: ($el, options) -> + + # This plugin gets called on the container + @$container = $el + + # Merge default options + @options = $.extend({}, @settings, options) + + # If the user provided a jQuery object to bind the "Add" + # bind it now or forever hold your peace. + @options.bindAddTo.click(@addClick) if @options.bindAddTo + + # Cache all the items + @$items = @$container.children() + + # If the user didn't provide a collectionName, autodetect it + unless @options.collectionName + @autodetectCollectionName() + + # Initialize existing items + @$items.each (i, el) => + $item = $(el) + + # If the user wants us to attempt to collect Rail's ID attributes, do it now + # Using the default rails helpers, ID attributes will wind up right after their + # propper containers in the form. + if @options.collectIdAttributes and $item.is('input') + # Move the _id field into its proper container + $item.appendTo($item.prev()) + # Remove it from the $items collection + @$items = @$items.not($item) + else + # Try to find and bind the destroy link if the user wanted one + @bindDestroy($item) + + # Now that we've collected ID attributes + @hideIfAlreadyDestroyed $(item) for item in @$items + + # Remove any items on load if the client implements a check and the check passes + if @options.removeOnLoadIf + @$items.each (i, el) => + $el = $(el) + if $el.call(true, @options.removeOnLoadIf, i) + $el.remove() + + + ######################## + ## ## + ## Instance Methods ## + ## ## + ######################## + + autodetectCollectionName: -> + pattern = /\[(.[^\]]*)_attributes\]/ + try + match = pattern.exec(@$items.first().find("#{@RELEVANT_INPUTS_SELECTOR}:first").attr('name'))[1] + if match != null + @options.collectionName = match + else + throw "Regex error" + catch error + console.log "Error detecting collection name", error + + addClick: (event) => + + @addItem() + + # Don't let the link do anything + event.preventDefault() + + addItem: -> + # Piece together an item + newIndex = @$items.length + $newClone = @applyIndexToItem(@extractClone(), newIndex) + + # Give the user a chance to make their own changes before we insert + if (@options.beforeAdd) + + # Stop the add process if the callback returns false + return false if !@options.beforeAdd.call(undefined, $newClone, newIndex) + + # Insert the new item after the last item + @$container.append($newClone) + + # Give the user a chance to make their own changes after insertion + @options.afterAdd.call(undefined, $newClone, newIndex) if (@options.afterAdd) + + # Add this item to the items list + @refreshItems() + + extractClone: -> + + # Are we restoring from an already created clone? + if @$restorableClone + + $record = @$restorableClone + + @$restorableClone = null + + else + $record = @options.$clone || @$items.first() + + # Make a deep clone (bound events and data) + $record = $record.clone(@options.deepClone) + + @bindDestroy($record) if @options.$clone or !@options.deepClone + + # Empty out the values of text inputs and selects + $record.find(':text, textarea, select').val('') + + # Reset checkboxes and radios + $record.find(':checkbox, :radio').attr("checked", false) + + # Empty out any hidden [id] or [_destroy] fields + $record.find('input[name$="\\[id\\]"]').remove() + $record.find('input[name$="\\[_destroy\\]"]').remove() + + # Make sure it's not hidden as we return. + # It would be hidden in the case where we're duplicating an + # already removed item for its template. + return $record.show() + + applyIndexToItem: ($item, index) -> + collectionName = @options.collectionName + + $item.find(@RELEVANT_INPUTS_SELECTOR).each (i, el) => + + $el = $(el) + + idRegExp = new RegExp("_#{collectionName}_attributes_\\d+_") + idReplacement = "_#{collectionName}_attributes_#{index}_" + nameRegExp = new RegExp("\\[#{collectionName}_attributes\\]\\[\\d+\\]") + nameReplacement = "[#{collectionName}_attributes][#{index}]" + + newID = $el.attr('id').replace(idRegExp, idReplacement) if $el.attr('id') + newName = $el.attr('name').replace(nameRegExp, nameReplacement) + + $el.attr + id: newID + name: newName + + $item.find('label[for]').each (i, el) => + $el = $(el) + try + forRegExp = new RegExp("_#{collectionName}_attributes_\\d+_") + forReplacement = "_#{collectionName}_attributes_#{index}_" + newFor = $el.attr('for').replace(forRegExp, forReplacement) + $el.attr('for', newFor) + catch error + console.log "Error updating label", error + + return $item + + hideIfAlreadyDestroyed: ($item) -> + $destroyField = $item.find("[name$='[_destroy]']") + if $destroyField.length && $destroyField.val() == "true" + @destroy $item + + # Hides a item from the user and marks it for deletion in the + # DOM by setting _destroy to true if the record already exists. If it + # is a new escalation, we simple delete the item + destroyClick: (event) => + event.preventDefault() + @destroy $(event.target).parentsUntil(@$container).last() + + destroy: ($item) -> + # If you're about to delete the last one, + # cache a clone of it first so we have something to show + # the next time user hits add + @$restorableClone = @extractClone() unless @$items.length-1 + + index = @indexForItem($item) + itemIsNew = $item.find('input[name$="\\[id\\]"]').length == 0 + + if (@options.beforeDestroy) + + # Stop the destroy process if the callback returns false + return false if !@options.beforeDestroy.call(undefined, $item, index, itemIsNew) + + # Add a blank item row if none are visible after this deletion + @addItem() unless @$items.filter(':visible').length-1 + + if itemIsNew + + $item.remove() + + else + + # Hide the item + $item.hide() + + # Add the _destroy field + otherFieldName = $item.find(':input[name]:first').attr('name') + attributePosition = otherFieldName.lastIndexOf('[') + destroyFieldName = "#{otherFieldName.substring(0, attributePosition)}[_destroy]" + # First look for an existing _destroy field + $destroyField = $item.find("input[name='#{destroyFieldName}']") + # If it doesn't exist, create it + if $destroyField.length == 0 + $destroyField = $("") + $item.append($destroyField) + $destroyField.val(true).change() + + @options.afterDestroy.call($item, index, itemIsNew) if (@options.afterDestroy) + + # Remove this item from the items list + @refreshItems() + + # Rename the remaining items + @resetIndexes() + + indexForItem: ($item) -> + regExp = new RegExp("\\[#{@options.collectionName}_attributes\\]\\[\\d+\\]") + name = $item.find("#{@RELEVANT_INPUTS_SELECTOR}:first").attr('name') + return parseInt(name.match(regExp)[0].split('][')[1].slice(0, -1), 10) + + refreshItems: -> + @$items = @$container.children() + + # Sets the proper association indices and labels to all items + # Used when removing items + resetIndexes: -> + @$items.each (i, el) => + $el = $(el) + + # Make sure this is actually a new position + oldIndex = @indexForItem($el) + return true if (i == oldIndex) + + @options.beforeMove.call($el, i, oldIndex) if (@options.beforeMove) + + # Change the number to the new index + @applyIndexToItem($el, i) + + @options.afterMove.call($el, i, oldIndex) if (@options.afterMove) + + bindDestroy: ($item) -> + $item.find(@options.destroySelector).click(@destroyClick) if (@options.destroySelector)