From adeedb2f1b69b16fa7ea19707ad42ff48df07db7 Mon Sep 17 00:00:00 2001 From: Benjamin ter Kuile Date: Thu, 19 Feb 2015 20:37:58 +0100 Subject: [PATCH] Basic fullcalendar integration --- .../modals/base_controller.js.coffee | 1 + .../modals/employee_edit.js.coffee | 21 + .../select_employee_controller.js.coffee | 7 + .../controllers/schedule_controller.js.coffee | 6 + .../supplier/app/helpers/colorbox.js.coffee | 2 + .../app/models/employee-shift.js.coffee | 12 + .../supplier/app/models/employee.js.coffee | 2 + .../javascripts/supplier/app/router.js.coffee | 1 + .../app/routes/employees_route.js.coffee | 2 +- .../app/routes/schedule_route.js.coffee | 2 + .../app/templates/employees/index.emblem | 6 + .../app/templates/global/_top_menu.emblem | 2 + .../app/templates/modals/employee_edit.emblem | 14 + .../templates/modals/select_employee.emblem | 7 + .../supplier/app/templates/schedule.emblem | 2 + .../supplier/app/views/schedule.js.coffee | 30 + .../supplier/foundation1/application.js.erb | 1 + .../supplier/foundation1/_qlists.css.sass | 4 + .../supplier/foundation1/application.css.sass | 1 + .../foundation1/components/_colorbox.css.sass | 22 + .../components/_select_employee.css.sass | 11 + .../suppliers/employee_shifts_controller.rb | 24 + .../suppliers/employees_controller.rb | 5 +- app/models/employee.rb | 4 +- app/models/employee_shift.rb | 9 + app/models/supplier.rb | 1 + .../supplier_employees_settings.rb | 27 +- .../suppliers/employee_serializer.rb | 2 +- .../suppliers/employee_shift_serializer.rb | 5 + .../suppliers/supplier_serializer.rb | 1 + config/locales/supplier.en.yml | 1 + config/routes.rb | 1 + vendor/assets/fullcalendar/fullcalendar.css | 977 ++ vendor/assets/fullcalendar/fullcalendar.js | 9813 +++++++++++++++++ 34 files changed, 11018 insertions(+), 8 deletions(-) create mode 100644 app/assets/javascripts/supplier/app/controllers/modals/employee_edit.js.coffee create mode 100644 app/assets/javascripts/supplier/app/controllers/modals/select_employee_controller.js.coffee create mode 100644 app/assets/javascripts/supplier/app/controllers/schedule_controller.js.coffee create mode 100644 app/assets/javascripts/supplier/app/helpers/colorbox.js.coffee create mode 100644 app/assets/javascripts/supplier/app/models/employee-shift.js.coffee create mode 100644 app/assets/javascripts/supplier/app/routes/schedule_route.js.coffee create mode 100644 app/assets/javascripts/supplier/app/templates/modals/select_employee.emblem create mode 100644 app/assets/javascripts/supplier/app/templates/schedule.emblem create mode 100644 app/assets/javascripts/supplier/app/views/schedule.js.coffee create mode 100644 app/assets/stylesheets/supplier/foundation1/components/_colorbox.css.sass create mode 100644 app/assets/stylesheets/supplier/foundation1/components/_select_employee.css.sass create mode 100644 app/controllers/suppliers/employee_shifts_controller.rb create mode 100644 app/models/employee_shift.rb create mode 100644 app/serializers/suppliers/employee_shift_serializer.rb create mode 100644 vendor/assets/fullcalendar/fullcalendar.css create mode 100644 vendor/assets/fullcalendar/fullcalendar.js diff --git a/app/assets/javascripts/supplier/app/controllers/modals/base_controller.js.coffee b/app/assets/javascripts/supplier/app/controllers/modals/base_controller.js.coffee index 15d97dec..4ddf5c8a 100644 --- a/app/assets/javascripts/supplier/app/controllers/modals/base_controller.js.coffee +++ b/app/assets/javascripts/supplier/app/controllers/modals/base_controller.js.coffee @@ -1,4 +1,5 @@ @App.modals.BaseController = Ember.ObjectController.extend + needs: ['application'] alert_message: "" title: (-> # return title if directly set by options diff --git a/app/assets/javascripts/supplier/app/controllers/modals/employee_edit.js.coffee b/app/assets/javascripts/supplier/app/controllers/modals/employee_edit.js.coffee new file mode 100644 index 00000000..216172d2 --- /dev/null +++ b/app/assets/javascripts/supplier/app/controllers/modals/employee_edit.js.coffee @@ -0,0 +1,21 @@ +App.modals.EmployeeEditController = App.modals.BaseController.extend + isNotSelf: (-> + @get('model.id') isnt @get('current_employee.id') + ).property('model.id') + + colors: (-> + # taken from http://www.somacon.com/p142.php + [ + '#458B74' + '#838B8B' + '#8B7D6B' + '#00008B' + '#8B2323' + '#8A2BE2' + '#458B00' + '#EEAD0E' + ] + ).property() + actions: + setColor: (color)-> + @set 'model.color', color diff --git a/app/assets/javascripts/supplier/app/controllers/modals/select_employee_controller.js.coffee b/app/assets/javascripts/supplier/app/controllers/modals/select_employee_controller.js.coffee new file mode 100644 index 00000000..6425a965 --- /dev/null +++ b/app/assets/javascripts/supplier/app/controllers/modals/select_employee_controller.js.coffee @@ -0,0 +1,7 @@ +App.modals.SelectEmployeeController = App.modals.BaseController.extend + employee: null + employees: (-> @store.all 'employee').property() + actions: + selectEmployee: (employee)-> + @set 'employee', employee + @send 'ok' diff --git a/app/assets/javascripts/supplier/app/controllers/schedule_controller.js.coffee b/app/assets/javascripts/supplier/app/controllers/schedule_controller.js.coffee new file mode 100644 index 00000000..0451802c --- /dev/null +++ b/app/assets/javascripts/supplier/app/controllers/schedule_controller.js.coffee @@ -0,0 +1,6 @@ +App.ScheduleController = Ember.ArrayController.extend + event_changed: (event)-> + @store.find('employee-shift', event.id).then (employee_shift)-> + employee_shift.set 'start_on', event.start.toDate() + employee_shift.set 'end_on', event.end.toDate() + employee_shift.save() diff --git a/app/assets/javascripts/supplier/app/helpers/colorbox.js.coffee b/app/assets/javascripts/supplier/app/helpers/colorbox.js.coffee new file mode 100644 index 00000000..4b830d95 --- /dev/null +++ b/app/assets/javascripts/supplier/app/helpers/colorbox.js.coffee @@ -0,0 +1,2 @@ +Ember.Handlebars.helper 'colorbox', (color, options)-> + "".htmlSafe() diff --git a/app/assets/javascripts/supplier/app/models/employee-shift.js.coffee b/app/assets/javascripts/supplier/app/models/employee-shift.js.coffee new file mode 100644 index 00000000..d002d91b --- /dev/null +++ b/app/assets/javascripts/supplier/app/models/employee-shift.js.coffee @@ -0,0 +1,12 @@ +attr = DS.attr +App.EmployeeShift = DS.Model.extend + start_on: attr('date') + end_on: attr('date') + employee: DS.belongsTo 'employee' + calendar_event: (-> + id: @id + title: @get('employee.name') + start: @get('start_on') + end: @get('end_on') + color: @get('employee.color') + ).property('start_on', 'end_on') diff --git a/app/assets/javascripts/supplier/app/models/employee.js.coffee b/app/assets/javascripts/supplier/app/models/employee.js.coffee index 4b669a04..367e06a3 100644 --- a/app/assets/javascripts/supplier/app/models/employee.js.coffee +++ b/app/assets/javascripts/supplier/app/models/employee.js.coffee @@ -4,6 +4,8 @@ App.Employee= DS.Model.extend Ember.Validations.Mixin, email: attr 'string' manager: attr 'boolean', defaultValue: false active: attr 'boolean', defaultValue: true + color: attr 'string', defaultValue: '#3a87ad' + employee_shifts: DS.hasMany('employee-shift') validations: name: {presence: true} diff --git a/app/assets/javascripts/supplier/app/router.js.coffee b/app/assets/javascripts/supplier/app/router.js.coffee index 1b439f15..a0536ea4 100644 --- a/app/assets/javascripts/supplier/app/router.js.coffee +++ b/app/assets/javascripts/supplier/app/router.js.coffee @@ -17,5 +17,6 @@ App.Router.map -> @route 'orders_display' # chromecast etc @route 'menu' @route 'settings' + @route 'schedule' @route 'empty' #@resource 'lists', queryParams: ['state'] diff --git a/app/assets/javascripts/supplier/app/routes/employees_route.js.coffee b/app/assets/javascripts/supplier/app/routes/employees_route.js.coffee index a386c344..7b00a783 100644 --- a/app/assets/javascripts/supplier/app/routes/employees_route.js.coffee +++ b/app/assets/javascripts/supplier/app/routes/employees_route.js.coffee @@ -1,2 +1,2 @@ App.EmployeesRoute = Ember.Route.extend - model: -> @store.find 'employee' + model: -> @store.all 'employee' diff --git a/app/assets/javascripts/supplier/app/routes/schedule_route.js.coffee b/app/assets/javascripts/supplier/app/routes/schedule_route.js.coffee new file mode 100644 index 00000000..784f0541 --- /dev/null +++ b/app/assets/javascripts/supplier/app/routes/schedule_route.js.coffee @@ -0,0 +1,2 @@ +App.ScheduleRoute = Ember.Route.extend + model: -> @store.find 'employee-shift' diff --git a/app/assets/javascripts/supplier/app/templates/employees/index.emblem b/app/assets/javascripts/supplier/app/templates/employees/index.emblem index 31d1f807..564e5769 100644 --- a/app/assets/javascripts/supplier/app/templates/employees/index.emblem +++ b/app/assets/javascripts/supplier/app/templates/employees/index.emblem @@ -6,6 +6,9 @@ tr th.name=t 'attributes.employee.name' th.email=t 'attributes.employee.email' + th.boolean= t 'attributes.employee.manager' + th.boolean= t 'attributes.employee.active' + th.colorbox= t 'attributes.employee.color' th.actions=t 'helpers.actions.title' tbody each employee in employees @@ -14,6 +17,9 @@ td.email = employee.email = errors employee.errors.email + td.boolean= boolean employee.manager + td.boolean= boolean employee.active + td.colorbox= colorbox employee.color td.actions can manage sections a.table-edit{ action 'editEmployee' employee }: span diff --git a/app/assets/javascripts/supplier/app/templates/global/_top_menu.emblem b/app/assets/javascripts/supplier/app/templates/global/_top_menu.emblem index 6bcdaf19..5cc14e3d 100644 --- a/app/assets/javascripts/supplier/app/templates/global/_top_menu.emblem +++ b/app/assets/javascripts/supplier/app/templates/global/_top_menu.emblem @@ -15,6 +15,8 @@ header.top-menu = t 'models.plural.list' = link-to "employees" class="top-menu-employees" = t 'models.plural.employee' + = link-to "schedule" class="top-menu-schedule" + = t 'supplier.top_menu.schedule' .extra-info{action "showSupplierStatusInfo"} .supplier-info-row .counter.supplier-orders-placed-count diff --git a/app/assets/javascripts/supplier/app/templates/modals/employee_edit.emblem b/app/assets/javascripts/supplier/app/templates/modals/employee_edit.emblem index f14d57bf..ae9bfff6 100644 --- a/app/assets/javascripts/supplier/app/templates/modals/employee_edit.emblem +++ b/app/assets/javascripts/supplier/app/templates/modals/employee_edit.emblem @@ -9,6 +9,20 @@ p=t 'employee.modal.body_header' .form-field = input type="email" valueBinding="model.email" action="save" = errors model.errors.email +if isNotSelf + .form-row.active + .form-label= t 'attributes.employee.manager' + .form-field= view "boolean-switch" value=model.manager + .form-row.active + .form-label= t 'attributes.employee.active' + .form-field= view "boolean-switch" value=model.active + +.form-row.active + .form-label= t 'attributes.employee.color' + .form-field.full + span.current-color= colorbox model.color + each color in colors + a{action "setColor" color}= colorbox color hr button.modal-close{action "close"}=t 'employee.modal.close_button' button.modal-confirm.right{action "save"} disabled=model.isInvalid diff --git a/app/assets/javascripts/supplier/app/templates/modals/select_employee.emblem b/app/assets/javascripts/supplier/app/templates/modals/select_employee.emblem new file mode 100644 index 00000000..1b12cd54 --- /dev/null +++ b/app/assets/javascripts/supplier/app/templates/modals/select_employee.emblem @@ -0,0 +1,7 @@ +p Select employee +ul.select-employees + each employee in employees + li.select-employee + = employee.name + a.employee-selector{action "selectEmployee" employee}: span +button.modal-close{action "close"}=t 'employee.select_modal.close_button' diff --git a/app/assets/javascripts/supplier/app/templates/schedule.emblem b/app/assets/javascripts/supplier/app/templates/schedule.emblem new file mode 100644 index 00000000..752bdeac --- /dev/null +++ b/app/assets/javascripts/supplier/app/templates/schedule.emblem @@ -0,0 +1,2 @@ +.row: .small-12.columns + #schedule-placeholder diff --git a/app/assets/javascripts/supplier/app/views/schedule.js.coffee b/app/assets/javascripts/supplier/app/views/schedule.js.coffee new file mode 100644 index 00000000..6b211cec --- /dev/null +++ b/app/assets/javascripts/supplier/app/views/schedule.js.coffee @@ -0,0 +1,30 @@ +App.ScheduleView = Ember.View.extend + classNames: ['schedule-view'] + didInsertElement: -> + placeholder = @$('#schedule-placeholder') + controller = @get('controller') + events = controller.get('model').map (employee_shift)->employee_shift.get('calendar_event') + placeholder.fullCalendar + header: + left: 'prev,next,today' + center: 'title' + right: 'month,agendaWeek,agendaDay' + selectable: true + selectHelper: true + select: (start, end)=> + controller.modal 'select_employee', + ok: -> + # this context is SelectEmployeeController + if employee = @get('employee') + shift = controller.store.createRecord 'employee-shift', start_on: start.toDate(), end_on: end.toDate() + shift.set 'employee', employee + shift.save() + placeholder.fullCalendar('renderEvent', shift.get('calendar_event'), true) + editable: true + defaultView: 'agendaWeek' + events: events + timezone: 'UTC' + eventDrop: controller.event_changed.bind(controller) + eventResize: controller.event_changed.bind(controller) + + diff --git a/app/assets/javascripts/supplier/foundation1/application.js.erb b/app/assets/javascripts/supplier/foundation1/application.js.erb index 80005ed4..b3121e8b 100644 --- a/app/assets/javascripts/supplier/foundation1/application.js.erb +++ b/app/assets/javascripts/supplier/foundation1/application.js.erb @@ -6,6 +6,7 @@ // require foundation FOUNDATION 5 JAVASCRIPT IMPLEMENTATIONS AND EMBER ARE NOT COMPATIBLE, FOUNDATION IS TOO SIMPLISTIC AT THE MOMENT AND DESTROYS DOM EVENTS //= require js-routes //= require moment +//= require fullcalendar //= require translations // require qwaiter // require ./qsupplier diff --git a/app/assets/stylesheets/supplier/foundation1/_qlists.css.sass b/app/assets/stylesheets/supplier/foundation1/_qlists.css.sass index 1a1612c4..58d35d9c 100644 --- a/app/assets/stylesheets/supplier/foundation1/_qlists.css.sass +++ b/app/assets/stylesheets/supplier/foundation1/_qlists.css.sass @@ -1,5 +1,7 @@ td.boolean +table-fit + .boolean-true + @extend .fa, .fa-check &.needs_help .boolean-true @extend .fa @@ -8,6 +10,8 @@ td.boolean .boolean-true @extend .fa @extend .fa-money +td.colorbox + +table-fit .list-orders-container .currency float: right diff --git a/app/assets/stylesheets/supplier/foundation1/application.css.sass b/app/assets/stylesheets/supplier/foundation1/application.css.sass index bace7c1c..40c31a11 100644 --- a/app/assets/stylesheets/supplier/foundation1/application.css.sass +++ b/app/assets/stylesheets/supplier/foundation1/application.css.sass @@ -1,6 +1,7 @@ //= require qtip //= require_directory ../base1-shared //= require pickdate +//= require fullcalendar @import bourbon @import ./qconstants @import ./foundation_and_overrides diff --git a/app/assets/stylesheets/supplier/foundation1/components/_colorbox.css.sass b/app/assets/stylesheets/supplier/foundation1/components/_colorbox.css.sass new file mode 100644 index 00000000..407ccc5a --- /dev/null +++ b/app/assets/stylesheets/supplier/foundation1/components/_colorbox.css.sass @@ -0,0 +1,22 @@ +.colorbox-container + line-height: 1px + display: inline-block + span + display: inline-block +.form-field + .current-color + .colorbox-container + padding: 4px + border: 2px solid #777 + margin-right: 16px + span + width: 32px + height: 32px + .colorbox-container + margin-right: 8px + span + width: 32px + height: 32px +td .colorbox-container span + width: 16px + height: 16px diff --git a/app/assets/stylesheets/supplier/foundation1/components/_select_employee.css.sass b/app/assets/stylesheets/supplier/foundation1/components/_select_employee.css.sass new file mode 100644 index 00000000..d761e8fe --- /dev/null +++ b/app/assets/stylesheets/supplier/foundation1/components/_select_employee.css.sass @@ -0,0 +1,11 @@ +ul.select-employees + list-style: none + li + padding-bottom: 7px + border-bottom: 1px solid #ccc + margin-bottom: 7px + +clearfix + .employee-selector + float: right + span + @extend .fa, .fa-2x, .fa-user-plus diff --git a/app/controllers/suppliers/employee_shifts_controller.rb b/app/controllers/suppliers/employee_shifts_controller.rb new file mode 100644 index 00000000..d4133e4c --- /dev/null +++ b/app/controllers/suppliers/employee_shifts_controller.rb @@ -0,0 +1,24 @@ +module Suppliers + class EmployeeShiftsController < Suppliers::ApplicationController + def index + render json: current_supplier.employee_shifts, each_serializer: Suppliers::EmployeeShiftSerializer + end + def create + @employee_shift.supplier = current_supplier + @employee_shift.save + render json: @employee_shift, serializer: Suppliers::EmployeeShiftSerializer + end + + def update + @employee_shift.update employee_shift_params + render json: @employee_shift, serializer: Suppliers::EmployeeShiftSerializer + end + + private + + def employee_shift_params + params.require(:employee_shift).permit(:start_on, :end_on, :employee_id) + end + + end +end diff --git a/app/controllers/suppliers/employees_controller.rb b/app/controllers/suppliers/employees_controller.rb index 21848b51..65692eb7 100644 --- a/app/controllers/suppliers/employees_controller.rb +++ b/app/controllers/suppliers/employees_controller.rb @@ -32,7 +32,8 @@ module Suppliers # PUT /employees/1.json def update @employee = Employee.find(params[:id]) - render json: {}, status: 404 unless @employee.supplier_id == current_supplier.id + render json: {}, status: 404 unless current_supplier.employee_ids.include?(@employee.id) + current_supplier.settings_for(@employee).update!(employee_params) respond_to do |format| if @employee.update_attributes(employee_params) format.json { head :no_content } @@ -57,7 +58,7 @@ module Suppliers private def employee_params - params.require(:employee).permit(:name, :email) + params.require(:employee).permit(:name, :email, :active, :manager, :color) end end end diff --git a/app/models/employee.rb b/app/models/employee.rb index ce3067bf..cd5586f6 100644 --- a/app/models/employee.rb +++ b/app/models/employee.rb @@ -3,7 +3,8 @@ class Employee include ActiveModel::SerializerSupport DEFAULT_SETTINGS = { 'manager' => false, - 'active' => true + 'active' => true, + 'color' => '#3a87ad' } attr_accessor *DEFAULT_SETTINGS.keys attr_reader :settings @@ -15,6 +16,7 @@ class Employee property :name #property :email + has_many :employee_shifts view :by_email, key: :email class << self diff --git a/app/models/employee_shift.rb b/app/models/employee_shift.rb new file mode 100644 index 00000000..0b98ff79 --- /dev/null +++ b/app/models/employee_shift.rb @@ -0,0 +1,9 @@ +class EmployeeShift + include SimplyStored::Couch + include ActiveModel::SerializerSupport + + property :start_on, type: Time + property :end_on, type: Time + belongs_to :supplier + belongs_to :employee +end diff --git a/app/models/supplier.rb b/app/models/supplier.rb index e557187c..a9511627 100644 --- a/app/models/supplier.rb +++ b/app/models/supplier.rb @@ -41,6 +41,7 @@ class Supplier has_many :orders, dependent: :destroy has_many :sections, dependent: :destroy has_and_belongs_to_many :employees, storing_keys: true + has_many :employee_shifts alias_method :non_enriced_employees, :employees def employees diff --git a/app/serializers/supplier_employees_settings.rb b/app/serializers/supplier_employees_settings.rb index f9941bec..4e2bf604 100644 --- a/app/serializers/supplier_employees_settings.rb +++ b/app/serializers/supplier_employees_settings.rb @@ -24,7 +24,7 @@ class SupplierEmployeesSettings end end - def persist + def to_store! supplier.employee_settings_storage = dictionary end @@ -42,7 +42,7 @@ class SupplierEmployeesSettings alias_method :orig_setter, :[]= def []=(*args) orig_setter(*args) - all_employees_settings.persist + all_employees_settings.to_store! end end @@ -74,7 +74,7 @@ class SupplierEmployeesSettings if employee = all_employees_settings.supplier.employees.find{|e| e.id == id} employee.public_send("#{attribute}=", value) end - all_employees_settings.supplier.save if persist + persist! if persist self end @@ -86,6 +86,27 @@ class SupplierEmployeesSettings set attribute, value, persist: true end + def update(params) + Employee::DEFAULT_SETTINGS.keys.each do |attribute| + if params.has_key?(attribute) + new_value = params.delete(attribute) + set attribute, new_value + end + end + params + end + + def update!(params) + update params + persist! + params + end + + def persist! + all_employees_settings.supplier.is_dirty + all_employees_settings.supplier.save + end + # Parse a method name to its underlying operations # settings.is_manager? # is a getter for the attribute manager diff --git a/app/serializers/suppliers/employee_serializer.rb b/app/serializers/suppliers/employee_serializer.rb index fb67404e..8a9c212f 100644 --- a/app/serializers/suppliers/employee_serializer.rb +++ b/app/serializers/suppliers/employee_serializer.rb @@ -1,5 +1,5 @@ class Suppliers::EmployeeSerializer < Qwaiter::Serializer self.root = :employee embed :ids, include: true - attributes :name, :email, :manager, :active + attributes :name, :email, :manager, :active, :color end diff --git a/app/serializers/suppliers/employee_shift_serializer.rb b/app/serializers/suppliers/employee_shift_serializer.rb new file mode 100644 index 00000000..0fe4927c --- /dev/null +++ b/app/serializers/suppliers/employee_shift_serializer.rb @@ -0,0 +1,5 @@ +class Suppliers::EmployeeShiftSerializer < Qwaiter::Serializer + self.root = :employee_shift + #embed :ids, include: true + attributes :start_on, :end_on, :employee_id +end diff --git a/app/serializers/suppliers/supplier_serializer.rb b/app/serializers/suppliers/supplier_serializer.rb index 7a3dbeca..8f6e9aad 100644 --- a/app/serializers/suppliers/supplier_serializer.rb +++ b/app/serializers/suppliers/supplier_serializer.rb @@ -9,4 +9,5 @@ class Suppliers::SupplierSerializer < Qwaiter::Serializer end has_many :sections, serializer: Suppliers::ExtendedSectionSerializer has_many :product_categories + has_many :employees, serializer: Suppliers::EmployeeSerializer end diff --git a/config/locales/supplier.en.yml b/config/locales/supplier.en.yml index 1973e282..309d675b 100644 --- a/config/locales/supplier.en.yml +++ b/config/locales/supplier.en.yml @@ -21,6 +21,7 @@ en: no_orders: No active orders top_menu: menu: Menu + schedule: Schedule active_lists: title: Active lists price: Price diff --git a/config/routes.rb b/config/routes.rb index 3af711b6..45603413 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -134,6 +134,7 @@ Qwaiter::Application.routes.draw do end resources :employees + resources :employee_shifts resources :products do collection do get :preview_products diff --git a/vendor/assets/fullcalendar/fullcalendar.css b/vendor/assets/fullcalendar/fullcalendar.css new file mode 100644 index 00000000..f97c7101 --- /dev/null +++ b/vendor/assets/fullcalendar/fullcalendar.css @@ -0,0 +1,977 @@ +/*! + * FullCalendar v2.2.7 Stylesheet + * Docs & License: http://arshaw.com/fullcalendar/ + * (c) 2013 Adam Shaw + */ + + +.fc { + direction: ltr; + text-align: left; +} + +.fc-rtl { + text-align: right; +} + +body .fc { /* extra precedence to overcome jqui */ + font-size: 1em; +} + + +/* Colors +--------------------------------------------------------------------------------------------------*/ + +.fc-unthemed th, +.fc-unthemed td, +.fc-unthemed hr, +.fc-unthemed thead, +.fc-unthemed tbody, +.fc-unthemed .fc-row, +.fc-unthemed .fc-popover { + border-color: #ddd; +} + +.fc-unthemed .fc-popover { + background-color: #fff; +} + +.fc-unthemed hr, +.fc-unthemed .fc-popover .fc-header { + background: #eee; +} + +.fc-unthemed .fc-popover .fc-header .fc-close { + color: #666; +} + +.fc-unthemed .fc-today { + background: #fcf8e3; +} + +.fc-highlight { /* when user is selecting cells */ + background: #bce8f1; + opacity: .3; + filter: alpha(opacity=30); /* for IE */ +} + +.fc-bgevent { /* default look for background events */ + background: rgb(143, 223, 130); + opacity: .3; + filter: alpha(opacity=30); /* for IE */ +} + +.fc-nonbusiness { /* default look for non-business-hours areas */ + /* will inherit .fc-bgevent's styles */ + background: #ccc; +} + + +/* Icons (inline elements with styled text that mock arrow icons) +--------------------------------------------------------------------------------------------------*/ + +.fc-icon { + display: inline-block; + font-size: 2em; + line-height: .5em; + height: .5em; /* will make the total height 1em */ + font-family: "Courier New", Courier, monospace; +} + +.fc-icon-left-single-arrow:after { + content: "\02039"; + font-weight: bold; +} + +.fc-icon-right-single-arrow:after { + content: "\0203A"; + font-weight: bold; +} + +.fc-icon-left-double-arrow:after { + content: "\000AB"; +} + +.fc-icon-right-double-arrow:after { + content: "\000BB"; +} + +.fc-icon-x:after { + content: "\000D7"; +} + + +/* Buttons (styled ' + ) + .click(function() { + // don't process clicks for disabled buttons + if (!button.hasClass(tm + '-state-disabled')) { + + buttonClick(); + + // after the click action, if the button becomes the "active" tab, or disabled, + // it should never have a hover class, so remove it now. + if ( + button.hasClass(tm + '-state-active') || + button.hasClass(tm + '-state-disabled') + ) { + button.removeClass(tm + '-state-hover'); + } + } + }) + .mousedown(function() { + // the *down* effect (mouse pressed in). + // only on buttons that are not the "active" tab, or disabled + button + .not('.' + tm + '-state-active') + .not('.' + tm + '-state-disabled') + .addClass(tm + '-state-down'); + }) + .mouseup(function() { + // undo the *down* effect + button.removeClass(tm + '-state-down'); + }) + .hover( + function() { + // the *hover* effect. + // only on buttons that are not the "active" tab, or disabled + button + .not('.' + tm + '-state-active') + .not('.' + tm + '-state-disabled') + .addClass(tm + '-state-hover'); + }, + function() { + // undo the *hover* effect + button + .removeClass(tm + '-state-hover') + .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup + } + ); + + groupChildren = groupChildren.add(button); + } + } + }); + + if (isOnlyButtons) { + groupChildren + .first().addClass(tm + '-corner-left').end() + .last().addClass(tm + '-corner-right').end(); + } + + if (groupChildren.length > 1) { + groupEl = $('
'); + if (isOnlyButtons) { + groupEl.addClass('fc-button-group'); + } + groupEl.append(groupChildren); + sectionEl.append(groupEl); + } + else { + sectionEl.append(groupChildren); // 1 or 0 children + } + }); + } + + return sectionEl; + } + + + function updateTitle(text) { + el.find('h2').text(text); + } + + + function activateButton(buttonName) { + el.find('.fc-' + buttonName + '-button') + .addClass(tm + '-state-active'); + } + + + function deactivateButton(buttonName) { + el.find('.fc-' + buttonName + '-button') + .removeClass(tm + '-state-active'); + } + + + function disableButton(buttonName) { + el.find('.fc-' + buttonName + '-button') + .attr('disabled', 'disabled') + .addClass(tm + '-state-disabled'); + } + + + function enableButton(buttonName) { + el.find('.fc-' + buttonName + '-button') + .removeAttr('disabled') + .removeClass(tm + '-state-disabled'); + } + + + function getViewsWithButtons() { + return viewsWithButtons; + } + +} + +;; + +fc.sourceNormalizers = []; +fc.sourceFetchers = []; + +var ajaxDefaults = { + dataType: 'json', + cache: false +}; + +var eventGUID = 1; + + +function EventManager(options) { // assumed to be a calendar + var t = this; + + + // exports + t.isFetchNeeded = isFetchNeeded; + t.fetchEvents = fetchEvents; + t.addEventSource = addEventSource; + t.removeEventSource = removeEventSource; + t.updateEvent = updateEvent; + t.renderEvent = renderEvent; + t.removeEvents = removeEvents; + t.clientEvents = clientEvents; + t.mutateEvent = mutateEvent; + t.normalizeEventDateProps = normalizeEventDateProps; + t.ensureVisibleEventRange = ensureVisibleEventRange; + + + // imports + var trigger = t.trigger; + var getView = t.getView; + var reportEvents = t.reportEvents; + + + // locals + var stickySource = { events: [] }; + var sources = [ stickySource ]; + var rangeStart, rangeEnd; + var currentFetchID = 0; + var pendingSourceCnt = 0; + var loadingLevel = 0; + var cache = []; // holds events that have already been expanded + + + $.each( + (options.events ? [ options.events ] : []).concat(options.eventSources || []), + function(i, sourceInput) { + var source = buildEventSource(sourceInput); + if (source) { + sources.push(source); + } + } + ); + + + + /* Fetching + -----------------------------------------------------------------------------*/ + + + function isFetchNeeded(start, end) { + return !rangeStart || // nothing has been fetched yet? + // or, a part of the new range is outside of the old range? (after normalizing) + start.clone().stripZone() < rangeStart.clone().stripZone() || + end.clone().stripZone() > rangeEnd.clone().stripZone(); + } + + + function fetchEvents(start, end) { + rangeStart = start; + rangeEnd = end; + cache = []; + var fetchID = ++currentFetchID; + var len = sources.length; + pendingSourceCnt = len; + for (var i=0; i eventRange system + function ensureVisibleEventRange(range) { + var allDay; + + if (!range.end) { + + allDay = range.allDay; // range might be more event-ish than we think + if (allDay == null) { + allDay = !range.start.hasTime(); + } + + range = { + start: range.start, + end: t.getDefaultEventEnd(allDay, range.start) + }; + } + return range; + } + + + // If the given event is a recurring event, break it down into an array of individual instances. + // If not a recurring event, return an array with the single original event. + // If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array. + // HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours). + function expandEvent(abstractEvent, _rangeStart, _rangeEnd) { + var events = []; + var dowHash; + var dow; + var i; + var date; + var startTime, endTime; + var start, end; + var event; + + _rangeStart = _rangeStart || rangeStart; + _rangeEnd = _rangeEnd || rangeEnd; + + if (abstractEvent) { + if (abstractEvent._recurring) { + + // make a boolean hash as to whether the event occurs on each day-of-week + if ((dow = abstractEvent.dow)) { + dowHash = {}; + for (i = 0; i < dow.length; i++) { + dowHash[dow[i]] = true; + } + } + + // iterate through every day in the current range + date = _rangeStart.clone().stripTime(); // holds the date of the current day + while (date.isBefore(_rangeEnd)) { + + if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week + + startTime = abstractEvent.start; // the stored start and end properties are times (Durations) + endTime = abstractEvent.end; // " + start = date.clone(); + end = null; + + if (startTime) { + start = start.time(startTime); + } + if (endTime) { + end = date.clone().time(endTime); + } + + event = $.extend({}, abstractEvent); // make a copy of the original + assignDatesToEvent( + start, end, + !startTime && !endTime, // allDay? + event + ); + events.push(event); + } + + date.add(1, 'days'); + } + } + else { + events.push(abstractEvent); // return the original event. will be a one-item array + } + } + + return events; + } + + + + /* Event Modification Math + -----------------------------------------------------------------------------------------*/ + + + // Modifies an event and all related events by applying the given properties. + // Special date-diffing logic is used for manipulation of dates. + // If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end. + // All date comparisons are done against the event's pristine _start and _end dates. + // Returns an object with delta information and a function to undo all operations. + // + function mutateEvent(event, props) { + var miscProps = {}; + var clearEnd; + var dateDelta; + var durationDelta; + var undoFunc; + + props = props || {}; + + // ensure new date-related values to compare against + if (!props.start) { + props.start = event.start.clone(); + } + if (props.end === undefined) { + props.end = event.end ? event.end.clone() : null; + } + if (props.allDay == null) { // is null or undefined? + props.allDay = event.allDay; + } + + normalizeEventDateProps(props); // massages start/end/allDay + + // clear the end date if explicitly changed to null + clearEnd = event._end !== null && props.end === null; + + // compute the delta for moving the start and end dates together + if (props.allDay) { + dateDelta = diffDay(props.start, event._start); // whole-day diff from start-of-day + } + else { + dateDelta = diffDayTime(props.start, event._start); + } + + // compute the delta for moving the end date (after applying dateDelta) + if (!clearEnd && props.end) { + durationDelta = diffDayTime( + // new duration + props.end, + props.start + ).subtract(diffDayTime( + // subtract old duration + event._end || t.getDefaultEventEnd(event._allDay, event._start), + event._start + )); + } + + // gather all non-date-related properties + $.each(props, function(name, val) { + if (isMiscEventPropName(name)) { + if (val !== undefined) { + miscProps[name] = val; + } + } + }); + + // apply the operations to the event and all related events + undoFunc = mutateEvents( + clientEvents(event._id), // get events with this ID + clearEnd, + props.allDay, + dateDelta, + durationDelta, + miscProps + ); + + return { + dateDelta: dateDelta, + durationDelta: durationDelta, + undo: undoFunc + }; + } + + + // Modifies an array of events in the following ways (operations are in order): + // - clear the event's `end` + // - convert the event to allDay + // - add `dateDelta` to the start and end + // - add `durationDelta` to the event's duration + // - assign `miscProps` to the event + // + // Returns a function that can be called to undo all the operations. + // + // TODO: don't use so many closures. possible memory issues when lots of events with same ID. + // + function mutateEvents(events, clearEnd, allDay, dateDelta, durationDelta, miscProps) { + var isAmbigTimezone = t.getIsAmbigTimezone(); + var undoFunctions = []; + + // normalize zero-length deltas to be null + if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; } + if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; } + + $.each(events, function(i, event) { + var oldProps; + var newProps; + + // build an object holding all the old values, both date-related and misc. + // for the undo function. + oldProps = { + start: event.start.clone(), + end: event.end ? event.end.clone() : null, + allDay: event.allDay + }; + $.each(miscProps, function(name) { + oldProps[name] = event[name]; + }); + + // new date-related properties. work off the original date snapshot. + // ok to use references because they will be thrown away when backupEventDates is called. + newProps = { + start: event._start, + end: event._end, + allDay: event._allDay + }; + + if (clearEnd) { + newProps.end = null; + } + + newProps.allDay = allDay; + + normalizeEventDateProps(newProps); // massages start/end/allDay + + if (dateDelta) { + newProps.start.add(dateDelta); + if (newProps.end) { + newProps.end.add(dateDelta); + } + } + + if (durationDelta) { + if (!newProps.end) { + newProps.end = t.getDefaultEventEnd(newProps.allDay, newProps.start); + } + newProps.end.add(durationDelta); + } + + // if the dates have changed, and we know it is impossible to recompute the + // timezone offsets, strip the zone. + if ( + isAmbigTimezone && + !newProps.allDay && + (dateDelta || durationDelta) + ) { + newProps.start.stripZone(); + if (newProps.end) { + newProps.end.stripZone(); + } + } + + $.extend(event, miscProps, newProps); // copy over misc props, then date-related props + backupEventDates(event); // regenerate internal _start/_end/_allDay + + undoFunctions.push(function() { + $.extend(event, oldProps); + backupEventDates(event); // regenerate internal _start/_end/_allDay + }); + }); + + return function() { + for (var i = 0; i < undoFunctions.length; i++) { + undoFunctions[i](); + } + }; + } + + + /* Business Hours + -----------------------------------------------------------------------------------------*/ + + t.getBusinessHoursEvents = getBusinessHoursEvents; + + + // Returns an array of events as to when the business hours occur in the given view. + // Abuse of our event system :( + function getBusinessHoursEvents() { + var optionVal = options.businessHours; + var defaultVal = { + className: 'fc-nonbusiness', + start: '09:00', + end: '17:00', + dow: [ 1, 2, 3, 4, 5 ], // monday - friday + rendering: 'inverse-background' + }; + var view = t.getView(); + var eventInput; + + if (optionVal) { + if (typeof optionVal === 'object') { + // option value is an object that can override the default business hours + eventInput = $.extend({}, defaultVal, optionVal); + } + else { + // option value is `true`. use default business hours + eventInput = defaultVal; + } + } + + if (eventInput) { + return expandEvent( + buildEventFromInput(eventInput), + view.start, + view.end + ); + } + + return []; + } + + + /* Overlapping / Constraining + -----------------------------------------------------------------------------------------*/ + + t.isEventRangeAllowed = isEventRangeAllowed; + t.isSelectionRangeAllowed = isSelectionRangeAllowed; + t.isExternalDropRangeAllowed = isExternalDropRangeAllowed; + + + function isEventRangeAllowed(range, event) { + var source = event.source || {}; + var constraint = firstDefined( + event.constraint, + source.constraint, + options.eventConstraint + ); + var overlap = firstDefined( + event.overlap, + source.overlap, + options.eventOverlap + ); + + range = ensureVisibleEventRange(range); // ensure a proper range with an end for isRangeAllowed + + return isRangeAllowed(range, constraint, overlap, event); + } + + + function isSelectionRangeAllowed(range) { + return isRangeAllowed(range, options.selectConstraint, options.selectOverlap); + } + + + // when `eventProps` is defined, consider this an event. + // `eventProps` can contain misc non-date-related info about the event. + function isExternalDropRangeAllowed(range, eventProps) { + var eventInput; + var event; + + // note: very similar logic is in View's reportExternalDrop + if (eventProps) { + eventInput = $.extend({}, eventProps, range); + event = expandEvent(buildEventFromInput(eventInput))[0]; + } + + if (event) { + return isEventRangeAllowed(range, event); + } + else { // treat it as a selection + + range = ensureVisibleEventRange(range); // ensure a proper range with an end for isSelectionRangeAllowed + + return isSelectionRangeAllowed(range); + } + } + + + // Returns true if the given range (caused by an event drop/resize or a selection) is allowed to exist + // according to the constraint/overlap settings. + // `event` is not required if checking a selection. + function isRangeAllowed(range, constraint, overlap, event) { + var constraintEvents; + var anyContainment; + var i, otherEvent; + var otherOverlap; + + // normalize. fyi, we're normalizing in too many places :( + range = { + start: range.start.clone().stripZone(), + end: range.end.clone().stripZone() + }; + + // the range must be fully contained by at least one of produced constraint events + if (constraint != null) { + + // not treated as an event! intermediate data structure + // TODO: use ranges in the future + constraintEvents = constraintToEvents(constraint); + + anyContainment = false; + for (i = 0; i < constraintEvents.length; i++) { + if (eventContainsRange(constraintEvents[i], range)) { + anyContainment = true; + break; + } + } + + if (!anyContainment) { + return false; + } + } + + for (i = 0; i < cache.length; i++) { // loop all events and detect overlap + otherEvent = cache[i]; + + // don't compare the event to itself or other related [repeating] events + if (event && event._id === otherEvent._id) { + continue; + } + + // there needs to be an actual intersection before disallowing anything + if (eventIntersectsRange(otherEvent, range)) { + + // evaluate overlap for the given range and short-circuit if necessary + if (overlap === false) { + return false; + } + else if (typeof overlap === 'function' && !overlap(otherEvent, event)) { + return false; + } + + // if we are computing if the given range is allowable for an event, consider the other event's + // EventObject-specific or Source-specific `overlap` property + if (event) { + otherOverlap = firstDefined( + otherEvent.overlap, + (otherEvent.source || {}).overlap + // we already considered the global `eventOverlap` + ); + if (otherOverlap === false) { + return false; + } + if (typeof otherOverlap === 'function' && !otherOverlap(event, otherEvent)) { + return false; + } + } + } + } + + return true; + } + + + // Given an event input from the API, produces an array of event objects. Possible event inputs: + // 'businessHours' + // An event ID (number or string) + // An object with specific start/end dates or a recurring event (like what businessHours accepts) + function constraintToEvents(constraintInput) { + + if (constraintInput === 'businessHours') { + return getBusinessHoursEvents(); + } + + if (typeof constraintInput === 'object') { + return expandEvent(buildEventFromInput(constraintInput)); + } + + return clientEvents(constraintInput); // probably an ID + } + + + // Does the event's date range fully contain the given range? + // start/end already assumed to have stripped zones :( + function eventContainsRange(event, range) { + var eventStart = event.start.clone().stripZone(); + var eventEnd = t.getEventEnd(event).stripZone(); + + return range.start >= eventStart && range.end <= eventEnd; + } + + + // Does the event's date range intersect with the given range? + // start/end already assumed to have stripped zones :( + function eventIntersectsRange(event, range) { + var eventStart = event.start.clone().stripZone(); + var eventEnd = t.getEventEnd(event).stripZone(); + + return range.start < eventEnd && range.end > eventStart; + } + +} + + +// updates the "backup" properties, which are preserved in order to compute diffs later on. +function backupEventDates(event) { + event._allDay = event.allDay; + event._start = event.start.clone(); + event._end = event.end ? event.end.clone() : null; +} + +;; + +/* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells. +----------------------------------------------------------------------------------------------------------------------*/ +// It is a manager for a DayGrid subcomponent, which does most of the heavy lifting. +// It is responsible for managing width/height. + +var BasicView = fcViews.basic = View.extend({ + + dayGrid: null, // the main subcomponent that does most of the heavy lifting + + dayNumbersVisible: false, // display day numbers on each day cell? + weekNumbersVisible: false, // display week numbers along the side? + + weekNumberWidth: null, // width of all the week-number cells running down the side + + headRowEl: null, // the fake row element of the day-of-week header + + + initialize: function() { + this.dayGrid = new DayGrid(this); + this.coordMap = this.dayGrid.coordMap; // the view's date-to-cell mapping is identical to the subcomponent's + }, + + + // Sets the display range and computes all necessary dates + setRange: function(range) { + View.prototype.setRange.call(this, range); // call the super-method + + this.dayGrid.breakOnWeeks = /year|month|week/.test(this.intervalUnit); // do before setRange + this.dayGrid.setRange(range); + }, + + + // Compute the value to feed into setRange. Overrides superclass. + computeRange: function(date) { + var range = View.prototype.computeRange.call(this, date); // get value from the super-method + + // year and month views should be aligned with weeks. this is already done for week + if (/year|month/.test(range.intervalUnit)) { + range.start.startOf('week'); + range.start = this.skipHiddenDays(range.start); + + // make end-of-week if not already + if (range.end.weekday()) { + range.end.add(1, 'week').startOf('week'); + range.end = this.skipHiddenDays(range.end, -1, true); // exclusively move backwards + } + } + + return range; + }, + + + // Renders the view into `this.el`, which should already be assigned + render: function() { + + this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible + this.weekNumbersVisible = this.opt('weekNumbers'); + this.dayGrid.numbersVisible = this.dayNumbersVisible || this.weekNumbersVisible; + + this.el.addClass('fc-basic-view').html(this.renderHtml()); + + this.headRowEl = this.el.find('thead .fc-row'); + + this.scrollerEl = this.el.find('.fc-day-grid-container'); + this.dayGrid.coordMap.containerEl = this.scrollerEl; // constrain clicks/etc to the dimensions of the scroller + + this.dayGrid.el = this.el.find('.fc-day-grid'); + this.dayGrid.render(this.hasRigidRows()); + }, + + + // Make subcomponents ready for cleanup + destroy: function() { + this.dayGrid.destroy(); + View.prototype.destroy.call(this); // call the super-method + }, + + + // Builds the HTML skeleton for the view. + // The day-grid component will render inside of a container defined by this HTML. + renderHtml: function() { + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + this.dayGrid.headHtml() + // render the day-of-week headers + '
' + + '
' + + '
' + + '
' + + '
'; + }, + + + // Generates the HTML that will go before the day-of week header cells. + // Queried by the DayGrid subcomponent when generating rows. Ordering depends on isRTL. + headIntroHtml: function() { + if (this.weekNumbersVisible) { + return '' + + '' + + '' + // needed for matchCellWidths + htmlEscape(this.opt('weekNumberTitle')) + + '' + + ''; + } + }, + + + // Generates the HTML that will go before content-skeleton cells that display the day/week numbers. + // Queried by the DayGrid subcomponent. Ordering depends on isRTL. + numberIntroHtml: function(row) { + if (this.weekNumbersVisible) { + return '' + + '' + + '' + // needed for matchCellWidths + this.calendar.calculateWeekNumber(this.dayGrid.getCell(row, 0).start) + + '' + + ''; + } + }, + + + // Generates the HTML that goes before the day bg cells for each day-row. + // Queried by the DayGrid subcomponent. Ordering depends on isRTL. + dayIntroHtml: function() { + if (this.weekNumbersVisible) { + return ''; + } + }, + + + // Generates the HTML that goes before every other type of row generated by DayGrid. Ordering depends on isRTL. + // Affects helper-skeleton and highlight-skeleton rows. + introHtml: function() { + if (this.weekNumbersVisible) { + return ''; + } + }, + + + // Generates the HTML for the s of the "number" row in the DayGrid's content skeleton. + // The number row will only exist if either day numbers or week numbers are turned on. + numberCellHtml: function(cell) { + var date = cell.start; + var classes; + + if (!this.dayNumbersVisible) { // if there are week numbers but not day numbers + return ''; // will create an empty space above events :( + } + + classes = this.dayGrid.getDayClasses(date); + classes.unshift('fc-day-number'); + + return '' + + '' + + date.date() + + ''; + }, + + + // Generates an HTML attribute string for setting the width of the week number column, if it is known + weekNumberStyleAttr: function() { + if (this.weekNumberWidth !== null) { + return 'style="width:' + this.weekNumberWidth + 'px"'; + } + return ''; + }, + + + // Determines whether each row should have a constant height + hasRigidRows: function() { + var eventLimit = this.opt('eventLimit'); + return eventLimit && typeof eventLimit !== 'number'; + }, + + + /* Dimensions + ------------------------------------------------------------------------------------------------------------------*/ + + + // Refreshes the horizontal dimensions of the view + updateWidth: function() { + if (this.weekNumbersVisible) { + // Make sure all week number cells running down the side have the same width. + // Record the width for cells created later. + this.weekNumberWidth = matchCellWidths( + this.el.find('.fc-week-number') + ); + } + }, + + + // Adjusts the vertical dimensions of the view to the specified values + setHeight: function(totalHeight, isAuto) { + var eventLimit = this.opt('eventLimit'); + var scrollerHeight; + + // reset all heights to be natural + unsetScroller(this.scrollerEl); + uncompensateScroll(this.headRowEl); + + this.dayGrid.destroySegPopover(); // kill the "more" popover if displayed + + // is the event limit a constant level number? + if (eventLimit && typeof eventLimit === 'number') { + this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after + } + + scrollerHeight = this.computeScrollerHeight(totalHeight); + this.setGridHeight(scrollerHeight, isAuto); + + // is the event limit dynamically calculated? + if (eventLimit && typeof eventLimit !== 'number') { + this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set + } + + if (!isAuto && setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars? + + compensateScroll(this.headRowEl, getScrollbarWidths(this.scrollerEl)); + + // doing the scrollbar compensation might have created text overflow which created more height. redo + scrollerHeight = this.computeScrollerHeight(totalHeight); + this.scrollerEl.height(scrollerHeight); + + this.restoreScroll(); + } + }, + + + // Sets the height of just the DayGrid component in this view + setGridHeight: function(height, isAuto) { + if (isAuto) { + undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding + } + else { + distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows + } + }, + + + /* Events + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders the given events onto the view and populates the segments array + renderEvents: function(events) { + this.dayGrid.renderEvents(events); + + this.updateHeight(); // must compensate for events that overflow the row + }, + + + // Retrieves all segment objects that are rendered in the view + getEventSegs: function() { + return this.dayGrid.getEventSegs(); + }, + + + // Unrenders all event elements and clears internal segment data + destroyEvents: function() { + this.recordScroll(); // removing events will reduce height and mess with the scroll, so record beforehand + this.dayGrid.destroyEvents(); + + // we DON'T need to call updateHeight() because: + // A) a renderEvents() call always happens after this, which will eventually call updateHeight() + // B) in IE8, this causes a flash whenever events are rerendered + }, + + + /* Dragging (for both events and external elements) + ------------------------------------------------------------------------------------------------------------------*/ + + + // A returned value of `true` signals that a mock "helper" event has been rendered. + renderDrag: function(dropLocation, seg) { + return this.dayGrid.renderDrag(dropLocation, seg); + }, + + + destroyDrag: function() { + this.dayGrid.destroyDrag(); + }, + + + /* Selection + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of a selection + renderSelection: function(range) { + this.dayGrid.renderSelection(range); + }, + + + // Unrenders a visual indications of a selection + destroySelection: function() { + this.dayGrid.destroySelection(); + } + +}); + +;; + +/* A month view with day cells running in rows (one-per-week) and columns +----------------------------------------------------------------------------------------------------------------------*/ + +setDefaults({ + fixedWeekCount: true +}); + +var MonthView = fcViews.month = BasicView.extend({ + + // Produces information about what range to display + computeRange: function(date) { + var range = BasicView.prototype.computeRange.call(this, date); // get value from super-method + var rowCnt; + + // ensure 6 weeks + if (this.isFixedWeeks()) { + rowCnt = Math.ceil(range.end.diff(range.start, 'weeks', true)); // could be partial weeks due to hiddenDays + range.end.add(6 - rowCnt, 'weeks'); + } + + return range; + }, + + + // Overrides the default BasicView behavior to have special multi-week auto-height logic + setGridHeight: function(height, isAuto) { + + isAuto = isAuto || this.opt('weekMode') === 'variable'; // LEGACY: weekMode is deprecated + + // if auto, make the height of each row the height that it would be if there were 6 weeks + if (isAuto) { + height *= this.rowCnt / 6; + } + + distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows + }, + + + isFixedWeeks: function() { + var weekMode = this.opt('weekMode'); // LEGACY: weekMode is deprecated + if (weekMode) { + return weekMode === 'fixed'; // if any other type of weekMode, assume NOT fixed + } + + return this.opt('fixedWeekCount'); + } + +}); + +MonthView.duration = { months: 1 }; + +;; + +/* A week view with simple day cells running horizontally +----------------------------------------------------------------------------------------------------------------------*/ + +fcViews.basicWeek = { + type: 'basic', + duration: { weeks: 1 } +}; +;; + +/* A view with a single simple day cell +----------------------------------------------------------------------------------------------------------------------*/ + +fcViews.basicDay = { + type: 'basic', + duration: { days: 1 } +}; +;; + +/* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically. +----------------------------------------------------------------------------------------------------------------------*/ +// Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on). +// Responsible for managing width/height. + +setDefaults({ + allDaySlot: true, + allDayText: 'all-day', + scrollTime: '06:00:00', + slotDuration: '00:30:00', + minTime: '00:00:00', + maxTime: '24:00:00', + slotEventOverlap: true +}); + +var AGENDA_ALL_DAY_EVENT_LIMIT = 5; + +fcViews.agenda = View.extend({ // AgendaView + + timeGrid: null, // the main time-grid subcomponent of this view + dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null + + axisWidth: null, // the width of the time axis running down the side + + noScrollRowEls: null, // set of fake row elements that must compensate when scrollerEl has scrollbars + + // when the time-grid isn't tall enough to occupy the given height, we render an
underneath + bottomRuleEl: null, + bottomRuleHeight: null, + + + initialize: function() { + this.timeGrid = new TimeGrid(this); + + if (this.opt('allDaySlot')) { // should we display the "all-day" area? + this.dayGrid = new DayGrid(this); // the all-day subcomponent of this view + + // the coordinate grid will be a combination of both subcomponents' grids + this.coordMap = new ComboCoordMap([ + this.dayGrid.coordMap, + this.timeGrid.coordMap + ]); + } + else { + this.coordMap = this.timeGrid.coordMap; + } + }, + + + /* Rendering + ------------------------------------------------------------------------------------------------------------------*/ + + + // Sets the display range and computes all necessary dates + setRange: function(range) { + View.prototype.setRange.call(this, range); // call the super-method + + this.timeGrid.setRange(range); + if (this.dayGrid) { + this.dayGrid.setRange(range); + } + }, + + + // Renders the view into `this.el`, which has already been assigned + render: function() { + + this.el.addClass('fc-agenda-view').html(this.renderHtml()); + + // the element that wraps the time-grid that will probably scroll + this.scrollerEl = this.el.find('.fc-time-grid-container'); + this.timeGrid.coordMap.containerEl = this.scrollerEl; // don't accept clicks/etc outside of this + + this.timeGrid.el = this.el.find('.fc-time-grid'); + this.timeGrid.render(); + + // the
that sometimes displays under the time-grid + this.bottomRuleEl = $('
') + .appendTo(this.timeGrid.el); // inject it into the time-grid + + if (this.dayGrid) { + this.dayGrid.el = this.el.find('.fc-day-grid'); + this.dayGrid.render(); + + // have the day-grid extend it's coordinate area over the
dividing the two grids + this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight(); + } + + this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller + }, + + + // Make subcomponents ready for cleanup + destroy: function() { + this.timeGrid.destroy(); + if (this.dayGrid) { + this.dayGrid.destroy(); + } + View.prototype.destroy.call(this); // call the super-method + }, + + + // Builds the HTML skeleton for the view. + // The day-grid and time-grid components will render inside containers defined by this HTML. + renderHtml: function() { + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + this.timeGrid.headHtml() + // render the day-of-week headers + '
' + + (this.dayGrid ? + '
' + + '
' : + '' + ) + + '
' + + '
' + + '
' + + '
'; + }, + + + // Generates the HTML that will go before the day-of week header cells. + // Queried by the TimeGrid subcomponent when generating rows. Ordering depends on isRTL. + headIntroHtml: function() { + var date; + var weekNumber; + var weekTitle; + var weekText; + + if (this.opt('weekNumbers')) { + date = this.timeGrid.getCell(0).start; + weekNumber = this.calendar.calculateWeekNumber(date); + weekTitle = this.opt('weekNumberTitle'); + + if (this.opt('isRTL')) { + weekText = weekNumber + weekTitle; + } + else { + weekText = weekTitle + weekNumber; + } + + return '' + + '' + + '' + // needed for matchCellWidths + htmlEscape(weekText) + + '' + + ''; + } + else { + return ''; + } + }, + + + // Generates the HTML that goes before the all-day cells. + // Queried by the DayGrid subcomponent when generating rows. Ordering depends on isRTL. + dayIntroHtml: function() { + return '' + + '' + + '' + // needed for matchCellWidths + (this.opt('allDayHtml') || htmlEscape(this.opt('allDayText'))) + + '' + + ''; + }, + + + // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column. + slotBgIntroHtml: function() { + return ''; + }, + + + // Generates the HTML that goes before all other types of cells. + // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid. + // Queried by the TimeGrid and DayGrid subcomponents when generating rows. Ordering depends on isRTL. + introHtml: function() { + return ''; + }, + + + // Generates an HTML attribute string for setting the width of the axis, if it is known + axisStyleAttr: function() { + if (this.axisWidth !== null) { + return 'style="width:' + this.axisWidth + 'px"'; + } + return ''; + }, + + + /* Dimensions + ------------------------------------------------------------------------------------------------------------------*/ + + + updateSize: function(isResize) { + if (isResize) { + this.timeGrid.resize(); + } + View.prototype.updateSize.call(this, isResize); + }, + + + // Refreshes the horizontal dimensions of the view + updateWidth: function() { + // make all axis cells line up, and record the width so newly created axis cells will have it + this.axisWidth = matchCellWidths(this.el.find('.fc-axis')); + }, + + + // Adjusts the vertical dimensions of the view to the specified values + setHeight: function(totalHeight, isAuto) { + var eventLimit; + var scrollerHeight; + + if (this.bottomRuleHeight === null) { + // calculate the height of the rule the very first time + this.bottomRuleHeight = this.bottomRuleEl.outerHeight(); + } + this.bottomRuleEl.hide(); // .show() will be called later if this
is necessary + + // reset all dimensions back to the original state + this.scrollerEl.css('overflow', ''); + unsetScroller(this.scrollerEl); + uncompensateScroll(this.noScrollRowEls); + + // limit number of events in the all-day area + if (this.dayGrid) { + this.dayGrid.destroySegPopover(); // kill the "more" popover if displayed + + eventLimit = this.opt('eventLimit'); + if (eventLimit && typeof eventLimit !== 'number') { + eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number + } + if (eventLimit) { + this.dayGrid.limitRows(eventLimit); + } + } + + if (!isAuto) { // should we force dimensions of the scroll container, or let the contents be natural height? + + scrollerHeight = this.computeScrollerHeight(totalHeight); + if (setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars? + + // make the all-day and header rows lines up + compensateScroll(this.noScrollRowEls, getScrollbarWidths(this.scrollerEl)); + + // the scrollbar compensation might have changed text flow, which might affect height, so recalculate + // and reapply the desired height to the scroller. + scrollerHeight = this.computeScrollerHeight(totalHeight); + this.scrollerEl.height(scrollerHeight); + + this.restoreScroll(); + } + else { // no scrollbars + // still, force a height and display the bottom rule (marks the end of day) + this.scrollerEl.height(scrollerHeight).css('overflow', 'hidden'); // in case
goes outside + this.bottomRuleEl.show(); + } + } + }, + + + // Sets the scroll value of the scroller to the initial pre-configured state prior to allowing the user to change it + initializeScroll: function() { + var _this = this; + var scrollTime = moment.duration(this.opt('scrollTime')); + var top = this.timeGrid.computeTimeTop(scrollTime); + + // zoom can give weird floating-point values. rather scroll a little bit further + top = Math.ceil(top); + + if (top) { + top++; // to overcome top border that slots beyond the first have. looks better + } + + function scroll() { + _this.scrollerEl.scrollTop(top); + } + + scroll(); + setTimeout(scroll, 0); // overrides any previous scroll state made by the browser + }, + + + /* Events + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders events onto the view and populates the View's segment array + renderEvents: function(events) { + var dayEvents = []; + var timedEvents = []; + var daySegs = []; + var timedSegs; + var i; + + // separate the events into all-day and timed + for (i = 0; i < events.length; i++) { + if (events[i].allDay) { + dayEvents.push(events[i]); + } + else { + timedEvents.push(events[i]); + } + } + + // render the events in the subcomponents + timedSegs = this.timeGrid.renderEvents(timedEvents); + if (this.dayGrid) { + daySegs = this.dayGrid.renderEvents(dayEvents); + } + + // the all-day area is flexible and might have a lot of events, so shift the height + this.updateHeight(); + }, + + + // Retrieves all segment objects that are rendered in the view + getEventSegs: function() { + return this.timeGrid.getEventSegs().concat( + this.dayGrid ? this.dayGrid.getEventSegs() : [] + ); + }, + + + // Unrenders all event elements and clears internal segment data + destroyEvents: function() { + + // if destroyEvents is being called as part of an event rerender, renderEvents will be called shortly + // after, so remember what the scroll value was so we can restore it. + this.recordScroll(); + + // destroy the events in the subcomponents + this.timeGrid.destroyEvents(); + if (this.dayGrid) { + this.dayGrid.destroyEvents(); + } + + // we DON'T need to call updateHeight() because: + // A) a renderEvents() call always happens after this, which will eventually call updateHeight() + // B) in IE8, this causes a flash whenever events are rerendered + }, + + + /* Dragging (for events and external elements) + ------------------------------------------------------------------------------------------------------------------*/ + + + // A returned value of `true` signals that a mock "helper" event has been rendered. + renderDrag: function(dropLocation, seg) { + if (dropLocation.start.hasTime()) { + return this.timeGrid.renderDrag(dropLocation, seg); + } + else if (this.dayGrid) { + return this.dayGrid.renderDrag(dropLocation, seg); + } + }, + + + destroyDrag: function() { + this.timeGrid.destroyDrag(); + if (this.dayGrid) { + this.dayGrid.destroyDrag(); + } + }, + + + /* Selection + ------------------------------------------------------------------------------------------------------------------*/ + + + // Renders a visual indication of a selection + renderSelection: function(range) { + if (range.start.hasTime() || range.end.hasTime()) { + this.timeGrid.renderSelection(range); + } + else if (this.dayGrid) { + this.dayGrid.renderSelection(range); + } + }, + + + // Unrenders a visual indications of a selection + destroySelection: function() { + this.timeGrid.destroySelection(); + if (this.dayGrid) { + this.dayGrid.destroySelection(); + } + } + +}); + +;; + +/* A week view with an all-day cell area at the top, and a time grid below +----------------------------------------------------------------------------------------------------------------------*/ + +fcViews.agendaWeek = { + type: 'agenda', + duration: { weeks: 1 } +}; +;; + +/* A day view with an all-day cell area at the top, and a time grid below +----------------------------------------------------------------------------------------------------------------------*/ + +fcViews.agendaDay = { + type: 'agenda', + duration: { days: 1 } +}; +;; + +}); \ No newline at end of file