diff --git a/.rspec b/.rspec index fe31725..53607ea 100644 --- a/.rspec +++ b/.rspec @@ -1,2 +1 @@ --colour --rturnip diff --git a/.ruby-gemset b/.ruby-gemset new file mode 100644 index 0000000..96e09d1 --- /dev/null +++ b/.ruby-gemset @@ -0,0 +1 @@ +velocipede diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..e5fea6c --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-1.9.3-p374 diff --git a/.rvmrc b/.rvmrc deleted file mode 100644 index 2424f67..0000000 --- a/.rvmrc +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash - -# This is an RVM Project .rvmrc file, used to automatically load the ruby -# development environment upon cd'ing into the directory - -# First we specify our desired [@], the @gemset name is optional. -environment_id="ruby-1.9.3-p374@velocipede" - -# -# Uncomment following line if you want options to be set only for given project. -# -# PROJECT_JRUBY_OPTS=( --1.9 ) - -# -# First we attempt to load the desired environment directly from the environment -# file. This is very fast and efficient compared to running through the entire -# CLI and selector. If you want feedback on which environment was used then -# insert the word 'use' after --create as this triggers verbose mode. -# -if [[ -d "${rvm_path:-$HOME/.rvm}/environments" \ - && -s "${rvm_path:-$HOME/.rvm}/environments/$environment_id" ]] -then - \. "${rvm_path:-$HOME/.rvm}/environments/$environment_id" - - if [[ -s "${rvm_path:-$HOME/.rvm}/hooks/after_use" ]] - then - . "${rvm_path:-$HOME/.rvm}/hooks/after_use" - fi -else - # If the environment file has not yet been created, use the RVM CLI to select. - if ! rvm --create use "$environment_id" - then - echo "Failed to create RVM environment '${environment_id}'." - return 1 - fi -fi - -# -# If you use an RVM gemset file to install a list of gems (*.gems), you can have -# it be automatically loaded. Uncomment the following and adjust the filename if -# necessary. -# -# filename=".gems" -# if [[ -s "$filename" ]] -# then -# rvm gemset import "$filename" | grep -v already | grep -v listed | grep -v complete | sed '/^$/d' -# fi - -# If you use bundler, this might be useful to you: -# if command -v bundle && [[ -s Gemfile ]] -# then -# bundle install -# fi - - diff --git a/Gemfile b/Gemfile index d09b8da..9bb8108 100644 --- a/Gemfile +++ b/Gemfile @@ -14,8 +14,9 @@ gem 'decent_exposure', '~> 1.0.1' gem 'devise', '~> 2.0.4' gem 'haml-rails', '~> 0.3.4' gem 'jquery-rails', '~> 2.0' -gem 'pg' +gem 'pg', '~> 0.17.1' gem 'will_paginate', '~> 3.0.3' +gem 'jbuilder', '~> 2.0.3' # Gems used only for assets and not required # in production environments by default. @@ -30,21 +31,21 @@ group :assets do end group :development, :test do - gem 'rspec-rails', '~> 2.8.1' + gem 'rspec-rails', '~> 2.14.0' gem 'factory_girl_rails', '~> 1.2' gem 'pry', '~> 0.9.8' - gem 'faker' + gem 'faker', '~> 1.2.0' end group :test do gem 'shoulda-matchers', '~> 1.0.0' - gem 'capybara', '~> 1.1.2' - gem 'turnip', '~> 0.3.0' - gem 'database_cleaner' - gem 'launchy' - gem 'spork' + gem 'capybara', '~> 2.2.1' + gem 'poltergeist', '~> 1.5.0' + gem 'database_cleaner', '~> 1.2.0' + gem 'launchy', '~> 2.4.2' + gem 'spork', '~> 0.9.2' #guard dependency for Mac OS 10 gem 'rb-fsevent', :require => false if RUBY_PLATFORM =~ /darwin/i - gem 'guard-spork' - gem 'guard-rspec' + gem 'guard-spork', '~> 1.5.1' + gem 'guard-rspec', '~> 4.2.6' end diff --git a/Gemfile.lock b/Gemfile.lock index 997ddc6..e9971ae 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -36,42 +36,43 @@ GEM activesupport (3.2.13) i18n (= 0.6.1) multi_json (~> 1.0) - addressable (2.3.3) - arel (3.0.2) - bcrypt-ruby (3.0.1) - bootstrap-will_paginate (0.0.9) + addressable (2.3.5) + arel (3.0.3) + bcrypt-ruby (3.1.2) + bootstrap-will_paginate (0.0.10) will_paginate builder (3.0.4) - cancan (1.6.9) - capybara (1.1.4) + cancan (1.6.10) + capybara (2.2.1) mime-types (>= 1.16) nokogiri (>= 1.3.3) rack (>= 1.0.0) rack-test (>= 0.5.4) - selenium-webdriver (~> 2.0) - xpath (~> 0.1.4) - childprocess (0.3.9) + xpath (~> 2.0) + celluloid (0.15.2) + timers (~> 1.1.0) + childprocess (0.4.0) ffi (~> 1.0, >= 1.0.11) - coderay (1.0.9) + cliver (0.3.2) + coderay (1.1.0) coffee-rails (3.2.2) coffee-script (>= 2.2.0) railties (~> 3.2.0) coffee-script (2.2.0) coffee-script-source execjs - coffee-script-source (1.6.2) - commonjs (0.2.6) - database_cleaner (0.9.1) + coffee-script-source (1.7.0) + commonjs (0.2.7) + database_cleaner (1.2.0) decent_exposure (1.0.2) - devise (2.0.5) + devise (2.0.6) bcrypt-ruby (~> 3.0) orm_adapter (~> 0.0.3) railties (~> 3.1) warden (~> 1.1.1) - diff-lcs (1.1.3) + diff-lcs (1.2.5) erubis (2.7.0) - execjs (1.4.0) - multi_json (~> 1.0) + execjs (2.0.2) factory_girl (2.6.4) activesupport (>= 2.3.9) factory_girl_rails (1.7.0) @@ -79,19 +80,18 @@ GEM railties (>= 3.0.0) faker (1.2.0) i18n (~> 0.5) - ffi (1.6.0) + ffi (1.9.3) formatador (0.2.4) - gherkin (2.11.6) - json (>= 1.7.6) - guard (1.7.0) + guard (2.4.0) formatador (>= 0.2.4) - listen (>= 0.6.0) - lumberjack (>= 1.0.2) - pry (>= 0.9.10) - thor (>= 0.14.6) - guard-rspec (1.2.1) - guard (>= 1.1) - guard-spork (1.5.0) + listen (~> 2.1) + lumberjack (~> 1.0) + pry (>= 0.9.12) + thor (>= 0.18.1) + guard-rspec (4.2.6) + guard (~> 2.1) + rspec (>= 2.14, < 4.0) + guard-spork (1.5.1) childprocess (>= 0.2.3) guard (>= 1.1) spork (>= 0.8.4) @@ -101,14 +101,17 @@ GEM activesupport (>= 3.1, < 4.1) haml (~> 3.1) railties (>= 3.1, < 4.1) - hike (1.2.1) + hike (1.2.3) i18n (0.6.1) + jbuilder (2.0.3) + activesupport (>= 3.0.0) + multi_json (>= 1.2.0) journey (1.0.4) - jquery-rails (2.2.1) + jquery-rails (2.3.0) railties (>= 3.0, < 5.0) thor (>= 0.14, < 2.0) - json (1.7.7) - launchy (2.2.0) + json (1.8.1) + launchy (2.4.2) addressable (~> 2.3) less (2.2.2) commonjs (~> 0.2.6) @@ -116,15 +119,18 @@ GEM actionpack (>= 3.1) less (~> 2.2.0) libv8 (3.3.10.4) - listen (0.7.3) - lumberjack (1.0.3) - mail (2.5.3) - i18n (>= 0.4.0) + listen (2.4.1) + celluloid (>= 0.15.2) + rb-fsevent (>= 0.9.3) + rb-inotify (>= 0.9) + lumberjack (1.0.4) + mail (2.5.4) mime-types (~> 1.16) treetop (~> 1.4.8) - method_source (0.8.1) - mime-types (1.22) - multi_json (1.8.2) + method_source (0.8.2) + mime-types (1.25.1) + mini_portile (0.5.2) + multi_json (1.8.4) netzke-basepack (0.8.4) netzke-core (~> 0.8.2) netzke-cancan (0.8.2) @@ -133,12 +139,18 @@ GEM netzke-core (0.8.4) execjs uglifier - nokogiri (1.5.9) + nokogiri (1.6.1) + mini_portile (~> 0.5.0) orm_adapter (0.0.7) - pg (0.15.1) + pg (0.17.1) + poltergeist (1.5.0) + capybara (~> 2.1) + cliver (~> 0.3.1) + multi_json (~> 1.0) + websocket-driver (>= 0.2.0) polyglot (0.3.3) - pry (0.9.12) - coderay (~> 1.0.5) + pry (0.9.12.6) + coderay (~> 1.0) method_source (~> 0.8) slop (~> 3.4) rack (1.4.5) @@ -163,31 +175,30 @@ GEM rake (>= 0.8.7) rdoc (~> 3.4) thor (>= 0.14.6, < 2.0) - rake (10.0.4) - rb-fsevent (0.9.3) + rake (10.1.1) + rb-fsevent (0.9.4) + rb-inotify (0.9.3) + ffi (>= 0.5.0) rdoc (3.12.2) json (~> 1.4) - rspec (2.8.0) - rspec-core (~> 2.8.0) - rspec-expectations (~> 2.8.0) - rspec-mocks (~> 2.8.0) - rspec-core (2.8.0) - rspec-expectations (2.8.0) - diff-lcs (~> 1.1.2) - rspec-mocks (2.8.0) - rspec-rails (2.8.1) + rspec (2.14.1) + rspec-core (~> 2.14.0) + rspec-expectations (~> 2.14.0) + rspec-mocks (~> 2.14.0) + rspec-core (2.14.7) + rspec-expectations (2.14.5) + diff-lcs (>= 1.1.3, < 2.0) + rspec-mocks (2.14.5) + rspec-rails (2.14.1) actionpack (>= 3.0) + activemodel (>= 3.0) activesupport (>= 3.0) railties (>= 3.0) - rspec (~> 2.8.0) - rubyzip (0.9.9) - selenium-webdriver (2.31.0) - childprocess (>= 0.2.5) - multi_json (~> 1.0) - rubyzip - websocket (~> 1.0.4) + rspec-core (~> 2.14.0) + rspec-expectations (~> 2.14.0) + rspec-mocks (~> 2.14.0) shoulda-matchers (1.0.0) - slop (3.4.4) + slop (3.4.7) spork (0.9.2) sprockets (2.2.2) hike (~> 1.2) @@ -197,27 +208,25 @@ GEM therubyracer (0.10.2) libv8 (~> 3.3.10) thor (0.18.1) - tilt (1.3.6) - treetop (1.4.12) + tilt (1.4.1) + timers (1.1.0) + treetop (1.4.15) polyglot polyglot (>= 0.3.1) - turnip (0.3.1) - gherkin (>= 2.5) - rspec (~> 2.0) twitter-bootstrap-rails (2.0.9) actionpack (>= 3.1) less-rails (~> 2.2.2) railties (>= 3.1) therubyracer (~> 0.10.1) tzinfo (0.3.38) - uglifier (2.1.2) + uglifier (2.4.0) execjs (>= 0.3.0) - multi_json (~> 1.0, >= 1.0.2) + json (>= 1.8.0) warden (1.1.1) rack (>= 1.0) - websocket (1.0.7) - will_paginate (3.0.4) - xpath (0.1.4) + websocket-driver (0.3.2) + will_paginate (3.0.5) + xpath (2.0.0) nokogiri (~> 1.3) PLATFORMS @@ -227,29 +236,30 @@ DEPENDENCIES acts_as_loggable! bootstrap-will_paginate (~> 0.0.6) cancan - capybara (~> 1.1.2) + capybara (~> 2.2.1) coffee-rails (~> 3.2.1) - database_cleaner + database_cleaner (~> 1.2.0) decent_exposure (~> 1.0.1) devise (~> 2.0.4) factory_girl_rails (~> 1.2) - faker - guard-rspec - guard-spork + faker (~> 1.2.0) + guard-rspec (~> 4.2.6) + guard-spork (~> 1.5.1) haml-rails (~> 0.3.4) + jbuilder (~> 2.0.3) jquery-rails (~> 2.0) - launchy + launchy (~> 2.4.2) netzke-basepack (~> 0.8.0) netzke-cancan netzke-core (~> 0.8.0) - pg + pg (~> 0.17.1) + poltergeist (~> 1.5.0) pry (~> 0.9.8) rails (= 3.2.13) rb-fsevent - rspec-rails (~> 2.8.1) + rspec-rails (~> 2.14.0) shoulda-matchers (~> 1.0.0) - spork - turnip (~> 0.3.0) + spork (~> 0.9.2) twitter-bootstrap-rails (~> 2.0.3) uglifier (>= 1.0.3) will_paginate (~> 3.0.3) diff --git a/README.md b/README.md index 3114149..7748c15 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,11 @@ At Velocipede, with a mobile friendly UI for users, I'm hoping we can get donati 1. Create your database `createdb -U velocipede --owner=velocipede velocipede` 1. Create your test database `createdb -U velocipede --owner=velocipede velocipede_test` +# Testing + +1. Install phantomjs `brew install phantomjs` +1. Run tests with `rspec` + # Optional Add icons diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 7cec09e..46f1298 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -12,4 +12,9 @@ // //= require jquery //= require jquery_ujs -//= require custom_netzke_helpers +//= require twitter/bootstrap/bootstrap-button +//= require twitter/bootstrap/bootstrap-modal +//= require utils +//= require bootstrap-datepicker +//= require bootstrap-timepicker +//= require jquery-date-format diff --git a/app/assets/javascripts/bikes.js b/app/assets/javascripts/bikes.js new file mode 100644 index 0000000..ba530d6 --- /dev/null +++ b/app/assets/javascripts/bikes.js @@ -0,0 +1,31 @@ +$('.btn').button(); + +$("#add_bike_submit").click(function(){ + + json_data = { bikes: [{ + serial_number: $("#serial_number").val(), + bike_brand_id: parseInt($("#bike_brand_id").val()), + shop_id: parseInt($("#shop_id").val()), + model: $("#model").val(), + bike_style_id: parseInt($('input[name=bike_style]:checked').val()), + seat_tube_height: parseFloat($("#seat_tube_height").val()), + bike_condition_id: parseInt($('input[name=bike_condition]:checked').val()), + bike_purpose_id: 1, + bike_wheel_size_id: parseInt($("#bike_wheel_size_id").val()), + }]}; + + $.ajax({ + url: $("#add_bike_submit").data("url"), + type: "POST", + data: JSON.stringify(json_data), + contentType: 'application/json', + dataType: "json", + success: function(data, status, xhr){ + window.location = data.bikes[0].id + "?add_bike=1"; + }, + error: function(data, status ){ + displayFormErrors(data.responseJSON); + } + }); + +}); diff --git a/app/assets/javascripts/bootstrap-datepicker.js b/app/assets/javascripts/bootstrap-datepicker.js new file mode 100755 index 0000000..bf3a56d --- /dev/null +++ b/app/assets/javascripts/bootstrap-datepicker.js @@ -0,0 +1,474 @@ +/* ========================================================= + * bootstrap-datepicker.js + * http://www.eyecon.ro/bootstrap-datepicker + * ========================================================= + * Copyright 2012 Stefan Petre + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================= */ + +!function( $ ) { + + // Picker object + + var Datepicker = function(element, options){ + this.element = $(element); + this.format = DPGlobal.parseFormat(options.format||this.element.data('date-format')||'mm/dd/yyyy'); + this.picker = $(DPGlobal.template) + .appendTo('body') + .on({ + click: $.proxy(this.click, this)//, + //mousedown: $.proxy(this.mousedown, this) + }); + this.isInput = this.element.is('input'); + this.component = this.element.is('.date') ? this.element.find('.add-on') : false; + + if (this.isInput) { + this.element.on({ + focus: $.proxy(this.show, this), + //blur: $.proxy(this.hide, this), + keyup: $.proxy(this.update, this) + }); + } else { + if (this.component){ + this.component.on('click', $.proxy(this.show, this)); + } else { + this.element.on('click', $.proxy(this.show, this)); + } + } + + this.minViewMode = options.minViewMode||this.element.data('date-minviewmode')||0; + if (typeof this.minViewMode === 'string') { + switch (this.minViewMode) { + case 'months': + this.minViewMode = 1; + break; + case 'years': + this.minViewMode = 2; + break; + default: + this.minViewMode = 0; + break; + } + } + this.viewMode = options.viewMode||this.element.data('date-viewmode')||0; + if (typeof this.viewMode === 'string') { + switch (this.viewMode) { + case 'months': + this.viewMode = 1; + break; + case 'years': + this.viewMode = 2; + break; + default: + this.viewMode = 0; + break; + } + } + this.startViewMode = this.viewMode; + this.weekStart = options.weekStart||this.element.data('date-weekstart')||0; + this.weekEnd = this.weekStart === 0 ? 6 : this.weekStart - 1; + this.onRender = options.onRender; + this.fillDow(); + this.fillMonths(); + this.update(); + this.showMode(); + }; + + Datepicker.prototype = { + constructor: Datepicker, + + show: function(e) { + this.picker.show(); + this.height = this.component ? this.component.outerHeight() : this.element.outerHeight(); + this.place(); + $(window).on('resize', $.proxy(this.place, this)); + if (e ) { + e.stopPropagation(); + e.preventDefault(); + } + if (!this.isInput) { + } + var that = this; + $(document).on('mousedown', function(ev){ + if ($(ev.target).closest('.datepicker').length == 0) { + that.hide(); + } + }); + this.element.trigger({ + type: 'show', + date: this.date + }); + }, + + hide: function(){ + this.picker.hide(); + $(window).off('resize', this.place); + this.viewMode = this.startViewMode; + this.showMode(); + if (!this.isInput) { + $(document).off('mousedown', this.hide); + } + //this.set(); + this.element.trigger({ + type: 'hide', + date: this.date + }); + }, + + set: function() { + var formated = DPGlobal.formatDate(this.date, this.format); + if (!this.isInput) { + if (this.component){ + this.element.find('input').prop('value', formated); + } + this.element.data('date', formated); + } else { + this.element.prop('value', formated); + } + }, + + setValue: function(newDate) { + if (typeof newDate === 'string') { + this.date = DPGlobal.parseDate(newDate, this.format); + } else { + this.date = new Date(newDate); + } + this.set(); + this.viewDate = new Date(this.date.getFullYear(), this.date.getMonth(), 1, 0, 0, 0, 0); + this.fill(); + }, + + place: function(){ + var offset = this.component ? this.component.offset() : this.element.offset(); + this.picker.css({ + top: offset.top + this.height, + left: offset.left + }); + }, + + update: function(newDate){ + this.date = DPGlobal.parseDate( + typeof newDate === 'string' ? newDate : (this.isInput ? this.element.prop('value') : this.element.data('date')), + this.format + ); + this.viewDate = new Date(this.date.getFullYear(), this.date.getMonth(), 1, 0, 0, 0, 0); + this.fill(); + }, + + fillDow: function(){ + var dowCnt = this.weekStart; + var html = ''; + while (dowCnt < this.weekStart + 7) { + html += ''+DPGlobal.dates.daysMin[(dowCnt++)%7]+''; + } + html += ''; + this.picker.find('.datepicker-days thead').append(html); + }, + + fillMonths: function(){ + var html = ''; + var i = 0 + while (i < 12) { + html += ''+DPGlobal.dates.monthsShort[i++]+''; + } + this.picker.find('.datepicker-months td').append(html); + }, + + fill: function() { + var d = new Date(this.viewDate), + year = d.getFullYear(), + month = d.getMonth(), + currentDate = this.date.valueOf(); + this.picker.find('.datepicker-days th:eq(1)') + .text(DPGlobal.dates.months[month]+' '+year); + var prevMonth = new Date(year, month-1, 28,0,0,0,0), + day = DPGlobal.getDaysInMonth(prevMonth.getFullYear(), prevMonth.getMonth()); + prevMonth.setDate(day); + prevMonth.setDate(day - (prevMonth.getDay() - this.weekStart + 7)%7); + var nextMonth = new Date(prevMonth); + nextMonth.setDate(nextMonth.getDate() + 42); + nextMonth = nextMonth.valueOf(); + var html = []; + var clsName, + prevY, + prevM; + while(prevMonth.valueOf() < nextMonth) { + if (prevMonth.getDay() === this.weekStart) { + html.push(''); + } + clsName = this.onRender(prevMonth); + prevY = prevMonth.getFullYear(); + prevM = prevMonth.getMonth(); + if ((prevM < month && prevY === year) || prevY < year) { + clsName += ' old'; + } else if ((prevM > month && prevY === year) || prevY > year) { + clsName += ' new'; + } + if (prevMonth.valueOf() === currentDate) { + clsName += ' active'; + } + html.push(''+prevMonth.getDate() + ''); + if (prevMonth.getDay() === this.weekEnd) { + html.push(''); + } + prevMonth.setDate(prevMonth.getDate()+1); + } + this.picker.find('.datepicker-days tbody').empty().append(html.join('')); + var currentYear = this.date.getFullYear(); + + var months = this.picker.find('.datepicker-months') + .find('th:eq(1)') + .text(year) + .end() + .find('span').removeClass('active'); + if (currentYear === year) { + months.eq(this.date.getMonth()).addClass('active'); + } + + html = ''; + year = parseInt(year/10, 10) * 10; + var yearCont = this.picker.find('.datepicker-years') + .find('th:eq(1)') + .text(year + '-' + (year + 9)) + .end() + .find('td'); + year -= 1; + for (var i = -1; i < 11; i++) { + html += ''+year+''; + year += 1; + } + yearCont.html(html); + }, + + click: function(e) { + e.stopPropagation(); + e.preventDefault(); + var target = $(e.target).closest('span, td, th'); + if (target.length === 1) { + switch(target[0].nodeName.toLowerCase()) { + case 'th': + switch(target[0].className) { + case 'switch': + this.showMode(1); + break; + case 'prev': + case 'next': + this.viewDate['set'+DPGlobal.modes[this.viewMode].navFnc].call( + this.viewDate, + this.viewDate['get'+DPGlobal.modes[this.viewMode].navFnc].call(this.viewDate) + + DPGlobal.modes[this.viewMode].navStep * (target[0].className === 'prev' ? -1 : 1) + ); + this.fill(); + this.set(); + break; + } + break; + case 'span': + if (target.is('.month')) { + var month = target.parent().find('span').index(target); + this.viewDate.setMonth(month); + } else { + var year = parseInt(target.text(), 10)||0; + this.viewDate.setFullYear(year); + } + if (this.viewMode !== 0) { + this.date = new Date(this.viewDate); + this.element.trigger({ + type: 'changeDate', + date: this.date, + viewMode: DPGlobal.modes[this.viewMode].clsName + }); + } + this.showMode(-1); + this.fill(); + this.set(); + break; + case 'td': + if (target.is('.day') && !target.is('.disabled')){ + var day = parseInt(target.text(), 10)||1; + var month = this.viewDate.getMonth(); + if (target.is('.old')) { + month -= 1; + } else if (target.is('.new')) { + month += 1; + } + var year = this.viewDate.getFullYear(); + this.date = new Date(year, month, day,0,0,0,0); + this.viewDate = new Date(year, month, Math.min(28, day),0,0,0,0); + this.fill(); + this.set(); + this.element.trigger({ + type: 'changeDate', + date: this.date, + viewMode: DPGlobal.modes[this.viewMode].clsName + }); + } + break; + } + } + }, + + mousedown: function(e){ + e.stopPropagation(); + e.preventDefault(); + }, + + showMode: function(dir) { + if (dir) { + this.viewMode = Math.max(this.minViewMode, Math.min(2, this.viewMode + dir)); + } + this.picker.find('>div').hide().filter('.datepicker-'+DPGlobal.modes[this.viewMode].clsName).show(); + } + }; + + $.fn.datepicker = function ( option, val ) { + return this.each(function () { + var $this = $(this), + data = $this.data('datepicker'), + options = typeof option === 'object' && option; + if (!data) { + $this.data('datepicker', (data = new Datepicker(this, $.extend({}, $.fn.datepicker.defaults,options)))); + } + if (typeof option === 'string') data[option](val); + }); + }; + + $.fn.datepicker.defaults = { + onRender: function(date) { + return ''; + } + }; + $.fn.datepicker.Constructor = Datepicker; + + var DPGlobal = { + modes: [ + { + clsName: 'days', + navFnc: 'Month', + navStep: 1 + }, + { + clsName: 'months', + navFnc: 'FullYear', + navStep: 1 + }, + { + clsName: 'years', + navFnc: 'FullYear', + navStep: 10 + }], + dates:{ + days: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], + daysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], + daysMin: ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"], + months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], + monthsShort: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + }, + isLeapYear: function (year) { + return (((year % 4 === 0) && (year % 100 !== 0)) || (year % 400 === 0)) + }, + getDaysInMonth: function (year, month) { + return [31, (DPGlobal.isLeapYear(year) ? 29 : 28), 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month] + }, + parseFormat: function(format){ + var separator = format.match(/[.\/\-\s].*?/), + parts = format.split(/\W+/); + if (!separator || !parts || parts.length === 0){ + throw new Error("Invalid date format."); + } + return {separator: separator, parts: parts}; + }, + parseDate: function(date, format) { + var parts = date.split(format.separator), + date = new Date(), + val; + date.setHours(0); + date.setMinutes(0); + date.setSeconds(0); + date.setMilliseconds(0); + if (parts.length === format.parts.length) { + var year = date.getFullYear(), day = date.getDate(), month = date.getMonth(); + for (var i=0, cnt = format.parts.length; i < cnt; i++) { + val = parseInt(parts[i], 10)||1; + switch(format.parts[i]) { + case 'dd': + case 'd': + day = val; + date.setDate(val); + break; + case 'mm': + case 'm': + month = val - 1; + date.setMonth(val - 1); + break; + case 'yy': + year = 2000 + val; + date.setFullYear(2000 + val); + break; + case 'yyyy': + year = val; + date.setFullYear(val); + break; + } + } + date = new Date(year, month, day, 0 ,0 ,0); + } + return date; + }, + formatDate: function(date, format){ + var val = { + d: date.getDate(), + m: date.getMonth() + 1, + yy: date.getFullYear().toString().substring(2), + yyyy: date.getFullYear() + }; + val.dd = (val.d < 10 ? '0' : '') + val.d; + val.mm = (val.m < 10 ? '0' : '') + val.m; + var date = []; + for (var i=0, cnt = format.parts.length; i < cnt; i++) { + date.push(val[format.parts[i]]); + } + return date.join(format.separator); + }, + headTemplate: ''+ + ''+ + '‹'+ + ''+ + '›'+ + ''+ + '', + contTemplate: '' + }; + DPGlobal.template = ''; + +}( window.jQuery ); \ No newline at end of file diff --git a/app/assets/javascripts/bootstrap-timepicker.js b/app/assets/javascripts/bootstrap-timepicker.js new file mode 100644 index 0000000..5972e3c --- /dev/null +++ b/app/assets/javascripts/bootstrap-timepicker.js @@ -0,0 +1,1097 @@ +/*! + * Timepicker Component for Twitter Bootstrap + * + * Copyright 2013 Joris de Wit + * + * Contributors https://github.com/jdewit/bootstrap-timepicker/graphs/contributors + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +(function($, window, document, undefined) { + 'use strict'; + + // TIMEPICKER PUBLIC CLASS DEFINITION + var Timepicker = function(element, options) { + this.widget = ''; + this.$element = $(element); + this.defaultTime = options.defaultTime; + this.disableFocus = options.disableFocus; + this.disableMousewheel = options.disableMousewheel; + this.isOpen = options.isOpen; + this.minuteStep = options.minuteStep; + this.modalBackdrop = options.modalBackdrop; + this.orientation = options.orientation; + this.secondStep = options.secondStep; + this.showInputs = options.showInputs; + this.showMeridian = options.showMeridian; + this.showSeconds = options.showSeconds; + this.template = options.template; + this.appendWidgetTo = options.appendWidgetTo; + this.showWidgetOnAddonClick = options.showWidgetOnAddonClick; + + this._init(); + }; + + Timepicker.prototype = { + + constructor: Timepicker, + _init: function() { + var self = this; + + if (this.showWidgetOnAddonClick && (this.$element.parent().hasClass('input-append') || this.$element.parent().hasClass('input-prepend'))) { + this.$element.parent('.input-append, .input-prepend').find('.add-on').on({ + 'click.timepicker': $.proxy(this.showWidget, this) + }); + this.$element.on({ + 'focus.timepicker': $.proxy(this.highlightUnit, this), + 'click.timepicker': $.proxy(this.highlightUnit, this), + 'keydown.timepicker': $.proxy(this.elementKeydown, this), + 'blur.timepicker': $.proxy(this.blurElement, this), + 'mousewheel.timepicker DOMMouseScroll.timepicker': $.proxy(this.mousewheel, this) + }); + } else { + if (this.template) { + this.$element.on({ + 'focus.timepicker': $.proxy(this.showWidget, this), + 'click.timepicker': $.proxy(this.showWidget, this), + 'blur.timepicker': $.proxy(this.blurElement, this), + 'mousewheel.timepicker DOMMouseScroll.timepicker': $.proxy(this.mousewheel, this) + }); + } else { + this.$element.on({ + 'focus.timepicker': $.proxy(this.highlightUnit, this), + 'click.timepicker': $.proxy(this.highlightUnit, this), + 'keydown.timepicker': $.proxy(this.elementKeydown, this), + 'blur.timepicker': $.proxy(this.blurElement, this), + 'mousewheel.timepicker DOMMouseScroll.timepicker': $.proxy(this.mousewheel, this) + }); + } + } + + if (this.template !== false) { + this.$widget = $(this.getTemplate()).on('click', $.proxy(this.widgetClick, this)); + } else { + this.$widget = false; + } + + if (this.showInputs && this.$widget !== false) { + this.$widget.find('input').each(function() { + $(this).on({ + 'click.timepicker': function() { $(this).select(); }, + 'keydown.timepicker': $.proxy(self.widgetKeydown, self), + 'keyup.timepicker': $.proxy(self.widgetKeyup, self) + }); + }); + } + + this.setDefaultTime(this.defaultTime); + }, + + blurElement: function() { + this.highlightedUnit = null; + this.updateFromElementVal(); + }, + + clear: function() { + this.hour = ''; + this.minute = ''; + this.second = ''; + this.meridian = ''; + + this.$element.val(''); + }, + + decrementHour: function() { + if (this.showMeridian) { + if (this.hour === 1) { + this.hour = 12; + } else if (this.hour === 12) { + this.hour--; + + return this.toggleMeridian(); + } else if (this.hour === 0) { + this.hour = 11; + + return this.toggleMeridian(); + } else { + this.hour--; + } + } else { + if (this.hour <= 0) { + this.hour = 23; + } else { + this.hour--; + } + } + }, + + decrementMinute: function(step) { + var newVal; + + if (step) { + newVal = this.minute - step; + } else { + newVal = this.minute - this.minuteStep; + } + + if (newVal < 0) { + this.decrementHour(); + this.minute = newVal + 60; + } else { + this.minute = newVal; + } + }, + + decrementSecond: function() { + var newVal = this.second - this.secondStep; + + if (newVal < 0) { + this.decrementMinute(true); + this.second = newVal + 60; + } else { + this.second = newVal; + } + }, + + elementKeydown: function(e) { + switch (e.keyCode) { + case 9: //tab + case 27: // escape + this.updateFromElementVal(); + break; + case 37: // left arrow + e.preventDefault(); + this.highlightPrevUnit(); + break; + case 38: // up arrow + e.preventDefault(); + switch (this.highlightedUnit) { + case 'hour': + this.incrementHour(); + this.highlightHour(); + break; + case 'minute': + this.incrementMinute(); + this.highlightMinute(); + break; + case 'second': + this.incrementSecond(); + this.highlightSecond(); + break; + case 'meridian': + this.toggleMeridian(); + this.highlightMeridian(); + break; + } + this.update(); + break; + case 39: // right arrow + e.preventDefault(); + this.highlightNextUnit(); + break; + case 40: // down arrow + e.preventDefault(); + switch (this.highlightedUnit) { + case 'hour': + this.decrementHour(); + this.highlightHour(); + break; + case 'minute': + this.decrementMinute(); + this.highlightMinute(); + break; + case 'second': + this.decrementSecond(); + this.highlightSecond(); + break; + case 'meridian': + this.toggleMeridian(); + this.highlightMeridian(); + break; + } + + this.update(); + break; + } + }, + + getCursorPosition: function() { + var input = this.$element.get(0); + + if ('selectionStart' in input) {// Standard-compliant browsers + + return input.selectionStart; + } else if (document.selection) {// IE fix + input.focus(); + var sel = document.selection.createRange(), + selLen = document.selection.createRange().text.length; + + sel.moveStart('character', - input.value.length); + + return sel.text.length - selLen; + } + }, + + getTemplate: function() { + var template, + hourTemplate, + minuteTemplate, + secondTemplate, + meridianTemplate, + templateContent; + + if (this.showInputs) { + hourTemplate = ''; + minuteTemplate = ''; + secondTemplate = ''; + meridianTemplate = ''; + } else { + hourTemplate = ''; + minuteTemplate = ''; + secondTemplate = ''; + meridianTemplate = ''; + } + + templateContent = ''+ + ''+ + ''+ + ''+ + ''+ + (this.showSeconds ? + ''+ + '' + : '') + + (this.showMeridian ? + ''+ + '' + : '') + + ''+ + ''+ + ' '+ + ''+ + ' '+ + (this.showSeconds ? + ''+ + '' + : '') + + (this.showMeridian ? + ''+ + '' + : '') + + ''+ + ''+ + ''+ + ''+ + ''+ + (this.showSeconds ? + ''+ + '' + : '') + + (this.showMeridian ? + ''+ + '' + : '') + + ''+ + '
   
