Просмотр исходного кода

Merge branch 'master' into weeknr-in-daycell

Peter Nowee 9 лет назад
Родитель
Сommit
f45873c467

+ 12 - 2
.travis.yml

@@ -1,7 +1,17 @@
 language: node_js
 node_js:
-  - 'node'
-  - 'stable'
+  - 'node' # implies latest version
+
+cache:
+  directories:
+  - node_modules
+  - lib
+
+env:
+  - CMD=test
+  - CMD=lint
 
 before_script:
   - bower install
+
+script: npm run $CMD

+ 8 - 0
CHANGELOG.md

@@ -1,4 +1,12 @@
 
+v2.9.0 (2016-07-10)
+-------------------
+
+- Setters for (almost) all options (#564).
+  See [docs](http://fullcalendar.io/docs/utilities/dynamic_options/) for more info.
+- Travis CI improvements (#3266)
+
+
 v2.8.0 (2016-06-19)
 -------------------
 

+ 2 - 2
Gruntfile.js

@@ -165,8 +165,8 @@ module.exports = function(grunt) {
 			configFile: 'build/karma.conf.js'
 		},
 		url: {}, // visit a URL in a browser
-		headless: { browsers: [ 'PhantomJS' ] },
-		single: { browsers: [ 'PhantomJS' ], singleRun: true, autoWatch: false }
+		headless: { browsers: [ 'PhantomJS_custom' ] },
+		single: { browsers: [ 'PhantomJS_custom' ], singleRun: true, autoWatch: false }
 	};
 
 

+ 2 - 2
README.md

@@ -1,4 +1,4 @@
-# FullCalendar
+# FullCalendar [![Build Status](https://travis-ci.org/fullcalendar/fullcalendar.svg?branch=master)](https://travis-ci.org/fullcalendar/fullcalendar)
 
 A full-sized drag & drop event calendar (jQuery plugin).
 
@@ -7,4 +7,4 @@ A full-sized drag & drop event calendar (jQuery plugin).
 - [Support](http://fullcalendar.io/support/)
 - [Contributing](CONTRIBUTING.md)
 - [Changelog](CHANGELOG.md)
-- [License](LICENSE.txt)
+- [License](LICENSE.txt)

+ 15 - 1
build/karma.conf.js

@@ -23,6 +23,7 @@ module.exports = function(config) {
 			'../lib/moment/moment.js',
 			'../lib/jquery/dist/jquery.js',
 			'../lib/jquery-ui/jquery-ui.js',
+			'../lib/jquery-ui/themes/cupertino/jquery-ui.min.css',
 
 			'../lib/jquery-simulate/jquery.simulate.js',
 			'../lib/jquery-mockjax/dist/jquery.mockjax.js',
@@ -76,6 +77,19 @@ module.exports = function(config) {
 		autoWatch: true,
 
 		// If browser does not capture in given timeout [ms], kill it
-		captureTimeout: 60000
+		captureTimeout: 60000,
+
+		// force a window size for PhantomJS, because it's usually unreasonably small, resulting in offset problems
+		customLaunchers: {
+			PhantomJS_custom: {
+				base: 'PhantomJS',
+				options: {
+					viewportSize: {
+						width: 1024,
+						height: 768
+					}
+				}
+			}
+		}
 	});
 };

+ 74 - 80
demos/languages.html

@@ -12,100 +12,94 @@
 <script>
 
 	$(document).ready(function() {
-		var currentLangCode = 'en';
+		var initialLangCode = 'en';
+
+		$('#calendar').fullCalendar({
+			header: {
+				left: 'prev,next today',
+				center: 'title',
+				right: 'month,agendaWeek,agendaDay'
+			},
+			defaultDate: '2016-06-12',
+			lang: initialLangCode,
+			buttonIcons: false, // show the prev/next text
+			weekNumbers: true,
+			editable: true,
+			eventLimit: true, // allow "more" link when too many events
+			events: [
+				{
+					title: 'All Day Event',
+					start: '2016-06-01'
+				},
+				{
+					title: 'Long Event',
+					start: '2016-06-07',
+					end: '2016-06-10'
+				},
+				{
+					id: 999,
+					title: 'Repeating Event',
+					start: '2016-06-09T16:00:00'
+				},
+				{
+					id: 999,
+					title: 'Repeating Event',
+					start: '2016-06-16T16:00:00'
+				},
+				{
+					title: 'Conference',
+					start: '2016-06-11',
+					end: '2016-06-13'
+				},
+				{
+					title: 'Meeting',
+					start: '2016-06-12T10:30:00',
+					end: '2016-06-12T12:30:00'
+				},
+				{
+					title: 'Lunch',
+					start: '2016-06-12T12:00:00'
+				},
+				{
+					title: 'Meeting',
+					start: '2016-06-12T14:30:00'
+				},
+				{
+					title: 'Happy Hour',
+					start: '2016-06-12T17:30:00'
+				},
+				{
+					title: 'Dinner',
+					start: '2016-06-12T20:00:00'
+				},
+				{
+					title: 'Birthday Party',
+					start: '2016-06-13T07:00:00'
+				},
+				{
+					title: 'Click for Google',
+					url: 'http://google.com/',
+					start: '2016-06-28'
+				}
+			]
+		});
 
 		// build the language selector's options
 		$.each($.fullCalendar.langs, function(langCode) {
 			$('#lang-selector').append(
 				$('<option/>')
 					.attr('value', langCode)
-					.prop('selected', langCode == currentLangCode)
+					.prop('selected', langCode == initialLangCode)
 					.text(langCode)
 			);
 		});
 
-		// rerender the calendar when the selected option changes
+		// when the selected option changes, dynamically change the calendar option
 		$('#lang-selector').on('change', function() {
 			if (this.value) {
-				currentLangCode = this.value;
-				$('#calendar').fullCalendar('destroy');
-				renderCalendar();
+				$('#calendar').fullCalendar('option', 'lang', this.value);
 			}
 		});
-
-		function renderCalendar() {
-			$('#calendar').fullCalendar({
-				header: {
-					left: 'prev,next today',
-					center: 'title',
-					right: 'month,agendaWeek,agendaDay'
-				},
-				defaultDate: '2016-06-12',
-				lang: currentLangCode,
-				buttonIcons: false, // show the prev/next text
-				weekNumbers: true,
-				editable: true,
-				eventLimit: true, // allow "more" link when too many events
-				events: [
-					{
-						title: 'All Day Event',
-						start: '2016-06-01'
-					},
-					{
-						title: 'Long Event',
-						start: '2016-06-07',
-						end: '2016-06-10'
-					},
-					{
-						id: 999,
-						title: 'Repeating Event',
-						start: '2016-06-09T16:00:00'
-					},
-					{
-						id: 999,
-						title: 'Repeating Event',
-						start: '2016-06-16T16:00:00'
-					},
-					{
-						title: 'Conference',
-						start: '2016-06-11',
-						end: '2016-06-13'
-					},
-					{
-						title: 'Meeting',
-						start: '2016-06-12T10:30:00',
-						end: '2016-06-12T12:30:00'
-					},
-					{
-						title: 'Lunch',
-						start: '2016-06-12T12:00:00'
-					},
-					{
-						title: 'Meeting',
-						start: '2016-06-12T14:30:00'
-					},
-					{
-						title: 'Happy Hour',
-						start: '2016-06-12T17:30:00'
-					},
-					{
-						title: 'Dinner',
-						start: '2016-06-12T20:00:00'
-					},
-					{
-						title: 'Birthday Party',
-						start: '2016-06-13T07:00:00'
-					},
-					{
-						title: 'Click for Google',
-						url: 'http://google.com/',
-						start: '2016-06-28'
-					}
-				]
-			});
-		}
-
-		renderCalendar();
 	});
 
 </script>

+ 38 - 46
demos/timezones.html

@@ -10,9 +10,43 @@
 <script>
 
 	$(document).ready(function() {
-		var currentTimezone = false;
 
-		// load the list of available timezones
+		$('#calendar').fullCalendar({
+			header: {
+				left: 'prev,next today',
+				center: 'title',
+				right: 'month,agendaWeek,agendaDay'
+			},
+			defaultDate: '2016-06-12',
+			editable: true,
+			selectable: true,
+			eventLimit: true, // allow "more" link when too many events
+			events: {
+				url: 'php/get-events.php',
+				error: function() {
+					$('#script-warning').show();
+				}
+			},
+			loading: function(bool) {
+				$('#loading').toggle(bool);
+			},
+			eventRender: function(event, el) {
+				// render the timezone offset below the event title
+				if (event.start.hasZone()) {
+					el.find('.fc-title').after(
+						$('<div class="tzo"/>').text(event.start.format('Z'))
+					);
+				}
+			},
+			dayClick: function(date) {
+				console.log('dayClick', date.format());
+			},
+			select: function(startDate, endDate) {
+				console.log('select', startDate.format(), endDate.format());
+			}
+		});
+
+		// load the list of available timezones, build the <select> options
 		$.getJSON('php/get-timezones.php', function(timezones) {
 			$.each(timezones, function(i, timezone) {
 				if (timezone != 'UTC') { // UTC is already in the list
@@ -23,52 +57,10 @@
 			});
 		});
 
-		// when the timezone selector changes, rerender the calendar
+		// when the timezone selector changes, dynamically change the calendar option
 		$('#timezone-selector').on('change', function() {
-			currentTimezone = this.value || false;
-			$('#calendar').fullCalendar('destroy');
-			renderCalendar();
+			$('#calendar').fullCalendar('option', 'timezone', this.value || false);
 		});
-
-		function renderCalendar() {
-			$('#calendar').fullCalendar({
-				header: {
-					left: 'prev,next today',
-					center: 'title',
-					right: 'month,agendaWeek,agendaDay'
-				},
-				defaultDate: '2016-06-12',
-				timezone: currentTimezone,
-				editable: true,
-				selectable: true,
-				eventLimit: true, // allow "more" link when too many events
-				events: {
-					url: 'php/get-events.php',
-					error: function() {
-						$('#script-warning').show();
-					}
-				},
-				loading: function(bool) {
-					$('#loading').toggle(bool);
-				},
-				eventRender: function(event, el) {
-					// render the timezone offset below the event title
-					if (event.start.hasZone()) {
-						el.find('.fc-title').after(
-							$('<div class="tzo"/>').text(event.start.format('Z'))
-						);
-					}
-				},
-				dayClick: function(date) {
-					console.log('dayClick', date.format());
-				},
-				select: function(startDate, endDate) {
-					console.log('select', startDate.format(), endDate.format());
-				}
-			});
-		}
-
-		renderCalendar();
 	});
 
 </script>

+ 1 - 0
lumbar.json

@@ -38,6 +38,7 @@
         "src/common/View.js",
         "src/common/Scroller.js",
         "src/Calendar.js",
+        "src/Calendar.options.js",
         "src/defaults.js",
         "src/lang.js",
         "src/Header.js",

+ 2 - 1
package.json

@@ -57,6 +57,7 @@
     "CONTRIBUTING.*"
   ],
   "scripts": {
-    "test" : "grunt dev && grunt karma:single"
+    "test" : "grunt dev && grunt karma:single",
+    "lint" : "grunt check"
   }
 }

+ 219 - 104
src/Calendar.js

@@ -4,6 +4,7 @@ var Calendar = FC.Calendar = Class.extend({
 	dirDefaults: null, // option defaults related to LTR or RTL
 	langDefaults: null, // option defaults related to current locale
 	overrides: null, // option overrides given to the fullCalendar constructor
+	dynamicOverrides: null, // options set with dynamic setter method. higher precedence than view overrides.
 	options: null, // all defaults combined with overrides
 	viewSpecCache: null, // cache of view definitions
 	view: null, // current View object
@@ -21,24 +22,25 @@ var Calendar = FC.Calendar = Class.extend({
 	},
 
 
-	// Initializes `this.options` and other important options-related objects
-	initOptions: function(overrides) {
+	// Computes the flattened options hash for the calendar and assigns to `this.options`.
+	// Assumes this.overrides and this.dynamicOverrides have already been initialized.
+	populateOptionsHash: function() {
 		var lang, langDefaults;
 		var isRTL, dirDefaults;
 
-		// converts legacy options into non-legacy ones.
-		// in the future, when this is removed, don't use `overrides` reference. make a copy.
-		overrides = massageOverrides(overrides);
-
-		lang = overrides.lang;
+		lang = firstDefined( // explicit lang option given?
+			this.dynamicOverrides.lang,
+			this.overrides.lang
+		);
 		langDefaults = langOptionHash[lang];
-		if (!langDefaults) {
+		if (!langDefaults) { // explicit lang option not given or invalid?
 			lang = Calendar.defaults.lang;
 			langDefaults = langOptionHash[lang] || {};
 		}
 
-		isRTL = firstDefined(
-			overrides.isRTL,
+		isRTL = firstDefined( // based on options computed so far, is direction RTL?
+			this.dynamicOverrides.isRTL,
+			this.overrides.isRTL,
 			langDefaults.isRTL,
 			Calendar.defaults.isRTL
 		);
@@ -46,16 +48,14 @@ var Calendar = FC.Calendar = Class.extend({
 
 		this.dirDefaults = dirDefaults;
 		this.langDefaults = langDefaults;
-		this.overrides = overrides;
 		this.options = mergeOptions([ // merge defaults and overrides. lowest to highest precedence
 			Calendar.defaults, // global defaults
 			dirDefaults,
 			langDefaults,
-			overrides
+			this.overrides,
+			this.dynamicOverrides
 		]);
-		populateInstanceComputableOptions(this.options);
-
-		this.viewSpecCache = {}; // somewhat unrelated
+		populateInstanceComputableOptions(this.options); // fill in gaps with computed options
 	},
 
 
@@ -169,7 +169,8 @@ var Calendar = FC.Calendar = Class.extend({
 			this.dirDefaults,
 			this.langDefaults, // locale and dir take precedence over view's defaults!
 			this.overrides, // calendar's overrides (options given to constructor)
-			spec.overrides // view's overrides (view-specific options)
+			spec.overrides, // view's overrides (view-specific options)
+			this.dynamicOverrides // dynamically set via setter. highest precedence
 		]);
 		populateInstanceComputableOptions(spec.options);
 	},
@@ -188,6 +189,7 @@ var Calendar = FC.Calendar = Class.extend({
 
 		// highest to lowest priority
 		spec.buttonTextOverride =
+			queryButtonText(this.dynamicOverrides) ||
 			queryButtonText(this.overrides) || // constructor-specified buttonText lookup hash takes precedence
 			spec.overrides.buttonText; // `buttonText` for view-specific options is a string
 
@@ -260,10 +262,6 @@ function Calendar_constructor(element, overrides) {
 	var t = this;
 
 
-	t.initOptions(overrides || {});
-	var options = this.options;
-
-	
 	// Exports
 	// -----------------------------------------------------------------------------------
 
@@ -288,50 +286,76 @@ function Calendar_constructor(element, overrides) {
 	t.getDate = getDate;
 	t.getCalendar = getCalendar;
 	t.getView = getView;
-	t.option = option;
+	t.option = option; // getter/setter method
 	t.trigger = trigger;
 
 
+	// Options
+	// -----------------------------------------------------------------------------------
+
+	t.dynamicOverrides = {};
+	t.viewSpecCache = {};
+	t.optionHandlers = {}; // for Calendar.options.js
+
+	// convert legacy options into non-legacy ones.
+	// in the future, when this is removed, don't use `overrides` reference. make a copy.
+	t.overrides = massageOverrides(overrides || {});
+
+	t.populateOptionsHash(); // sets this.options
+
+
 
 	// Language-data Internals
 	// -----------------------------------------------------------------------------------
 	// Apply overrides to the current language's data
 
+	var localeData;
 
-	var localeData = createObject( // make a cheap copy
-		getMomentLocaleData(options.lang) // will fall back to en
-	);
+	// Called immediately, and when any of the options change.
+	// Happens before any internal objects rebuild or rerender, because this is very core.
+	t.bindOptions([
+		'lang', 'monthNames', 'monthNamesShort', 'dayNames', 'dayNamesShort', 'firstDay', 'weekNumberCalculation'
+	], function(lang, monthNames, monthNamesShort, dayNames, dayNamesShort, firstDay, weekNumberCalculation) {
 
-	if (options.monthNames) {
-		localeData._months = options.monthNames;
-	}
-	if (options.monthNamesShort) {
-		localeData._monthsShort = options.monthNamesShort;
-	}
-	if (options.dayNames) {
-		localeData._weekdays = options.dayNames;
-	}
-	if (options.dayNamesShort) {
-		localeData._weekdaysShort = options.dayNamesShort;
-	}
-	if (options.firstDay != null) {
-		var _week = createObject(localeData._week); // _week: { dow: # }
-		_week.dow = options.firstDay;
-		localeData._week = _week;
-	}
+		localeData = createObject( // make a cheap copy
+			getMomentLocaleData(lang) // will fall back to en
+		);
 
-	// assign a normalized value, to be used by our .week() moment extension
-	localeData._fullCalendar_weekCalc = (function(weekCalc) {
-		if (typeof weekCalc === 'function') {
-			return weekCalc;
+		if (monthNames) {
+			localeData._months = monthNames;
+		}
+		if (monthNamesShort) {
+			localeData._monthsShort = monthNamesShort;
 		}
-		else if (weekCalc === 'local') {
-			return weekCalc;
+		if (dayNames) {
+			localeData._weekdays = dayNames;
 		}
-		else if (weekCalc === 'iso' || weekCalc === 'ISO') {
-			return 'ISO';
+		if (dayNamesShort) {
+			localeData._weekdaysShort = dayNamesShort;
+		}
+		if (firstDay != null) {
+			var _week = createObject(localeData._week); // _week: { dow: # }
+			_week.dow = firstDay;
+			localeData._week = _week;
 		}
-	})(options.weekNumberCalculation);
+
+		if (weekNumberCalculation === 'iso') {
+			weekNumberCalculation = 'ISO'; // normalize
+		}
+		if ( // whitelist certain kinds of input
+			weekNumberCalculation === 'ISO' ||
+			weekNumberCalculation === 'local' ||
+			typeof weekNumberCalculation === 'function'
+		) {
+			localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it
+		}
+
+		// If the internal current date object already exists, move to new locale.
+		// We do NOT need to do this technique for event dates, because this happens when converting to "segments".
+		if (date) {
+			localizeMoment(date); // sets to localeData
+		}
+	});
 
 
 
@@ -339,8 +363,8 @@ function Calendar_constructor(element, overrides) {
 	// -----------------------------------------------------------------------------------
 
 
-	t.defaultAllDayEventDuration = moment.duration(options.defaultAllDayEventDuration);
-	t.defaultTimedEventDuration = moment.duration(options.defaultTimedEventDuration);
+	t.defaultAllDayEventDuration = moment.duration(t.options.defaultAllDayEventDuration);
+	t.defaultTimedEventDuration = moment.duration(t.options.defaultTimedEventDuration);
 
 
 	// Builds a moment using the settings of the current calendar: timezone and language.
@@ -348,7 +372,7 @@ function Calendar_constructor(element, overrides) {
 	t.moment = function() {
 		var mom;
 
-		if (options.timezone === 'local') {
+		if (t.options.timezone === 'local') {
 			mom = FC.moment.apply(null, arguments);
 
 			// Force the moment to be local, because FC.moment doesn't guarantee it.
@@ -356,28 +380,34 @@ function Calendar_constructor(element, overrides) {
 				mom.local();
 			}
 		}
-		else if (options.timezone === 'UTC') {
+		else if (t.options.timezone === 'UTC') {
 			mom = FC.moment.utc.apply(null, arguments); // process as UTC
 		}
 		else {
 			mom = FC.moment.parseZone.apply(null, arguments); // let the input decide the zone
 		}
 
+		localizeMoment(mom);
+
+		return mom;
+	};
+
+
+	// Updates the given moment's locale settings to the current calendar locale settings.
+	function localizeMoment(mom) {
 		if ('_locale' in mom) { // moment 2.8 and above
 			mom._locale = localeData;
 		}
 		else { // pre-moment-2.8
 			mom._lang = localeData;
 		}
-
-		return mom;
-	};
+	}
 
 
 	// Returns a boolean about whether or not the calendar knows how to calculate
 	// the timezone offset of arbitrary dates in the current timezone.
 	t.getIsAmbigTimezone = function() {
-		return options.timezone !== 'local' && options.timezone !== 'UTC';
+		return t.options.timezone !== 'local' && t.options.timezone !== 'UTC';
 	};
 
 
@@ -406,7 +436,7 @@ function Calendar_constructor(element, overrides) {
 	// Returns a moment for the current date, as defined by the client's computer or from the `now` option.
 	// Will return an moment with an ambiguous timezone.
 	t.getNow = function() {
-		var now = options.now;
+		var now = t.options.now;
 		if (typeof now === 'function') {
 			now = now();
 		}
@@ -448,7 +478,7 @@ function Calendar_constructor(element, overrides) {
 	// Produces a human-readable string for the given duration.
 	// Side-effect: changes the locale of the given duration.
 	t.humanizeDuration = function(duration) {
-		return (duration.locale || duration.lang).call(duration, options.lang) // works moment-pre-2.8
+		return (duration.locale || duration.lang).call(duration, t.options.lang) // works moment-pre-2.8
 			.humanize();
 	};
 
@@ -458,7 +488,7 @@ function Calendar_constructor(element, overrides) {
 	// -----------------------------------------------------------------------------------
 
 
-	EventManager.call(t, options);
+	EventManager.call(t);
 	var isFetchNeeded = t.isFetchNeeded;
 	var fetchEvents = t.fetchEvents;
 	var fetchEventSources = t.fetchEventSources;
@@ -471,7 +501,6 @@ function Calendar_constructor(element, overrides) {
 
 	var _element = element[0];
 	var header;
-	var headerElement;
 	var content;
 	var tm; // for making theme classes
 	var currentView; // NOTE: keep this in sync with this.view
@@ -489,8 +518,8 @@ function Calendar_constructor(element, overrides) {
 
 
 	// compute the initial ambig-timezone date
-	if (options.defaultDate != null) {
-		date = t.moment(options.defaultDate).stripZone();
+	if (t.options.defaultDate != null) {
+		date = t.moment(t.options.defaultDate).stripZone();
 	}
 	else {
 		date = t.getNow(); // getNow already returns unzoned
@@ -510,38 +539,43 @@ function Calendar_constructor(element, overrides) {
 	
 	
 	function initialRender() {
-		tm = options.theme ? 'ui' : 'fc';
 		element.addClass('fc');
 
-		if (options.isRTL) {
-			element.addClass('fc-rtl');
-		}
-		else {
-			element.addClass('fc-ltr');
-		}
+		// called immediately, and upon option change
+		t.bindOption('theme', function(theme) {
+			tm = theme ? 'ui' : 'fc'; // affects a larger scope
+			element.toggleClass('ui-widget', theme);
+			element.toggleClass('fc-unthemed', !theme);
+		});
 
-		if (options.theme) {
-			element.addClass('ui-widget');
-		}
-		else {
-			element.addClass('fc-unthemed');
-		}
+		// called immediately, and upon option change.
+		// HACK: lang often affects isRTL, so we explicitly listen to that too.
+		t.bindOptions([ 'isRTL', 'lang' ], function(isRTL) {
+			element.toggleClass('fc-ltr', !isRTL);
+			element.toggleClass('fc-rtl', isRTL);
+		});
 
 		content = $("<div class='fc-view-container'/>").prependTo(element);
 
-		header = t.header = new Header(t, options);
-		headerElement = header.render();
-		if (headerElement) {
-			element.prepend(headerElement);
-		}
+		header = t.header = new Header(t);
+		renderHeader();
 
-		renderView(options.defaultView);
+		renderView(t.options.defaultView);
 
-		if (options.handleWindowResize) {
-			windowResizeProxy = debounce(windowResize, options.windowResizeDelay); // prevents rapid calls
+		if (t.options.handleWindowResize) {
+			windowResizeProxy = debounce(windowResize, t.options.windowResizeDelay); // prevents rapid calls
 			$(window).resize(windowResizeProxy);
 		}
 	}
+
+
+	// can be called repeatedly and Header will rerender
+	function renderHeader() {
+		header.render();
+		if (header.el) {
+			element.prepend(header.el);
+		}
+	}
 	
 	
 	function destroy() {
@@ -575,15 +609,14 @@ function Calendar_constructor(element, overrides) {
 
 	// Renders a view because of a date change, view-type change, or for the first time.
 	// If not given a viewType, keep the current view but render different dates.
-	function renderView(viewType) {
+	// Accepts an optional scroll state to restore to.
+	function renderView(viewType, explicitScrollState) {
 		ignoreWindowResize++;
 
 		// if viewType is changing, remove the old view's rendering
 		if (currentView && viewType && currentView.type !== viewType) {
-			header.deactivateButton(currentView.type);
 			freezeContentHeight(); // prevent a scroll jump when view element is removed
-			currentView.removeElement();
-			currentView = t.view = null;
+			clearView();
 		}
 
 		// if viewType changed, or the view was never created, create a fresh view
@@ -610,7 +643,7 @@ function Calendar_constructor(element, overrides) {
 			) {
 				if (elementVisible()) {
 
-					currentView.display(date); // will call freezeContentHeight
+					currentView.display(date, explicitScrollState); // will call freezeContentHeight
 					unfreezeContentHeight(); // immediately unfreeze regardless of whether display is async
 
 					// need to do this after View::render, so dates are calculated
@@ -626,6 +659,32 @@ function Calendar_constructor(element, overrides) {
 		ignoreWindowResize--;
 	}
 
+
+	// Unrenders the current view and reflects this change in the Header.
+	// Unregsiters the `currentView`, but does not remove from viewByType hash.
+	function clearView() {
+		header.deactivateButton(currentView.type);
+		currentView.removeElement();
+		currentView = t.view = null;
+	}
+
+
+	// Destroys the view, including the view object. Then, re-instantiates it and renders it.
+	// Maintains the same scroll state.
+	// TODO: maintain any other user-manipulated state.
+	function reinitView() {
+		ignoreWindowResize++;
+		freezeContentHeight();
+
+		var viewType = currentView.type;
+		var scrollState = currentView.queryScroll();
+		clearView();
+		renderView(viewType, scrollState);
+
+		unfreezeContentHeight();
+		ignoreWindowResize--;
+	}
+
 	
 
 	// Resizing
@@ -641,7 +700,7 @@ function Calendar_constructor(element, overrides) {
 
 
 	t.isHeightAuto = function() {
-		return options.contentHeight === 'auto' || options.height === 'auto';
+		return t.options.contentHeight === 'auto' || t.options.height === 'auto';
 	};
 	
 	
@@ -669,14 +728,14 @@ function Calendar_constructor(element, overrides) {
 	
 	
 	function _calcSize() { // assumes elementVisible
-		if (typeof options.contentHeight === 'number') { // exists and not 'auto'
-			suggestedViewHeight = options.contentHeight;
+		if (typeof t.options.contentHeight === 'number') { // exists and not 'auto'
+			suggestedViewHeight = t.options.contentHeight;
 		}
-		else if (typeof options.height === 'number') { // exists and not 'auto'
-			suggestedViewHeight = options.height - (headerElement ? headerElement.outerHeight(true) : 0);
+		else if (typeof t.options.height === 'number') { // exists and not 'auto'
+			suggestedViewHeight = t.options.height - (header.el ? header.el.outerHeight(true) : 0);
 		}
 		else {
-			suggestedViewHeight = Math.round(content.width() / Math.max(options.aspectRatio, .5));
+			suggestedViewHeight = Math.round(content.width() / Math.max(t.options.aspectRatio, .5));
 		}
 	}
 	
@@ -721,7 +780,7 @@ function Calendar_constructor(element, overrides) {
 	
 
 	function getAndRenderEvents() {
-		if (!options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) {
+		if (!t.options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) {
 			fetchAndRenderEvents();
 		}
 		else {
@@ -900,13 +959,69 @@ function Calendar_constructor(element, overrides) {
 	
 	
 	function option(name, value) {
-		if (value === undefined) {
-			return options[name];
+		var newOptionHash;
+
+		if (typeof name === 'string') {
+			if (value === undefined) { // getter
+				return t.options[name];
+			}
+			else { // setter for individual option
+				newOptionHash = {};
+				newOptionHash[name] = value;
+				setOptions(newOptionHash);
+			}
 		}
-		if (name == 'height' || name == 'contentHeight' || name == 'aspectRatio') {
-			options[name] = value;
-			updateSize(true); // true = allow recalculation of height
+		else if (typeof name === 'object') { // compound setter with object input
+			setOptions(name);
+		}
+	}
+
+
+	function setOptions(newOptionHash) {
+		var optionCnt = 0;
+		var optionName;
+
+		for (optionName in newOptionHash) {
+			t.dynamicOverrides[optionName] = newOptionHash[optionName];
 		}
+
+		t.viewSpecCache = {}; // the dynamic override invalidates the options in this cache, so just clear it
+		t.populateOptionsHash(); // this.options needs to be recomputed after the dynamic override
+
+		// trigger handlers after this.options has been updated
+		for (optionName in newOptionHash) {
+			t.triggerOptionHandlers(optionName); // recall bindOption/bindOptions
+			optionCnt++;
+		}
+
+		// special-case handling of single option change.
+		// if only one option change, `optionName` will be its name.
+		if (optionCnt === 1) {
+			if (optionName === 'height' || optionName === 'contentHeight' || optionName === 'aspectRatio') {
+				updateSize(true); // true = allow recalculation of height
+				return;
+			}
+			else if (optionName === 'defaultDate') {
+				return; // can't change date this way. use gotoDate instead
+			}
+			else if (optionName === 'businessHours') {
+				if (currentView) {
+					currentView.unrenderBusinessHours();
+					currentView.renderBusinessHours();
+				}
+				return;
+			}
+			else if (optionName === 'timezone') {
+				t.rezoneArrayEventSources();
+				refetchEvents();
+				return;
+			}
+		}
+
+		// catch-all. rerender the header and rebuild/rerender the current view
+		renderHeader();
+		viewsByType = {}; // even non-current views will be affected by this option change. do before rerender
+		reinitView();
 	}
 	
 	
@@ -916,8 +1031,8 @@ function Calendar_constructor(element, overrides) {
 		thisObj = thisObj || _element;
 		this.triggerWith(name, thisObj, args); // Emitter's method
 
-		if (options[name]) {
-			return options[name].apply(thisObj, args);
+		if (t.options[name]) {
+			return t.options[name].apply(thisObj, args);
 		}
 	}
 

+ 62 - 0
src/Calendar.options.js

@@ -0,0 +1,62 @@
+/*
+Options binding/triggering system.
+*/
+Calendar.mixin({
+
+	// A map of option names to arrays of handler objects. Initialized to {} in Calendar.
+	// Format for a handler object:
+	// {
+	//   func // callback function to be called upon change
+	//   names // option names whose values should be given to func
+	// }
+	optionHandlers: null, 
+
+	// Calls handlerFunc immediately, and when the given option has changed.
+	// handlerFunc will be given the option value.
+	bindOption: function(optionName, handlerFunc) {
+		this.bindOptions([ optionName ], handlerFunc);
+	},
+
+	// Calls handlerFunc immediately, and when any of the given options change.
+	// handlerFunc will be given each option value as ordered function arguments.
+	bindOptions: function(optionNames, handlerFunc) {
+		var handlerObj = { func: handlerFunc, names: optionNames };
+		var i;
+
+		for (i = 0; i < optionNames.length; i++) {
+			this.registerOptionHandlerObj(optionNames[i], handlerObj);
+		}
+
+		this.triggerOptionHandlerObj(handlerObj);
+	},
+
+	// Puts the given handler object into the internal hash
+	registerOptionHandlerObj: function(optionName, handlerObj) {
+		(this.optionHandlers[optionName] || (this.optionHandlers[optionName] = []))
+			.push(handlerObj);
+	},
+
+	// Reports that the given option has changed, and calls all appropriate handlers.
+	triggerOptionHandlers: function(optionName) {
+		var handlerObjs = this.optionHandlers[optionName] || [];
+		var i;
+
+		for (i = 0; i < handlerObjs.length; i++) {
+			this.triggerOptionHandlerObj(handlerObjs[i]);
+		}
+	},
+
+	// Calls the callback for a specific handler object, passing in the appropriate arguments.
+	triggerOptionHandlerObj: function(handlerObj) {
+		var optionNames = handlerObj.names;
+		var optionValues = [];
+		var i;
+
+		for (i = 0; i < optionNames.length; i++) {
+			optionValues.push(this.options[optionNames[i]]);
+		}
+
+		handlerObj.func.apply(this, optionValues); // maintain the Calendar's `this` context
+	}
+
+});

+ 45 - 19
src/EventManager.js

@@ -10,7 +10,7 @@ var ajaxDefaults = {
 var eventGUID = 1;
 
 
-function EventManager(options) { // assumed to be a calendar
+function EventManager() { // assumed to be a calendar
 	var t = this;
 	
 	
@@ -47,7 +47,7 @@ function EventManager(options) { // assumed to be a calendar
 
 
 	$.each(
-		(options.events ? [ options.events ] : []).concat(options.eventSources || []),
+		(t.options.events ? [ t.options.events ] : []).concat(t.options.eventSources || []),
 		function(i, sourceInput) {
 			var source = buildEventSource(sourceInput);
 			if (source) {
@@ -181,7 +181,7 @@ function EventManager(options) { // assumed to be a calendar
 				source,
 				rangeStart.clone(),
 				rangeEnd.clone(),
-				options.timezone,
+				t.options.timezone,
 				callback
 			);
 
@@ -204,7 +204,7 @@ function EventManager(options) { // assumed to be a calendar
 					t, // this, the Calendar object
 					rangeStart.clone(),
 					rangeEnd.clone(),
-					options.timezone,
+					t.options.timezone,
 					function(events) {
 						callback(events);
 						t.popLoading();
@@ -239,9 +239,9 @@ function EventManager(options) { // assumed to be a calendar
 				// and not affect the passed-in object.
 				var data = $.extend({}, customData || {});
 
-				var startParam = firstDefined(source.startParam, options.startParam);
-				var endParam = firstDefined(source.endParam, options.endParam);
-				var timezoneParam = firstDefined(source.timezoneParam, options.timezoneParam);
+				var startParam = firstDefined(source.startParam, t.options.startParam);
+				var endParam = firstDefined(source.endParam, t.options.endParam);
+				var timezoneParam = firstDefined(source.timezoneParam, t.options.timezoneParam);
 
 				if (startParam) {
 					data[startParam] = rangeStart.format();
@@ -249,8 +249,8 @@ function EventManager(options) { // assumed to be a calendar
 				if (endParam) {
 					data[endParam] = rangeEnd.format();
 				}
-				if (options.timezone && options.timezone != 'local') {
-					data[timezoneParam] = options.timezone;
+				if (t.options.timezone && t.options.timezone != 'local') {
+					data[timezoneParam] = t.options.timezone;
 				}
 
 				t.pushLoading();
@@ -582,7 +582,7 @@ function EventManager(options) { // assumed to be a calendar
 
 		reportEvents(cache);
 	}
-	
+
 	
 	function clientEvents(filter) {
 		if ($.isFunction(filter)) {
@@ -596,7 +596,33 @@ function EventManager(options) { // assumed to be a calendar
 		}
 		return cache; // else, return all
 	}
-	
+
+
+	// Makes sure all array event sources have their internal event objects
+	// converted over to the Calendar's current timezone.
+	t.rezoneArrayEventSources = function() {
+		var i;
+		var events;
+		var j;
+
+		for (i = 0; i < sources.length; i++) {
+			events = sources[i].events;
+			if ($.isArray(events)) {
+
+				for (j = 0; j < events.length; j++) {
+					rezoneEventDates(events[j]);
+				}
+			}
+		}
+	};
+
+	function rezoneEventDates(event) {
+		event.start = t.moment(event.start);
+		if (event.end) {
+			event.end = t.moment(event.end);
+		}
+		backupEventDates(event);
+	}
 	
 	
 	/* Event Normalization
@@ -612,8 +638,8 @@ function EventManager(options) { // assumed to be a calendar
 		var start, end;
 		var allDay;
 
-		if (options.eventDataTransform) {
-			input = options.eventDataTransform(input);
+		if (t.options.eventDataTransform) {
+			input = t.options.eventDataTransform(input);
 		}
 		if (source && source.eventDataTransform) {
 			input = source.eventDataTransform(input);
@@ -679,7 +705,7 @@ function EventManager(options) { // assumed to be a calendar
 			if (allDay === undefined) { // still undefined? fallback to default
 				allDay = firstDefined(
 					source ? source.allDayDefault : undefined,
-					options.allDayDefault
+					t.options.allDayDefault
 				);
 				// still undefined? normalizeEventDates will calculate it
 			}
@@ -715,7 +741,7 @@ function EventManager(options) { // assumed to be a calendar
 		}
 
 		if (!eventProps.end) {
-			if (options.forceEventDuration) {
+			if (t.options.forceEventDuration) {
 				eventProps.end = t.getDefaultEventEnd(eventProps.allDay, eventProps.start);
 			}
 			else {
@@ -1016,7 +1042,7 @@ function EventManager(options) { // assumed to be a calendar
 	// Returns an array of events as to when the business hours occur in the given view.
 	// Abuse of our event system :(
 	function getBusinessHoursEvents(wholeDay) {
-		var optionVal = options.businessHours;
+		var optionVal = t.options.businessHours;
 		var defaultVal = {
 			className: 'fc-nonbusiness',
 			start: '09:00',
@@ -1068,12 +1094,12 @@ function EventManager(options) { // assumed to be a calendar
 		var constraint = firstDefined(
 			event.constraint,
 			source.constraint,
-			options.eventConstraint
+			t.options.eventConstraint
 		);
 		var overlap = firstDefined(
 			event.overlap,
 			source.overlap,
-			options.eventOverlap
+			t.options.eventOverlap
 		);
 		return isSpanAllowed(span, constraint, overlap, event);
 	}
@@ -1102,7 +1128,7 @@ function EventManager(options) { // assumed to be a calendar
 
 	// Determines the given span (unzoned start/end with other misc data) can be selected.
 	function isSelectionSpanAllowed(span) {
-		return isSpanAllowed(span, options.selectConstraint, options.selectOverlap);
+		return isSpanAllowed(span, t.options.selectConstraint, t.options.selectOverlap);
 	}
 
 

+ 42 - 20
src/Header.js

@@ -3,7 +3,7 @@
 ----------------------------------------------------------------------------------------------------------------------*/
 // TODO: rename all header-related things to "toolbar"
 
-function Header(calendar, options) {
+function Header(calendar) {
 	var t = this;
 	
 	// exports
@@ -15,38 +15,50 @@ function Header(calendar, options) {
 	t.disableButton = disableButton;
 	t.enableButton = enableButton;
 	t.getViewsWithButtons = getViewsWithButtons;
+	t.el = null; // mirrors local `el`
 	
 	// locals
-	var el = $();
+	var el;
 	var viewsWithButtons = [];
 	var tm;
 
 
+	// can be called repeatedly and will rerender
 	function render() {
+		var options = calendar.options;
 		var sections = options.header;
 
 		tm = options.theme ? 'ui' : 'fc';
 
 		if (sections) {
-			el = $("<div class='fc-toolbar'/>")
-				.append(renderSection('left'))
+			if (!el) {
+				el = this.el = $("<div class='fc-toolbar'/>");
+			}
+			else {
+				el.empty();
+			}
+			el.append(renderSection('left'))
 				.append(renderSection('right'))
 				.append(renderSection('center'))
 				.append('<div class="fc-clear"/>');
-
-			return el;
+		}
+		else {
+			removeElement();
 		}
 	}
 	
 	
 	function removeElement() {
-		el.remove();
-		el = $();
+		if (el) {
+			el.remove();
+			el = t.el = null;
+		}
 	}
 	
 	
 	function renderSection(position) {
 		var sectionEl = $('<div class="fc-' + position + '"/>');
+		var options = calendar.options;
 		var buttonStr = options.header[position];
 
 		if (buttonStr) {
@@ -72,7 +84,7 @@ function Header(calendar, options) {
 						isOnlyButtons = false;
 					}
 					else {
-						if ((customButtonProps = (calendar.options.customButtons || {})[buttonName])) {
+						if ((customButtonProps = (options.customButtons || {})[buttonName])) {
 							buttonClick = function(ev) {
 								if (customButtonProps.click) {
 									customButtonProps.click.call(button[0], ev);
@@ -208,33 +220,43 @@ function Header(calendar, options) {
 	
 	
 	function updateTitle(text) {
-		el.find('h2').text(text);
+		if (el) {
+			el.find('h2').text(text);
+		}
 	}
 	
 	
 	function activateButton(buttonName) {
-		el.find('.fc-' + buttonName + '-button')
-			.addClass(tm + '-state-active');
+		if (el) {
+			el.find('.fc-' + buttonName + '-button')
+				.addClass(tm + '-state-active');
+		}
 	}
 	
 	
 	function deactivateButton(buttonName) {
-		el.find('.fc-' + buttonName + '-button')
-			.removeClass(tm + '-state-active');
+		if (el) {
+			el.find('.fc-' + buttonName + '-button')
+				.removeClass(tm + '-state-active');
+		}
 	}
 	
 	
 	function disableButton(buttonName) {
-		el.find('.fc-' + buttonName + '-button')
-			.prop('disabled', true)
-			.addClass(tm + '-state-disabled');
+		if (el) {
+			el.find('.fc-' + buttonName + '-button')
+				.prop('disabled', true)
+				.addClass(tm + '-state-disabled');
+		}
 	}
 	
 	
 	function enableButton(buttonName) {
-		el.find('.fc-' + buttonName + '-button')
-			.prop('disabled', false)
-			.removeClass(tm + '-state-disabled');
+		if (el) {
+			el.find('.fc-' + buttonName + '-button')
+				.prop('disabled', false)
+				.removeClass(tm + '-state-disabled');
+		}
 	}
 
 

+ 5 - 0
src/basic/BasicView.js

@@ -123,6 +123,11 @@ var BasicView = FC.BasicView = View.extend({
 	},
 
 
+	unrenderBusinessHours: function() {
+		this.dayGrid.unrenderBusinessHours();
+	},
+
+
 	// Builds the HTML skeleton for the view.
 	// The day-grid component will render inside of a container defined by this HTML.
 	renderSkeletonHtml: function() {

+ 5 - 0
src/common/DayGrid.js

@@ -70,6 +70,11 @@ var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, {
 	},
 
 
+	unrenderBusinessHours: function() {
+		this.unrenderFill('businessHours');
+	},
+
+
 	// Generates the HTML for a single row, which is a div that wraps a table.
 	// `row` is the row number.
 	renderDayRowHtml: function(row, isRigid) {

+ 15 - 5
src/common/View.js

@@ -271,12 +271,12 @@ var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, {
 	// Does everything necessary to display the view centered around the given unzoned date.
 	// Does every type of rendering EXCEPT rendering events.
 	// Is asychronous and returns a promise.
-	display: function(date) {
+	display: function(date, explicitScrollState) {
 		var _this = this;
-		var scrollState = null;
+		var prevScrollState = null;
 
-		if (this.displaying) {
-			scrollState = this.queryScroll();
+		if (explicitScrollState != null && this.displaying) { // don't need prevScrollState if explicitScrollState
+			prevScrollState = this.queryScroll();
 		}
 
 		this.calendar.freezeContentHeight();
@@ -285,7 +285,17 @@ var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, {
 			return (
 				_this.displaying =
 					syncThen(_this.displayView(date), function() { // displayView might return a promise
-						_this.forceScroll(_this.computeInitialScroll(scrollState));
+
+						// caller of display() wants a specific scroll state?
+						if (explicitScrollState != null) {
+							// we make an assumption that this is NOT the initial render,
+							// and thus don't need forceScroll (is inconveniently asynchronous)
+							_this.setScroll(explicitScrollState);
+						}
+						else {
+							_this.forceScroll(_this.computeInitialScroll(prevScrollState));
+						}
+
 						_this.calendar.unfreezeContentHeight();
 						_this.triggerRender();
 					})

+ 40 - 0
tests/automated/businessHours.js

@@ -33,8 +33,48 @@ describe('businessHours', function() {
 	});
 
 
+	describe('when used as a dynamic option', function() {
+		[ 'agendaWeek', 'month' ].forEach(function(viewName) {
+
+			it('allows dynamic turning on', function() {
+				$('#cal').fullCalendar({
+					defaultView: viewName,
+					businessHours: false
+				});
+				var rootEl = $('.fc-view > *:first');
+				expect(rootEl.length).toBe(1);
+
+				expect(queryNonBusinessSegs().length).toBe(0);
+				$('#cal').fullCalendar('option', 'businessHours', true);
+				expect(queryNonBusinessSegs().length).toBeGreaterThan(0);
+
+				expect($('.fc-view > *:first')[0]).toBe(rootEl[0]); // same element. didn't completely rerender
+			});
+
+			it('allows dynamic turning off', function() {
+				$('#cal').fullCalendar({
+					defaultView: viewName,
+					businessHours: true
+				});
+				var rootEl = $('.fc-view > *:first');
+				expect(rootEl.length).toBe(1);
+
+				expect(queryNonBusinessSegs().length).toBeGreaterThan(0);
+				$('#cal').fullCalendar('option', 'businessHours', false);
+				expect(queryNonBusinessSegs().length).toBe(0);
+
+				expect($('.fc-view > *:first')[0]).toBe(rootEl[0]); // same element. didn't completely rerender
+			});
+		});
+	});
+
+
 	function queryNonBusinessSegsInCol(col) {
 		return $('.fc-time-grid .fc-content-skeleton td:not(.fc-axis):eq(' + col + ') .fc-nonbusiness');
 	}
 
+	function queryNonBusinessSegs() {
+		return $('.fc-nonbusiness');
+	}
+
 });

+ 21 - 0
tests/automated/events-function.js

@@ -86,6 +86,27 @@ describe('events as a function', function() {
 		$('#cal').fullCalendar(options);
 	});
 
+	it('requests correctly when timezone changed dynamically', function(done) {
+		var callCnt = 0;
+
+		options.timezone = 'America/Chicago';
+		options.events = function(start, end, timezone, callback) {
+			callCnt++;
+			if (callCnt === 1) {
+				expect(timezone).toEqual('America/Chicago');
+				setTimeout(function() {
+					$('#cal').fullCalendar('option', 'timezone', 'UTC');
+				}, 0);
+			}
+			else if (callCnt === 2) {
+				expect(timezone).toEqual('UTC');
+				done();
+			}
+		};
+
+		$('#cal').fullCalendar(options);
+	});
+
 	it('requests correctly with event source extended form', function(done) {
 		var eventSource = {
 			className: 'customeventclass',

+ 7 - 0
tests/automated/header-rendering.js

@@ -68,6 +68,13 @@ describe('header rendering', function() {
 		});
 	});
 
+	it('allow for dynamically changing', function() {
+		$('#calendar').fullCalendar();
+		expect($('.fc-toolbar')).toBeInDOM();
+		$('#calendar').fullCalendar('option', 'header', false);
+		expect($('.fc-toolbar')).not.toBeInDOM();
+	});
+
 	describe('renders left and right literally', function() {
 		[ true, false ].forEach(function(isRTL) {
 			describe('when isRTL is ' + isRTL, function() {

+ 13 - 0
tests/automated/isRTL.js

@@ -12,4 +12,17 @@ describe('isRTL', function() {
 	// NOTE: don't put tests related to other options in here!
 	// Put them in the test file for the individual option!
 
+	it('adapts to dynamic option change', function() {
+		affix('#cal');
+		$('#cal').fullCalendar({
+			isRTL: false
+		});
+		expect($('#cal')).toHaveClass('fc-ltr');
+		expect($('#cal')).not.toHaveClass('fc-rtl');
+
+		$('#cal').fullCalendar('option', 'isRTL', true);
+		expect($('#cal')).toHaveClass('fc-rtl');
+		expect($('#cal')).not.toHaveClass('fc-ltr');
+	});
+
 });

+ 15 - 0
tests/automated/lang.js

@@ -69,4 +69,19 @@ describe('lang', function() {
 		expect($('.fc-event .fc-time')).toHaveText('10:00');
 	});
 
+	it('allows dynamic setting', function() {
+		affix('#cal');
+		$('#cal').fullCalendar({
+			lang: 'es',
+			defaultDate: '2016-07-10',
+			defaultView: 'month'
+		});
+		expect($('.fc h2')).toHaveText('julio 2016');
+		expect($('.fc')).not.toHaveClass('fc-rtl');
+
+		$('#cal').fullCalendar('option', 'lang', 'ar');
+		expect($('.fc h2')).toHaveText('تموز يوليو ٢٠١٦');
+		expect($('.fc')).toHaveClass('fc-rtl');
+	});
+
 });

+ 12 - 2
tests/automated/refetchEvents.js

@@ -91,7 +91,11 @@ describe('refetchEvents', function() {
 				// set a 100ms timeout on this event source
 				options.eventSources[0].events = function(start, end, timezone, callback) {
 					var events = [
-						{ id: '1', start: '2015-08-07T02:00:00', end: '2015-08-07T03:00:00', title: 'event A', className: 'fetch' + fetchCount }
+						{ id: '1',
+						  start: '2015-08-07T02:00:00',
+						  end: '2015-08-07T03:00:00',
+						  title: 'event A', className: 'fetch' + fetchCount
+						}
 					];
 
 					setTimeout(function() {
@@ -123,7 +127,13 @@ describe('refetchEvents', function() {
 		function createEventGenerator() {
 			return function(start, end, timezone, callback) {
 				var events = [
-					{ id: 1, start: '2015-08-07T02:00:00', end: '2015-08-07T03:00:00', title: 'event A', className: 'fetch' + fetchCount }
+					{
+					  id: 1,
+					  start: '2015-08-07T02:00:00',
+					  end: '2015-08-07T03:00:00',
+					  title: 'event A',
+					  className: 'fetch' + fetchCount
+					}
 				];
 
 				callback(events);

+ 7 - 7
tests/automated/removeEventSource.js

@@ -56,21 +56,21 @@ describe('removeEventSource', function() {
 
 		var source1 = function(start, end, timezone, callback) {
 			setTimeout(function() {
-				callback([{
+				callback([ {
 					title: 'event1',
 					className: 'event1',
 					start: '2014-08-01T02:00:00'
-				}]);
+				} ]);
 			}, 100);
 		};
 
 		var source2 = function(start, end, timezone, callback) {
 			setTimeout(function() {
-				callback([{
+				callback([ {
 					title: 'event2',
 					className: 'event2',
 					start: '2014-08-01T02:00:00'
-				}]);
+				} ]);
 			}, 100);
 		};
 
@@ -94,10 +94,10 @@ describe('removeEventSource', function() {
 
 	describe('when multiple sources share the same fetching function', function() {
 		var fetchFunc = function(start, end, timezone, callback) {
-			callback([{
+			callback([ {
 				title: 'event',
 				start: '2014-08-01T02:00:00'
-			}]);
+			} ]);
 		};
 		beforeEach(function() {
 			options.eventSources = [
@@ -192,4 +192,4 @@ describe('removeEventSource', function() {
 		expect($('.fc-event').length).toBe(cnt);
 		expect($('#cal').fullCalendar('clientEvents').length).toBe(cnt);
 	}
-});
+});

+ 2 - 2
tests/automated/removeEventSources.js

@@ -41,11 +41,11 @@ describe('removeEventSources', function() {
 		return {
 			id: id,
 			events: function(start, end, timezone, callback) {
-				callback([{
+				callback([ {
 					title: 'event' + id,
 					className: 'event' + id,
 					start: '2014-08-01T02:00:00'
-				}]);
+				} ]);
 			}
 		};
 	}

+ 55 - 0
tests/automated/theme.js

@@ -0,0 +1,55 @@
+describe('theme', function() {
+
+	it('can be changed dynamically', function() {
+		affix('#cal');
+		$('#cal').fullCalendar({
+			defaultView: 'agendaWeek'
+		});
+
+		expect($('.fc')).toHaveClass('fc-unthemed');
+		expect($('.fc')).not.toHaveClass('ui-widget');
+		expect($('.fc-toolbar button .fc-icon').length).toBeGreaterThan(0);
+		expect($('.fc-toolbar button .ui-icon').length).toBe(0);
+		expect($('.ui-widget-header').length).toBe(0);
+
+		$('.fc-scroller').scrollTop(99999); // scroll all the way down
+		var scrollTop = $('.fc-scroller').scrollTop();
+
+		// change option!
+		$('#cal').fullCalendar('option', 'theme', true);
+
+		expect($('.fc')).toHaveClass('ui-widget');
+		expect($('.fc')).not.toHaveClass('fc-unthemed');
+		expect($('.fc-toolbar button .fc-icon').length).toBe(0);
+		expect($('.fc-toolbar button .ui-icon').length).toBeGreaterThan(0);
+		expect($('.ui-widget-header').length).toBeGreaterThan(0);
+
+		// similar scroll state after the change
+		expect(Math.abs(scrollTop - $('.fc-scroller').scrollTop())).toBeLessThan(5);
+	});
+
+
+	// this tests the options setter with a single hash argument.
+	// TODO: not best place for this.
+	it('can be change with other options', function() {
+		affix('#cal');
+		$('#cal').fullCalendar({
+			defaultView: 'agendaWeek'
+		});
+
+		expect($('.fc')).toHaveClass('fc-unthemed');
+		expect($('.fc')).not.toHaveClass('ui-widget');
+		expect($('.fc-nonbusiness').length).toBe(0);
+
+		// change option!
+		$('#cal').fullCalendar('option', {
+			theme: true,
+			businessHours: true
+		});
+
+		expect($('.fc')).toHaveClass('ui-widget');
+		expect($('.fc')).not.toHaveClass('fc-unthemed');
+		expect($('.fc-nonbusiness').length).toBeGreaterThan(0);
+	});
+
+});

+ 92 - 48
tests/automated/timezone.js

@@ -31,83 +31,127 @@ describe('timezone', function() {
 		};
 	});
 
+
 	it('receives events correctly when no timezone', function(done) {
 		options.eventAfterAllRender = function() {
-			var allDayEvent = $('#cal').fullCalendar('clientEvents', '1')[0];
-			var timedEvent = $('#cal').fullCalendar('clientEvents', '2')[0];
-			var zonedEvent = $('#cal').fullCalendar('clientEvents', '3')[0];
-			expect(allDayEvent.start.hasZone()).toEqual(false);
-			expect(allDayEvent.start.hasTime()).toEqual(false);
-			expect(allDayEvent.start.format()).toEqual('2014-05-02');
-			expect(timedEvent.start.hasZone()).toEqual(false);
-			expect(timedEvent.start.hasTime()).toEqual(true);
-			expect(timedEvent.start.format()).toEqual('2014-05-10T12:00:00');
-			expect(zonedEvent.start.hasZone()).toEqual(true);
-			expect(zonedEvent.start.hasTime()).toEqual(true);
-			expect(zonedEvent.start.format()).toEqual('2014-05-10T14:00:00+11:00');
+			expectNoTimezone();
 			done();
 		};
 		$('#cal').fullCalendar(options);
 	});
 
+	function expectNoTimezone() {
+		var allDayEvent = $('#cal').fullCalendar('clientEvents', '1')[0];
+		var timedEvent = $('#cal').fullCalendar('clientEvents', '2')[0];
+		var zonedEvent = $('#cal').fullCalendar('clientEvents', '3')[0];
+		expect(allDayEvent.start.hasZone()).toEqual(false);
+		expect(allDayEvent.start.hasTime()).toEqual(false);
+		expect(allDayEvent.start.format()).toEqual('2014-05-02');
+		expect(timedEvent.start.hasZone()).toEqual(false);
+		expect(timedEvent.start.hasTime()).toEqual(true);
+		expect(timedEvent.start.format()).toEqual('2014-05-10T12:00:00');
+		expect(zonedEvent.start.hasZone()).toEqual(true);
+		expect(zonedEvent.start.hasTime()).toEqual(true);
+		expect(zonedEvent.start.format()).toEqual('2014-05-10T14:00:00+11:00');
+	}
+
+
 	it('receives events correctly when local timezone', function(done) {
 		options.timezone = 'local';
 		options.eventAfterAllRender = function() {
-			var allDayEvent = $('#cal').fullCalendar('clientEvents', '1')[0];
-			var timedEvent = $('#cal').fullCalendar('clientEvents', '2')[0];
-			var zonedEvent = $('#cal').fullCalendar('clientEvents', '3')[0];
-			expect(allDayEvent.start.hasZone()).toEqual(false);
-			expect(allDayEvent.start.hasTime()).toEqual(false);
-			expect(allDayEvent.start.format()).toEqual('2014-05-02');
-			expect(timedEvent.start.hasZone()).toEqual(true);
-			expect(timedEvent.start.hasTime()).toEqual(true);
-			expect(timedEvent.start.zone()).toEqual(new Date(2014, 4, 10, 12).getTimezoneOffset());
-			expect(zonedEvent.start.hasZone()).toEqual(true);
-			expect(zonedEvent.start.hasTime()).toEqual(true);
-			expect(zonedEvent.start.zone()).toEqual(new Date('Sat May 10 2014 14:00:00 GMT+1100').getTimezoneOffset());
+			expectLocalTimezone();
 			done();
 		};
 		$('#cal').fullCalendar(options);
 	});
 
+	function expectLocalTimezone() {
+		var allDayEvent = $('#cal').fullCalendar('clientEvents', '1')[0];
+		var timedEvent = $('#cal').fullCalendar('clientEvents', '2')[0];
+		var zonedEvent = $('#cal').fullCalendar('clientEvents', '3')[0];
+		expect(allDayEvent.start.hasZone()).toEqual(false);
+		expect(allDayEvent.start.hasTime()).toEqual(false);
+		expect(allDayEvent.start.format()).toEqual('2014-05-02');
+		expect(timedEvent.start.hasZone()).toEqual(true);
+		expect(timedEvent.start.hasTime()).toEqual(true);
+		expect(timedEvent.start.zone()).toEqual(new Date(2014, 4, 10, 12).getTimezoneOffset());
+		expect(zonedEvent.start.hasZone()).toEqual(true);
+		expect(zonedEvent.start.hasTime()).toEqual(true);
+		expect(zonedEvent.start.zone()).toEqual(new Date('Sat May 10 2014 14:00:00 GMT+1100').getTimezoneOffset());
+	}
+
+
 	it('receives events correctly when UTC timezone', function(done) {
 		options.timezone = 'UTC';
 		options.eventAfterAllRender = function() {
-			var allDayEvent = $('#cal').fullCalendar('clientEvents', '1')[0];
-			var timedEvent = $('#cal').fullCalendar('clientEvents', '2')[0];
-			var zonedEvent = $('#cal').fullCalendar('clientEvents', '3')[0];
-			expect(allDayEvent.start.hasZone()).toEqual(false);
-			expect(allDayEvent.start.hasTime()).toEqual(false);
-			expect(allDayEvent.start.format()).toEqual('2014-05-02');
-			expect(timedEvent.start.hasZone()).toEqual(true);
-			expect(timedEvent.start.hasTime()).toEqual(true);
-			expect(timedEvent.start.format()).toEqual('2014-05-10T12:00:00Z');
-			expect(zonedEvent.start.hasZone()).toEqual(true);
-			expect(zonedEvent.start.hasTime()).toEqual(true);
-			expect(zonedEvent.start.format()).toEqual('2014-05-10T03:00:00Z');
+			expectUtcTimezone();
 			done();
 		};
 		$('#cal').fullCalendar(options);
 	});
 
+	function expectUtcTimezone() {
+		var allDayEvent = $('#cal').fullCalendar('clientEvents', '1')[0];
+		var timedEvent = $('#cal').fullCalendar('clientEvents', '2')[0];
+		var zonedEvent = $('#cal').fullCalendar('clientEvents', '3')[0];
+		expect(allDayEvent.start.hasZone()).toEqual(false);
+		expect(allDayEvent.start.hasTime()).toEqual(false);
+		expect(allDayEvent.start.format()).toEqual('2014-05-02');
+		expect(timedEvent.start.hasZone()).toEqual(true);
+		expect(timedEvent.start.hasTime()).toEqual(true);
+		expect(timedEvent.start.format()).toEqual('2014-05-10T12:00:00Z');
+		expect(zonedEvent.start.hasZone()).toEqual(true);
+		expect(zonedEvent.start.hasTime()).toEqual(true);
+		expect(zonedEvent.start.format()).toEqual('2014-05-10T03:00:00Z');
+	}
+
+
 	it('receives events correctly when custom timezone', function(done) {
 		options.timezone = 'America/Chicago';
 		options.eventAfterAllRender = function() {
-			var allDayEvent = $('#cal').fullCalendar('clientEvents', '1')[0];
-			var timedEvent = $('#cal').fullCalendar('clientEvents', '2')[0];
-			var zonedEvent = $('#cal').fullCalendar('clientEvents', '3')[0];
-			expect(allDayEvent.start.hasZone()).toEqual(false);
-			expect(allDayEvent.start.hasTime()).toEqual(false);
-			expect(allDayEvent.start.format()).toEqual('2014-05-02');
-			expect(timedEvent.start.hasZone()).toEqual(false);
-			expect(timedEvent.start.hasTime()).toEqual(true);
-			expect(timedEvent.start.format()).toEqual('2014-05-10T12:00:00');
-			expect(zonedEvent.start.hasZone()).toEqual(true);
-			expect(zonedEvent.start.hasTime()).toEqual(true);
-			expect(zonedEvent.start.format()).toEqual('2014-05-10T14:00:00+11:00');
+			expectCustomTimezone();
 			done();
 		};
 		$('#cal').fullCalendar(options);
 	});
 
+	function expectCustomTimezone() {
+		var allDayEvent = $('#cal').fullCalendar('clientEvents', '1')[0];
+		var timedEvent = $('#cal').fullCalendar('clientEvents', '2')[0];
+		var zonedEvent = $('#cal').fullCalendar('clientEvents', '3')[0];
+		expect(allDayEvent.start.hasZone()).toEqual(false);
+		expect(allDayEvent.start.hasTime()).toEqual(false);
+		expect(allDayEvent.start.format()).toEqual('2014-05-02');
+		expect(timedEvent.start.hasZone()).toEqual(false);
+		expect(timedEvent.start.hasTime()).toEqual(true);
+		expect(timedEvent.start.format()).toEqual('2014-05-10T12:00:00');
+		expect(zonedEvent.start.hasZone()).toEqual(true);
+		expect(zonedEvent.start.hasTime()).toEqual(true);
+		expect(zonedEvent.start.format()).toEqual('2014-05-10T14:00:00+11:00');
+	}
+
+
+	it('can be set dynamically', function(done) {
+		var callCnt = 0;
+		var rootEl;
+
+		options.timezone = false;
+		options.eventAfterAllRender = function() {
+			callCnt++;
+			if (callCnt === 1) {
+				expectNoTimezone();
+				rootEl = $('.fc-view > *:first');
+				expect(rootEl.length).toBe(1);
+				$('#cal').fullCalendar('option', 'timezone', 'UTC'); // will cause second call...
+			}
+			else if (callCnt === 2) {
+				expectUtcTimezone();
+				expect($('.fc-view > *:first')[0]).toBe(rootEl[0]); // ensure didn't rerender whole calendar
+				done();
+			}
+		};
+
+		$('#cal').fullCalendar(options);
+	});
+
 });

+ 123 - 0
tests/dynamic-options.html

@@ -0,0 +1,123 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset='utf-8' />
+<link href='../lib/jquery-ui/themes/cupertino/jquery-ui.min.css' rel='stylesheet' />
+<link href='../dist/fullcalendar.css' rel='stylesheet' />
+<link href='../dist/fullcalendar.print.css' rel='stylesheet' media='print' />
+<script src='../lib/moment/moment.js'></script>
+<script src='../lib/jquery/dist/jquery.js'></script>
+<script src='../dist/fullcalendar.js'></script>
+<script>
+
+	$(document).ready(function() {
+		var theme = false;
+		var isRTL = false;
+		var businessHours = false;
+
+		$('#change-theme-button').on('click', function() {
+			theme = !theme;
+			$('#calendar').fullCalendar('option', 'theme', theme);
+		});
+
+		$('#change-dir-button').on('click', function() {
+			isRTL = !isRTL;
+			$('#calendar').fullCalendar('option', 'isRTL', isRTL);
+		});
+
+		$('#change-businessHours-button').on('click', function() {
+			businessHours = !businessHours;
+			$('#calendar').fullCalendar('option', 'businessHours', businessHours);
+		});
+
+		$('#change-all-button').on('click', function() {
+			theme = !theme;
+			isRTL = !isRTL;
+			businessHours = !businessHours;
+			$('#calendar').fullCalendar('option', {
+				theme: theme,
+				isRTL: isRTL,
+				businessHours: businessHours
+			});
+		});
+
+		$('#calendar').fullCalendar({
+			theme: theme,
+			isRTL: isRTL,
+			businessHours: businessHours,
+			header: {
+				left: 'prev,next today',
+				center: 'title',
+				right: 'month,agendaWeek,agendaDay'
+			},
+			defaultDate: '2014-06-12',
+			editable: true,
+			events: [
+				{
+					title: 'All Day Event',
+					start: '2014-06-01'
+				},
+				{
+					title: 'Long Event',
+					start: '2014-06-07',
+					end: '2014-06-10'
+				},
+				{
+					id: 999,
+					title: 'Repeating Event',
+					start: '2014-06-09T16:00:00'
+				},
+				{
+					id: 999,
+					title: 'Repeating Event',
+					start: '2014-06-16T16:00:00'
+				},
+				{
+					title: 'Meeting',
+					start: '2014-06-12T10:30:00',
+					end: '2014-06-12T12:30:00'
+				},
+				{
+					title: 'Lunch',
+					start: '2014-06-12T12:00:00'
+				},
+				{
+					title: 'Birthday Party',
+					start: '2014-06-13T07:00:00'
+				},
+				{
+					title: 'Click for Google',
+					url: 'http://google.com/',
+					start: '2014-06-28'
+				}
+			]
+		});
+
+	});
+
+</script>
+<style>
+
+	body {
+		margin: 0;
+		padding: 0;
+		font-family: "Lucida Grande",Helvetica,Arial,Verdana,sans-serif;
+		font-size: 14px;
+	}
+
+	#calendar {
+		width: 900px;
+		margin: 40px auto;
+	}
+
+</style>
+</head>
+<body>
+	<button id='change-theme-button'>change theme</button>
+	<button id='change-dir-button'>change dir</button>
+	<button id='change-businessHours-button'>change businessHours</button>
+	<button id='change-all-button'>change all</button>
+
+	<div id='calendar'></div>
+</body>
+</html>