diff --git a/app/assets/javascripts/v_libras/requests/new.js b/app/assets/javascripts/v_libras/requests/new.js new file mode 100644 index 0000000..bb09da3 --- /dev/null +++ b/app/assets/javascripts/v_libras/requests/new.js @@ -0,0 +1,93 @@ +$(function () { + $("#vlibras-wizard").steps({ + headerTag: "h2", + bodyTag: "section", + transitionEffect: "slideLeft", + stepsOrientation: "horizontal", + enablePagination: true, + forceMoveForward: true, + + onStepChanging: stepValidation, + onStepChanged: stepChanged, + onFinished: finished, + + labels: { + cancel: "Cancelar", + current: "etapa atual:", + pagination: "Pagination", + finish: "Finalizar", + next: "Próximo", + previous: "Anterior", + loading: "Carregando ..." + } + }); + + function stepChanged(event, currentIndex, priorIndex) { + var totalSteps = $("#vlibras-wizard .content section").size(); + + if ((currentIndex + 1) === totalSteps) { + $("#btn-next").text("Finalizar"); + } else { + $("#btn-next").text("Próximo"); + } + + deactivateNextButton(); + } + + function finished(event, currentIndex) { + $("#vlibras-form").submit(); + } + + function stepValidation(event, currentIndex, newIndex) { + return true; + } + + $('#menu #btn-next').click(function() { + // Number of steps + var totalSteps = $("#vlibras-wizard .content section").size(); + var currentStep = $("#vlibras-wizard").steps('getCurrentIndex'); + + if ((currentStep + 1) === totalSteps) { + $("#vlibras-wizard").steps('finish'); + } else { + $("#vlibras-wizard").steps('next'); + } + }); + + function activateNextButton() { + $("#btn-next").prop('disabled', false); + } + + function deactivateNextButton() { + $("#btn-next").prop('disabled', true); + } + + + + /* + * Validates video and subtitle extension and activate the next button + */ + $("#vlibras-wizard #subtitle-upload").change(function() { + var acceptedFileTypes = ["srt"]; + validateFileWizard($(this), acceptedFileTypes); + }); + + $("#vlibras-wizard #video-upload").change(function() { + var acceptedFileTypes = ["flv", "ts", "avi", "mp4", "mov", "webm", "wmv", "mkv"]; + validateFileWizard($(this), acceptedFileTypes); + }); + + function validateFileWizard(input, acceptedFileTypes) { + var isValidFile = checkType(input, acceptedFileTypes); + + if (isValidFile) { + activateNextButton(); + } else { + deactivateNextButton(); + input.val(null); + alert("Apenas os formatos abaixo são aceitos:\n\n" + acceptedFileTypes.join(", ")); + } + + return true; + } +}); \ No newline at end of file diff --git a/app/assets/javascripts/v_libras/requests/rapid.js b/app/assets/javascripts/v_libras/requests/rapid.js index aa5bb31..76f7cb4 100644 --- a/app/assets/javascripts/v_libras/requests/rapid.js +++ b/app/assets/javascripts/v_libras/requests/rapid.js @@ -17,4 +17,32 @@ $(function() { if ($("#service-video")[0].checked) { $("#service-video").click(); } -}); \ No newline at end of file +}); + + +/* + * File type verification + */ + +$(function() { + $("#subtitle-upload").change(function() { + var acceptedFileTypes = ["srt"]; + validateFile($(this), acceptedFileTypes); + }); + + $("#video-upload").change(function() { + var acceptedFileTypes = ["flv", "ts", "avi", "mp4", "mov", "webm", "wmv", "mkv"]; + validateFile($(this), acceptedFileTypes); + }); + + function validateFile(input, acceptedFileTypes) { + var isValidFile = checkType(input, acceptedFileTypes); + + if (!isValidFile) { + input.val(null); + alert("Apenas os formatos abaixo são aceitos:\n\n" + acceptedFileTypes.join(", ")); + } + + return true; + } +}); diff --git a/app/assets/javascripts/v_libras/requests/shared.js b/app/assets/javascripts/v_libras/requests/shared.js new file mode 100644 index 0000000..c923ae7 --- /dev/null +++ b/app/assets/javascripts/v_libras/requests/shared.js @@ -0,0 +1,13 @@ +function checkType(file, acceptedFileTypes) { + var ext = file.val().split('.').pop().toLowerCase(); + var isValidFile = false; + + for (var i = 0; i < acceptedFileTypes.length; i++) { + if (ext == acceptedFileTypes[i]) { + isValidFile = true; + break; + } + } + + return isValidFile; +} \ No newline at end of file diff --git a/app/assets/stylesheets/application.css.scss b/app/assets/stylesheets/application.css.scss index 0628331..b16c919 100644 --- a/app/assets/stylesheets/application.css.scss +++ b/app/assets/stylesheets/application.css.scss @@ -41,10 +41,4 @@ color:#BBB; text-shadow: 1px 1px #AAA; } -} - -@media (max-width: 980px) { - .dropdown ul.dropdown-menu { - display: block; - } } \ No newline at end of file diff --git a/app/assets/stylesheets/bootstrap.css.less b/app/assets/stylesheets/bootstrap.css.less index 23c2443..67d84eb 100644 --- a/app/assets/stylesheets/bootstrap.css.less +++ b/app/assets/stylesheets/bootstrap.css.less @@ -50,16 +50,6 @@ footer { } -.upload{ - width: 100%; - height: 480px; - text-align:center; -} - -.upload a{ - text-align:center; -} - .center{ text-align:center; } @@ -112,23 +102,26 @@ label { font-size:18px; } -#upload{ +.upload{ margin-top:10px; background-color:#DDD; height:100px; - text-transform:uppercase; color:#000; - text-align:center; + text-align: center; -moz-box-shadow: 0 0 1px 1px #888; - -webkit-box-shadow: 0 0 1px 1px#888; + -webkit-box-shadow: 0 0 1px 1px #888; box-shadow: 0 0 1px 1px #888; } -input{ + + + + +input { display: inline-block; *display: inline; - padding: 4px 12px; + padding: 0px; margin-bottom: 0; *margin-left: .3em; font-size: 14px; @@ -327,6 +320,10 @@ input[type="checkbox"] { width: auto; } +input[type="file"] { + width: 100%; +} + select, input[type="file"] { height: 30px; diff --git a/app/assets/stylesheets/bootstrap_overrides.css.scss b/app/assets/stylesheets/bootstrap_overrides.css.scss index 996ce37..070b256 100644 --- a/app/assets/stylesheets/bootstrap_overrides.css.scss +++ b/app/assets/stylesheets/bootstrap_overrides.css.scss @@ -1,3 +1,9 @@ +.container-fluid { + max-width: 1024px; + margin: auto; +} + + .breadcrumb { padding: 7px 14px; margin: 0 0 18px; @@ -45,8 +51,16 @@ color: #333333; } + +/* Fix menu */ @media (max-width: 767px) { .nav-collapse.user-menu { float: left; } +} + +@media (max-width: 980px) { + .dropdown ul.dropdown-menu { + display: block; + } } \ No newline at end of file diff --git a/app/assets/stylesheets/components/video_player.css.scss b/app/assets/stylesheets/components/video_player.css.scss index 35c0d5e..5ce38ec 100644 --- a/app/assets/stylesheets/components/video_player.css.scss +++ b/app/assets/stylesheets/components/video_player.css.scss @@ -7,6 +7,10 @@ box-shadow: 0 0 5px 5px #888; } +.video-wizard { + max-width: 50%; +} + .video-vlibras { max-width: 50%; } diff --git a/app/assets/stylesheets/v_libras/requests.css.scss b/app/assets/stylesheets/v_libras/requests.css.scss new file mode 100644 index 0000000..3079a61 --- /dev/null +++ b/app/assets/stylesheets/v_libras/requests.css.scss @@ -0,0 +1,5 @@ +#vlibras-wizard { + .actions { + display: none; + } +} \ No newline at end of file diff --git a/app/controllers/v_libras/requests_controller.rb b/app/controllers/v_libras/requests_controller.rb index 9f9b0dd..e8d5d4e 100644 --- a/app/controllers/v_libras/requests_controller.rb +++ b/app/controllers/v_libras/requests_controller.rb @@ -8,26 +8,23 @@ class VLibras::RequestsController < ApplicationController @request = VLibras::Request.new end - def create - @request = VLibras::Request.build_from_params(params, current_user) - - video = FileUploader.new - video.cache!(params[:video]) - - subtitle = FileUploader.new - subtitle.cache!(params[:subtitle]) + def new - files = { :video => video, :subtitle => subtitle } + end + def create + @request = VLibras::Request.build_from_params(params, current_user) if @request.save - @request.perform_request(files) + @request.perform_request(@request.files) flash[:success] = 'Sua requisição foi submetida com sucesso!' redirect_to v_libras_videos_path else - flash[:error] = 'Algo deu errado com a sua requisição.' - render :action => :rapid + flash[:error] = 'Algo deu errado com a sua requisição. Por favor verifique opções escolhidas.' + flash[:warning] = @request.errors.full_messages.to_sentence.humanize + + redirect_to :back end end diff --git a/app/models/v_libras/request.rb b/app/models/v_libras/request.rb index 69d39d0..3cbd676 100644 --- a/app/models/v_libras/request.rb +++ b/app/models/v_libras/request.rb @@ -15,19 +15,21 @@ class VLibras::Request < ActiveRecord::Base serialize :params - attr_accessor :video + attr_accessor :files belongs_to :owner, :class => User has_one :video, :class => VLibras::Video, :dependent => :destroy validates :service_type, - presence: true, - inclusion: { in: %w(video-legenda video), message: "%{value} is not a valid service type" } + presence: true, + inclusion: { in: %w(video-legenda video) } validates :status, presence: true, - inclusion: { in: %w(created processing error success), message: "%{value} is not a valid status" } + inclusion: { in: %w(created processing error success) } + + validate :match_files_with_service_type before_validation :default_values @@ -37,9 +39,23 @@ class VLibras::Request < ActiveRecord::Base request = self.new request.service_type = params[:service] - request.video_filename = params[:video].original_filename request.owner = user + request.files = {} + + if params[:video] + request.video_filename = params[:video].original_filename + video = FileUploader.new + video.cache!(params[:video]) + request.files.merge!(:video => video) + end + + if params[:subtitle] + subtitle = FileUploader.new + subtitle.cache!(params[:subtitle]) + request.files.merge!(:subtitle => subtitle) + end + request.params = params[:params] request @@ -55,7 +71,18 @@ class VLibras::Request < ActiveRecord::Base end handle_asynchronously :perform_request + private + def match_files_with_service_type + if files[:video].nil? + errors.add(:base, 'Você precisa enviar um vídeo.') + end + + if (service_type == 'video-legenda') && files[:subtitle].nil? + errors.add(:base, 'Você precisa enviar uma legenda.') + end + end + def default_values self.status ||= 'created' end diff --git a/app/views/layouts/_menu.haml b/app/views/layouts/_menu.haml index 9c32965..3f9c3a3 100644 --- a/app/views/layouts/_menu.haml +++ b/app/views/layouts/_menu.haml @@ -9,9 +9,9 @@ = link_to "GTAaaS", home_path, :class => "brand" - - if current_user.present? - .nav-collapse - %ul.nav + .nav-collapse + %ul.nav + - if current_user.present? %li= link_to t('shared.main'), home_path %li.dropdown @@ -26,7 +26,7 @@ = t('wikivideos.my_videos') - if current_user.videos.not_seen.any? %span.label.label-success= current_user.videos.not_seen.size - %li= link_to t('videos.new'), '#' + %li= link_to t('videos.new'), new_v_libras_request_path %li.divider %li= link_to t('shared.form_alternative'), rapid_v_libras_requests_path @@ -42,7 +42,7 @@ %li.hidden= link_to t('shared.slibras') - %li= link_to t('shared.about'), "http://gtaaas.lavid.ufpb.br/projeto", :target => "blank" + %li= link_to t('shared.about'), "http://gtaaas.lavid.ufpb.br/projeto", :target => "blank" - if current_user.present? .nav-collapse.pull-right.user-menu diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index b573c2c..083b95e 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -4,7 +4,10 @@ GTAaaS <%= stylesheet_link_tag "application", :media => "all" %> <%= javascript_include_tag "application" %> + <%= yield :js %> + <%= yield :css %> + <%= csrf_meta_tags %> diff --git a/app/views/static/home.haml b/app/views/static/home.haml index 0e2b5b3..e888ffc 100644 --- a/app/views/static/home.haml +++ b/app/views/static/home.haml @@ -1 +1,2 @@ -%h1 Home \ No newline at end of file +.breadcrumb + %h1 Menu \ No newline at end of file diff --git a/app/views/v_libras/requests/new.haml b/app/views/v_libras/requests/new.haml new file mode 100644 index 0000000..4346b2a --- /dev/null +++ b/app/views/v_libras/requests/new.haml @@ -0,0 +1,57 @@ +- content_for :js do + = javascript_include_tag "jquery.steps.js" + = javascript_include_tag "v_libras/requests/shared" + = javascript_include_tag "v_libras/requests/new" + +- content_for :css do + = stylesheet_link_tag "jquery.steps.css" + = stylesheet_link_tag "v_libras/requests" + +.breadcrumb + %h3 Novo vídeo + += form_tag v_libras_requests_path, method: :post, :multipart => true, :id => 'vlibras-form' do |f| + .content.row-fluid + #vlibras-wizard + %h2 Vídeo + %section + %p + instruções + upload do arquivo de vído + só libera o botão quando o video for escolhido + + = html5_video_tag("/video.mp4", 'id', 'video-wizard', :autoplay => 'autoplay') + + .span4 + = file_field_tag 'video', :id => 'video-upload' + + %h2 Áudio ou Legenda + %section + %p + escolha se é audio ou legenda + se for legenda, mostra o input file e quando arquivo for selecionado, libera o botão de avançar + + .span4 + = file_field_tag 'subtitle', :id => 'subtitle-upload' + %h2 Posição + %section + %p + asd + + %h2 Tamanho + %section + %p + botão de + + #menu.center + = button_tag 'Próximo', :class => "btn btn-large btn-success", :id => 'btn-next', :disabled => true + +.row-fluid + #step-service + + #step-video-upload + + #step-subtitle-upload + + #step-size + + #step-position diff --git a/app/views/v_libras/requests/rapid.haml b/app/views/v_libras/requests/rapid.haml index 0bcc990..119b166 100644 --- a/app/views/v_libras/requests/rapid.haml +++ b/app/views/v_libras/requests/rapid.haml @@ -1,4 +1,5 @@ - content_for :js do + = javascript_include_tag "v_libras/requests/shared" = javascript_include_tag "v_libras/requests/rapid" .row-fluid @@ -27,15 +28,18 @@ = radio_button_tag :service, 'video-legenda', false, :id => 'service-video-subtitle' Legenda (.SRT) - #url.hide + #url.field.hide = label_tag :video, t('videos.url'), :class => "bold" - = file_field_tag :video, :onchange => "return check_video(this)" - #legend.hide + = file_field_tag :video, :prompt => "Arquivo de vídeo", :id => 'video-upload' + + #legend.field.hide = label_tag :subtitle, t('videos.subtitle'), :class => "bold" - = file_field_tag :subtitle, :prompt => "LEGENDA", :onchange => "return check_subtitle(this)" + = file_field_tag :subtitle, :prompt => "Legenda", :id => 'subtitle-upload' + .field = label_tag 'params[tamanho]', t('videos.window_size'), :class => "bold" = select_tag 'params[tamanho]', options_for_select([['Pequena', 'pequeno'], ['Média', 'medio'], ['Grande', 'grande']]) + .field %p %b @@ -43,6 +47,7 @@ = select_tag 'params[posicao]', options_for_select([[t('videos.top_left'), 'superior-esquerdo'], [t('videos.top_right'), 'superior-direito'], [t('videos.bottom_right'),'inferior-direito'], [t('videos.bottom_left'), 'inferior-esquerdo']]) + .field = label_tag 'params[transparencia]', t('videos.transparency'), :class => "bold" = select_tag 'params[transparencia]', options_for_select([['Opaco', 'opaco'], ['Transparente', 'transparente']]) diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index b185f2e..dc26005 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -1,2 +1,8 @@ +Rails.application.config.assets.precompile += %w( v_libras/requests/shared.js ) Rails.application.config.assets.precompile += %w( v_libras/requests/rapid.js ) -Rails.application.config.assets.precompile += %w( v_libras/videos/index.js ) \ No newline at end of file +Rails.application.config.assets.precompile += %w( v_libras/requests/new.js ) +Rails.application.config.assets.precompile += %w( v_libras/videos/index.js ) +Rails.application.config.assets.precompile += %w( jquery.steps.js ) + +Rails.application.config.assets.precompile += %w( v_libras/requests.css ) +Rails.application.config.assets.precompile += %w( jquery.steps.css ) \ No newline at end of file diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 97f46f3..b3011d8 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -299,6 +299,10 @@ pt-BR: one: 'Usuário' other: 'Usuários' + attributes: + v_libras/request: + service_type: 'Tipo de serviço' + errors: template: header: diff --git a/lib/api_client/client.rb b/lib/api_client/client.rb index c017761..97cd9a1 100644 --- a/lib/api_client/client.rb +++ b/lib/api_client/client.rb @@ -4,37 +4,49 @@ module ApiClient::Client include HTTMultiParty default_timeout 10 * 60 - def self.submit(request, files) - o = { query: request.params.clone } - o[:query].merge!({ :servico => request.service_type }) - o[:query].merge!({ :callback => "http://150.165.205.166:3000/v_libras/requests/callback?request_id=#{request.id}" }) - - o[:query].merge!({ :video => files[:video].file.to_file }) - - unless files[:subtitle].file.nil? - o[:query].merge!({ :legenda => files[:subtitle].file.to_file }) - o[:query].merge!({ :linguagem => 'portugues' }) - end - Delayed::Worker.logger.debug "[VLibras::Request] Options: #{o}" - - response = self.post(ApiClient::API_URL, o) + def self.submit(request, files) + options = process_params(request, files) + Delayed::Worker.logger.debug "[VLibras::Request] Options: #{options}" + response = self.post(ApiClient::API_URL, options) Delayed::Worker.logger.debug "[VLibras::Request] Status #{response.response.code}" if response.response.code == '200' - + # Response is processed by callback else - request.update!(:response => response.body, :status => 'error') + request.update!(:status => 'error', :response => response.body) end + rescue => e - request.update!(:status => 'error', :response => e) + request.update!(:status => 'error', :response => e.to_s) ensure # FIXME: Running on another thread. Websocket not working :( Delayed::Worker.logger.debug "[VLibras::Request] Sending message to websocket channel" WebsocketRails[:requests_update].trigger(:update, {a: :b, c: :d}) end + + # + # Process the params from the request AR object + # and return options to make the post request + # + def self.process_params(request, files) + options = { query: request.params.clone } + options[:query].merge!(:servico => request.service_type) + options[:query].merge!(:callback => "http://150.165.205.197:3000/v_libras/requests/callback?request_id=#{request.id}") + + options[:query].merge!(:video => files[:video].file.to_file) + + unless files[:subtitle].nil? + options[:query].merge!(:legenda => files[:subtitle].file.to_file) + options[:query].merge!(:linguagem => 'portugues') + end + + return options + end + + private def self.url_with_service(service) URI.encode("#{ApiClient::API_URL}?servico=#{service}") diff --git a/vendor/assets/javascripts/jquery.steps.js b/vendor/assets/javascripts/jquery.steps.js new file mode 100644 index 0000000..4eb5126 --- /dev/null +++ b/vendor/assets/javascripts/jquery.steps.js @@ -0,0 +1,2015 @@ +/*! + * jQuery Steps v1.0.7 - 05/07/2014 + * Copyright (c) 2014 Rafael Staib (http://www.jquery-steps.com) + * Licensed under MIT http://www.opensource.org/licenses/MIT + */ +;(function ($, undefined) +{ +$.fn.extend({ + _aria: function (name, value) + { + return this.attr("aria-" + name, value); + }, + + _removeAria: function (name) + { + return this.removeAttr("aria-" + name); + }, + + _enableAria: function (enable) + { + return (enable == null || enable) ? + this.removeClass("disabled")._aria("disabled", "false") : + this.addClass("disabled")._aria("disabled", "true"); + }, + + _showAria: function (show) + { + return (show == null || show) ? + this.show()._aria("hidden", "false") : + this.hide()._aria("hidden", "true"); + }, + + _selectAria: function (select) + { + return (select == null || select) ? + this.addClass("current")._aria("selected", "true") : + this.removeClass("current")._aria("selected", "false"); + }, + + _id: function (id) + { + return (id) ? this.attr("id", id) : this.attr("id"); + } +}); + +if (!String.prototype.format) +{ + String.prototype.format = function() + { + var args = (arguments.length === 1 && $.isArray(arguments[0])) ? arguments[0] : arguments; + var formattedString = this; + for (var i = 0; i < args.length; i++) + { + var pattern = new RegExp("\\{" + i + "\\}", "gm"); + formattedString = formattedString.replace(pattern, args[i]); + } + return formattedString; + }; +} + +/** + * A global unique id count. + * + * @static + * @private + * @property _uniqueId + * @type Integer + **/ +var _uniqueId = 0; + +/** + * The plugin prefix for cookies. + * + * @final + * @private + * @property _cookiePrefix + * @type String + **/ +var _cookiePrefix = "jQu3ry_5teps_St@te_"; + +/** + * Suffix for the unique tab id. + * + * @final + * @private + * @property _tabSuffix + * @type String + * @since 0.9.7 + **/ +var _tabSuffix = "-t-"; + +/** + * Suffix for the unique tabpanel id. + * + * @final + * @private + * @property _tabpanelSuffix + * @type String + * @since 0.9.7 + **/ +var _tabpanelSuffix = "-p-"; + +/** + * Suffix for the unique title id. + * + * @final + * @private + * @property _titleSuffix + * @type String + * @since 0.9.7 + **/ +var _titleSuffix = "-h-"; + +/** + * An error message for an "index out of range" error. + * + * @final + * @private + * @property _indexOutOfRangeErrorMessage + * @type String + **/ +var _indexOutOfRangeErrorMessage = "Index out of range."; + +/** + * An error message for an "missing corresponding element" error. + * + * @final + * @private + * @property _missingCorrespondingElementErrorMessage + * @type String + **/ +var _missingCorrespondingElementErrorMessage = "One or more corresponding step {0} are missing."; + +/** + * Adds a step to the cache. + * + * @static + * @private + * @method addStepToCache + * @param wizard {Object} A jQuery wizard object + * @param step {Object} The step object to add + **/ +function addStepToCache(wizard, step) +{ + getSteps(wizard).push(step); +} + +function analyzeData(wizard, options, state) +{ + var stepTitles = wizard.children(options.headerTag), + stepContents = wizard.children(options.bodyTag); + + // Validate content + if (stepTitles.length > stepContents.length) + { + throwError(_missingCorrespondingElementErrorMessage, "contents"); + } + else if (stepTitles.length < stepContents.length) + { + throwError(_missingCorrespondingElementErrorMessage, "titles"); + } + + var startIndex = options.startIndex; + + state.stepCount = stepTitles.length; + + // Tries to load the saved state (step position) + if (options.saveState && $.cookie) + { + var savedState = $.cookie(_cookiePrefix + getUniqueId(wizard)); + // Sets the saved position to the start index if not undefined or out of range + var savedIndex = parseInt(savedState, 0); + if (!isNaN(savedIndex) && savedIndex < state.stepCount) + { + startIndex = savedIndex; + } + } + + state.currentIndex = startIndex; + + stepTitles.each(function (index) + { + var item = $(this), // item == header + content = stepContents.eq(index), + modeData = content.data("mode"), + mode = (modeData == null) ? contentMode.html : getValidEnumValue(contentMode, + (/^\s*$/.test(modeData) || isNaN(modeData)) ? modeData : parseInt(modeData, 0)), + contentUrl = (mode === contentMode.html || content.data("url") === undefined) ? + "" : content.data("url"), + contentLoaded = (mode !== contentMode.html && content.data("loaded") === "1"), + step = $.extend({}, stepModel, { + title: item.html(), + content: (mode === contentMode.html) ? content.html() : "", + contentUrl: contentUrl, + contentMode: mode, + contentLoaded: contentLoaded + }); + + addStepToCache(wizard, step); + }); +} + +/** + * Triggers the onCanceled event. + * + * @static + * @private + * @method cancel + * @param wizard {Object} The jQuery wizard object + **/ +function cancel(wizard) +{ + wizard.triggerHandler("canceled"); +} + +function decreaseCurrentIndexBy(state, decreaseBy) +{ + return state.currentIndex - decreaseBy; +} + +/** + * Removes the control functionality completely and transforms the current state to the initial HTML structure. + * + * @static + * @private + * @method destroy + * @param wizard {Object} A jQuery wizard object + **/ +function destroy(wizard, options) +{ + var eventNamespace = getEventNamespace(wizard); + + // Remove virtual data objects from the wizard + wizard.unbind(eventNamespace).removeData("uid").removeData("options") + .removeData("state").removeData("steps").removeData("eventNamespace") + .find(".actions a").unbind(eventNamespace); + + // Remove attributes and CSS classes from the wizard + wizard.removeClass(options.clearFixCssClass + " vertical"); + + var contents = wizard.find(".content > *"); + + // Remove virtual data objects from panels and their titles + contents.removeData("loaded").removeData("mode").removeData("url"); + + // Remove attributes, CSS classes and reset inline styles on all panels and their titles + contents.removeAttr("id").removeAttr("role").removeAttr("tabindex") + .removeAttr("class").removeAttr("style")._removeAria("labelledby") + ._removeAria("hidden"); + + // Empty panels if the mode is set to 'async' or 'iframe' + wizard.find(".content > [data-mode='async'],.content > [data-mode='iframe']").empty(); + + var wizardSubstitute = $("<{0} class=\"{1}\">".format(wizard.get(0).tagName, wizard.attr("class"))); + + var wizardId = wizard._id(); + if (wizardId != null && wizardId !== "") + { + wizardSubstitute._id(wizardId); + } + + wizardSubstitute.html(wizard.find(".content").html()); + wizard.after(wizardSubstitute); + wizard.remove(); + + return wizardSubstitute; +} + +/** + * Triggers the onFinishing and onFinished event. + * + * @static + * @private + * @method finishStep + * @param wizard {Object} The jQuery wizard object + * @param state {Object} The state container of the current wizard + **/ +function finishStep(wizard, state) +{ + var currentStep = wizard.find(".steps li").eq(state.currentIndex); + + if (wizard.triggerHandler("finishing", [state.currentIndex])) + { + currentStep.addClass("done").removeClass("error"); + wizard.triggerHandler("finished", [state.currentIndex]); + } + else + { + currentStep.addClass("error"); + } +} + +/** + * Gets or creates if not exist an unique event namespace for the given wizard instance. + * + * @static + * @private + * @method getEventNamespace + * @param wizard {Object} A jQuery wizard object + * @return {String} Returns the unique event namespace for the given wizard + */ +function getEventNamespace(wizard) +{ + var eventNamespace = wizard.data("eventNamespace"); + + if (eventNamespace == null) + { + eventNamespace = "." + getUniqueId(wizard); + wizard.data("eventNamespace", eventNamespace); + } + + return eventNamespace; +} + +function getStepAnchor(wizard, index) +{ + var uniqueId = getUniqueId(wizard); + + return wizard.find("#" + uniqueId + _tabSuffix + index); +} + +function getStepPanel(wizard, index) +{ + var uniqueId = getUniqueId(wizard); + + return wizard.find("#" + uniqueId + _tabpanelSuffix + index); +} + +function getStepTitle(wizard, index) +{ + var uniqueId = getUniqueId(wizard); + + return wizard.find("#" + uniqueId + _titleSuffix + index); +} + +function getOptions(wizard) +{ + return wizard.data("options"); +} + +function getState(wizard) +{ + return wizard.data("state"); +} + +function getSteps(wizard) +{ + return wizard.data("steps"); +} + +/** + * Gets a specific step object by index. + * + * @static + * @private + * @method getStep + * @param index {Integer} An integer that belongs to the position of a step + * @return {Object} A specific step object + **/ +function getStep(wizard, index) +{ + var steps = getSteps(wizard); + + if (index < 0 || index >= steps.length) + { + throwError(_indexOutOfRangeErrorMessage); + } + + return steps[index]; +} + +/** + * Gets or creates if not exist an unique id from the given wizard instance. + * + * @static + * @private + * @method getUniqueId + * @param wizard {Object} A jQuery wizard object + * @return {String} Returns the unique id for the given wizard + */ +function getUniqueId(wizard) +{ + var uniqueId = wizard.data("uid"); + + if (uniqueId == null) + { + uniqueId = wizard._id(); + if (uniqueId == null) + { + uniqueId = "steps-uid-".concat(_uniqueId); + wizard._id(uniqueId); + } + + _uniqueId++; + wizard.data("uid", uniqueId); + } + + return uniqueId; +} + +/** + * Gets a valid enum value by checking a specific enum key or value. + * + * @static + * @private + * @method getValidEnumValue + * @param enumType {Object} Type of enum + * @param keyOrValue {Object} Key as `String` or value as `Integer` to check for + */ +function getValidEnumValue(enumType, keyOrValue) +{ + validateArgument("enumType", enumType); + validateArgument("keyOrValue", keyOrValue); + + // Is key + if (typeof keyOrValue === "string") + { + var value = enumType[keyOrValue]; + if (value === undefined) + { + throwError("The enum key '{0}' does not exist.", keyOrValue); + } + + return value; + } + // Is value + else if (typeof keyOrValue === "number") + { + for (var key in enumType) + { + if (enumType[key] === keyOrValue) + { + return keyOrValue; + } + } + + throwError("Invalid enum value '{0}'.", keyOrValue); + } + // Type is not supported + else + { + throwError("Invalid key or value type."); + } +} + +/** + * Routes to the next step. + * + * @static + * @private + * @method goToNextStep + * @param wizard {Object} The jQuery wizard object + * @param options {Object} Settings of the current wizard + * @param state {Object} The state container of the current wizard + * @return {Boolean} Indicates whether the action executed + **/ +function goToNextStep(wizard, options, state) +{ + return paginationClick(wizard, options, state, increaseCurrentIndexBy(state, 1)); +} + +/** + * Routes to the previous step. + * + * @static + * @private + * @method goToPreviousStep + * @param wizard {Object} The jQuery wizard object + * @param options {Object} Settings of the current wizard + * @param state {Object} The state container of the current wizard + * @return {Boolean} Indicates whether the action executed + **/ +function goToPreviousStep(wizard, options, state) +{ + return paginationClick(wizard, options, state, decreaseCurrentIndexBy(state, 1)); +} + +/** + * Routes to a specific step by a given index. + * + * @static + * @private + * @method goToStep + * @param wizard {Object} The jQuery wizard object + * @param options {Object} Settings of the current wizard + * @param state {Object} The state container of the current wizard + * @param index {Integer} The position (zero-based) to route to + * @return {Boolean} Indicates whether the action succeeded or failed + **/ +function goToStep(wizard, options, state, index) +{ + if (index < 0 || index >= state.stepCount) + { + throwError(_indexOutOfRangeErrorMessage); + } + + if (options.forceMoveForward && index < state.currentIndex) + { + return; + } + + var oldIndex = state.currentIndex; + if (wizard.triggerHandler("stepChanging", [state.currentIndex, index])) + { + // Save new state + state.currentIndex = index; + saveCurrentStateToCookie(wizard, options, state); + + // Change visualisation + refreshStepNavigation(wizard, options, state, oldIndex); + refreshPagination(wizard, options, state); + loadAsyncContent(wizard, options, state); + startTransitionEffect(wizard, options, state, index, oldIndex); + + wizard.triggerHandler("stepChanged", [index, oldIndex]); + } + else + { + wizard.find(".steps li").eq(oldIndex).addClass("error"); + } + + return true; +} + +function increaseCurrentIndexBy(state, increaseBy) +{ + return state.currentIndex + increaseBy; +} + +/** + * Initializes the component. + * + * @static + * @private + * @method initialize + * @param options {Object} The component settings + **/ +function initialize(options) +{ + /*jshint -W040 */ + var opts = $.extend(true, {}, defaults, options); + + return this.each(function () + { + var wizard = $(this); + var state = { + currentIndex: opts.startIndex, + currentStep: null, + stepCount: 0, + transitionElement: null + }; + + // Create data container + wizard.data("options", opts); + wizard.data("state", state); + wizard.data("steps", []); + + analyzeData(wizard, opts, state); + render(wizard, opts, state); + registerEvents(wizard, opts); + + // Trigger focus + if (opts.autoFocus && _uniqueId === 0) + { + getStepAnchor(wizard, opts.startIndex).focus(); + } + }); +} + +/** + * Inserts a new step to a specific position. + * + * @static + * @private + * @method insertStep + * @param wizard {Object} The jQuery wizard object + * @param options {Object} Settings of the current wizard + * @param state {Object} The state container of the current wizard + * @param index {Integer} The position (zero-based) to add + * @param step {Object} The step object to add + * @example + * $("#wizard").steps().insert(0, { + * title: "Title", + * content: "", // optional + * contentMode: "async", // optional + * contentUrl: "/Content/Step/1" // optional + * }); + * @chainable + **/ +function insertStep(wizard, options, state, index, step) +{ + if (index < 0 || index > state.stepCount) + { + throwError(_indexOutOfRangeErrorMessage); + } + + // TODO: Validate step object + + // Change data + step = $.extend({}, stepModel, step); + insertStepToCache(wizard, index, step); + if (state.currentIndex !== state.stepCount && state.currentIndex >= index) + { + state.currentIndex++; + saveCurrentStateToCookie(wizard, options, state); + } + state.stepCount++; + + var contentContainer = wizard.find(".content"), + header = $("<{0}>{1}".format(options.headerTag, step.title)), + body = $("<{0}>".format(options.bodyTag)); + + if (step.contentMode == null || step.contentMode === contentMode.html) + { + body.html(step.content); + } + + if (index === 0) + { + contentContainer.prepend(body).prepend(header); + } + else + { + getStepPanel(wizard, (index - 1)).after(body).after(header); + } + + renderBody(wizard, state, body, index); + renderTitle(wizard, options, state, header, index); + refreshSteps(wizard, options, state, index); + if (index === state.currentIndex) + { + refreshStepNavigation(wizard, options, state); + } + refreshPagination(wizard, options, state); + + return wizard; +} + +/** + * Inserts a step object to the cache at a specific position. + * + * @static + * @private + * @method insertStepToCache + * @param wizard {Object} A jQuery wizard object + * @param index {Integer} The position (zero-based) to add + * @param step {Object} The step object to add + **/ +function insertStepToCache(wizard, index, step) +{ + getSteps(wizard).splice(index, 0, step); +} + +/** + * Handles the keyup DOM event for pagination. + * + * @static + * @private + * @event keyup + * @param event {Object} An event object + */ +function keyUpHandler(event) +{ + var wizard = $(this), + options = getOptions(wizard), + state = getState(wizard); + + if (options.suppressPaginationOnFocus && wizard.find(":focus").is(":input")) + { + event.preventDefault(); + return false; + } + + var keyCodes = { left: 37, right: 39 }; + if (event.keyCode === keyCodes.left) + { + event.preventDefault(); + goToPreviousStep(wizard, options, state); + } + else if (event.keyCode === keyCodes.right) + { + event.preventDefault(); + goToNextStep(wizard, options, state); + } +} + +/** + * Loads and includes async content. + * + * @static + * @private + * @method loadAsyncContent + * @param wizard {Object} A jQuery wizard object + * @param options {Object} Settings of the current wizard + * @param state {Object} The state container of the current wizard + */ +function loadAsyncContent(wizard, options, state) +{ + if (state.stepCount > 0) + { + var currentStep = getStep(wizard, state.currentIndex); + + if (!options.enableContentCache || !currentStep.contentLoaded) + { + switch (getValidEnumValue(contentMode, currentStep.contentMode)) + { + case contentMode.iframe: + wizard.find(".content > .body").eq(state.currentIndex).empty() + .html("