'+ hourTemplate +':'+ minuteTemplate +':'+ secondTemplate +' '+ meridianTemplate +'
  
'; + + switch(this.template) { + case 'modal': + template = ''; + break; + case 'dropdown': + template = ''; + break; + } + + return template; + }, + + getTime: function() { + if (this.hour === '') { + return ''; + } + + return this.hour + ':' + (this.minute.toString().length === 1 ? '0' + this.minute : this.minute) + (this.showSeconds ? ':' + (this.second.toString().length === 1 ? '0' + this.second : this.second) : '') + (this.showMeridian ? ' ' + this.meridian : ''); + }, + + hideWidget: function() { + if (this.isOpen === false) { + return; + } + + this.$element.trigger({ + 'type': 'hide.timepicker', + 'time': { + 'value': this.getTime(), + 'hours': this.hour, + 'minutes': this.minute, + 'seconds': this.second, + 'meridian': this.meridian + } + }); + + if (this.template === 'modal' && this.$widget.modal) { + this.$widget.modal('hide'); + } else { + this.$widget.removeClass('open'); + } + + $(document).off('mousedown.timepicker, touchend.timepicker'); + + this.isOpen = false; + // show/hide approach taken by datepicker + this.$widget.detach(); + }, + + highlightUnit: function() { + this.position = this.getCursorPosition(); + if (this.position >= 0 && this.position <= 2) { + this.highlightHour(); + } else if (this.position >= 3 && this.position <= 5) { + this.highlightMinute(); + } else if (this.position >= 6 && this.position <= 8) { + if (this.showSeconds) { + this.highlightSecond(); + } else { + this.highlightMeridian(); + } + } else if (this.position >= 9 && this.position <= 11) { + this.highlightMeridian(); + } + }, + + highlightNextUnit: function() { + switch (this.highlightedUnit) { + case 'hour': + this.highlightMinute(); + break; + case 'minute': + if (this.showSeconds) { + this.highlightSecond(); + } else if (this.showMeridian){ + this.highlightMeridian(); + } else { + this.highlightHour(); + } + break; + case 'second': + if (this.showMeridian) { + this.highlightMeridian(); + } else { + this.highlightHour(); + } + break; + case 'meridian': + this.highlightHour(); + break; + } + }, + + highlightPrevUnit: function() { + switch (this.highlightedUnit) { + case 'hour': + if(this.showMeridian){ + this.highlightMeridian(); + } else if (this.showSeconds) { + this.highlightSecond(); + } else { + this.highlightMinute(); + } + break; + case 'minute': + this.highlightHour(); + break; + case 'second': + this.highlightMinute(); + break; + case 'meridian': + if (this.showSeconds) { + this.highlightSecond(); + } else { + this.highlightMinute(); + } + break; + } + }, + + highlightHour: function() { + var $element = this.$element.get(0), + self = this; + + this.highlightedUnit = 'hour'; + + if ($element.setSelectionRange) { + setTimeout(function() { + if (self.hour < 10) { + $element.setSelectionRange(0,1); + } else { + $element.setSelectionRange(0,2); + } + }, 0); + } + }, + + highlightMinute: function() { + var $element = this.$element.get(0), + self = this; + + this.highlightedUnit = 'minute'; + + if ($element.setSelectionRange) { + setTimeout(function() { + if (self.hour < 10) { + $element.setSelectionRange(2,4); + } else { + $element.setSelectionRange(3,5); + } + }, 0); + } + }, + + highlightSecond: function() { + var $element = this.$element.get(0), + self = this; + + this.highlightedUnit = 'second'; + + if ($element.setSelectionRange) { + setTimeout(function() { + if (self.hour < 10) { + $element.setSelectionRange(5,7); + } else { + $element.setSelectionRange(6,8); + } + }, 0); + } + }, + + highlightMeridian: function() { + var $element = this.$element.get(0), + self = this; + + this.highlightedUnit = 'meridian'; + + if ($element.setSelectionRange) { + if (this.showSeconds) { + setTimeout(function() { + if (self.hour < 10) { + $element.setSelectionRange(8,10); + } else { + $element.setSelectionRange(9,11); + } + }, 0); + } else { + setTimeout(function() { + if (self.hour < 10) { + $element.setSelectionRange(5,7); + } else { + $element.setSelectionRange(6,8); + } + }, 0); + } + } + }, + + incrementHour: function() { + if (this.showMeridian) { + if (this.hour === 11) { + this.hour++; + return this.toggleMeridian(); + } else if (this.hour === 12) { + this.hour = 0; + } + } + if (this.hour === 23) { + this.hour = 0; + + return; + } + this.hour++; + }, + + incrementMinute: function(step) { + var newVal; + + if (step) { + newVal = this.minute + step; + } else { + newVal = this.minute + this.minuteStep - (this.minute % this.minuteStep); + } + + if (newVal > 59) { + this.incrementHour(); + this.minute = newVal - 60; + } else { + this.minute = newVal; + } + }, + + incrementSecond: function() { + var newVal = this.second + this.secondStep - (this.second % this.secondStep); + + if (newVal > 59) { + this.incrementMinute(true); + this.second = newVal - 60; + } else { + this.second = newVal; + } + }, + + mousewheel: function(e) { + if (this.disableMousewheel) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + var delta = e.originalEvent.wheelDelta || -e.originalEvent.detail, + scrollTo = null; + + if (e.type === 'mousewheel') { + scrollTo = (e.originalEvent.wheelDelta * -1); + } + else if (e.type === 'DOMMouseScroll') { + scrollTo = 40 * e.originalEvent.detail; + } + + if (scrollTo) { + e.preventDefault(); + $(this).scrollTop(scrollTo + $(this).scrollTop()); + } + + switch (this.highlightedUnit) { + case 'minute': + if (delta > 0) { + this.incrementMinute(); + } else { + this.decrementMinute(); + } + this.highlightMinute(); + break; + case 'second': + if (delta > 0) { + this.incrementSecond(); + } else { + this.decrementSecond(); + } + this.highlightSecond(); + break; + case 'meridian': + this.toggleMeridian(); + this.highlightMeridian(); + break; + default: + if (delta > 0) { + this.incrementHour(); + } else { + this.decrementHour(); + } + this.highlightHour(); + break; + } + + return false; + }, + + // This method was adapted from bootstrap-datepicker. + place : function() { + if (this.isInline) { + return; + } + var widgetWidth = this.$widget.outerWidth(), widgetHeight = this.$widget.outerHeight(), visualPadding = 10, windowWidth = + $(window).width(), windowHeight = $(window).height(), scrollTop = $(window).scrollTop(); + + var zIndex = parseInt(this.$element.parents().filter(function() {}).first().css('z-index'), 10) + 10; + var offset = this.component ? this.component.parent().offset() : this.$element.offset(); + var height = this.component ? this.component.outerHeight(true) : this.$element.outerHeight(false); + var width = this.component ? this.component.outerWidth(true) : this.$element.outerWidth(false); + var left = offset.left, top = offset.top; + + this.$widget.removeClass('timepicker-orient-top timepicker-orient-bottom timepicker-orient-right timepicker-orient-left'); + + if (this.orientation.x !== 'auto') { + this.picker.addClass('datepicker-orient-' + this.orientation.x); + if (this.orientation.x === 'right') { + left -= widgetWidth - width; + } + } else{ + // auto x orientation is best-placement: if it crosses a window edge, fudge it sideways + // Default to left + this.$widget.addClass('timepicker-orient-left'); + if (offset.left < 0) { + left -= offset.left - visualPadding; + } else if (offset.left + widgetWidth > windowWidth) { + left = windowWidth - widgetWidth - visualPadding; + } + } + // auto y orientation is best-situation: top or bottom, no fudging, decision based on which shows more of the widget + var yorient = this.orientation.y, topOverflow, bottomOverflow; + if (yorient === 'auto') { + topOverflow = -scrollTop + offset.top - widgetHeight; + bottomOverflow = scrollTop + windowHeight - (offset.top + height + widgetHeight); + if (Math.max(topOverflow, bottomOverflow) === bottomOverflow) { + yorient = 'top'; + } else { + yorient = 'bottom'; + } + } + this.$widget.addClass('timepicker-orient-' + yorient); + if (yorient === 'top'){ + top += height; + } else{ + top -= widgetHeight + parseInt(this.$widget.css('padding-top'), 10); + } + + this.$widget.css({ + top : top, + left : left, + zIndex : zIndex + }); + }, + + remove: function() { + $('document').off('.timepicker'); + if (this.$widget) { + this.$widget.remove(); + } + delete this.$element.data().timepicker; + }, + + setDefaultTime: function(defaultTime) { + if (!this.$element.val()) { + if (defaultTime === 'current') { + var dTime = new Date(), + hours = dTime.getHours(), + minutes = dTime.getMinutes(), + seconds = dTime.getSeconds(), + meridian = 'AM'; + + if (seconds !== 0) { + seconds = Math.ceil(dTime.getSeconds() / this.secondStep) * this.secondStep; + if (seconds === 60) { + minutes += 1; + seconds = 0; + } + } + + if (minutes !== 0) { + minutes = Math.ceil(dTime.getMinutes() / this.minuteStep) * this.minuteStep; + if (minutes === 60) { + hours += 1; + minutes = 0; + } + } + + if (this.showMeridian) { + if (hours === 0) { + hours = 12; + } else if (hours >= 12) { + if (hours > 12) { + hours = hours - 12; + } + meridian = 'PM'; + } else { + meridian = 'AM'; + } + } + + this.hour = hours; + this.minute = minutes; + this.second = seconds; + this.meridian = meridian; + + this.update(); + + } else if (defaultTime === false) { + this.hour = 0; + this.minute = 0; + this.second = 0; + this.meridian = 'AM'; + } else { + this.setTime(defaultTime); + } + } else { + this.updateFromElementVal(); + } + }, + + setTime: function(time, ignoreWidget) { + if (!time) { + this.clear(); + return; + } + + var timeArray, + hour, + minute, + second, + meridian; + + if (typeof time === 'object' && time.getMonth){ + // this is a date object + hour = time.getHours(); + minute = time.getMinutes(); + second = time.getSeconds(); + + if (this.showMeridian){ + meridian = 'AM'; + if (hour > 12){ + meridian = 'PM'; + hour = hour % 12; + } + + if (hour === 12){ + meridian = 'PM'; + } + } + } else { + if (time.match(/p/i) !== null) { + meridian = 'PM'; + } else { + meridian = 'AM'; + } + + time = time.replace(/[^0-9\:]/g, ''); + + timeArray = time.split(':'); + + hour = timeArray[0] ? timeArray[0].toString() : timeArray.toString(); + minute = timeArray[1] ? timeArray[1].toString() : ''; + second = timeArray[2] ? timeArray[2].toString() : ''; + + // idiot proofing + if (hour.length > 4) { + second = hour.substr(4, 2); + } + if (hour.length > 2) { + minute = hour.substr(2, 2); + hour = hour.substr(0, 2); + } + if (minute.length > 2) { + second = minute.substr(2, 2); + minute = minute.substr(0, 2); + } + if (second.length > 2) { + second = second.substr(2, 2); + } + + hour = parseInt(hour, 10); + minute = parseInt(minute, 10); + second = parseInt(second, 10); + + if (isNaN(hour)) { + hour = 0; + } + if (isNaN(minute)) { + minute = 0; + } + if (isNaN(second)) { + second = 0; + } + + if (this.showMeridian) { + if (hour < 1) { + hour = 1; + } else if (hour > 12) { + hour = 12; + } + } else { + if (hour >= 24) { + hour = 23; + } else if (hour < 0) { + hour = 0; + } + if (hour < 13 && meridian === 'PM') { + hour = hour + 12; + } + } + + if (minute < 0) { + minute = 0; + } else if (minute >= 60) { + minute = 59; + } + + if (this.showSeconds) { + if (isNaN(second)) { + second = 0; + } else if (second < 0) { + second = 0; + } else if (second >= 60) { + second = 59; + } + } + } + + this.hour = hour; + this.minute = minute; + this.second = second; + this.meridian = meridian; + + this.update(ignoreWidget); + }, + + showWidget: function() { + if (this.isOpen) { + return; + } + + if (this.$element.is(':disabled')) { + return; + } + + // show/hide approach taken by datepicker + this.$widget.appendTo(this.appendWidgetTo); + var self = this; + $(document).on('mousedown.timepicker, touchend.timepicker', function (e) { + // This condition was inspired by bootstrap-datepicker. + // The element the timepicker is invoked on is the input but it has a sibling for addon/button. + if (!(self.$element.parent().find(e.target).length || + self.$widget.is(e.target) || + self.$widget.find(e.target).length)) { + self.hideWidget(); + } + }); + + this.$element.trigger({ + 'type': 'show.timepicker', + 'time': { + 'value': this.getTime(), + 'hours': this.hour, + 'minutes': this.minute, + 'seconds': this.second, + 'meridian': this.meridian + } + }); + + this.place(); + if (this.disableFocus) { + this.$element.blur(); + } + + // widget shouldn't be empty on open + if (this.hour === '') { + if (this.defaultTime) { + this.setDefaultTime(this.defaultTime); + } else { + this.setTime('0:0:0'); + } + } + + if (this.template === 'modal' && this.$widget.modal) { + this.$widget.modal('show').on('hidden', $.proxy(this.hideWidget, this)); + } else { + if (this.isOpen === false) { + this.$widget.addClass('open'); + } + } + + this.isOpen = true; + }, + + toggleMeridian: function() { + this.meridian = this.meridian === 'AM' ? 'PM' : 'AM'; + }, + + update: function(ignoreWidget) { + this.updateElement(); + if (!ignoreWidget) { + this.updateWidget(); + } + + this.$element.trigger({ + 'type': 'changeTime.timepicker', + 'time': { + 'value': this.getTime(), + 'hours': this.hour, + 'minutes': this.minute, + 'seconds': this.second, + 'meridian': this.meridian + } + }); + }, + + updateElement: function() { + this.$element.val(this.getTime()).change(); + }, + + updateFromElementVal: function() { + this.setTime(this.$element.val()); + }, + + updateWidget: function() { + if (this.$widget === false) { + return; + } + + var hour = this.hour, + minute = this.minute.toString().length === 1 ? '0' + this.minute : this.minute, + second = this.second.toString().length === 1 ? '0' + this.second : this.second; + + if (this.showInputs) { + this.$widget.find('input.bootstrap-timepicker-hour').val(hour); + this.$widget.find('input.bootstrap-timepicker-minute').val(minute); + + if (this.showSeconds) { + this.$widget.find('input.bootstrap-timepicker-second').val(second); + } + if (this.showMeridian) { + this.$widget.find('input.bootstrap-timepicker-meridian').val(this.meridian); + } + } else { + this.$widget.find('span.bootstrap-timepicker-hour').text(hour); + this.$widget.find('span.bootstrap-timepicker-minute').text(minute); + + if (this.showSeconds) { + this.$widget.find('span.bootstrap-timepicker-second').text(second); + } + if (this.showMeridian) { + this.$widget.find('span.bootstrap-timepicker-meridian').text(this.meridian); + } + } + }, + + updateFromWidgetInputs: function() { + if (this.$widget === false) { + return; + } + + var t = this.$widget.find('input.bootstrap-timepicker-hour').val() + ':' + + this.$widget.find('input.bootstrap-timepicker-minute').val() + + (this.showSeconds ? ':' + this.$widget.find('input.bootstrap-timepicker-second').val() : '') + + (this.showMeridian ? this.$widget.find('input.bootstrap-timepicker-meridian').val() : '') + ; + + this.setTime(t, true); + }, + + widgetClick: function(e) { + e.stopPropagation(); + e.preventDefault(); + + var $input = $(e.target), + action = $input.closest('a').data('action'); + + if (action) { + this[action](); + } + this.update(); + + if ($input.is('input')) { + $input.get(0).setSelectionRange(0,2); + } + }, + + widgetKeydown: function(e) { + var $input = $(e.target), + name = $input.attr('class').replace('bootstrap-timepicker-', ''); + + switch (e.keyCode) { + case 9: //tab + if ((this.showMeridian && name === 'meridian') || (this.showSeconds && name === 'second') || (!this.showMeridian && !this.showSeconds && name === 'minute')) { + return this.hideWidget(); + } + break; + case 27: // escape + this.hideWidget(); + break; + case 38: // up arrow + e.preventDefault(); + switch (name) { + case 'hour': + this.incrementHour(); + break; + case 'minute': + this.incrementMinute(); + break; + case 'second': + this.incrementSecond(); + break; + case 'meridian': + this.toggleMeridian(); + break; + } + this.setTime(this.getTime()); + $input.get(0).setSelectionRange(0,2); + break; + case 40: // down arrow + e.preventDefault(); + switch (name) { + case 'hour': + this.decrementHour(); + break; + case 'minute': + this.decrementMinute(); + break; + case 'second': + this.decrementSecond(); + break; + case 'meridian': + this.toggleMeridian(); + break; + } + this.setTime(this.getTime()); + $input.get(0).setSelectionRange(0,2); + break; + } + }, + + widgetKeyup: function(e) { + if ((e.keyCode === 65) || (e.keyCode === 77) || (e.keyCode === 80) || (e.keyCode === 46) || (e.keyCode === 8) || (e.keyCode >= 46 && e.keyCode <= 57)) { + this.updateFromWidgetInputs(); + } + } + }; + + //TIMEPICKER PLUGIN DEFINITION + $.fn.timepicker = function(option) { + var args = Array.apply(null, arguments); + args.shift(); + return this.each(function() { + var $this = $(this), + data = $this.data('timepicker'), + options = typeof option === 'object' && option; + + if (!data) { + $this.data('timepicker', (data = new Timepicker(this, $.extend({}, $.fn.timepicker.defaults, options, $(this).data())))); + } + + if (typeof option === 'string') { + data[option].apply(data, args); + } + }); + }; + + $.fn.timepicker.defaults = { + defaultTime: 'current', + disableFocus: false, + disableMousewheel: false, + isOpen: false, + minuteStep: 15, + modalBackdrop: false, + orientation: { x: 'auto', y: 'auto'}, + secondStep: 15, + showSeconds: false, + showInputs: true, + showMeridian: true, + template: 'dropdown', + appendWidgetTo: 'body', + showWidgetOnAddonClick: true + }; + + $.fn.timepicker.Constructor = Timepicker; + +})(jQuery, window, document); diff --git a/app/assets/javascripts/devise/sessions.js b/app/assets/javascripts/devise/sessions.js index e58270c..054b4ce 100644 --- a/app/assets/javascripts/devise/sessions.js +++ b/app/assets/javascripts/devise/sessions.js @@ -1,7 +1,7 @@ $(document).ready(function(){ $("#checkin_menu").show(); $("#checkin").click( function(e){ - var username = $("#user_email").val(); + var username = $("#user_username").val(); var password = $("#user_password").val(); $.ajax({ type: 'POST', @@ -13,7 +13,7 @@ $(document).ready(function(){ complete: function() { }, success: function(data) { alert("Checked IN!"); - $("#user_email").val(''); + $("#user_username").val(''); $("#user_password").val(''); }, error: function(data,textStatus) { @@ -22,7 +22,7 @@ $(document).ready(function(){ }) }); $("#checkout").click( function(e){ - var username = $("#user_email").val(); + var username = $("#user_username").val(); var password = $("#user_password").val(); $.ajax({ type: 'POST', @@ -34,7 +34,8 @@ $(document).ready(function(){ complete: function() { }, success: function(data) { alert("Checked OUT!"); - $("#user_email").val(''); + + $("#user_username").val(''); $("#user_password").val(''); }, error: function(data,textStatus) { diff --git a/app/assets/javascripts/jquery-date-format.js b/app/assets/javascripts/jquery-date-format.js new file mode 100644 index 0000000..fa5175e --- /dev/null +++ b/app/assets/javascripts/jquery-date-format.js @@ -0,0 +1,445 @@ +var DateFormat = {}; + +(function($) { + var daysInWeek = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + var shortMonthsInYear = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + var longMonthsInYear = ['January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December']; + var shortMonthsToNumber = { 'Jan': '01', 'Feb': '02', 'Mar': '03', 'Apr': '04', 'May': '05', 'Jun': '06', + 'Jul': '07', 'Aug': '08', 'Sep': '09', 'Oct': '10', 'Nov': '11', 'Dec': '12' }; + + var YYYYMMDD_MATCHER = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.?\d{0,3}[Z\-+]?(\d{2}:?\d{2})?/; + + $.format = (function() { + function numberToLongDay(value) { + // 0 to Sunday + // 1 to Monday + return daysInWeek[parseInt(value, 10)] || value; + } + + function numberToShortMonth(value) { + // 1 to Jan + // 2 to Feb + var monthArrayIndex = parseInt(value, 10) - 1; + return shortMonthsInYear[monthArrayIndex] || value; + } + + function numberToLongMonth(value) { + // 1 to January + // 2 to February + var monthArrayIndex = parseInt(value, 10) - 1; + return longMonthsInYear[monthArrayIndex] || value; + } + + function shortMonthToNumber(value) { + // Jan to 01 + // Feb to 02 + return shortMonthsToNumber[value] || value; + } + + function parseTime(value) { + // 10:54:50.546 + // => hour: 10, minute: 54, second: 50, millis: 546 + // 10:54:50 + // => hour: 10, minute: 54, second: 50, millis: '' + var time = value, + values, + subValues, + hour, + minute, + second, + millis = '', + delimited, + timeArray; + + if(time.indexOf('.') !== -1) { + delimited = time.split('.'); + // split time and milliseconds + time = delimited[0]; + millis = delimited[1]; + } + + timeArray = time.split(':'); + + if(timeArray.length === 3) { + hour = timeArray[0]; + minute = timeArray[1]; + // '20 GMT-0200 (BRST)'.replace(/\s.+/, '').replace(/[a-z]/gi, ''); + // => 20 + // '20Z'.replace(/\s.+/, '').replace(/[a-z]/gi, ''); + // => 20 + second = timeArray[2].replace(/\s.+/, '').replace(/[a-z]/gi, ''); + // '01:10:20 GMT-0200 (BRST)'.replace(/\s.+/, '').replace(/[a-z]/gi, ''); + // => 01:10:20 + // '01:10:20Z'.replace(/\s.+/, '').replace(/[a-z]/gi, ''); + // => 01:10:20 + time = time.replace(/\s.+/, '').replace(/[a-z]/gi, ''); + return { + time: time, + hour: hour, + minute: minute, + second: second, + millis: millis + }; + } + + return { time : '', hour : '', minute : '', second : '', millis : '' }; + } + + + function padding(value, length) { + var paddingCount = length - String(value).length; + for(var i = 0; i < paddingCount; i++) { + value = '0' + value; + } + return value; + } + + return { + + parseDate: function(value) { + var parsedDate = { + date: null, + year: null, + month: null, + dayOfMonth: null, + dayOfWeek: null, + time: null + }; + + if(typeof value == 'number') { + return this.parseDate(new Date(value)); + } else if(typeof value.getFullYear == 'function') { + parsedDate.year = String(value.getFullYear()); + // d = new Date(1900, 1, 1) // 1 for Feb instead of Jan. + // => Thu Feb 01 1900 00:00:00 + parsedDate.month = String(value.getMonth() + 1); + parsedDate.dayOfMonth = String(value.getDate()); + parsedDate.time = parseTime(value.toTimeString()); + } else if(value.search(YYYYMMDD_MATCHER) != -1) { + /* 2009-04-19T16:11:05+02:00 || 2009-04-19T16:11:05Z */ + values = value.split(/[T\+-]/); + parsedDate.year = values[0]; + parsedDate.month = values[1]; + parsedDate.dayOfMonth = values[2]; + parsedDate.time = parseTime(values[3].split('.')[0]); + } else { + values = value.split(' '); + switch (values.length) { + case 6: + /* Wed Jan 13 10:43:41 CET 2010 */ + parsedDate.year = values[5]; + parsedDate.month = shortMonthToNumber(values[1]); + parsedDate.dayOfMonth = values[2]; + parsedDate.time = parseTime(values[3]); + break; + case 2: + /* 2009-12-18 10:54:50.546 */ + subValues = values[0].split('-'); + parsedDate.year = subValues[0]; + parsedDate.month = subValues[1]; + parsedDate.dayOfMonth = subValues[2]; + parsedDate.time = parseTime(values[1]); + break; + case 7: + /* Tue Mar 01 2011 12:01:42 GMT-0800 (PST) */ + case 9: + /* added by Larry, for Fri Apr 08 2011 00:00:00 GMT+0800 (China Standard Time) */ + case 10: + /* added by Larry, for Fri Apr 08 2011 00:00:00 GMT+0200 (W. Europe Daylight Time) */ + parsedDate.year = values[3]; + parsedDate.month = shortMonthToNumber(values[1]); + parsedDate.dayOfMonth = values[2]; + parsedDate.time = parseTime(values[4]); + break; + case 1: + /* added by Jonny, for 2012-02-07CET00:00:00 (Doctrine Entity -> Json Serializer) */ + subValues = values[0].split(''); + parsedDate.year = subValues[0] + subValues[1] + subValues[2] + subValues[3]; + parsedDate.month = subValues[5] + subValues[6]; + parsedDate.dayOfMonth = subValues[8] + subValues[9]; + parsedDate.time = parseTime(subValues[13] + subValues[14] + subValues[15] + subValues[16] + subValues[17] + subValues[18] + subValues[19] + subValues[20]); + break; + default: + return null; + } + } + parsedDate.date = new Date(parsedDate.year, parsedDate.month - 1, parsedDate.dayOfMonth); + parsedDate.dayOfWeek = String(parsedDate.date.getDay()); + + return parsedDate; + }, + + date : function(value, format) { + try { + var parsedDate = this.parseDate(value); + + if(parsedDate === null) { + return value; + } + + var date = parsedDate.date, + year = parsedDate.year, + month = parsedDate.month, + dayOfMonth = parsedDate.dayOfMonth, + dayOfWeek = parsedDate.dayOfWeek, + time = parsedDate.time; + + var pattern = '', + retValue = '', + unparsedRest = '', + inQuote = false; + + /* Issue 1 - variable scope issue in format.date (Thanks jakemonO) */ + for(var i = 0; i < format.length; i++) { + var currentPattern = format.charAt(i); + // Look-Ahead Right (LALR) + var nextRight = format.charAt(i + 1); + + if (inQuote) { + if (currentPattern == "'") { + retValue += (pattern === '') ? "'" : pattern; + pattern = ''; + inQuote = false; + } else { + pattern += currentPattern; + } + continue; + } + pattern += currentPattern; + unparsedRest = ''; + switch (pattern) { + case 'ddd': + retValue += numberToLongDay(dayOfWeek); + pattern = ''; + break; + case 'dd': + if(nextRight === 'd') { + break; + } + retValue += padding(dayOfMonth, 2); + pattern = ''; + break; + case 'd': + if(nextRight === 'd') { + break; + } + retValue += parseInt(dayOfMonth, 10); + pattern = ''; + break; + case 'D': + if(dayOfMonth == 1 || dayOfMonth == 21 || dayOfMonth == 31) { + dayOfMonth = parseInt(dayOfMonth, 10) + 'st'; + } else if(dayOfMonth == 2 || dayOfMonth == 22) { + dayOfMonth = parseInt(dayOfMonth, 10) + 'nd'; + } else if(dayOfMonth == 3 || dayOfMonth == 23) { + dayOfMonth = parseInt(dayOfMonth, 10) + 'rd'; + } else { + dayOfMonth = parseInt(dayOfMonth, 10) + 'th'; + } + retValue += dayOfMonth; + pattern = ''; + break; + case 'MMMM': + retValue += numberToLongMonth(month); + pattern = ''; + break; + case 'MMM': + if(nextRight === 'M') { + break; + } + retValue += numberToShortMonth(month); + pattern = ''; + break; + case 'MM': + if(nextRight === 'M') { + break; + } + retValue += padding(month, 2); + pattern = ''; + break; + case 'M': + if(nextRight === 'M') { + break; + } + retValue += parseInt(month, 10); + pattern = ''; + break; + case 'y': + case 'yyy': + if(nextRight === 'y') { + break; + } + retValue += pattern; + pattern = ''; + break; + case 'yy': + if(nextRight === 'y') { + break; + } + retValue += String(year).slice(-2); + pattern = ''; + break; + case 'yyyy': + retValue += year; + pattern = ''; + break; + case 'HH': + retValue += padding(time.hour, 2); + pattern = ''; + break; + case 'H': + if(nextRight === 'H') { + break; + } + retValue += parseInt(time.hour, 10); + pattern = ''; + break; + case 'hh': + /* time.hour is '00' as string == is used instead of === */ + hour = (parseInt(time.hour, 10) === 0 ? 12 : time.hour < 13 ? time.hour + : time.hour - 12); + retValue += padding(hour, 2); + pattern = ''; + break; + case 'h': + if(nextRight === 'h') { + break; + } + hour = (parseInt(time.hour, 10) === 0 ? 12 : time.hour < 13 ? time.hour + : time.hour - 12); + retValue += parseInt(hour, 10); + // Fixing issue https://github.com/phstc/jquery-dateFormat/issues/21 + // retValue = parseInt(retValue, 10); + pattern = ''; + break; + case 'mm': + retValue += padding(time.minute, 2); + pattern = ''; + break; + case 'm': + if(nextRight === 'm') { + break; + } + retValue += time.minute; + pattern = ''; + break; + case 'ss': + /* ensure only seconds are added to the return string */ + retValue += padding(time.second.substring(0, 2), 2); + pattern = ''; + break; + case 's': + if(nextRight === 's') { + break; + } + retValue += time.second; + pattern = ''; + break; + case 'S': + case 'SS': + if(nextRight === 'S') { + break; + } + retValue += pattern; + pattern = ''; + break; + case 'SSS': + retValue += time.millis.substring(0, 3); + pattern = ''; + break; + case 'a': + retValue += time.hour >= 12 ? 'PM' : 'AM'; + pattern = ''; + break; + case 'p': + retValue += time.hour >= 12 ? 'p.m.' : 'a.m.'; + pattern = ''; + break; + case "'": + pattern = ''; + inQuote = true; + break; + default: + retValue += currentPattern; + pattern = ''; + break; + } + } + retValue += unparsedRest; + return retValue; + } catch (e) { + if(console && console.log) { + console.log(e); + } + return value; + } + }, + /* + * JavaScript Pretty Date + * Copyright (c) 2011 John Resig (ejohn.org) + * Licensed under the MIT and GPL licenses. + * + * Takes an ISO time and returns a string representing how long ago the date + * represents + * + * ('2008-01-28T20:24:17Z') // => '2 hours ago' + * ('2008-01-27T22:24:17Z') // => 'Yesterday' + * ('2008-01-26T22:24:17Z') // => '2 days ago' + * ('2008-01-14T22:24:17Z') // => '2 weeks ago' + * ('2007-12-15T22:24:17Z') // => 'more than 5 weeks ago' + * + */ + prettyDate : function(time) { + var date; + var diff; + var day_diff; + + if(typeof time === 'string' || typeof time === 'number') { + date = new Date(time); + } + + if(typeof time === 'object') { + date = new Date(time.toString()); + } + + diff = (((new Date()).getTime() - date.getTime()) / 1000); + + day_diff = Math.floor(diff / 86400); + + if(isNaN(day_diff) || day_diff < 0) { + return; + } + + if(diff < 60) { + return 'just now'; + } else if(diff < 120) { + return '1 minute ago'; + } else if(diff < 3600) { + return Math.floor(diff / 60) + ' minutes ago'; + } else if(diff < 7200) { + return '1 hour ago'; + } else if(diff < 86400) { + return Math.floor(diff / 3600) + ' hours ago'; + } else if(day_diff === 1) { + return 'Yesterday'; + } else if(day_diff < 7) { + return day_diff + ' days ago'; + } else if(day_diff < 31) { + return Math.ceil(day_diff / 7) + ' weeks ago'; + } else if(day_diff >= 31) { + return 'more than 5 weeks ago'; + } + }, + toBrowserTimeZone : function(value, format) { + return this.date(new Date(value), format || 'MM/dd/yyyy HH:mm:ss'); + } + }; + }()); +}(DateFormat)); +;// require dateFormat.js +// please check `dist/jquery.dateFormat.js` for a complete version +(function($) { + $.format = DateFormat.format; +}(jQuery)); diff --git a/app/assets/javascripts/custom_netzke_helpers.js b/app/assets/javascripts/panel.js similarity index 100% rename from app/assets/javascripts/custom_netzke_helpers.js rename to app/assets/javascripts/panel.js diff --git a/app/assets/javascripts/site.js b/app/assets/javascripts/site.js index e69de29..bebec98 100644 --- a/app/assets/javascripts/site.js +++ b/app/assets/javascripts/site.js @@ -0,0 +1,9 @@ +$("#index_logout").click(function(){ + $.ajax({ + type: "DELETE", + url: $("#index_logout").data("url"), + complete: function(){ + window.location.href="/"; + } + }); +}); diff --git a/app/assets/javascripts/task_lists.js b/app/assets/javascripts/task_lists.js new file mode 100644 index 0000000..b17d8d4 --- /dev/null +++ b/app/assets/javascripts/task_lists.js @@ -0,0 +1,32 @@ +$(".task_list_task").click(function(){ + $("#update_tasks_submit").removeClass("disabled"); +}); + +$("#update_tasks_submit").click(function(){ + + tasks = []; + $(".task_list_task").each(function(){ + tasks.push({ + id: parseInt($(this).data("id")), + done: $(this).is(":checked") + }); + }); + + json_data = { tasks: tasks }; + + $.ajax({ + url: $("#update_tasks_submit").data("url"), + type: "PUT", + data: JSON.stringify(json_data), + contentType: 'application/json', + dataType: "json", + success: function(data, status, xhr){ + //should re-render via JS, but for now reload + location.reload(); + }, + error: function(data, status ){ + alert("An error occured updating tasks"); + //displayFormErrors(data.responseJSON); + } + }); +}); diff --git a/app/assets/javascripts/time_entries.js b/app/assets/javascripts/time_entries.js new file mode 100644 index 0000000..d6d51b6 --- /dev/null +++ b/app/assets/javascripts/time_entries.js @@ -0,0 +1,81 @@ +$(document).ready(function(){ + + var currentdate = new Date(); + $("#date_id").datepicker().on('changeDate', function(ev){ + $("#date_id").datepicker('hide'); + }); + $("#date_id").datepicker('setValue', currentdate); + + $("#start_time_id").timepicker(); + $("#end_time_id").timepicker(); + + $("#add_time_entry_submit").click(function(){ + date = $("#date_id").val(); + start_date = new Date(date + " " + $("#start_time_id").val()); + end_date = new Date(date + " " + $("#end_time_id").val()); + + forward = $("#add_time_entry_submit").data("forward"); + + // If a bike is selected, forward to the bike + // checklist. + bike_id = parseInt($("#bike_id").val()); + if( bike_id > 0 ){ + forward = "/task_lists/" + bike_id + "/edit"; + } + + //FIXME: Ideally, we'd submit the dates as ISO, but I can't figure out + // how to get Netzke to render UTC dates correctly (it calls to_json + // somewhere and drops off the timezone). For the time being, save dates + // in locale like Netzke. + json_data = { time_entries: [{ + start_date: $.format.date(start_date, "dd-MM-yyyy hh:mm a"), + end_date: $.format.date(end_date, "dd-MM-yyyy hh:mm a"), + log_action_id: parseInt($('input[name=action_id]:checked').val()), + bike_id: bike_id, + description: $("#description_id").val(), + }]}; + + $.ajax({ + url: $("#add_time_entry_submit").data("url"), + type: "POST", + data: JSON.stringify(json_data), + contentType: 'application/json', + dataType: "json", + success: function(data, status, xhr){ + window.location = forward; + }, + error: function(data, status ){ + displayFormErrors(data.responseJSON); + } + }); + + }); + + $(".work_entry-delete-btn").click(function(){ + row = $(this).closest("tr"); + entry_id = row.data("id"); + start_date = row.data("start_date"); + duration = row.data("duration"); + description = row.data("description"); + $("#work_entry_start_date").html(start_date); + $("#work_entry_duration").html(duration); + $("#work_entry_description").html(description); + $("#confirmation_delete").data("entry_id", entry_id); + }); + + $("#confirmation_delete").click(function(){ + entry_id = $(this).data("entry_id"); + url = $("#confirmation_delete").data("url") + entry_id; + $.ajax({ + url: url, + type: "delete", + contentType: 'application/json', + success: function(data, status, xhr){ + location.reload(); + }, + error: function(data, status ){ + displayFormErrors(data.responseJSON); + } + }); + }); +}); diff --git a/app/assets/javascripts/utils.js b/app/assets/javascripts/utils.js new file mode 100644 index 0000000..0b99956 --- /dev/null +++ b/app/assets/javascripts/utils.js @@ -0,0 +1,8 @@ +function displayFormErrors(data){ + if( data.errors != undefined ){ + $.each( data.errors, function( field, errorMsg) { + $("#"+field).parents(".control-group").addClass("error"); + $("#"+field).siblings(".help-block").html(errorMsg); + }); + } +} diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 3b5cc66..6b66844 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -9,5 +9,7 @@ * compiled file, but it's generally better to create a new file per style scope. * *= require_self - *= require_tree . + *= require datepicker + *= require bootstrap-timepicker + *= require bootstrap_and_overrides */ diff --git a/app/assets/stylesheets/bootstrap-timepicker.css b/app/assets/stylesheets/bootstrap-timepicker.css new file mode 100644 index 0000000..fa34752 --- /dev/null +++ b/app/assets/stylesheets/bootstrap-timepicker.css @@ -0,0 +1,146 @@ +/*! + * Timepicker Component for Twitter Bootstrap + * + * Copyright 2013 Joris de Wit + * + * Contributors https://github.com/jdewit/bootstrap-timepicker/graphs/contributors + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +.bootstrap-timepicker { + position: relative; +} +.bootstrap-timepicker.pull-right .bootstrap-timepicker-widget.dropdown-menu { + left: auto; + right: 0; +} +.bootstrap-timepicker.pull-right .bootstrap-timepicker-widget.dropdown-menu:before { + left: auto; + right: 12px; +} +.bootstrap-timepicker.pull-right .bootstrap-timepicker-widget.dropdown-menu:after { + left: auto; + right: 13px; +} +.bootstrap-timepicker .add-on { + cursor: pointer; +} +.bootstrap-timepicker .add-on i { + display: inline-block; + width: 16px; + height: 16px; +} +.bootstrap-timepicker-widget.dropdown-menu { + padding: 4px; +} +.bootstrap-timepicker-widget.dropdown-menu.open { + display: inline-block; +} +.bootstrap-timepicker-widget.dropdown-menu:before { + border-bottom: 7px solid rgba(0, 0, 0, 0.2); + border-left: 7px solid transparent; + border-right: 7px solid transparent; + content: ""; + display: inline-block; + position: absolute; +} +.bootstrap-timepicker-widget.dropdown-menu:after { + border-bottom: 6px solid #FFFFFF; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + content: ""; + display: inline-block; + position: absolute; +} +.bootstrap-timepicker-widget.timepicker-orient-left:before { + left: 6px; +} +.bootstrap-timepicker-widget.timepicker-orient-left:after { + left: 7px; +} +.bootstrap-timepicker-widget.timepicker-orient-right:before { + right: 6px; +} +.bootstrap-timepicker-widget.timepicker-orient-right:after { + right: 7px; +} +.bootstrap-timepicker-widget.timepicker-orient-top:before { + top: -7px; +} +.bootstrap-timepicker-widget.timepicker-orient-top:after { + top: -6px; +} +.bootstrap-timepicker-widget.timepicker-orient-bottom:before { + bottom: -7px; + border-bottom: 0; + border-top: 7px solid #999; +} +.bootstrap-timepicker-widget.timepicker-orient-bottom:after { + bottom: -6px; + border-bottom: 0; + border-top: 6px solid #ffffff; +} +.bootstrap-timepicker-widget a.btn, +.bootstrap-timepicker-widget input { + border-radius: 4px; +} +.bootstrap-timepicker-widget table { + width: 100%; + margin: 0; +} +.bootstrap-timepicker-widget table td { + text-align: center; + height: 30px; + margin: 0; + padding: 2px; +} +.bootstrap-timepicker-widget table td:not(.separator) { + min-width: 30px; +} +.bootstrap-timepicker-widget table td span { + width: 100%; +} +.bootstrap-timepicker-widget table td a { + border: 1px transparent solid; + width: 100%; + display: inline-block; + margin: 0; + padding: 8px 0; + outline: 0; + color: #333; +} +.bootstrap-timepicker-widget table td a:hover { + text-decoration: none; + background-color: #eee; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + border-color: #ddd; +} +.bootstrap-timepicker-widget table td a i { + margin-top: 2px; + font-size: 18px; +} +.bootstrap-timepicker-widget table td input { + width: 25px; + margin: 0; + text-align: center; +} +.bootstrap-timepicker-widget .modal-content { + padding: 4px; +} +@media (min-width: 767px) { + .bootstrap-timepicker-widget.modal { + width: 200px; + margin-left: -100px; + } +} +@media (max-width: 767px) { + .bootstrap-timepicker { + width: 100%; + } + .bootstrap-timepicker .dropdown-menu { + width: 100%; + } +} diff --git a/app/assets/stylesheets/bootstrap_and_overrides.css.less b/app/assets/stylesheets/bootstrap_and_overrides.css.less index 4210c11..1d0efba 100644 --- a/app/assets/stylesheets/bootstrap_and_overrides.css.less +++ b/app/assets/stylesheets/bootstrap_and_overrides.css.less @@ -1,10 +1,26 @@ @import "twitter/bootstrap/bootstrap"; +body { + padding-top: 60px; + padding-left: 20px; +} + @import "twitter/bootstrap/responsive"; // Set the correct sprite paths @iconSpritePath: asset-path('twitter/bootstrap/glyphicons-halflings.png'); @iconWhiteSpritePath: asset-path('twitter/bootstrap/glyphicons-halflings-white.png'); +// Set the Font Awesome (Font Awesome is default. You can disable by commenting below lines) +// Note: If you use asset_path() here, your compiled boostrap_and_overrides.css will not +// have the proper paths. So for now we use the absolute path. +@fontAwesomeEotPath: '/assets/fontawesome-webfont.eot'; +@fontAwesomeWoffPath: '/assets/fontawesome-webfont.woff'; +@fontAwesomeTtfPath: '/assets/fontawesome-webfont.ttf'; +@fontAwesomeSvgPath: '/assets/fontawesome-webfont.svg'; + +// Font Awesome +@import "fontawesome"; + // Your custom LESS stylesheets goes here // // Since bootstrap was imported above you have access to its mixins which @@ -15,3 +31,17 @@ // // Example: // @linkColor: #ff0000; + +[data-toggle="buttons-radio"] > .btn > input[type="radio"], +[data-toggle="buttons"] > .btn > input[type="checkbox"] { + display: none; +} + +.inline-block { + display: inline-block; +} + +.control-group.error .btn-group > .btn { + color: #b94a48; + border-color: #b94a48; +} diff --git a/app/assets/stylesheets/datepicker.css b/app/assets/stylesheets/datepicker.css new file mode 100755 index 0000000..b7065b7 --- /dev/null +++ b/app/assets/stylesheets/datepicker.css @@ -0,0 +1,182 @@ +/*! + * Datepicker for Bootstrap + * + * Copyright 2012 Stefan Petre + * Licensed under the Apache License v2.0 + * http://www.apache.org/licenses/LICENSE-2.0 + * + */ +.datepicker { + top: 0; + left: 0; + padding: 4px; + margin-top: 1px; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + /*.dow { + border-top: 1px solid #ddd !important; + }*/ + +} +.datepicker:before { + content: ''; + display: inline-block; + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-bottom: 7px solid #ccc; + border-bottom-color: rgba(0, 0, 0, 0.2); + position: absolute; + top: -7px; + left: 6px; +} +.datepicker:after { + content: ''; + display: inline-block; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid #ffffff; + position: absolute; + top: -6px; + left: 7px; +} +.datepicker > div { + display: none; +} +.datepicker table { + width: 100%; + margin: 0; +} +.datepicker td, +.datepicker th { + text-align: center; + width: 20px; + height: 20px; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; +} +.datepicker td.day:hover { + background: #eeeeee; + cursor: pointer; +} +.datepicker td.day.disabled { + color: #eeeeee; +} +.datepicker td.old, +.datepicker td.new { + color: #999999; +} +.datepicker td.active, +.datepicker td.active:hover { + color: #ffffff; + background-color: #006dcc; + background-image: -moz-linear-gradient(top, #0088cc, #0044cc); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0044cc)); + background-image: -webkit-linear-gradient(top, #0088cc, #0044cc); + background-image: -o-linear-gradient(top, #0088cc, #0044cc); + background-image: linear-gradient(to bottom, #0088cc, #0044cc); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0088cc', endColorstr='#ff0044cc', GradientType=0); + border-color: #0044cc #0044cc #002a80; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + *background-color: #0044cc; + /* Darken IE7 buttons by default so they stand out more given they won't have borders */ + + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + color: #fff; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); +} +.datepicker td.active:hover, +.datepicker td.active:hover:hover, +.datepicker td.active:focus, +.datepicker td.active:hover:focus, +.datepicker td.active:active, +.datepicker td.active:hover:active, +.datepicker td.active.active, +.datepicker td.active:hover.active, +.datepicker td.active.disabled, +.datepicker td.active:hover.disabled, +.datepicker td.active[disabled], +.datepicker td.active:hover[disabled] { + color: #ffffff; + background-color: #0044cc; + *background-color: #003bb3; +} +.datepicker td.active:active, +.datepicker td.active:hover:active, +.datepicker td.active.active, +.datepicker td.active:hover.active { + background-color: #003399 \9; +} +.datepicker td span { + display: block; + width: 47px; + height: 54px; + line-height: 54px; + float: left; + margin: 2px; + cursor: pointer; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; +} +.datepicker td span:hover { + background: #eeeeee; +} +.datepicker td span.active { + color: #ffffff; + background-color: #006dcc; + background-image: -moz-linear-gradient(top, #0088cc, #0044cc); + background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#0088cc), to(#0044cc)); + background-image: -webkit-linear-gradient(top, #0088cc, #0044cc); + background-image: -o-linear-gradient(top, #0088cc, #0044cc); + background-image: linear-gradient(to bottom, #0088cc, #0044cc); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff0088cc', endColorstr='#ff0044cc', GradientType=0); + border-color: #0044cc #0044cc #002a80; + border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); + *background-color: #0044cc; + /* Darken IE7 buttons by default so they stand out more given they won't have borders */ + + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + color: #fff; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); +} +.datepicker td span.active:hover, +.datepicker td span.active:focus, +.datepicker td span.active:active, +.datepicker td span.active.active, +.datepicker td span.active.disabled, +.datepicker td span.active[disabled] { + color: #ffffff; + background-color: #0044cc; + *background-color: #003bb3; +} +.datepicker td span.active:active, +.datepicker td span.active.active { + background-color: #003399 \9; +} +.datepicker td span.old { + color: #999999; +} +.datepicker th.switch { + width: 145px; +} +.datepicker th.next, +.datepicker th.prev { + font-size: 21px; +} +.datepicker thead tr:first-child th { + cursor: pointer; +} +.datepicker thead tr:first-child th:hover { + background: #eeeeee; +} +.input-append.date .add-on i, +.input-prepend.date .add-on i { + display: block; + cursor: pointer; + width: 16px; + height: 16px; +} \ No newline at end of file diff --git a/app/components/tasks.rb b/app/components/tasks.rb index 3956250..c153ded 100644 --- a/app/components/tasks.rb +++ b/app/components/tasks.rb @@ -2,8 +2,6 @@ class Tasks < Netzke::Basepack::Grid def configure(c) super - #disable by default, will be enabled once bike is clicked - c.disabled = true task_list_id = nil if session[:selected_bike_id] task_list_id = Bike.find_by_id(session[:selected_bike_id]).task_list.id @@ -31,13 +29,14 @@ class Tasks < Netzke::Basepack::Grid end def default_fields_for_forms - fields = [] - fields << { :no_binding => true, :xtype => 'displayfield', :fieldLabel => "No Bike Selected", :value => "Select a Bike First!"} - fields.concat( [ + bike = Bike.find_by_id(session[:selected_bike_id]) + bike = "Select a Bike First!" if bike.nil? + [ + { :no_binding => true, :xtype => 'displayfield', :fieldLabel => "Bike Selected", :value => "#{bike.to_s}"}, :done, :task, - :notes - ]) + :notes, + ] end #override with nil to remove actions diff --git a/app/components/users/javascripts/init_component.js b/app/components/users/javascripts/init_component.js index f2c7e47..edb5592 100644 --- a/app/components/users/javascripts/init_component.js +++ b/app/components/users/javascripts/init_component.js @@ -33,7 +33,7 @@ Ext.Msg.alert("Success", "New Password: "+data.password); }, error: function(data,textStatus) { - Ext.Msg.alert( "Error", JSON.parse(data.responseText)["error"]); + Ext.Msg.alert( "Error", JSON.parse(data.responseText)["errors"][0]); } }); } diff --git a/app/controllers/api/v1/base_controller.rb b/app/controllers/api/v1/base_controller.rb index a3ed9c1..e2dec11 100644 --- a/app/controllers/api/v1/base_controller.rb +++ b/app/controllers/api/v1/base_controller.rb @@ -6,12 +6,12 @@ class Api::V1::BaseController < ActionController::Base private def authenticate_user if params[:username] - user = User.find_for_database_authentication( :email => params[:username] ) + user = User.find_for_database_authentication( :username => params[:username] ) @current_user = user if user && user.valid_password?( params[:password] ) if @current_user.nil? msg = "Username/Password/Token invalid" - render :json => {:error => msg }, :status => 403 and return + render :json => {:error => msg }, :status => 401 and return end else authenticate_user! diff --git a/app/controllers/api/v1/bikes_controller.rb b/app/controllers/api/v1/bikes_controller.rb new file mode 100644 index 0000000..976a04f --- /dev/null +++ b/app/controllers/api/v1/bikes_controller.rb @@ -0,0 +1,32 @@ +class Api::V1::BikesController < Api::V1::BaseController + CANNOT_MANAGE = "You do not have permission to manage bikes." + EXPECTED_BIKE = "Expected bike in submitted data" + NOT_FOUND = "The bike could not be found." + + before_filter :check_bike_permission, except: :show + + def create + if params[:bikes] && bike = params[:bikes].first + @bike = Bike.new(bike) + if !@bike.save + render json: { errors: @bike.errors }, status: 422 and return + end + else + render json: { errors: [EXPECTED_BIKE]}, status: 422 and return + end + end + + def show + @bike = Bike.find_by_id(params[:id]) + if @bike.nil? + render json: { errors: [NOT_FOUND] }, status: 404 and return + end + end + + private + def check_bike_permission + if cannot? :manage, Bike + render json: { errors: [CANNOT_MANAGE]}, status: 403 and return + end + end +end diff --git a/app/controllers/api/v1/task_lists_controller.rb b/app/controllers/api/v1/task_lists_controller.rb new file mode 100644 index 0000000..6c89a5e --- /dev/null +++ b/app/controllers/api/v1/task_lists_controller.rb @@ -0,0 +1,24 @@ +class Api::V1::TaskListsController < Api::V1::BaseController + CANNOT_MANAGE = "You do not have permission to manage this task list." + NOT_FOUND = "The task list could not be found." + + before_filter :get_task_list + before_filter :check_task_list_permission, except: :show + + def show + end + + private + def get_task_list + @task_list = TaskList.find_by_id(params[:id]) + if @task_list.nil? + render json: { errors: [NOT_FOUND] }, status: 404 and return + end + end + + def check_task_list_permission + if cannot? :manage, Bike and @task_list.item != current_user.bike + render json: { errors: [CANNOT_MANAGE]}, status: 403 and return + end + end +end diff --git a/app/controllers/api/v1/tasks_controller.rb b/app/controllers/api/v1/tasks_controller.rb new file mode 100644 index 0000000..9bb8562 --- /dev/null +++ b/app/controllers/api/v1/tasks_controller.rb @@ -0,0 +1,64 @@ +class Api::V1::TasksController < Api::V1::BaseController + EXPECTED_TASKS = "Expected a list of tasks in submitted data." + CANNOT_MANAGE = "You do not have permission to manage this task." + NOT_FOUND = "The task could not be found." + + before_filter :validate_params + before_filter :get_tasks + before_filter :check_task_permission, except: :show + + def update + errors = [] + @tasks.each do |task_hash| + task = task_hash[:record] + attrs = task_hash[:new_attributes] + task.update_attributes(attrs) + if !task.errors.empty? + errors << { id: task.id, errors: task.errors } + end + end + + if !errors.empty? + render json: { errors: errors }, status: 422 and return + end + end + + private + def validate_params + if params[:tasks].nil? and not params[:tasks].kind_of?(Array) + render json: { errors: [EXPECTED_TASKS]}, status: 422 and return + end + end + + def get_tasks + @tasks = [] + errors = [] + + params[:tasks].each do |task| + t = Task.find_by_id(task[:id]) + if t.nil? + errors << { id: task[:id], error: NOT_FOUND } + else + @tasks << { record: t, new_attributes: task } + end + end + + if !errors.empty? + render json: { errors: errors }, status: 404 and return + end + end + + def check_task_permission + errors = [] + @tasks.each do |task_hash| + task = task_hash[:record] + if task.task_list.item != current_user.bike + errors << { id: task[:id], error: CANNOT_MANAGE } + end + end + + if cannot? :manage, Bike and !errors.empty? + render json: { errors: errors}, status: 403 and return + end + end +end diff --git a/app/controllers/api/v1/time_entries_controller.rb b/app/controllers/api/v1/time_entries_controller.rb new file mode 100644 index 0000000..e1b8768 --- /dev/null +++ b/app/controllers/api/v1/time_entries_controller.rb @@ -0,0 +1,33 @@ +class Api::V1::TimeEntriesController < Api::V1::BaseController + EXPECTED_TIME_ENTRY = "Expected time entry in submitted data" + NOT_FOUND = "Time entry not found" + + def create + if params[:time_entries] && time_entry = params[:time_entries].first + time_entry.merge!({ loggable_id: current_user.id, + logger_id: current_user.id }) + bike_id = time_entry[:bike_id].to_i + + @time_entry = TimeEntry.new(time_entry.except(:bike_id)) + + if bike_id > 0 + @time_entry.copy_to_bike_history(bike_id) + end + + if !@time_entry.save + render json: { errors: @time_entry.errors }, status: 422 and return + end + else + render json: { errors: [EXPECTED_TIME_ENTRY]}, status: 422 and return + end + end + + def delete + if time_entry = TimeEntry.find_by_id(params[:id]) + time_entry.delete + render nothing: true, status: 204 and return + else + render json: { errors: [NOT_FOUND]}, status: 404 and return + end + end +end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index 35176bd..a9eacd7 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -1,21 +1,27 @@ require 'securerandom' class Api::V1::UsersController < Api::V1::BaseController + CANNOT_MANAGE = "You do not have the permission to manager users" + NOT_FOUND = "User not found" + NOT_ALLOWED = "Not allowed to reset your own password in this fashion" + PASS_LENGTH = 8 def password_reset if can? :manage, User user = User.find_by_id(params[:user_id]) - render :json => { "error" => "User not found"}, :status => 404 and return if user.nil? - render :json => { "error" => "Not allowed to reset your own password in this fashion."}, :status => 403 and return if user.id == current_user.id + render :json => { "errors" => [NOT_FOUND]}, :status => 404 and return if user.nil? + render :json => { "errors" => [NOT_ALLOWED]}, :status => 403 and return if user.id == current_user.id - new_pass = SecureRandom.hex[0,8] + new_pass = SecureRandom.hex[0,PASS_LENGTH] user.password = new_pass user.save render :json => { "password" => new_pass}, :status => 200 and return else - render :json => { "error" => "You do not have the permission"}, :status => 403 and return + render :json => { "errors" => [CANNOT_MANAGE]}, :status => 403 and return end end + +=begin Is this here by accident? Commenting out for now (1/30/14) def checkout #must use @current_user since user may not have signed in if !@current_user.checked_in? @@ -25,4 +31,5 @@ class Api::V1::UsersController < Api::V1::BaseController render :nothing => true, :status => 204 and return end end +=end end diff --git a/app/controllers/bikes_controller.rb b/app/controllers/bikes_controller.rb new file mode 100644 index 0000000..981ccd3 --- /dev/null +++ b/app/controllers/bikes_controller.rb @@ -0,0 +1,16 @@ +class BikesController < AuthenticatedController + + def new + @brands = BikeBrand.all.map{ |b| [b.brand, b.id] } + @brands.unshift( ["Select a brand", -1] ) + @wheel_sizes = BikeWheelSize.all.map{ |w| [w.display_string, w.id] } + @wheel_sizes.unshift( ["Select a wheel size", -1] ) + end + + def show + @bike = Bike.find_by_id(params[:id]) + @task_list_id = @bike.task_list.id + @show_add_bike = true if params[:add_bike] + end + +end diff --git a/app/controllers/panel_controller.rb b/app/controllers/panel_controller.rb new file mode 100644 index 0000000..097d1de --- /dev/null +++ b/app/controllers/panel_controller.rb @@ -0,0 +1,7 @@ +class PanelController < ApplicationController + + def index + render :inline => "<%=netzke :app_view, :layout => true %>", :layout => "netzke" + end + +end diff --git a/app/controllers/site_controller.rb b/app/controllers/site_controller.rb index 0263ee0..4290aa9 100644 --- a/app/controllers/site_controller.rb +++ b/app/controllers/site_controller.rb @@ -1,7 +1,6 @@ class SiteController < ApplicationController def index - render :inline => "<%= netzke :app_view, :layout => true %>", :layout => "application" + @bike = current_user.bike end - end diff --git a/app/controllers/task_lists_controller.rb b/app/controllers/task_lists_controller.rb new file mode 100644 index 0000000..8c1d97f --- /dev/null +++ b/app/controllers/task_lists_controller.rb @@ -0,0 +1,5 @@ +class TaskListsController < AuthenticatedController + def edit + @task_list = TaskList.find_by_id(params[:id]) + end +end diff --git a/app/controllers/time_entries_controller.rb b/app/controllers/time_entries_controller.rb new file mode 100644 index 0000000..e019dc9 --- /dev/null +++ b/app/controllers/time_entries_controller.rb @@ -0,0 +1,16 @@ +class TimeEntriesController < AuthenticatedController + + def new + @bikes = Bike.all.map{ |b| [b.to_s , b.id] } + if bike = current_user.bike + @bikes.unshift( [bike.to_s, bike.id] ) + end + @bikes.unshift( ["Non-bike work", -1] ) + end + + def index + @user_time_entries = TimeEntry.where(loggable_id: current_user.id) + @credits_available = current_user.total_credits + @hours_worked = current_user.total_hours + end +end diff --git a/app/models/bike.rb b/app/models/bike.rb index 7403c33..70f2277 100644 --- a/app/models/bike.rb +++ b/app/models/bike.rb @@ -16,13 +16,13 @@ class Bike < ActiveRecord::Base validates :shop_id, :presence => true, :uniqueness => true, :numericality => { :only_integer => true } validates :serial_number, :length => { :minimum => 3 } validates :model, :length => { :maximum => 50 } - validates :bike_brand_id, :presence => true - validates :color, :presence => true - validates :bike_style_id, :presence => true - validates :seat_tube_height, :presence => true - validates :bike_wheel_size_id, :presence => true - validates :bike_condition_id, :presence => true - validates :bike_purpose_id, :presence => true + validates :bike_brand_id, :presence => true, :numericality => { greater_than: 0, message: "is not a valid brand" } + #validates :color, :presence => true + validates :bike_style_id, :presence => true, :numericality => { greater_than: 0, message: "is not a valid style" } + validates :seat_tube_height, :presence => true, :numericality => true + validates :bike_wheel_size_id, :presence => true, :numericality => { greater_than: 0, message: "is not a valid wheel size" } + validates :bike_condition_id, :presence => true, :numericality => { greater_than: 0, message: "is not a valid condition" } + validates :bike_purpose_id, :presence => true, :numericality => { greater_than: 0, message: "is not a valid purpose" } self.per_page = 15 diff --git a/app/models/task.rb b/app/models/task.rb index 4a1a5a0..85bfa5b 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -1,5 +1,5 @@ class Task < ActiveRecord::Base - attr_accessible :task, :notes, :done + attr_accessible :task, :notes, :done, :task_list_id belongs_to :task_list diff --git a/app/models/task_list.rb b/app/models/task_list.rb index 49ace63..85ab5ed 100644 --- a/app/models/task_list.rb +++ b/app/models/task_list.rb @@ -4,7 +4,7 @@ class TaskList < ActiveRecord::Base attr_accessible :item_id, :item_type, :name belongs_to :item, :polymorphic => true - has_many :tasks + has_many :tasks, order: "id ASC" after_save :create_default_bike_tasks diff --git a/app/models/time_entry.rb b/app/models/time_entry.rb new file mode 100644 index 0000000..745e5b4 --- /dev/null +++ b/app/models/time_entry.rb @@ -0,0 +1,27 @@ +class TimeEntry < ActsAsLoggable::Log + default_scope where( loggable_type: "User", + logger_type: "User", + log_action_type: "ActsAsLoggable::UserAction").where("log_action_id != 4").order("start_date DESC") + + def copy_to_bike_history(bike_id) + self.assign_attributes({ + copy_log: true, + copy_type: 'Bike', + copy_id: bike_id, + copy_action_type: 'ActsAsLoggable::BikeAction', + copy_action_id: 4 + }) + end + + def duration + end_date - start_date + end + + def duration_in_hours + (duration / 1.hour).round(2) + end + + def type + log_action.try(:action) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index cd657a1..a455258 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -50,9 +50,9 @@ class User < ActiveRecord::Base #default BUILDBIKE/CLASS ID is 5 purpose_id = 5 Bike.find_by_sql(" - SELECT * + SELECT * FROM bikes - INNER JOIN( + INNER JOIN( SELECT * FROM transactions WHERE customer_id = #{self.id} @@ -65,15 +65,16 @@ class User < ActiveRecord::Base end def total_credits_spent - log_action = ::ActsAsLoggable::TransactionAction.find_by_action("TIME") + log_action_id = 1 #TIME transaction_logs. where( "log_action_id = ? AND log_action_type = ?", - log_action.id, log_action.class.to_s). + log_action_id, ::ActsAsLoggable::TransactionAction.to_s). sum{ |r| r.description.to_i }.round(2) end def total_earned_credits - log_action = ::ActsAsLoggable::UserAction.find_by_action("CHECKIN") + volunteer_id = 1 + staff_id = 3 # Find the first credit conversion which has a created_at date before the # log's created_at date and join it to the log's row so we can calculate @@ -82,7 +83,7 @@ class User < ActiveRecord::Base # # The DISTINCT ON, and ORDER BY are important to getting the # single conversion rate that applies to the respective log. - ::ActsAsLoggable::Log.find_by_sql(" + ::ActsAsLoggable::Log.find_by_sql([" SELECT DISTINCT ON (logs.created_at) start_date, end_date, conversion.conversion, conversion.created_at FROM logs @@ -90,23 +91,26 @@ class User < ActiveRecord::Base SELECT conversion, created_at FROM credit_conversions ) AS conversion ON logs.created_at > conversion.created_at - WHERE logs.loggable_id = #{self.id} + WHERE logs.loggable_id = :id AND logs.loggable_type = 'User' - AND (log_action_id != #{log_action.id} AND log_action_type = '#{log_action.class.to_s}') - ORDER BY logs.created_at, conversion.created_at DESC"). + AND (log_action_id IN (:credit_actions) AND log_action_type = :log_action_type) + ORDER BY logs.created_at, conversion.created_at DESC", + {id: self.id, + credit_actions: [volunteer_id, staff_id], + log_action_type: ::ActsAsLoggable::UserAction.to_s}]). sum{ |l| ((l.end_date - l.start_date)/3600) * l.conversion.to_i}.round(2) end def total_hours - log_action = ::ActsAsLoggable::UserAction.find_by_action("CHECKIN") - logs.where("log_action_id != ? AND log_action_type = ?", log_action.id, log_action.class.to_s).sum { |l| (l.end_date - l.start_date)/3600 }.round(2) + log_action_id = 4 #CHECKIN + logs.where("log_action_id != ? AND log_action_type = ?", log_action_id, ::ActsAsLoggable::UserAction.to_s).sum { |l| (l.end_date - l.start_date)/3600 }.round(2) end def current_month_hours - log_action = ::ActsAsLoggable::UserAction.find_by_action("CHECKIN") + log_action_id = 4 #CHECKIN #TODO need to prevent users from saving logs across months, force to create a new log if crossing month current_month_range = (Time.now.beginning_of_month..Time.now.end_of_month) - logs.where("log_action_id != ? AND log_action_type = ?", log_action.id, log_action.class.to_s) + logs.where("log_action_id != ? AND log_action_type = ?", log_action_id, ::ActsAsLoggable::UserAction.to_s) .where( :start_date => current_month_range) .where( :end_date => current_month_range) .sum { |l| (l.end_date - l.start_date)/3600 } @@ -114,28 +118,31 @@ class User < ActiveRecord::Base end def checked_in? - log_action = ::ActsAsLoggable::UserAction.find_by_action("CHECKIN") - checked = logs.where( log_action_id: log_action.id). + #default CHECKIN log action is id, yea yea should be a constant + log_action_id = 4 + checked = logs.where( log_action_id: log_action_id). where("start_date >= ?", Time.zone.now.beginning_of_day). where("start_date = end_date") !checked.empty? end def checkin - log_action = ::ActsAsLoggable::UserAction.find_by_action("CHECKIN") + #default CHECKIN log action is id, yea yea should be a constant + log_action_id = 4 time = Time.now logs.create( logger_id: self.id, logger_type: self.class.to_s, start_date: time, end_date: time, - log_action_id: log_action.id, - log_action_type: log_action.class.to_s) + log_action_id: log_action_id, + log_action_type: ::ActsAsLoggable::UserAction.to_s) save end def checkout - log_action = ::ActsAsLoggable::UserAction.find_by_action("CHECKIN") - checked = logs.where( log_action_id: log_action.id). + #default CHECKIN log action is id, yea yea should be a constant + log_action_id = 4 + checked = logs.where( log_action_id: log_action_id). where("start_date >= ?", Time.zone.now.beginning_of_day). where("start_date = end_date").first checked.end_date = Time.now diff --git a/app/views/api/v1/bikes/create.json.jbuilder b/app/views/api/v1/bikes/create.json.jbuilder new file mode 100644 index 0000000..0b73e71 --- /dev/null +++ b/app/views/api/v1/bikes/create.json.jbuilder @@ -0,0 +1,4 @@ +json.bikes [@bike] do |bike| + json.array! bike + json.set! :href, api_bike_path(bike) +end diff --git a/app/views/api/v1/bikes/show.json.jbuilder b/app/views/api/v1/bikes/show.json.jbuilder new file mode 100644 index 0000000..cdaee86 --- /dev/null +++ b/app/views/api/v1/bikes/show.json.jbuilder @@ -0,0 +1,3 @@ +json.bikes [@bike] do |bike| + json.array! bike +end diff --git a/app/views/api/v1/task_lists/show.json.jbuilder b/app/views/api/v1/task_lists/show.json.jbuilder new file mode 100644 index 0000000..f11a6da --- /dev/null +++ b/app/views/api/v1/task_lists/show.json.jbuilder @@ -0,0 +1,15 @@ +json.task_lists [@task_list] do |tl| + json.array! tl + json.links do + json.bike do + json.href api_bike_path(tl.item) + json.id tl.item_id + end + json.tasks tl.tasks do |task| + json.id task.id + json.done task.done + json.notes task.notes + json.task task.task + end + end +end diff --git a/app/views/api/v1/tasks/update.json.jbuilder b/app/views/api/v1/tasks/update.json.jbuilder new file mode 100644 index 0000000..0dffca7 --- /dev/null +++ b/app/views/api/v1/tasks/update.json.jbuilder @@ -0,0 +1,3 @@ +json.tasks [@tasks.map{|x| x[:record]}] do |task| + json.array! task +end diff --git a/app/views/api/v1/time_entries/create.json.jbuilder b/app/views/api/v1/time_entries/create.json.jbuilder new file mode 100644 index 0000000..5bc0f01 --- /dev/null +++ b/app/views/api/v1/time_entries/create.json.jbuilder @@ -0,0 +1,4 @@ +json.time_entries [@time_entry] do |time_entry| + json.array! time_entry + #json.set! :href, api_time_entry_path(time_entry) +end diff --git a/app/views/bikes/new.html.haml b/app/views/bikes/new.html.haml new file mode 100644 index 0000000..eb652a9 --- /dev/null +++ b/app/views/bikes/new.html.haml @@ -0,0 +1,62 @@ +%a{ class: "btn btn-default btn-lg", href: root_path} + %span{ class:"icon-home"} +%h2 Add Bike + +%p + .control-group + .controls + %input{id: "shop_id", placeholder: "Shop ID", type: "number", min:0, class: "input-lg" } + .help-block + .control-group + .controls + = select_tag(:bike_brand_id, options_for_select(@brands)) + .help-block + .control-group + .controls + %input{id: "model", placeholder: "Model", type: "text", class: "input-lg" } + .help-block + .control-group + .controls + %input{id: "serial_number", placeholder: "Serial Number", type: "text", class: "input-lg" } + .help-block + .control-group + .controls + .btn-group{ "data-toggle" => "buttons-radio"} + %label{ class: "btn btn-default"} + %input{ type: "radio", name: "bike_style", value: 3} RD + %label{ class: "btn btn-default"} + %input{ type: "radio", name: "bike_style", value: 1} MTN + %label{ class: "btn btn-default"} + %input{ type: "radio", name: "bike_style", value: 2} HYB + %label{ class: "btn btn-default"} + %input{ type: "radio", name: "bike_style", value: 4} OTHER + %input{ id: "bike_style_id", type: "hidden"} + .help-block + .control-group + .controls + = select_tag(:bike_wheel_size_id, options_for_select(@wheel_sizes), id: :bike_wheel_size_id) + .help-block + .control-group + .controls + .btn-group{ "data-toggle" => "buttons-radio"} + %label{ class: "btn btn-default"} + %input{ type: "radio", name: "bike_condition", value: 2} Poor + %label{ class: "btn btn-default"} + %input{ type: "radio", name: "bike_condition", value: 3} Fair + %label{ class: "btn btn-default"} + %input{ type: "radio", name: "bike_condition", value: 4} Good + %label{ class: "btn btn-default"} + %input{ type: "radio", name: "bike_condition", value: 5} Excellent + %input{ id: "bike_condition_id", type: "hidden"} + .help-block + .control-group + .controls + %input{id: "seat_tube_height", placeholder: "Seat Tube (cm)", type: "number", min: 0, max: 100, class: "input-lg" } + .help-block + -# Commenting this out until description is added to Bike + %p + %input{id: "bike_description", placeholder: "Short description", type: "text", class: "input-lg" } + .control-group + .controls + %input{id: "add_bike_submit", value: "Add Bike", type: "button", class: "btn btn-lg btn-block btn-primary", "data-url" => "#{api_create_bike_path}"} + diff --git a/app/views/bikes/show.html.haml b/app/views/bikes/show.html.haml new file mode 100644 index 0000000..ca71f7d --- /dev/null +++ b/app/views/bikes/show.html.haml @@ -0,0 +1,27 @@ +- if @show_add_bike + %p + %a{class: "btn btn-lg btn-block btn-info", href: new_bike_path} Add Another Bike? + +%a{ class: "btn btn-default btn-lg", href: root_path} + %span{ class:"icon-home"} +%h2 #{@bike.shop_id}: #{@bike.bike_brand} +%h2 #{@bike.model} + +%dl.dl-horizontal + %dt Type + %dd #{@bike.bike_style} + %dt Wheel Size + %dd #{@bike.bike_wheel_size.display_string} + %dt Condition + %dd #{@bike.bike_condition} + %dt Seat Tube (cm) + %dd #{@bike.seat_tube_height} + %dt Purpose + %dd #{@bike.bike_purpose} + - if !@bike.value.nil? + %dt Value + %dd #{@bike.value} + - if !@bike.color.nil? + %dt Color + %dd{ style: "width: 50px; background-color: ##{@bike.color}; border: black; border-width: 1px; border-style: solid;"} #{@bike.color} +%a{class: "btn btn-lg btn-block btn-primary", href: edit_task_list_path(@task_list_id) } View Checklist diff --git a/app/views/devise/_links.erb b/app/views/devise/_links.erb index 33b1120..fc68770 100644 --- a/app/views/devise/_links.erb +++ b/app/views/devise/_links.erb @@ -1,21 +1,28 @@ <%- if controller_name != 'sessions' %> - <%= link_to "Sign in", new_session_path(resource_name) %>
+

+ <%= link_to "Sign in", new_session_path(resource_name), class: "btn btn-block btn-default" %>
+

<% end -%> <%- if devise_mapping.registerable? && controller_name != 'registrations' %> - <%= link_to "Sign up", new_registration_path(resource_name) %>
+

+ <%= link_to "Sign up", new_registration_path(resource_name), class: "btn btn-block btn-default" %>
+

<% end -%> <%- if devise_mapping.recoverable? && controller_name != 'passwords' %> - <%= link_to "Forgot your password?", new_password_path(resource_name) %>
+

+ <%= link_to "Forgot your password?", new_password_path(resource_name), class: "btn btn-block btn-default" %>
+

<% end -%> <%- if devise_mapping.confirmable? && controller_name != 'confirmations' %> - <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name) %>
+

+ <%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name), class: "btn btn-block btn-default" %>
+

<% end -%> <%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %> - <%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name) %>
<% end -%> <%- if devise_mapping.omniauthable? %> diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb index b9b59d4..ee989dd 100644 --- a/app/views/devise/passwords/new.html.erb +++ b/app/views/devise/passwords/new.html.erb @@ -1,12 +1,16 @@ -

Forgot your password?

+<%= stylesheet_link_tag "bootstrap_and_overrides", :media => "all" %> +

Forgot your password?

+
+
<%= form_for(resource, :as => resource_name, :url => password_path(resource_name), :html => { :method => :post }) do |f| %> <%= devise_error_messages! %> -
<%= f.label :email %>
- <%= f.email_field :email %>
+
+ <%= f.email_field :email, placeholder: "Email", class: "form-control input-lg" %> +
-
<%= f.submit "Send me reset password instructions" %>
+
<%= f.submit "Reset Password", class:"btn btn-lg btn-primary"%>
<% end %> <%= render "links" %> diff --git a/app/views/devise/registrations/_user_profile_fields.html.haml b/app/views/devise/registrations/_user_profile_fields.html.haml index 1a3ec42..471e288 100644 --- a/app/views/devise/registrations/_user_profile_fields.html.haml +++ b/app/views/devise/registrations/_user_profile_fields.html.haml @@ -1,25 +1,14 @@ -%fieldset - .control-group - = f.label :addrStreet1, :class => "control-label" - .controls - = f.text_field :addrStreet1, :class => "control-label" - .control-group - = f.label :addrStreet2, :class => "control-label" - .controls - = f.text_field :addrStreet2, :class => "control-label" - .control-group - = f.label :addrCity, :class => "control-label" - .controls - = f.text_field :addrCity, :class => "control-label" - .control-group - = f.label :addrState, :class => "control-label" - .controls - = f.text_field :addrState, :class => "control-label" - .control-group - = f.label :addrZip, :class => "control-label" - .controls - = f.text_field :addrZip, :class => "control-label" - .control-group - = f.label :phone, :class => "control-label" - .controls - = f.text_field :phone, :class => "control-label" +%p + %fieldset + .form-group + = f.text_field :addrStreet1, placeholder: "Street Address Line 1", :class => "form-control input-lg" + .form-group + = f.text_field :addrStreet2, placeholder: "Street Address Line 2",:class => "form-control input-lg" + .form-group + = f.text_field :addrCity, placeholder: "City", :class => "form-control input-lg" + .form-group + = f.text_field :addrState, placeholder: "State Abbreviation", :class => "form-control input-lg" + .form-group + = f.text_field :addrZip, placeholder: "Zip Code", :class => "form-control input-lg" + .form-group + = f.text_field :phone, placeholder: "Phone", :class => "form-control input-lg" diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index efece1c..1b7d6b5 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -1,36 +1,23 @@ = stylesheet_link_tag "bootstrap_and_overrides", :media => "all" -= form_for(resource, :as => resource_name, :url => registration_path(resource_name), :html => { :class => "form-horizontal"}) do |f| += form_for(resource, :as => resource_name, :url => registration_path(resource_name)) do |f| = devise_error_messages! .controls %h2 Sign up - .control-group - = f.label :username, :class => "control-label" - .controls - = f.text_field :username - .control-group - = f.label :first_name, :class => "control-label" - .controls - = f.text_field :first_name - .control-group - = f.label :last_name, :class => "control-label" - .controls - = f.text_field :last_name - .control-group - = f.label :email, :class => "control-label" - .controls - = f.email_field :email + .form-group + = f.text_field :username, placeholder: "Username", :class => "form-control input-lg" + .form-group + = f.text_field :first_name, placeholder: "First Name", :class => "form-control input-lg" + .form-group + = f.text_field :last_name, placeholder: "Last Name", :class => "form-control input-lg" + .form-group + = f.email_field :email, placeholder: "E-mail", :class => "form-control input-lg" - profile_builder = resource.user_profiles.empty? ? resource.user_profiles.build : resource.user_profiles = f.fields_for :user_profiles, profile_builder do |builder| = render 'user_profile_fields', f: builder - .control-group - = f.label :password, :class => "control-label" - .controls - = f.password_field :password - .control-group - = f.label :password_confirmation, :class => "control-label" - .controls - = f.password_field :password_confirmation - .control-group - .controls - = f.submit "Sign up" + .form-group + = f.password_field :password, placeholder: "Password", :class => "form-control input-lg" + .form-group + = f.password_field :password_confirmation, placeholder: "Password Confirmation", :class => "form-control input-lg" + .form-group + = f.submit "Sign up", class:"btn btn-lg btn-primary" = render "links" diff --git a/app/views/devise/sessions/new.html.erb b/app/views/devise/sessions/new.html.erb index f38b9b1..141a25c 100644 --- a/app/views/devise/sessions/new.html.erb +++ b/app/views/devise/sessions/new.html.erb @@ -5,34 +5,44 @@

<%= form_for(resource, :as => resource_name, :url => session_path(resource_name)) do |f| %> -
<%= f.label :username%>
- <%= f.text_field :username%>
+
+ <%= f.text_field :username, placeholder: "Username", class: "form-control input-lg"%> +
-
<%= f.label :password %>
- <%= f.password_field :password %>
+
+ <%= f.password_field :password, placeholder: "Password", class: "form-control input-lg" %> +
+
<% if devise_mapping.rememberable? -%> -
<%= f.check_box :remember_me %> <%= f.label :remember_me %>
+ <% end -%> +
-
<%= f.submit "Sign in" %>
- - - <%= f.hidden_field :password, :value => 'password' %> -
<%= f.submit "Sign in as #{user.username}" %>
+ <%= f.hidden_field :username, :value => user.username%> + <%= f.hidden_field :password, :value => 'password' %> + <%= f.submit "Sign in as #{user.username}", class:"btn btn-info" %> <% end %> <% end %> +

<% end %> diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 1754796..0666f2e 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -2,14 +2,15 @@ %html{:lang => "en"} %head %meta{:charset => "utf-8"}/ + %meta{ name: "viewport", content: "width=device-width", "initial-scale" => "1.0"} %title= content_for?(:title) ? yield(:title) : "Velocipede" - = load_netzke = csrf_meta_tags + = stylesheet_link_tag "bootstrap_and_overrides", "datepicker", "bootstrap-timepicker", :media => "all" /[if lt IE 9] = javascript_include_tag "http://html5shim.googlecode.com/svn/trunk/html5.js" :css body { - padding-top: 60px; /* 60px to make the container go all the way to the bottom of the topbar */ + padding-top: 5px; } .x-boundlist-item { white-space: nowrap; @@ -24,11 +25,11 @@ - if flash[:alert] %p{:class => 'alert'}= flash[:alert] .row - .span13 + .span12 = yield %footer - %p © Velocipede 2013 + %p © BikeShed #{Time.now.year} = javascript_include_tag "application" = javascript_include_tag params[:controller] diff --git a/app/views/layouts/netzke.html.haml b/app/views/layouts/netzke.html.haml new file mode 100644 index 0000000..e7db693 --- /dev/null +++ b/app/views/layouts/netzke.html.haml @@ -0,0 +1,11 @@ +!!! 5 +%html{:lang => "en"} + %head + %meta{:charset => "utf-8"}/ + %title= content_for?(:title) ? yield(:title) : "Velocipede" + = load_netzke + = csrf_meta_tags + %body + = yield + = javascript_include_tag "application" + = javascript_include_tag params[:controller] diff --git a/app/views/site/index.html.haml b/app/views/site/index.html.haml new file mode 100644 index 0000000..239bcc8 --- /dev/null +++ b/app/views/site/index.html.haml @@ -0,0 +1,21 @@ += stylesheet_link_tag "bootstrap_and_overrides", :media => "all" +%h2 Dashboard + +%p + %p + %a{class: "btn btn-lg btn-block btn-primary", href: new_time_entry_path} Add Time Entry + %p + %a{class: "btn btn-lg btn-block btn-primary", href: time_entries_path} View Timesheet + %p + %a{class: "btn btn-lg btn-block btn-primary", href: new_bike_path} Add Bike + +- if !@bike.nil? + %p + %a{class: "btn btn-lg btn-block btn-primary", href: bike_path(@bike)} View Your Bike + + +%p + %p + %a{class: "btn btn-lg btn-block btn-primary hidden-xs", href: admin_index_path} Admin View + %p + %input{id: "index_logout", value: "Logout", type: "button", class: "btn btn-lg btn-block btn-danger", "data-url" => destroy_user_session_path } diff --git a/app/views/task_lists/edit.haml b/app/views/task_lists/edit.haml new file mode 100644 index 0000000..bc5a409 --- /dev/null +++ b/app/views/task_lists/edit.haml @@ -0,0 +1,16 @@ +%a{ class: "btn btn-default btn-lg", href: root_path} + %span{ class:"icon-home"} +%h2 Task List +%h3 + %a{ href: bike_path(@task_list.item)}#{@task_list.item.shop_id} #{@task_list.item.bike_brand} +%h4 #{@task_list.item.model} + +%p + .control-group + - @task_list.tasks.each do |task| + .controls + %input{class: "task_list_task", type: "checkbox", "data-id" => task.id, checked: task.done} + #{task.task} + .control-group + .controls + %input{id: "update_tasks_submit", value: "Save Changes", type: "button", class: "btn btn-lg btn-block btn-primary disabled", "data-url" => "#{api_update_task_path}"} diff --git a/app/views/time_entries/index.haml b/app/views/time_entries/index.haml new file mode 100644 index 0000000..1875d17 --- /dev/null +++ b/app/views/time_entries/index.haml @@ -0,0 +1,44 @@ +%a{ class: "btn btn-default btn-lg", href: root_path} + %span{ class:"icon-home"} +%h2 Your Timesheet + +%dl.dl-horizontal + %dd + %span.badge #{@hours_worked} + Total Hours Worked + %dd + %span.badge #{@credits_available} + Total Credits Available + +%table.table + %tbody + - @user_time_entries.each do |entry| + %tr{ "data-id" => entry.id, "data-description" => entry.description, + "data-duration" => entry.duration_in_hours, + "data-start_date" => entry.start_date.to_date.to_formatted_s(:rfc822)} + %td #{entry.start_date.to_date.to_formatted_s(:rfc822)} + %td #{entry.duration_in_hours} + %td #{entry.type} + %td #{truncate(entry.description)} + %td + %button{ class: "btn icon-remove btn-danger work_entry-delete-btn", role: "button", "data-toggle" => "modal", "data-target" => "#confirmation" } + +%a{class: "btn btn-lg btn-block btn-primary", href: new_time_entry_path } Add Time Entry + +.modal{ id: "confirmation", class: "hide", role: "dialog", "aria-labelledby" => "confirmation_title", "aria-hidden" => "true", tabindex: -1 } + .modal-header + %button{ class: "close", "data-dismiss" => "modal", "aria-hidden" => "true"}x + %h4{ id: "confirmation_title" } Are you sure you want to delete? + .modal-body + %div{ id: "work_entry_start_date" } + Start Date + %div{ id: "work_entry_duration" } + Duration + %div{ id: "work_entry_description" } + Description + .modal-footer + %button{ class: "btn", "data-dismiss" => "modal", "aria-hidden" => "true"} Close + %button{ id: "confirmation_delete", class: "btn btn-primary", "data-url" => "api/v1/time_entries/" } Delete + + + diff --git a/app/views/time_entries/new.haml b/app/views/time_entries/new.haml new file mode 100644 index 0000000..49a4eeb --- /dev/null +++ b/app/views/time_entries/new.haml @@ -0,0 +1,42 @@ +%a{ class: "btn btn-default btn-lg", href: root_path} + %span{ class:"icon-home"} +%h2 Add Time Entry + +%p + .control-group + .controls + %input{id: "date_id", placeholder: "Date", type: "text", class: "datepicker input-small" } + .help-block + .control-group + .controls{ class: "bootstrap-timepicker"} + %label Start + %input{id: "start_time_id", placeholder: "Time ID", type: "text", class: "input-small" } + .hidden{ id: "start_date" } + .help-block + .control-group + .controls + %label End + %input{id: "end_time_id", placeholder: "Time ID", type: "text", class: "input-small" } + .hidden{ id: "end_date" } + .help-block + .control-group + .controls + .btn-group{ "data-toggle" => "buttons-radio"} + %label{ class: "btn btn-default"} + %input{ type: "radio", name: "action_id", value: 1} Volunteer + %label{ class: "btn btn-default"} + %input{ type: "radio", name: "action_id", value: 2} Personal + %label{ class: "btn btn-default"} + %input{ type: "radio", name: "action_id", value: 3} Staff + .hidden{ id: "log_action_id" } + .help-block + .control-group + .controls + %label + = select_tag(:bike_id, options_for_select(@bikes)) + .control-group + .controls + %textarea{id: "description_id", placeholder: "Work description", class: "input-lg" } + .control-group + .controls + %input{id: "add_time_entry_submit", value: "Add Time Entry", type: "button", class: "btn btn-lg btn-block btn-primary", "data-url" => "#{api_create_time_entry_path}", "data-forward" => "#{"."}"} diff --git a/config/database.yml.example b/config/database.yml.example index ab246bd..de29ed3 100644 --- a/config/database.yml.example +++ b/config/database.yml.example @@ -10,7 +10,7 @@ development: # Do not set this db to the same as development or production. test: adapter: postgresql - database: velocipede + database: velocipede_test username: velocipede password: host: 127.0.0.1 diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 9ad09e1..814c010 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -222,11 +222,12 @@ Devise.setup do |config| # end end -#Check in the user if they sign in. (Devise uses Warden) -Warden::Manager.after_set_user do |user,auth,opts| - # this essentially gets called after every netzke request, but alas, - # only using the after_authenticaion callback doesn't get fired after - # user creation. - user.checkin unless user.checked_in? +unless Rails.env.test? + #Check in the user if they sign in. (Devise uses Warden) + Warden::Manager.after_set_user do |user,auth,opts| + # this essentially gets called after every netzke request, but alas, + # only using the after_authenticaion callback doesn't get fired after + # user creation. + user.checkin unless user.checked_in? + end end - diff --git a/config/routes.rb b/config/routes.rb index 1e005fb..8ef1a8d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,13 +4,34 @@ Velocipede::Application.routes.draw do netzke root :to => 'site#index' + get 'admin/index', to: 'panel#index', as: "admin_index" + + get 'bikes/new', to: 'bikes#new', as: "new_bike" + get 'bikes/:id', to: 'bikes#show', as: "bike" + + get 'task_lists/:id/edit' => "task_lists#edit", as: "edit_task_list" + + get 'time_entries' => "time_entries#index", as: "time_entries" + get 'time_entries/new' => "time_entries#new", as: "new_time_entry" + ########################### # API Routes - scope 'api', :module => :api do + scope 'api', :module => :api, defaults: {format: :json} do scope 'v1', :module => :v1 do - post 'checkin' => "logs#checkin", :as => "api_checkin" + post 'checkin' => "logs#checkin", :as => "api_checkin" post 'checkout' => "logs#checkout", :as => "api_checkout" - post 'reset' => "users#password_reset", :as => "api_password_reset" + + post 'reset' => "users#password_reset", :as => "api_password_reset" + + get 'bikes/:id' => "bikes#show", as: "api_bike" + post 'bikes/create' => "bikes#create", as: "api_create_bike" + + get 'task_lists/:id' => "task_lists#show", as: "api_task_list" + + put 'tasks/update' => "tasks#update", as: "api_update_task" + + post 'time_entries/create' => "time_entries#create", as: "api_create_time_entry" + delete 'time_entries/:id' => "time_entries#delete", as: "api_delete_time_entry" end end diff --git a/db/seed/fixtures/bike_styles.yml b/db/seed/fixtures/bike_styles.yml index 5673983..38d42a7 100644 --- a/db/seed/fixtures/bike_styles.yml +++ b/db/seed/fixtures/bike_styles.yml @@ -7,3 +7,6 @@ hybrid: road: id: 3 style: ROAD +other: + id: 4 + style: OTHER diff --git a/db/seeds.rb b/db/seeds.rb index e06aaf5..60e7917 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -41,7 +41,7 @@ if Rails.env.development? #create fake bikes if Bike.all.empty? 42.times do |n| - FactoryGirl.create(:bike) + FactoryGirl.create(:seed_bike) end end elsif Rails.env.production? diff --git a/spec/controllers/api/bikes_controller_spec.rb b/spec/controllers/api/bikes_controller_spec.rb new file mode 100644 index 0000000..f52dff0 --- /dev/null +++ b/spec/controllers/api/bikes_controller_spec.rb @@ -0,0 +1,134 @@ +require 'spec_helper' + +describe Api::V1::BikesController do + render_views + + describe "#create" do + context "as a user" do + before(:each) do + @user = FactoryGirl.create(:user) + sign_in @user + end + it "returns 403" do + post :create + expect(@response.code.to_i).to eql 403 + end + + it "returns an error message" do + post :create + json = JSON.parse(@response.body) + expect(json["errors"].first).to eql Api::V1::BikesController::CANNOT_MANAGE + end + end + + context "as a bike admin" do + before(:each) do + @user = FactoryGirl.create(:bike_admin) + sign_in @user + end + + context "with no bike in json data" do + it "returns error status" do + post :create + expect(@response.code.to_i).to eql 422 + end + + it "returns an error message" do + post :create + json = JSON.parse(@response.body) + expect(json["errors"].first).to eql Api::V1::BikesController::EXPECTED_BIKE + end + end + + context "with valid bike in json data" do + before(:each) do + bike_data = { bikes: [{ + serial_number: "XKCD", + bike_brand_id: 1, + shop_id: 1, + model: "Bike Model", + bike_style_id: 1, + seat_tube_height: 52, + bike_condition_id: 1, + bike_purpose_id: 1, + bike_wheel_size_id: 1, + }]} + #this is necessary because render_views does not work with sign_in devise helper + @submit_json = api_submit_json(@user, bike_data) + #not sure why format: :json not working + request.accept = 'application/json' + end + + it "returns 200" do + post :create, @submit_json + expect(@response.code.to_i).to eql 200 + end + + it "returns the created bike json" do + post :create, @submit_json + json = JSON.parse(@response.body) + expect(json).to have_key("bikes") + expect(json.to_s).to include(@submit_json[:bikes].first[:serial_number]) + end + end + + context "with invalid bike in json data" do + before(:each) do + @submit_json = { bikes: [{ + serial_number: "XKCD", + }]} + end + + it "returns 422" do + post :create, @submit_json + expect(@response.code.to_i).to eql 422 + end + + it "returns the fields with errors" do + post :create, @submit_json + json = JSON.parse(@response.body) + expect(json).to have_key("errors") + expect(json.to_s).to include("can't be blank") + end + end + end + end + + describe "#show" do + context "as a user" do + before(:each) do + @user = FactoryGirl.create(:user) + sign_in @user + end + + context "no bike exists" do + it "returns 404" do + get :show, id: 999 + expect(@response.code.to_i).to eql 404 + end + + it "returns an error message" do + get :show, id: 999 + json = JSON.parse(@response.body) + expect(json["errors"].first).to eql Api::V1::BikesController::NOT_FOUND + end + end + + context "a bike exists" do + let!(:bike){ FactoryGirl.create(:bike) } + + it "returns 200" do + get :show, id: bike.id, format: :json + expect(@response.code.to_i).to eql 200 + end + + it "returns the bike json" do + get :show, id: bike.id, format: :json + json = JSON.parse(@response.body) + expect(json).to have_key("bikes") + expect(json.to_s).to include(bike.serial_number) + end + end + end + end +end diff --git a/spec/controllers/api/logs_controller_spec.rb b/spec/controllers/api/logs_controller_spec.rb new file mode 100644 index 0000000..595cdb1 --- /dev/null +++ b/spec/controllers/api/logs_controller_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe Api::V1::LogsController do + + describe "#checkin" do + + before(:each) do + @user = FactoryGirl.create(:user) + sign_in @user + controller.stub(:current_user).and_return(@user) + end + + context "user is not checked in" do + before(:each) do + @user.stub(:checked_in?).and_return(false) + end + + it "returns 204" do + @user.stub(:checkin) + post :checkin + expect(@response.code.to_i).to eql 204 + end + + it "checks in a user" do + expect(@user).to receive(:checkin) + post :checkin + end + end + + context "user is already checked in" do + before(:each) do + @user.stub(:checked_in?).and_return(true) + end + + it "returns 404 if the user is already checked in" do + post :checkin + expect(@response.code.to_i).to eql 404 + end + end + end + + describe "#checkout" do + + before(:each) do + @user = FactoryGirl.create(:user) + sign_in @user + controller.stub(:current_user).and_return(@user) + end + + context "user is not checked in" do + before(:each) do + @user.stub(:checked_in?).and_return(false) + end + + it "returns 404" do + @user.stub(:checkin) + post :checkout + expect(@response.code.to_i).to eql 404 + end + end + + context "user is already checked in" do + before(:each) do + @user.stub(:checked_in?).and_return(true) + end + + it "returns 204" do + @user.stub(:checkout) + post :checkout + expect(@response.code.to_i).to eql 204 + end + + it "checks out a user" do + expect(@user).to receive(:checkout) + post :checkout + end + end + end +end diff --git a/spec/controllers/api/task_lists_controller_spec.rb b/spec/controllers/api/task_lists_controller_spec.rb new file mode 100644 index 0000000..60dfd33 --- /dev/null +++ b/spec/controllers/api/task_lists_controller_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe Api::V1::TaskListsController do + render_views + + describe "#show" do + context "as a user" do + before(:each) do + @user = FactoryGirl.create(:user) + sign_in @user + end + + context "task list exists" do + let!(:task_list){ FactoryGirl.create(:task_list) } + + it "returns 200" do + get :show, id: task_list.id, format: :json + expect(@response.code.to_i).to eql 200 + end + + it "returns valid task list json" do + get :show, id: task_list.id, format: :json + json = JSON.parse(@response.body) + expect(json.to_s).to include(task_list.name) + end + + it "returns task list with tasks json" do + get :show, id: task_list.id, format: :json + json = JSON.parse(@response.body) + expect(task_list.tasks.count).to be > 0 + task_list.tasks.each do |task| + expect(json.to_s).to include(task.task) + end + end + end + + context "task list does not exist" do + it "returns 404" do + get :show, id: 999, format: :json + expect(@response.code.to_i).to eql 404 + end + + it "returns an error message" do + get :show, id: 999, format: :json + json = JSON.parse(@response.body) + expect(json["errors"].first). + to eql Api::V1::TaskListsController::NOT_FOUND + end + end + end + end +end diff --git a/spec/controllers/api/tasks_controller.rb b/spec/controllers/api/tasks_controller.rb new file mode 100644 index 0000000..cc0e219 --- /dev/null +++ b/spec/controllers/api/tasks_controller.rb @@ -0,0 +1,100 @@ +require 'spec_helper' + +describe Api::V1::TasksController do + render_views + + describe "#update" do + context "as a user with a bike" do + before(:each) do + @user = FactoryGirl.create(:user_with_bike) + sign_in @user + end + + context "with no tasks in json data" do + it "returns 400" do + put :update + expect(@response.code.to_i).to eql 422 + end + + it "returns an error message" do + put :update + json = JSON.parse(@response.body) + expect(json["errors"].first).to eql Api::V1::TasksController::EXPECTED_TASKS + end + end + + context "with non existing task in data" do + before(:each) do + @submit_json = { tasks: [ + { id: 9999, done: false }, + ]} + end + + it "returns 404" do + put :update, @submit_json + expect(@response.code.to_i).to eql 404 + end + + it "returns an error message" do + put :update, @submit_json + json = JSON.parse(@response.body) + expect(json["errors"].to_s).to include(Api::V1::TasksController::NOT_FOUND) + end + end + + context "with valid tasks json data" do + before(:each) do + @task = FactoryGirl.create(:task, done: false, task_list_id: @user.bike.task_list.id) + task_data = { tasks: [ + { id: @task.id, done: true}, + ]} + #this is necessary because render_views does not work with sign_in devise helper + @submit_json = api_submit_json(@user, task_data) + #not sure why format: :json not working + request.accept = 'application/json' + end + + it "returns 200" do + put :update, @submit_json + expect(@response.code.to_i).to eql 200 + end + + it "returns the updated task json" do + put :update, @submit_json + json = JSON.parse(@response.body) + expect(json).to have_key("tasks") + expect(json.to_s).to include(Task.find_by_id(@task.id).done.to_s) + end + + it "updates the task" do + expect{put :update, @submit_json}. + to change{ Task.find_by_id(@task.id).done }. + from(false).to(true) + end + end + end + + context "as a user without a bike" do + before(:each) do + @user = FactoryGirl.create(:user, bike_id: nil) + sign_in @user + + task = FactoryGirl.create(:task) + @submit_json = { tasks: [ + { id: task.id, done: false }, + ]} + end + + it "should return 403" do + put :update, @submit_json + expect(@response.code.to_i).to eql 403 + end + + it "returns an error message" do + put :update, @submit_json + json = JSON.parse(@response.body) + expect(json["errors"].to_s).to include(Api::V1::TasksController::CANNOT_MANAGE) + end + end + end +end diff --git a/spec/controllers/api/time_entries_controller_spec.rb b/spec/controllers/api/time_entries_controller_spec.rb new file mode 100644 index 0000000..bd18b63 --- /dev/null +++ b/spec/controllers/api/time_entries_controller_spec.rb @@ -0,0 +1,117 @@ +require 'spec_helper' + +describe Api::V1::TimeEntriesController do + render_views + + describe "#create" do + + context "as a user" do + let!(:user){ FactoryGirl.create(:user) } + + before(:each) do + sign_in user + end + + context "with no time entry in json data" do + it "returns error status" do + post :create + json = JSON.parse(@response.body) + expect(@response.code.to_i).to eql 422 + end + + it "returns an error message" do + post :create + json = JSON.parse(@response.body) + expect(json["errors"].first).to eql Api::V1::TimeEntriesController::EXPECTED_TIME_ENTRY + end + end + + context "with valid time entry in json data" do + + before(:each) do + time_data = { time_entries: [{ + start_date: Time.zone.now, + end_date: Time.zone.now + 60, + log_action_id: 1, + bike_id: -1, + description: "My description"}] + } + + #this is necessary because render_views does not work with sign_in devise helper + @submit_json = api_submit_json(user, time_data) + #not sure why format: :json not working + request.accept = 'application/json' + end + + it "returns 200" do + post :create, @submit_json + expect(@response.code.to_i).to eql 200 + end + + it "returns the created time entry json" do + post :create, @submit_json + json = JSON.parse(@response.body) + expect(json).to have_key("time_entries") + expect(json.to_s).to include(@submit_json[:time_entries].first[:description]) + end + end + + context "with invalid time entry in json data" do + before(:each) do + @submit_json = { time_entries: [{ + description: "My description", + }]} + end + + it "returns 422" do + post :create, @submit_json + expect(@response.code.to_i).to eql 422 + end + + it "returns the fields with errors" do + post :create, @submit_json + json = JSON.parse(@response.body) + expect(json).to have_key("errors") + expect(json.to_s).to include("can't be blank") + end + end + end + end + + describe "#delete" do + context "as a user" do + let!(:user){ FactoryGirl.create(:user) } + + before(:each) do + sign_in user + end + + context "entry does not exist" do + it "returns 404" do + delete :delete, id: 9000 + expect(@response.code.to_i).to eql 404 + end + + it "returns not found" do + delete :delete, id: 9000 + json = JSON.parse(@response.body) + expect(json).to have_key("errors") + expect(json.to_s).to include("not found") + end + end + + context "entry exists" do + let!(:entry){ FactoryGirl.create(:time_entry) } + + it "deletes the time entry" do + expect{delete :delete, id: entry.id}.to change{TimeEntry.count} + end + + it "returns 204" do + delete :delete, id: entry.id + expect(@response.code.to_i).to eql 204 + end + end + end + end +end diff --git a/spec/controllers/api/users_controller_spec.rb b/spec/controllers/api/users_controller_spec.rb new file mode 100644 index 0000000..5748990 --- /dev/null +++ b/spec/controllers/api/users_controller_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +describe Api::V1::UsersController do + + describe "#password_reset" do + + context "as a user" do + before(:each) do + @user = FactoryGirl.create(:user) + sign_in @user + end + + it "returns 403" do + post :password_reset + expect(@response.code.to_i).to eql 403 + end + + it "returns an error message" do + post :password_reset + json = JSON.parse(@response.body) + expect(json["errors"].first).to eql Api::V1::UsersController::CANNOT_MANAGE + end + + end + + context "as an admin" do + before(:each) do + @user = FactoryGirl.create(:admin) + sign_in @user + end + + it "forbids a user to reset their own password" do + post :password_reset, user_id: @user.id + expect(@response.code.to_i).to eql 403 + json = JSON.parse(@response.body) + expect(json["errors"].first).to eql Api::V1::UsersController::NOT_ALLOWED + end + + context "with no user in json data" do + it "returns 404" do + post :password_reset + expect(@response.code.to_i).to eql 404 + end + + it "returns an error message" do + post :password_reset + json = JSON.parse(@response.body) + expect(json["errors"].first).to eql Api::V1::UsersController::NOT_FOUND + end + end + + context "another user exists" do + before(:each) do + @user2 = FactoryGirl.create(:user) + end + + it "returns 200" do + post :password_reset, user_id: @user2.id + expect(@response.code.to_i).to eql 200 + end + + it "returns that users new password" do + post :password_reset, user_id: @user2.id + json = JSON.parse(@response.body) + expect(json["password"].length).to eql Api::V1::UsersController::PASS_LENGTH + end + + end + + end + end +end diff --git a/spec/factories/bike_brands.rb b/spec/factories/bike_brands.rb new file mode 100644 index 0000000..9b2f2df --- /dev/null +++ b/spec/factories/bike_brands.rb @@ -0,0 +1,5 @@ +FactoryGirl.define do + factory :bike_brand do + brand {Faker::Commerce.product_name} + end +end diff --git a/spec/factories/bike_conditions.rb b/spec/factories/bike_conditions.rb new file mode 100644 index 0000000..fcb8b5e --- /dev/null +++ b/spec/factories/bike_conditions.rb @@ -0,0 +1,5 @@ +FactoryGirl.define do + factory :bike_condition do + condition "POOR" + end +end diff --git a/spec/factories/bike_purposes.rb b/spec/factories/bike_purposes.rb new file mode 100644 index 0000000..a854e0b --- /dev/null +++ b/spec/factories/bike_purposes.rb @@ -0,0 +1,5 @@ +FactoryGirl.define do + factory :bike_purpose do + purpose "SHOP" + end +end diff --git a/spec/factories/bike_styles.rb b/spec/factories/bike_styles.rb new file mode 100644 index 0000000..b1653e8 --- /dev/null +++ b/spec/factories/bike_styles.rb @@ -0,0 +1,5 @@ +FactoryGirl.define do + factory :bike_style do + style {Faker::Commerce.product_name} + end +end diff --git a/spec/factories/bike_wheel_sizes.rb b/spec/factories/bike_wheel_sizes.rb new file mode 100644 index 0000000..08bf418 --- /dev/null +++ b/spec/factories/bike_wheel_sizes.rb @@ -0,0 +1,12 @@ +FactoryGirl.define do + factory :bike_wheel_size do + twmm 40 + rdmm 635 + twin "1 1/2 [1 3/8]" + rdin 28 + twfr "38B [35B]" + rdfr 700 + description "a big wheel" + tire_common_score 1 + end +end diff --git a/spec/factories/bikes.rb b/spec/factories/bikes.rb index e2f04ea..18ea59f 100644 --- a/spec/factories/bikes.rb +++ b/spec/factories/bikes.rb @@ -2,6 +2,23 @@ FactoryGirl.define do factory :bike do + sequence(:shop_id) {|n| n} + sequence :serial_number do |n| + "#{Faker::Code.isbn}-#{n}" + end + bike_brand { FactoryGirl.create(:bike_brand) } + model { Faker::Commerce.product_name } + color "FFFFFF" + bike_style { FactoryGirl.create(:bike_style) } + seat_tube_height 42 + top_tube_length 42 + bike_wheel_size { FactoryGirl.create(:bike_wheel_size) } + value 200 + bike_condition { FactoryGirl.create(:bike_condition) } + bike_purpose { FactoryGirl.create(:bike_purpose) } + end + + factory :seed_bike do sequence(:shop_id) {|n| n} sequence :serial_number do |n| "#{Faker::Code.isbn}-#{n}" diff --git a/spec/factories/task_lists.rb b/spec/factories/task_lists.rb new file mode 100644 index 0000000..5b70366 --- /dev/null +++ b/spec/factories/task_lists.rb @@ -0,0 +1,7 @@ +FactoryGirl.define do + factory :task_list do + item_id { FactoryGirl.create(:bike).id } + item_type "Bike" + name { Faker::Lorem.words.join(" ")} + end +end diff --git a/spec/factories/tasks.rb b/spec/factories/tasks.rb new file mode 100644 index 0000000..1b83704 --- /dev/null +++ b/spec/factories/tasks.rb @@ -0,0 +1,7 @@ +FactoryGirl.define do + factory :task do + task_list { FactoryGirl.create(:task_list) } + done false + task { Faker::Lorem.words(7).join(" ")} + end +end diff --git a/spec/factories/time_entries.rb b/spec/factories/time_entries.rb new file mode 100644 index 0000000..64ff916 --- /dev/null +++ b/spec/factories/time_entries.rb @@ -0,0 +1,10 @@ +FactoryGirl.define do + factory :time_entry do + loggable_type User.to_s + logger_type User.to_s + start_date Time.now + end_date Time.now + log_action_id 1 + log_action_type ActsAsLoggable::UserAction.to_s + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index e29025d..b75147c 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -36,5 +36,9 @@ FactoryGirl.define do end end + factory :user_with_bike do + bike { FactoryGirl.create(:bike) } + end + end end diff --git a/spec/features/bikes/new_spec.rb b/spec/features/bikes/new_spec.rb new file mode 100644 index 0000000..639b0e4 --- /dev/null +++ b/spec/features/bikes/new_spec.rb @@ -0,0 +1,40 @@ +require "spec_helper" + +feature "Bikes" do + before(:each) do + # create brand and wheel size + @bike_brand = BikeBrand.create( brand: "Huffy") + @bike_wheel_size = BikeWheelSize.create( description: "MyWheelSize") + + @user = FactoryGirl.create(:bike_admin) + visit new_user_session_path + fill_in "user_username", with: @user.username + fill_in "user_password", with: @user.password + click_button "Sign in" + end + + scenario "User creates a new bike", js: true do + visit new_bike_path + fill_in "shop_id", with: 1 + fill_in "model", with: "Huffle Puffle" + fill_in "serial_number", with: "XKCD" + find('label.btn.btn-default', text: 'RD').click + find('label.btn.btn-default', text: 'Poor').click + fill_in "seat_tube_height", with: 52 + select @bike_brand.brand, from: "bike_brand_id" + select @bike_wheel_size.description, from: "bike_wheel_size_id" + click_button "Add Bike" + expect(page).to have_text(@bike_brand.brand) + end + + scenario "User submits a bike with errors", js: true do + visit new_bike_path + click_button "Add Bike" + expect(page).to have_text(:all, "is not a number") + expect(page).to have_text(:all, "is not a valid brand") + expect(page).to have_text(:all, "is too short") + expect(page).to have_text(:all, "is not a valid style") + expect(page).to have_text(:all, "is not a valid wheel size") + expect(page).to have_text(:all, "is not a valid condition") + end +end diff --git a/spec/requests/devise/registrations_spec.rb b/spec/features/devise/registrations_spec.rb similarity index 57% rename from spec/requests/devise/registrations_spec.rb rename to spec/features/devise/registrations_spec.rb index 7ed6a7c..45bde07 100644 --- a/spec/requests/devise/registrations_spec.rb +++ b/spec/features/devise/registrations_spec.rb @@ -1,10 +1,11 @@ require 'spec_helper' describe "New User Registrations" do + it 'should have a link to sign up on the homepage' do visit root_path - page.should have_link 'Register' - click_link 'Register' + page.should have_link 'Sign up' + click_link 'Sign up' current_path.should == new_user_registration_path end @@ -13,30 +14,29 @@ describe "New User Registrations" do visit new_user_registration_path end it 'should have the additional user fields on the registration page' do - page.should have_field 'First name' - page.should have_field 'Last name' - page.should have_field 'Nickname' - page.should have_field 'Email' - page.should have_field 'Password' - page.should have_field 'Password confirmation' + page.should have_field 'user_first_name' + page.should have_field 'user_last_name' + page.should have_field 'user_email' + page.should have_field 'user_password' + page.should have_field 'user_password_confirmation' page.should have_button 'Sign up' end context 'required non-devise fields' do before do - fill_in 'Email', :with => 'FF@example.com' - fill_in 'Password', :with => 'password' - fill_in 'Password confirmation', :with => 'password' + fill_in 'user_email', :with => 'FF@example.com' + fill_in 'user_password', :with => 'password' + fill_in 'user_password_confirmation', :with => 'password' end it 'should require first name' do - fill_in 'Last name', :with => 'Footer' + fill_in 'user_last_name', :with => 'Footer' click_button 'Sign up' page.should have_content "First name can't be blank" end it 'should require last name' do - fill_in 'First name', :with => 'Frank' + fill_in 'user_first_name', :with => 'Frank' click_button 'Sign up' page.should have_content "Last name can't be blank" end diff --git a/spec/features/site/index_spec.rb b/spec/features/site/index_spec.rb new file mode 100644 index 0000000..e341a43 --- /dev/null +++ b/spec/features/site/index_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe "Index Page" do + + before(:each) do + @user = FactoryGirl.create(:bike_admin) + visit root_path + fill_in "user_username", with: @user.username + fill_in "user_password", with: @user.password + end + + it 'should have a link to check in' do + page.should have_button 'CHECK IN' + end + + it 'should have a link to check out' do + page.should have_button 'CHECK OUT' + end + + it 'clicking check in should check in a user', js: true do + pending + expect{click_button 'CHECK IN'}. + to change{@user.checked_in?}. + from(false).to(true) + end + + it 'clicking check out should check out a user', js: true do + pending + @user.checkin + expect{click_button 'CHECK OUT'}. + to change{@user.checked_in?}. + from(true).to(false) + end +end diff --git a/spec/features/time_entries/index_spec.rb b/spec/features/time_entries/index_spec.rb new file mode 100644 index 0000000..82d89ef --- /dev/null +++ b/spec/features/time_entries/index_spec.rb @@ -0,0 +1,22 @@ +require "spec_helper" + +feature "TimeEntries" do + let!(:user){ FactoryGirl.create(:user) } + let!(:entry){ FactoryGirl.create(:time_entry, loggable_id: user.id) } + + before(:each) do + visit new_user_session_path + fill_in "user_username", with: user.username + fill_in "user_password", with: user.password + click_button "Sign in" + end + + scenario "User deletes a time entry", js: true do + visit time_entries_path + save_screenshot("/tmp/testingpoop.png") + find('button.work_entry-delete-btn').trigger('click') + click_button "Delete" + expect(page).to have_text("Your Timesheet") + expect(TimeEntry.count).to eql 0 + end +end diff --git a/spec/features/time_entries/new_spec.rb b/spec/features/time_entries/new_spec.rb new file mode 100644 index 0000000..eac3280 --- /dev/null +++ b/spec/features/time_entries/new_spec.rb @@ -0,0 +1,48 @@ +require "spec_helper" + +feature "TimeEntries" do + let!(:user){ FactoryGirl.create(:user) } + + before(:each) do + visit new_user_session_path + fill_in "user_username", with: user.username + fill_in "user_password", with: user.password + click_button "Sign in" + end + + scenario "User creates a new time entry", js: true do + visit new_time_entry_path + fill_in "date_id", with: "04/27/2014" + fill_in "start_time_id", with: "3:00 PM" + fill_in "end_time_id", with: "3:15 PM" + find('label.btn.btn-default', text: 'Personal').trigger('click') + fill_in "description_id", with: "My Description" + click_button "Add Time Entry" + expect(page).to have_text("My Description") + expect(TimeEntry.count).to be > 0 + end + + context "A bike exists" do + let!(:bike){ FactoryGirl.create(:bike) } + + scenario "User creates a new bike work time entry", js: true do + visit new_time_entry_path + fill_in "date_id", with: "04/27/2014" + fill_in "start_time_id", with: "3:00 PM" + fill_in "end_time_id", with: "3:15 PM" + find('label.btn.btn-default', text: 'Personal').trigger('click') + select bike.to_s, from: "bike_id" + fill_in "description_id", with: "My Description" + click_button "Add Time Entry" + expect(page).to have_text("Task List") + expect(TimeEntry.count).to be > 0 + end + end + + scenario "User submits a time entry with errors", js: true do + visit new_time_entry_path + click_button "Add Time Entry" + expect(page).to have_text(:all, "can't be blank") + end + +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b434b1b..669eb41 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,7 @@ require 'rubygems' require 'spork' require 'rails' +require 'capybara/poltergeist' #uncomment the following line to use spork with the debugger #require 'spork/ext/ruby-debug' @@ -19,12 +20,13 @@ Spork.prefork do require 'rspec/rails' require 'rspec/autorun' require 'capybara/rspec' - require 'turnip/capybara' # Requires supporting ruby files with custom matchers and macros, etc, # in spec/support/ and its subdirectories. Dir[Rails.root.join("spec/support/**/*.rb")].each {|f| require f} + Capybara.javascript_driver = :poltergeist + RSpec.configure do |config| # ## Mock Framework # @@ -66,19 +68,19 @@ Spork.prefork do config.infer_base_class_for_anonymous_controllers = false config.include FactoryGirlStepHelpers config.include FactoryGirl::Syntax::Methods + config.include ApiTestHelpers + config.include Devise::TestHelpers, type: :controller #allows :focus tag to only run specific tests config.treat_symbols_as_metadata_keys_with_true_values = true config.filter_run :focus => true config.run_all_when_everything_filtered = true - end end Spork.each_run do # This code will be run each time you run your specs. - #Reload Factories #FYI make sure that Factories use strings instead of class constants FactoryGirl.reload diff --git a/spec/support/api_test_helpers.rb b/spec/support/api_test_helpers.rb new file mode 100644 index 0000000..4300a9c --- /dev/null +++ b/spec/support/api_test_helpers.rb @@ -0,0 +1,5 @@ +module ApiTestHelpers + def api_submit_json(user, json_hash) + json_hash.merge({username: user.username, password: user.password}) + end +end