AMBARI-10064. Storm Ambari view (Sriharsha Chintalapani via srimanth)
Project: http://git-wip-us.apache.org/repos/asf/ambari/repo Commit: http://git-wip-us.apache.org/repos/asf/ambari/commit/cd6398a1 Tree: http://git-wip-us.apache.org/repos/asf/ambari/tree/cd6398a1 Diff: http://git-wip-us.apache.org/repos/asf/ambari/diff/cd6398a1 Branch: refs/heads/trunk Commit: cd6398a1dad3ea9503d408d98623088ecb76ef3b Parents: 11ef5ca Author: Srimanth Gunturi <[email protected]> Authored: Fri May 22 12:40:51 2015 -0700 Committer: Srimanth Gunturi <[email protected]> Committed: Fri May 22 12:41:02 2015 -0700 ---------------------------------------------------------------------- contrib/views/storm/pom.xml | 105 + .../org/apache/ambari/storm/ProxyServlet.java | 100 + contrib/views/storm/src/main/resources/404.html | 169 + .../storm/src/main/resources/WEB-INF/web.xml | 37 + .../views/storm/src/main/resources/index.html | 56 + .../bower/backbone-forms/js/backbone-forms.js | 2446 +++++ .../libs/bower/backbone-forms/js/list.js | 650 ++ .../js/backbone.babysitter.js | 190 + .../js/backbone.marionette.js | 3128 ++++++ .../bower/backbone.wreqr/js/backbone.wreqr.js | 435 + .../libs/bower/backbone/js/backbone.js | 1608 +++ .../libs/bower/backgrid/css/backgrid.min.css | 1 + .../libs/bower/backgrid/js/backgrid.js | 2887 ++++++ .../resources/libs/bower/bootbox/js/bootbox.js | 894 ++ .../libs/bower/bootstrap/css/bootstrap.min.css | 5 + .../fonts/glyphicons-halflings-regular.svg | 288 + .../bootstrap/js/bootstrap-filestyle.min.js | 1 + .../libs/bower/bootstrap/js/bootstrap-notify.js | 97 + .../libs/bower/bootstrap/js/bootstrap.js | 2306 +++++ .../bower/font-awesome/css/font-awesome.min.css | 4 + .../font-awesome/fonts/fontawesome-webfont.svg | 565 ++ .../libs/bower/globalize/js/globalize.js | 1586 +++ .../jquery-fileupload/js/jquery.fileupload.js | 1201 +++ .../libs/bower/jquery-ui/css/jquery-ui.min.css | 5 + .../jquery-ui/js/jquery-ui-1.10.3.custom.js | 12 + .../bower/jquery-ui/js/jquery.ui.widget.min.js | 5 + .../resources/libs/bower/jquery/js/jquery.js | 9205 ++++++++++++++++++ .../libs/bower/jquery/js/jquery.min.js | 5 + .../libs/bower/jquery/js/jquery.min.map | 1 + .../require-handlebars-plugin/js/handlebars.js | 2220 +++++ .../bower/require-handlebars-plugin/js/hbs.js | 492 + .../js/i18nprecompile.js | 57 + .../bower/require-handlebars-plugin/js/json2.js | 365 + .../libs/bower/requirejs-text/js/text.js | 390 + .../libs/bower/requirejs/js/require.js | 2083 ++++ .../libs/bower/underscore/js/underscore.js | 1415 +++ .../main/resources/libs/other/arbor-graphics.js | 51 + .../main/resources/libs/other/arbor-tween.js | 81 + .../src/main/resources/libs/other/arbor.js | 67 + .../storm/src/main/resources/scripts/App.js | 41 + .../scripts/collection/BaseCollection.js | 61 + .../scripts/collection/VTopologyList.js | 42 + .../resources/scripts/globalize/message/en.js | 180 + .../storm/src/main/resources/scripts/main.js | 151 + .../main/resources/scripts/models/BaseModel.js | 71 + .../main/resources/scripts/models/Cluster.js | 42 + .../src/main/resources/scripts/models/VBolt.js | 42 + .../src/main/resources/scripts/models/VError.js | 42 + .../main/resources/scripts/models/VExecutor.js | 42 + .../src/main/resources/scripts/models/VModel.js | 42 + .../main/resources/scripts/models/VNimbus.js | 42 + .../resources/scripts/models/VNimbusConfig.js | 42 + .../resources/scripts/models/VOutputStat.js | 42 + .../src/main/resources/scripts/models/VSpout.js | 44 + .../resources/scripts/models/VSupervisor.js | 42 + .../main/resources/scripts/models/VTopology.js | 56 + .../resources/scripts/models/VTopologyConfig.js | 42 + .../main/resources/scripts/modules/Helpers.js | 157 + .../src/main/resources/scripts/modules/Vent.js | 22 + .../src/main/resources/scripts/router/Router.js | 98 + .../src/main/resources/scripts/utils/Globals.js | 30 + .../main/resources/scripts/utils/LangSupport.js | 116 + .../main/resources/scripts/utils/Overrides.js | 218 + .../main/resources/scripts/utils/TableLayout.js | 106 + .../src/main/resources/scripts/utils/Utils.js | 133 + .../scripts/views/Cluster/ClusterSummary.js | 381 + .../scripts/views/Spout/SpoutCollectionView.js | 53 + .../scripts/views/Spout/SpoutItemView.js | 355 + .../scripts/views/Topology/RebalanceForm.js | 84 + .../scripts/views/Topology/TopologyDetail.js | 734 ++ .../scripts/views/Topology/TopologyForm.js | 102 + .../scripts/views/Topology/TopologyGraphView.js | 423 + .../scripts/views/Topology/TopologySummary.js | 300 + .../main/resources/scripts/views/site/Header.js | 79 + .../storm/src/main/resources/styles/default.css | 298 + .../templates/cluster/clusterSummary.html | 83 + .../main/resources/templates/site/header.html | 23 + .../templates/spout/spoutItemView.html | 46 + .../templates/topology/topologyDetail.html | 108 + .../templates/topology/topologyForm.html | 8 + .../templates/topology/topologySummary.html | 23 + contrib/views/storm/src/main/resources/view.xml | 22 + pom.xml | 3 + 83 files changed, 40284 insertions(+) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/ambari/blob/cd6398a1/contrib/views/storm/pom.xml ---------------------------------------------------------------------- diff --git a/contrib/views/storm/pom.xml b/contrib/views/storm/pom.xml new file mode 100644 index 0000000..8ad78e5 --- /dev/null +++ b/contrib/views/storm/pom.xml @@ -0,0 +1,105 @@ +<!-- + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + --> + <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> + <parent> + <groupId>org.apache.ambari.contrib.views</groupId> + <artifactId>ambari-contrib-views</artifactId> + <version>2.0.0-SNAPSHOT</version> + </parent> + <properties> + <ambari.dir>${project.parent.parent.parent.basedir}</ambari.dir> + </properties> + <modelVersion>4.0.0</modelVersion> + <groupId>org.apache.ambari.contrib.views</groupId> + <artifactId>storm-view</artifactId> + <name>Storm_Monitoring</name> + <version>0.1.0</version> + <description>Storm Monitoring View</description> + + <build> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-compiler-plugin</artifactId> + <version>3.0</version> + <configuration> + <source>1.7</source> + <target>1.7</target> + </configuration> + </plugin> + <plugin> + <groupId>org.codehaus.mojo</groupId> + <artifactId>rpm-maven-plugin</artifactId> + <version>2.0.1</version> + <executions> + <execution> + <phase>none</phase> + <goals> + <goal>rpm</goal> + </goals> + </execution> + </executions> + </plugin> + <plugin> + <groupId>org.apache.rat</groupId> + <artifactId>apache-rat-plugin</artifactId> + <configuration> + <excludes> + <exclude>src/main/resources/libs/**</exclude> + <exclude>src/main/resources/styles/**</exclude> + <exclude>src/main/resources/templates/**</exclude> + </excludes> + </configuration> + <executions> + <execution> + <phase>test</phase> + <goals> + <goal>check</goal> + </goals> + </execution> + </executions> + </plugin> + </plugins> + </build> + <dependencies> + <dependency> + <groupId>com.sun.jersey</groupId> + <artifactId>jersey-server</artifactId> + <version>1.8</version> + </dependency> + + <dependency> + <groupId>org.apache.ambari</groupId> + <artifactId>ambari-views</artifactId> + <version>${ambari.version}</version> + </dependency> + + <dependency> + <groupId>javax.servlet</groupId> + <artifactId>javax.servlet-api</artifactId> + <version>3.0.1</version> + <scope>provided</scope> + </dependency> + + <dependency> + <groupId>com.google.inject</groupId> + <artifactId>guice</artifactId> + </dependency> + + </dependencies> +</project> http://git-wip-us.apache.org/repos/asf/ambari/blob/cd6398a1/contrib/views/storm/src/main/java/org/apache/ambari/storm/ProxyServlet.java ---------------------------------------------------------------------- diff --git a/contrib/views/storm/src/main/java/org/apache/ambari/storm/ProxyServlet.java b/contrib/views/storm/src/main/java/org/apache/ambari/storm/ProxyServlet.java new file mode 100644 index 0000000..6e6bea2 --- /dev/null +++ b/contrib/views/storm/src/main/java/org/apache/ambari/storm/ProxyServlet.java @@ -0,0 +1,100 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ambari.view.storm; + +import org.apache.ambari.view.ViewContext; + +import java.io.IOException; +import java.io.PrintWriter; +import javax.servlet.ServletConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.net.URLDecoder; +import java.util.*; +import java.io.*; + +/** + * Simple servlet for proxying requests with doAs impersonation. + */ +public class ProxyServlet extends HttpServlet { + + private ViewContext viewContext; + + @Override + public void init(ServletConfig config) throws ServletException { + super.init(config); + + ServletContext context = config.getServletContext(); + viewContext = (ViewContext) context.getAttribute(ViewContext.CONTEXT_ATTRIBUTE); + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { + InputStream body = null; + String urlToRead = URLDecoder.decode(request.getParameter("url")); + HashMap<String,String> headersMap = this.getHeaders(request); + InputStream resultStream = viewContext.getURLStreamProvider().readAsCurrent(urlToRead, "GET", body, headersMap); + this.setResponse(request, response, resultStream); + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { + InputStream stream = request.getInputStream(); + String urlToRead = URLDecoder.decode(request.getParameter("url")); + HashMap<String,String> headersMap = this.getHeaders(request); + InputStream resultStream = viewContext.getURLStreamProvider().readAsCurrent(urlToRead, "POST", stream, headersMap); + this.setResponse(request, response, resultStream); + } + + /** + * Get headers from request + * @param request HTTPServletRequest + * @return HashMap map containing headers + */ + public HashMap<String,String> getHeaders(HttpServletRequest request){ + Enumeration headerNames = request.getHeaderNames(); + HashMap<String,String> map = new HashMap<String, String>(); + while (headerNames.hasMoreElements()) { + String key = (String) headerNames.nextElement(); + String value = request.getHeader(key); + map.put(key, value); + } + map.put("Content-Type",request.getContentType()); + return map; + } + + /** + * Set response to the get/post request + * @param request HttpServletRequest + * @param response HttpServletResponse + * @param resultStream InputStream + */ + public void setResponse(HttpServletRequest request, HttpServletResponse response, InputStream resultStream) throws IOException{ + Scanner scanner = new Scanner(resultStream).useDelimiter("\\A"); + String result = scanner.hasNext() ? scanner.next() : ""; + Boolean notFound = result == "" || result.indexOf("\"exception\":\"NotFoundException\"") != -1; + response.setContentType(request.getContentType()); + response.setStatus(notFound ? HttpServletResponse.SC_NOT_FOUND : HttpServletResponse.SC_OK); + PrintWriter writer = response.getWriter(); + writer.print(result); + } +} http://git-wip-us.apache.org/repos/asf/ambari/blob/cd6398a1/contrib/views/storm/src/main/resources/404.html ---------------------------------------------------------------------- diff --git a/contrib/views/storm/src/main/resources/404.html b/contrib/views/storm/src/main/resources/404.html new file mode 100644 index 0000000..4f16be5 --- /dev/null +++ b/contrib/views/storm/src/main/resources/404.html @@ -0,0 +1,169 @@ +<!-- +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. Kerberos, LDAP, Custom. Binary/Htt +--> +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>Page Not Found :(</title> + <style> + ::-moz-selection { + background: #b3d4fc; + text-shadow: none; + } + + ::selection { + background: #b3d4fc; + text-shadow: none; + } + + html { + padding: 30px 10px; + font-size: 20px; + line-height: 1.4; + color: #737373; + background: #f0f0f0; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + } + + html, + input { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + } + + body { + max-width: 500px; + _width: 500px; + padding: 30px 20px 50px; + border: 1px solid #b3b3b3; + border-radius: 4px; + margin: 0 auto; + box-shadow: 0 1px 10px #a7a7a7, inset 0 1px 0 #fff; + background: #fcfcfc; + } + + h1 { + margin: 0 10px; + font-size: 50px; + text-align: center; + } + + h1 span { + color: #bbb; + } + + h3 { + margin: 1.5em 0 0.5em; + } + + p { + margin: 1em 0; + } + + ul { + padding: 0 0 0 40px; + margin: 1em 0; + } + + .container { + max-width: 380px; + _width: 380px; + margin: 0 auto; + } + + /* google search */ + + #goog-fixurl ul { + list-style: none; + padding: 0; + margin: 0; + } + + #goog-fixurl form { + margin: 0; + } + + #goog-wm-qt, + #goog-wm-sb { + border: 1px solid #bbb; + font-size: 16px; + line-height: normal; + vertical-align: top; + color: #444; + border-radius: 2px; + } + + #goog-wm-qt { + width: 220px; + height: 20px; + padding: 5px; + margin: 5px 10px 0 0; + box-shadow: inset 0 1px 1px #ccc; + } + + #goog-wm-sb { + display: inline-block; + height: 32px; + padding: 0 10px; + margin: 5px 0 0; + white-space: nowrap; + cursor: pointer; + background-color: #f5f5f5; + background-image: -webkit-linear-gradient(rgba(255,255,255,0), #f1f1f1); + background-image: -moz-linear-gradient(rgba(255,255,255,0), #f1f1f1); + background-image: -ms-linear-gradient(rgba(255,255,255,0), #f1f1f1); + background-image: -o-linear-gradient(rgba(255,255,255,0), #f1f1f1); + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + *overflow: visible; + *display: inline; + *zoom: 1; + } + + #goog-wm-sb:hover, + #goog-wm-sb:focus { + border-color: #aaa; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); + background-color: #f8f8f8; + } + + #goog-wm-qt:hover, + #goog-wm-qt:focus { + border-color: #105cb6; + outline: 0; + color: #222; + } + + input::-moz-focus-inner { + padding: 0; + border: 0; + } + </style> + </head> + <body> + <div class="container"> + <h1>Not found <span>:(</span></h1> + <p>Sorry, but the page you were trying to view does not exist.</p> + <p>It looks like this was the result of either:</p> + <ul> + <li>a mistyped address</li> + <li>an out-of-date link</li> + </ul> + </div> + </body> +</html> http://git-wip-us.apache.org/repos/asf/ambari/blob/cd6398a1/contrib/views/storm/src/main/resources/WEB-INF/web.xml ---------------------------------------------------------------------- diff --git a/contrib/views/storm/src/main/resources/WEB-INF/web.xml b/contrib/views/storm/src/main/resources/WEB-INF/web.xml new file mode 100644 index 0000000..e406de1 --- /dev/null +++ b/contrib/views/storm/src/main/resources/WEB-INF/web.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="ISO-8859-1" ?> + +<!-- +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. Kerberos, LDAP, Custom. Binary/Htt +--> + +<web-app xmlns="http://java.sun.com/xml/ns/j2ee" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd" + version="2.4"> + + <display-name>Proxy Application</display-name> + <description> + This is the proxy application. + </description> + <servlet> + <servlet-name>ProxyServlet</servlet-name> + <servlet-class>org.apache.ambari.view.storm.ProxyServlet</servlet-class> + </servlet> + <servlet-mapping> + <servlet-name>ProxyServlet</servlet-name> + <url-pattern>/proxy</url-pattern> + </servlet-mapping> +</web-app> http://git-wip-us.apache.org/repos/asf/ambari/blob/cd6398a1/contrib/views/storm/src/main/resources/index.html ---------------------------------------------------------------------- diff --git a/contrib/views/storm/src/main/resources/index.html b/contrib/views/storm/src/main/resources/index.html new file mode 100644 index 0000000..0f59153 --- /dev/null +++ b/contrib/views/storm/src/main/resources/index.html @@ -0,0 +1,56 @@ +<!doctype html> +<!-- +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. See the NOTICE file distributed with +this work for additional information regarding copyright ownership. +The ASF licenses this file to You under the Apache License, Version 2.0 +(the "License"); you may not use this file except in compliance with +the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. Kerberos, LDAP, Custom. Binary/Htt +--> +<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]--> +<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]--> +<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]--> +<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]--> + <head> + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> + <title>Storm Monitoring</title> + <meta name="description" content=""> + <meta name="viewport" content="width=device-width"> + <!-- Place favicon.ico and apple-touch-icon.png in the root directory --> + <link rel="stylesheet" href="libs/bower/backgrid/css/backgrid.min.css"> + <link rel="stylesheet" href="libs/bower/bootstrap/css/bootstrap.min.css"> + <link rel="stylesheet" href="libs/bower/font-awesome/css/font-awesome.min.css"> + <link rel="stylesheet" href="libs/bower/jquery-ui/css/jquery-ui.min.css"> + <link rel="stylesheet" href="styles/default.css"> + </head> + + <body> + <div class="loading"></div> + <div class='container-fluid'> + <div class="row"> + <div class="col-md-12"> + <section id="header"></section> + <section id="content"> + <div class="notifications top-right"></div> + <div class="tab-content"> + <div role="sv_tabpanel" class="tab-pane active" id="topology"></div> + <div role="sv_tabpanel" class="tab-pane" id="cluster"></div> + </div> + </section> + </div> + </div> + </div> + <!-- build:js scripts/main.js --> + <script data-main="scripts/main" src="libs/bower/requirejs/js/require.js"></script> + <!-- endbuild --> + </body> +</html> http://git-wip-us.apache.org/repos/asf/ambari/blob/cd6398a1/contrib/views/storm/src/main/resources/libs/bower/backbone-forms/js/backbone-forms.js ---------------------------------------------------------------------- diff --git a/contrib/views/storm/src/main/resources/libs/bower/backbone-forms/js/backbone-forms.js b/contrib/views/storm/src/main/resources/libs/bower/backbone-forms/js/backbone-forms.js new file mode 100644 index 0000000..9e02ebc --- /dev/null +++ b/contrib/views/storm/src/main/resources/libs/bower/backbone-forms/js/backbone-forms.js @@ -0,0 +1,2446 @@ +/** + * Backbone Forms v0.13.0 + * + * Copyright (c) 2013 Charles Davison, Pow Media Ltd + * + * License and more information at: + * http://github.com/powmedia/backbone-forms + */ +;(function(root) { + + //DEPENDENCIES + //CommonJS + if (typeof exports !== 'undefined' && typeof require !== 'undefined') { + var $ = root.jQuery || root.Zepto || root.ender || require('jquery'), + _ = root._ || require('underscore'), + Backbone = root.Backbone || require('backbone'); + } + + //Browser + else { + var $ = root.jQuery, + _ = root._, + Backbone = root.Backbone; + } + + + //SOURCE + //================================================================================================== +//FORM +//================================================================================================== + +var Form = Backbone.View.extend({ + + /** + * Constructor + * + * @param {Object} [options.schema] + * @param {Backbone.Model} [options.model] + * @param {Object} [options.data] + * @param {String[]|Object[]} [options.fieldsets] + * @param {String[]} [options.fields] + * @param {String} [options.idPrefix] + * @param {Form.Field} [options.Field] + * @param {Form.Fieldset} [options.Fieldset] + * @param {Function} [options.template] + */ + initialize: function(options) { + var self = this; + + options = options || {}; + + //Find the schema to use + var schema = this.schema = (function() { + //Prefer schema from options + if (options.schema) return _.result(options, 'schema'); + + //Then schema on model + var model = options.model; + if (model && model.schema) { + return (_.isFunction(model.schema)) ? model.schema() : model.schema; + } + + //Then built-in schema + if (self.schema) { + return (_.isFunction(self.schema)) ? self.schema() : self.schema; + } + + //Fallback to empty schema + return {}; + })(); + + //Store important data + _.extend(this, _.pick(options, 'model', 'data', 'idPrefix', 'templateData')); + + //Override defaults + var constructor = this.constructor; + this.template = options.template || this.template || constructor.template; + this.Fieldset = options.Fieldset || this.Fieldset || constructor.Fieldset; + this.Field = options.Field || this.Field || constructor.Field; + this.NestedField = options.NestedField || this.NestedField || constructor.NestedField; + + //Check which fields will be included (defaults to all) + var selectedFields = this.selectedFields = options.fields || _.keys(schema); + + //Create fields + var fields = this.fields = {}; + + _.each(selectedFields, function(key) { + var fieldSchema = schema[key]; + fields[key] = this.createField(key, fieldSchema); + }, this); + + //Create fieldsets + var fieldsetSchema = options.fieldsets || [selectedFields], + fieldsets = this.fieldsets = []; + + _.each(fieldsetSchema, function(itemSchema) { + this.fieldsets.push(this.createFieldset(itemSchema)); + }, this); + }, + + /** + * Creates a Fieldset instance + * + * @param {String[]|Object[]} schema Fieldset schema + * + * @return {Form.Fieldset} + */ + createFieldset: function(schema) { + var options = { + schema: schema, + fields: this.fields + }; + + return new this.Fieldset(options); + }, + + /** + * Creates a Field instance + * + * @param {String} key + * @param {Object} schema Field schema + * + * @return {Form.Field} + */ + createField: function(key, schema) { + var options = { + form: this, + key: key, + schema: schema, + idPrefix: this.idPrefix + }; + + if (this.model) { + options.model = this.model; + } else if (this.data) { + options.value = this.data[key]; + } else { + options.value = null; + } + + var field = new this.Field(options); + + this.listenTo(field.editor, 'all', this.handleEditorEvent); + + return field; + }, + + /** + * Callback for when an editor event is fired. + * Re-triggers events on the form as key:event and triggers additional form-level events + * + * @param {String} event + * @param {Editor} editor + */ + handleEditorEvent: function(event, editor) { + //Re-trigger editor events on the form + var formEvent = editor.key+':'+event; + + this.trigger.call(this, formEvent, this, editor, Array.prototype.slice.call(arguments, 2)); + + //Trigger additional events + switch (event) { + case 'change': + this.trigger('change', this); + break; + + case 'focus': + if (!this.hasFocus) this.trigger('focus', this); + break; + + case 'blur': + if (this.hasFocus) { + //TODO: Is the timeout etc needed? + var self = this; + setTimeout(function() { + var focusedField = _.find(self.fields, function(field) { + return field.editor.hasFocus; + }); + + if (!focusedField) self.trigger('blur', self); + }, 0); + } + break; + } + }, + + render: function() { + var self = this, + fields = this.fields; + + //Render form + var $form = $($.trim(this.template(_.result(this, 'templateData')))); + + //Render standalone editors + $form.find('[data-editors]').add($form).each(function(i, el) { + var $container = $(el), + selection = $container.attr('data-editors'); + + if (_.isUndefined(selection)) return; + + //Work out which fields to include + var keys = (selection == '*') + ? self.selectedFields || _.keys(fields) + : selection.split(','); + + //Add them + _.each(keys, function(key) { + var field = fields[key]; + + $container.append(field.editor.render().el); + }); + }); + + //Render standalone fields + $form.find('[data-fields]').add($form).each(function(i, el) { + var $container = $(el), + selection = $container.attr('data-fields'); + + if (_.isUndefined(selection)) return; + + //Work out which fields to include + var keys = (selection == '*') + ? self.selectedFields || _.keys(fields) + : selection.split(','); + + //Add them + _.each(keys, function(key) { + var field = fields[key]; + + $container.append(field.render().el); + }); + }); + + //Render fieldsets + $form.find('[data-fieldsets]').add($form).each(function(i, el) { + var $container = $(el), + selection = $container.attr('data-fieldsets'); + + if (_.isUndefined(selection)) return; + + _.each(self.fieldsets, function(fieldset) { + $container.append(fieldset.render().el); + }); + }); + + //Set the main element + this.setElement($form); + + //Set class + $form.addClass(this.className); + + return this; + }, + + /** + * Validate the data + * + * @return {Object} Validation errors + */ + validate: function(options) { + var self = this, + fields = this.fields, + model = this.model, + errors = {}; + + options = options || {}; + + //Collect errors from schema validation + _.each(fields, function(field) { + var error = field.validate(); + if (error) { + errors[field.key] = error; + } + }); + + //Get errors from default Backbone model validator + if (!options.skipModelValidate && model && model.validate) { + var modelErrors = model.validate(this.getValue()); + + if (modelErrors) { + var isDictionary = _.isObject(modelErrors) && !_.isArray(modelErrors); + + //If errors are not in object form then just store on the error object + if (!isDictionary) { + errors._others = errors._others || []; + errors._others.push(modelErrors); + } + + //Merge programmatic errors (requires model.validate() to return an object e.g. { fieldKey: 'error' }) + if (isDictionary) { + _.each(modelErrors, function(val, key) { + //Set error on field if there isn't one already + if (fields[key] && !errors[key]) { + fields[key].setError(val); + errors[key] = val; + } + + else { + //Otherwise add to '_others' key + errors._others = errors._others || []; + var tmpErr = {}; + tmpErr[key] = val; + errors._others.push(tmpErr); + } + }); + } + } + } + + return _.isEmpty(errors) ? null : errors; + }, + + /** + * Update the model with all latest values. + * + * @param {Object} [options] Options to pass to Model#set (e.g. { silent: true }) + * + * @return {Object} Validation errors + */ + commit: function(options) { + //Validate + options = options || {}; + + var validateOptions = { + skipModelValidate: !options.validate + }; + + var errors = this.validate(validateOptions); + if (errors) return errors; + + //Commit + var modelError; + + var setOptions = _.extend({ + error: function(model, e) { + modelError = e; + } + }, options); + + this.model.set(this.getValue(), setOptions); + + if (modelError) return modelError; + }, + + /** + * Get all the field values as an object. + * Use this method when passing data instead of objects + * + * @param {String} [key] Specific field value to get + */ + getValue: function(key) { + //Return only given key if specified + if (key) return this.fields[key].getValue(); + + //Otherwise return entire form + var values = {}; + _.each(this.fields, function(field) { + values[field.key] = field.getValue(); + }); + + return values; + }, + + /** + * Update field values, referenced by key + * + * @param {Object|String} key New values to set, or property to set + * @param val Value to set + */ + setValue: function(prop, val) { + var data = {}; + if (typeof prop === 'string') { + data[prop] = val; + } else { + data = prop; + } + + var key; + for (key in this.schema) { + if (data[key] !== undefined) { + this.fields[key].setValue(data[key]); + } + } + }, + + /** + * Returns the editor for a given field key + * + * @param {String} key + * + * @return {Editor} + */ + getEditor: function(key) { + var field = this.fields[key]; + if (!field) throw new Error('Field not found: '+key); + + return field.editor; + }, + + /** + * Gives the first editor in the form focus + */ + focus: function() { + if (this.hasFocus) return; + + //Get the first field + var fieldset = this.fieldsets[0], + field = fieldset.getFieldAt(0); + + if (!field) return; + + //Set focus + field.editor.focus(); + }, + + /** + * Removes focus from the currently focused editor + */ + blur: function() { + if (!this.hasFocus) return; + + var focusedField = _.find(this.fields, function(field) { + return field.editor.hasFocus; + }); + + if (focusedField) focusedField.editor.blur(); + }, + + /** + * Manages the hasFocus property + * + * @param {String} event + */ + trigger: function(event) { + if (event === 'focus') { + this.hasFocus = true; + } + else if (event === 'blur') { + this.hasFocus = false; + } + + return Backbone.View.prototype.trigger.apply(this, arguments); + }, + + /** + * Override default remove function in order to remove embedded views + * + * TODO: If editors are included directly with data-editors="x", they need to be removed + * May be best to use XView to manage adding/removing views + */ + remove: function() { + _.each(this.fieldsets, function(fieldset) { + fieldset.remove(); + }); + + _.each(this.fields, function(field) { + field.remove(); + }); + + return Backbone.View.prototype.remove.apply(this, arguments); + } + +}, { + + //STATICS + template: _.template('\ + <form data-fieldsets></form>\ + ', null, this.templateSettings), + + templateSettings: { + evaluate: /<%([\s\S]+?)%>/g, + interpolate: /<%=([\s\S]+?)%>/g, + escape: /<%-([\s\S]+?)%>/g + }, + + editors: {} + +}); + + +//================================================================================================== +//VALIDATORS +//================================================================================================== + +Form.validators = (function() { + + var validators = {}; + + validators.errMessages = { + required: 'This field is required', + regexp: 'Invalid', + email: 'Invalid email address', + url: 'Invalid URL', + match: _.template('Must match field "<%= field %>"', null, Form.templateSettings) + }; + + validators.required = function(options) { + options = _.extend({ + type: 'required', + message: this.errMessages.required + }, options); + + return function required(value) { + options.value = value; + + var err = { + type: options.type, + message: _.isFunction(options.message) ? options.message(options) : options.message + }; + + if (value === null || value === undefined || value === false || value === '') return err; + }; + }; + + validators.regexp = function(options) { + if (!options.regexp) throw new Error('Missing required "regexp" option for "regexp" validator'); + + options = _.extend({ + type: 'regexp', + message: this.errMessages.regexp + }, options); + + return function regexp(value) { + options.value = value; + + var err = { + type: options.type, + message: _.isFunction(options.message) ? options.message(options) : options.message + }; + + //Don't check empty values (add a 'required' validator for this) + if (value === null || value === undefined || value === '') return; + + if (!options.regexp.test(value)) return err; + }; + }; + + validators.email = function(options) { + options = _.extend({ + type: 'email', + message: this.errMessages.email, + regexp: /^[\w\-]{1,}([\w\-\+.]{1,1}[\w\-]{1,}){0,}[@][\w\-]{1,}([.]([\w\-]{1,})){1,3}$/ + }, options); + + return validators.regexp(options); + }; + + validators.url = function(options) { + options = _.extend({ + type: 'url', + message: this.errMessages.url, + regexp: /^(http|https):\/\/(([A-Z0-9][A-Z0-9_\-]*)(\.[A-Z0-9][A-Z0-9_\-]*)+)(:(\d+))?\/?/i + }, options); + + return validators.regexp(options); + }; + + validators.match = function(options) { + if (!options.field) throw new Error('Missing required "field" options for "match" validator'); + + options = _.extend({ + type: 'match', + message: this.errMessages.match + }, options); + + return function match(value, attrs) { + options.value = value; + + var err = { + type: options.type, + message: _.isFunction(options.message) ? options.message(options) : options.message + }; + + //Don't check empty values (add a 'required' validator for this) + if (value === null || value === undefined || value === '') return; + + if (value !== attrs[options.field]) return err; + }; + }; + + + return validators; + +})(); + + +//================================================================================================== +//FIELDSET +//================================================================================================== + +Form.Fieldset = Backbone.View.extend({ + + /** + * Constructor + * + * Valid fieldset schemas: + * ['field1', 'field2'] + * { legend: 'Some Fieldset', fields: ['field1', 'field2'] } + * + * @param {String[]|Object[]} options.schema Fieldset schema + * @param {Object} options.fields Form fields + */ + initialize: function(options) { + options = options || {}; + + //Create the full fieldset schema, merging defaults etc. + var schema = this.schema = this.createSchema(options.schema); + + //Store the fields for this fieldset + this.fields = _.pick(options.fields, schema.fields); + + //Override defaults + this.template = options.template || this.constructor.template; + }, + + /** + * Creates the full fieldset schema, normalising, merging defaults etc. + * + * @param {String[]|Object[]} schema + * + * @return {Object} + */ + createSchema: function(schema) { + //Normalise to object + if (_.isArray(schema)) { + schema = { fields: schema }; + } + + //Add null legend to prevent template error + schema.legend = schema.legend || null; + + return schema; + }, + + /** + * Returns the field for a given index + * + * @param {Number} index + * + * @return {Field} + */ + getFieldAt: function(index) { + var key = this.schema.fields[index]; + + return this.fields[key]; + }, + + /** + * Returns data to pass to template + * + * @return {Object} + */ + templateData: function() { + return this.schema; + }, + + /** + * Renders the fieldset and fields + * + * @return {Fieldset} this + */ + render: function() { + var schema = this.schema, + fields = this.fields; + + //Render fieldset + var $fieldset = $($.trim(this.template(_.result(this, 'templateData')))); + + //Render fields + $fieldset.find('[data-fields]').add($fieldset).each(function(i, el) { + var $container = $(el), + selection = $container.attr('data-fields'); + + if (_.isUndefined(selection)) return; + + _.each(fields, function(field) { + $container.append(field.render().el); + }); + }); + + this.setElement($fieldset); + + return this; + }, + + /** + * Remove embedded views then self + */ + remove: function() { + _.each(this.fields, function(field) { + field.remove(); + }); + + Backbone.View.prototype.remove.call(this); + } + +}, { + //STATICS + + template: _.template('\ + <fieldset class="form-horizontal" data-fields>\ + <% if (legend) { %>\ + <legend><%= legend %></legend>\ + <% } %>\ + </fieldset>\ + ', null, Form.templateSettings) + +}); + + +//================================================================================================== +//FIELD +//================================================================================================== + +Form.Field = Backbone.View.extend({ + + /** + * Constructor + * + * @param {Object} options.key + * @param {Object} options.form + * @param {Object} [options.schema] + * @param {Function} [options.schema.template] + * @param {Backbone.Model} [options.model] + * @param {Object} [options.value] + * @param {String} [options.idPrefix] + * @param {Function} [options.template] + * @param {Function} [options.errorClassName] + */ + initialize: function(options) { + options = options || {}; + + //Store important data + _.extend(this, _.pick(options, 'form', 'key', 'model', 'value', 'idPrefix')); + + //Create the full field schema, merging defaults etc. + var schema = this.schema = this.createSchema(options.schema); + + //Override defaults + this.template = options.template || schema.template || this.constructor.template; + this.errorClassName = options.errorClassName || this.constructor.errorClassName; + + //Create editor + this.editor = this.createEditor(); + }, + + /** + * Creates the full field schema, merging defaults etc. + * + * @param {Object|String} schema + * + * @return {Object} + */ + createSchema: function(schema) { + if (_.isString(schema)) schema = { type: schema }; + + //Set defaults + schema = _.extend({ + type: 'Text', + title: this.createTitle() + }, schema); + + //Get the real constructor function i.e. if type is a string such as 'Text' + schema.type = (_.isString(schema.type)) ? Form.editors[schema.type] : schema.type; + + return schema; + }, + + /** + * Creates the editor specified in the schema; either an editor string name or + * a constructor function + * + * @return {View} + */ + createEditor: function() { + var options = _.extend( + _.pick(this, 'schema', 'form', 'key', 'model', 'value'), + { id: this.createEditorId() } + ); + + var constructorFn = this.schema.type; + + return new constructorFn(options); + }, + + /** + * Creates the ID that will be assigned to the editor + * + * @return {String} + */ + createEditorId: function() { + var prefix = this.idPrefix, + id = this.key; + + //Replace periods with underscores (e.g. for when using paths) + id = id.replace(/\./g, '_'); + + //If a specific ID prefix is set, use it + if (_.isString(prefix) || _.isNumber(prefix)) return prefix + id; + if (_.isNull(prefix)) return id; + + //Otherwise, if there is a model use it's CID to avoid conflicts when multiple forms are on the page + if (this.model) return this.model.cid + '_' + id; + + return id; + }, + + /** + * Create the default field title (label text) from the key name. + * (Converts 'camelCase' to 'Camel Case') + * + * @return {String} + */ + createTitle: function() { + var str = this.key; + + //Add spaces + str = str.replace(/([A-Z])/g, ' $1'); + + //Uppercase first character + str = str.replace(/^./, function(str) { return str.toUpperCase(); }); + + return str; + }, + + /** + * Returns the data to be passed to the template + * + * @return {Object} + */ + templateData: function() { + var schema = this.schema; + + return { + help: schema.help || '', + title: schema.title, + fieldAttrs: schema.fieldAttrs, + editorAttrs: schema.editorAttrs, + key: this.key, + editorId: this.editor.id + }; + }, + + /** + * Render the field and editor + * + * @return {Field} self + */ + render: function() { + var schema = this.schema, + editor = this.editor; + + //Only render the editor if Hidden + if (schema.type == Form.editors.Hidden) { + return this.setElement(editor.render().el); + } + + //Render field + var $field = $($.trim(this.template(_.result(this, 'templateData')))); + + if (schema.fieldClass) $field.addClass(schema.fieldClass); + if (schema.fieldAttrs) $field.attr(schema.fieldAttrs); + + //Render editor + $field.find('[data-editor]').add($field).each(function(i, el) { + var $container = $(el), + selection = $container.attr('data-editor'); + + if (_.isUndefined(selection)) return; + + $container.append(editor.render().el); + }); + + this.setElement($field); + + return this; + }, + + /** + * Check the validity of the field + * + * @return {String} + */ + validate: function() { + var error = this.editor.validate(); + + if (error) { + this.setError(error.message); + } else { + this.clearError(); + } + + return error; + }, + + /** + * Set the field into an error state, adding the error class and setting the error message + * + * @param {String} msg Error message + */ + setError: function(msg) { + //Nested form editors (e.g. Object) set their errors internally + if (this.editor.hasNestedForm) return; + + //Add error CSS class + this.$el.addClass(this.errorClassName); + + //Set error message + this.$('[data-error]').html(msg); + }, + + /** + * Clear the error state and reset the help message + */ + clearError: function() { + //Remove error CSS class + this.$el.removeClass(this.errorClassName); + + //Clear error message + this.$('[data-error]').empty(); + }, + + /** + * Update the model with the new value from the editor + * + * @return {Mixed} + */ + commit: function() { + return this.editor.commit(); + }, + + /** + * Get the value from the editor + * + * @return {Mixed} + */ + getValue: function() { + return this.editor.getValue(); + }, + + /** + * Set/change the value of the editor + * + * @param {Mixed} value + */ + setValue: function(value) { + this.editor.setValue(value); + }, + + /** + * Give the editor focus + */ + focus: function() { + this.editor.focus(); + }, + + /** + * Remove focus from the editor + */ + blur: function() { + this.editor.blur(); + }, + + /** + * Remove the field and editor views + */ + remove: function() { + this.editor.remove(); + + Backbone.View.prototype.remove.call(this); + } + +}, { + //STATICS + + template: _.template('\ + <div class="form-group">\ + <label class="col-sm-3 control-label" align="right" for="<%= editorId %>"><%= title %></label>\ + <div class="col-sm-9">\ + <span data-editor></span>\ + <div data-error></div>\ + <div><%= help %></div>\ + </div>\ + </div>\ + ', null, Form.templateSettings), + + /** + * CSS class name added to the field when there is a validation error + */ + errorClassName: 'error' + +}); + + +//================================================================================================== +//NESTEDFIELD +//================================================================================================== + +Form.NestedField = Form.Field.extend({ + + template: _.template($.trim('\ + <div>\ + <span data-editor></span>\ + <% if (help) { %>\ + <div><%= help %></div>\ + <% } %>\ + <div data-error></div>\ + </div>\ + '), null, Form.templateSettings) + +}); + +/** + * Base editor (interface). To be extended, not used directly + * + * @param {Object} options + * @param {String} [options.id] Editor ID + * @param {Model} [options.model] Use instead of value, and use commit() + * @param {String} [options.key] The model attribute key. Required when using 'model' + * @param {Mixed} [options.value] When not using a model. If neither provided, defaultValue will be used + * @param {Object} [options.schema] Field schema; may be required by some editors + * @param {Object} [options.validators] Validators; falls back to those stored on schema + * @param {Object} [options.form] The form + */ +Form.Editor = Form.editors.Base = Backbone.View.extend({ + + defaultValue: null, + + hasFocus: false, + + initialize: function(options) { + var options = options || {}; + + //Set initial value + if (options.model) { + if (!options.key) throw new Error("Missing option: 'key'"); + + this.model = options.model; + + this.value = this.model.get(options.key); + } + else if (options.value !== undefined) { + this.value = options.value; + } + + if (this.value === undefined) this.value = this.defaultValue; + + //Store important data + _.extend(this, _.pick(options, 'key', 'form')); + + var schema = this.schema = options.schema || {}; + + this.validators = options.validators || schema.validators; + + //Main attributes + this.$el.attr('id', this.id); + this.$el.attr('name', this.getName()); + if (schema.editorClass) this.$el.addClass(schema.editorClass); + if (schema.editorAttrs) this.$el.attr(schema.editorAttrs); + }, + + /** + * Get the value for the form input 'name' attribute + * + * @return {String} + * + * @api private + */ + getName: function() { + var key = this.key || ''; + + //Replace periods with underscores (e.g. for when using paths) + return key.replace(/\./g, '_'); + }, + + /** + * Get editor value + * Extend and override this method to reflect changes in the DOM + * + * @return {Mixed} + */ + getValue: function() { + return this.value; + }, + + /** + * Set editor value + * Extend and override this method to reflect changes in the DOM + * + * @param {Mixed} value + */ + setValue: function(value) { + this.value = value; + }, + + /** + * Give the editor focus + * Extend and override this method + */ + focus: function() { + throw new Error('Not implemented'); + }, + + /** + * Remove focus from the editor + * Extend and override this method + */ + blur: function() { + throw new Error('Not implemented'); + }, + + /** + * Update the model with the current value + * + * @param {Object} [options] Options to pass to model.set() + * @param {Boolean} [options.validate] Set to true to trigger built-in model validation + * + * @return {Mixed} error + */ + commit: function(options) { + var error = this.validate(); + if (error) return error; + + this.listenTo(this.model, 'invalid', function(model, e) { + error = e; + }); + this.model.set(this.key, this.getValue(), options); + + if (error) return error; + }, + + /** + * Check validity + * + * @return {Object|Undefined} + */ + validate: function() { + var $el = this.$el, + error = null, + value = this.getValue(), + formValues = this.form ? this.form.getValue() : {}, + validators = this.validators, + getValidator = this.getValidator; + + if (validators) { + //Run through validators until an error is found + _.every(validators, function(validator) { + error = getValidator(validator)(value, formValues); + + return error ? false : true; + }); + } + + return error; + }, + + /** + * Set this.hasFocus, or call parent trigger() + * + * @param {String} event + */ + trigger: function(event) { + if (event === 'focus') { + this.hasFocus = true; + } + else if (event === 'blur') { + this.hasFocus = false; + } + + return Backbone.View.prototype.trigger.apply(this, arguments); + }, + + /** + * Returns a validation function based on the type defined in the schema + * + * @param {RegExp|String|Function} validator + * @return {Function} + */ + getValidator: function(validator) { + var validators = Form.validators; + + //Convert regular expressions to validators + if (_.isRegExp(validator)) { + return validators.regexp({ regexp: validator }); + } + + //Use a built-in validator if given a string + if (_.isString(validator)) { + if (!validators[validator]) throw new Error('Validator "'+validator+'" not found'); + + return validators[validator](); + } + + //Functions can be used directly + if (_.isFunction(validator)) return validator; + + //Use a customised built-in validator if given an object + if (_.isObject(validator) && validator.type) { + var config = validator; + + return validators[config.type](config); + } + + //Unkown validator type + throw new Error('Invalid validator: ' + validator); + } +}); + +/** + * Text + * + * Text input with focus, blur and change events + */ +Form.editors.Text = Form.Editor.extend({ + + tagName: 'input', + + defaultValue: '', + + previousValue: '', + + events: { + 'keyup': 'determineChange', + 'keypress': function(event) { + var self = this; + setTimeout(function() { + self.determineChange(); + }, 0); + }, + 'select': function(event) { + this.trigger('select', this); + }, + 'focus': function(event) { + this.trigger('focus', this); + }, + 'blur': function(event) { + this.trigger('blur', this); + } + }, + + initialize: function(options) { + Form.editors.Base.prototype.initialize.call(this, options); + + var schema = this.schema; + + //Allow customising text type (email, phone etc.) for HTML5 browsers + var type = 'text'; + + if (schema && schema.editorAttrs && schema.editorAttrs.type) type = schema.editorAttrs.type; + if (schema && schema.dataType) type = schema.dataType; + + this.$el.attr('type', type); + }, + + /** + * Adds the editor to the DOM + */ + render: function() { + this.setValue(this.value); + + return this; + }, + + determineChange: function(event) { + var currentValue = this.$el.val(); + var changed = (currentValue !== this.previousValue); + + if (changed) { + this.previousValue = currentValue; + + this.trigger('change', this); + } + }, + + /** + * Returns the current editor value + * @return {String} + */ + getValue: function() { + return this.$el.val(); + }, + + /** + * Sets the value of the form element + * @param {String} + */ + setValue: function(value) { + this.$el.val(value); + }, + + focus: function() { + if (this.hasFocus) return; + + this.$el.focus(); + }, + + blur: function() { + if (!this.hasFocus) return; + + this.$el.blur(); + }, + + select: function() { + this.$el.select(); + } + +}); + +/** + * TextArea editor + */ +Form.editors.TextArea = Form.editors.Text.extend({ + + tagName: 'textarea', + + /** + * Override Text constructor so type property isn't set (issue #261) + */ + initialize: function(options) { + Form.editors.Base.prototype.initialize.call(this, options); + } + +}); + +/** + * Password editor + */ +Form.editors.Password = Form.editors.Text.extend({ + + initialize: function(options) { + Form.editors.Text.prototype.initialize.call(this, options); + + this.$el.attr('type', 'password'); + } + +}); + +/** + * NUMBER + * + * Normal text input that only allows a number. Letters etc. are not entered. + */ +Form.editors.Number = Form.editors.Text.extend({ + + defaultValue: 0, + + events: _.extend({}, Form.editors.Text.prototype.events, { + 'keypress': 'onKeyPress', + 'change': 'onKeyPress' + }), + + initialize: function(options) { + Form.editors.Text.prototype.initialize.call(this, options); + + var schema = this.schema; + + this.$el.attr('type', 'number'); + + if (!schema || !schema.editorAttrs || !schema.editorAttrs.step) { + // provide a default for `step` attr, + // but don't overwrite if already specified + this.$el.attr('step', 'any'); + } + this.$el.attr('class','form-control'); + this.$el.attr('min','0'); + }, + + /** + * Check value is numeric + */ + onKeyPress: function(event) { + var self = this, + delayedDetermineChange = function() { + setTimeout(function() { + self.determineChange(); + }, 0); + }; + + //Allow backspace + if (event.charCode === 0) { + delayedDetermineChange(); + return; + } + + //Get the whole new value so that we can prevent things like double decimals points etc. + var newVal = this.$el.val() + if( event.charCode != undefined ) { + newVal = newVal + String.fromCharCode(event.charCode); + } + + var numeric = /^[0-9]*\.?[0-9]*?$/.test(newVal); + + if (numeric) { + delayedDetermineChange(); + } + else { + event.preventDefault(); + } + }, + + getValue: function() { + var value = this.$el.val(); + + return value === "" ? null : parseFloat(value, 10); + }, + + setValue: function(value) { + value = (function() { + if (_.isNumber(value)) return value; + + if (_.isString(value) && value !== '') return parseFloat(value, 10); + + return null; + })(); + + if (_.isNaN(value)) value = null; + + Form.editors.Text.prototype.setValue.call(this, value); + } + +}); + +/** + * Hidden editor + */ +Form.editors.Hidden = Form.editors.Text.extend({ + + defaultValue: '', + + initialize: function(options) { + Form.editors.Text.prototype.initialize.call(this, options); + + this.$el.attr('type', 'hidden'); + }, + + focus: function() { + + }, + + blur: function() { + + } + +}); + +/** + * Checkbox editor + * + * Creates a single checkbox, i.e. boolean value + */ +Form.editors.Checkbox = Form.editors.Base.extend({ + + defaultValue: false, + + tagName: 'input', + + events: { + 'click': function(event) { + this.trigger('change', this); + }, + 'focus': function(event) { + this.trigger('focus', this); + }, + 'blur': function(event) { + this.trigger('blur', this); + } + }, + + initialize: function(options) { + Form.editors.Base.prototype.initialize.call(this, options); + + this.$el.attr('type', 'checkbox'); + }, + + /** + * Adds the editor to the DOM + */ + render: function() { + this.setValue(this.value); + + return this; + }, + + getValue: function() { + return this.$el.prop('checked'); + }, + + setValue: function(value) { + if (value) { + this.$el.prop('checked', true); + }else{ + this.$el.prop('checked', false); + } + }, + + focus: function() { + if (this.hasFocus) return; + + this.$el.focus(); + }, + + blur: function() { + if (!this.hasFocus) return; + + this.$el.blur(); + } + +}); + +/** + * Select editor + * + * Renders a <select> with given options + * + * Requires an 'options' value on the schema. + * Can be an array of options, a function that calls back with the array of options, a string of HTML + * or a Backbone collection. If a collection, the models must implement a toString() method + */ +Form.editors.Select = Form.editors.Base.extend({ + + tagName: 'select', + + events: { + 'change': function(event) { + this.trigger('change', this); + }, + 'focus': function(event) { + this.trigger('focus', this); + }, + 'blur': function(event) { + this.trigger('blur', this); + } + }, + + initialize: function(options) { + Form.editors.Base.prototype.initialize.call(this, options); + + if (!this.schema || !this.schema.options) throw new Error("Missing required 'schema.options'"); + }, + + render: function() { + this.setOptions(this.schema.options); + + return this; + }, + + /** + * Sets the options that populate the <select> + * + * @param {Mixed} options + */ + setOptions: function(options) { + var self = this; + + //If a collection was passed, check if it needs fetching + if (options instanceof Backbone.Collection) { + var collection = options; + + //Don't do the fetch if it's already populated + if (collection.length > 0) { + this.renderOptions(options); + } else { + collection.fetch({ + success: function(collection) { + self.renderOptions(options); + } + }); + } + } + + //If a function was passed, run it to get the options + else if (_.isFunction(options)) { + options(function(result) { + self.renderOptions(result); + }, self); + } + + //Otherwise, ready to go straight to renderOptions + else { + this.renderOptions(options); + } + }, + + /** + * Adds the <option> html to the DOM + * @param {Mixed} Options as a simple array e.g. ['option1', 'option2'] + * or as an array of objects e.g. [{val: 543, label: 'Title for object 543'}] + * or as a string of <option> HTML to insert into the <select> + * or any object + */ + renderOptions: function(options) { + var $select = this.$el, + html; + + html = this._getOptionsHtml(options); + + //Insert options + $select.html(html); + + //Select correct option + this.setValue(this.value); + }, + + _getOptionsHtml: function(options) { + var html; + //Accept string of HTML + if (_.isString(options)) { + html = options; + } + + //Or array + else if (_.isArray(options)) { + html = this._arrayToHtml(options); + } + + //Or Backbone collection + else if (options instanceof Backbone.Collection) { + html = this._collectionToHtml(options); + } + + else if (_.isFunction(options)) { + var newOptions; + + options(function(opts) { + newOptions = opts; + }, this); + + html = this._getOptionsHtml(newOptions); + //Or any object + }else{ + html=this._objectToHtml(options); + } + + return html; + }, + + + getValue: function() { + return this.$el.val(); + }, + + setValue: function(value) { + this.$el.val(value); + }, + + focus: function() { + if (this.hasFocus) return; + + this.$el.focus(); + }, + + blur: function() { + if (!this.hasFocus) return; + + this.$el.blur(); + }, + + /** + * Transforms a collection into HTML ready to use in the renderOptions method + * @param {Backbone.Collection} + * @return {String} + */ + _collectionToHtml: function(collection) { + //Convert collection to array first + var array = []; + collection.each(function(model) { + array.push({ val: model.id, label: model.toString() }); + }); + + //Now convert to HTML + var html = this._arrayToHtml(array); + + return html; + }, + /** + * Transforms an object into HTML ready to use in the renderOptions method + * @param {Object} + * @return {String} + */ + _objectToHtml: function(obj) { + //Convert object to array first + var array = []; + for(var key in obj){ + if( obj.hasOwnProperty( key ) ) { + array.push({ val: key, label: obj[key] }); + } + } + + //Now convert to HTML + var html = this._arrayToHtml(array); + + return html; + }, + + + + /** + * Create the <option> HTML + * @param {Array} Options as a simple array e.g. ['option1', 'option2'] + * or as an array of objects e.g. [{val: 543, label: 'Title for object 543'}] + * @return {String} HTML + */ + _arrayToHtml: function(array) { + var html = []; + + //Generate HTML + _.each(array, function(option) { + if (_.isObject(option)) { + if (option.group) { + html.push('<optgroup label="'+option.group+'">'); + html.push(this._getOptionsHtml(option.options)) + html.push('</optgroup>'); + } else { + var val = (option.val || option.val === 0) ? option.val : ''; + html.push('<option value="'+val+'">'+option.label+'</option>'); + } + } + else { + html.push('<option>'+option+'</option>'); + } + }, this); + + return html.join(''); + } + +}); + +/** + * Radio editor + * + * Renders a <ul> with given options represented as <li> objects containing radio buttons + * + * Requires an 'options' value on the schema. + * Can be an array of options, a function that calls back with the array of options, a string of HTML + * or a Backbone collection. If a collection, the models must implement a toString() method + */ +Form.editors.Radio = Form.editors.Select.extend({ + + tagName: 'ul', + + events: { + 'change input[type=radio]': function() { + this.trigger('change', this); + }, + 'focus input[type=radio]': function() { + if (this.hasFocus) return; + this.trigger('focus', this); + }, + 'blur input[type=radio]': function() { + if (!this.hasFocus) return; + var self = this; + setTimeout(function() { + if (self.$('input[type=radio]:focus')[0]) return; + self.trigger('blur', self); + }, 0); + } + }, + + getValue: function() { + return this.$('input[type=radio]:checked').val(); + }, + + setValue: function(value) { + this.$('input[type=radio]').val([value]); + }, + + focus: function() { + if (this.hasFocus) return; + + var checked = this.$('input[type=radio]:checked'); + if (checked[0]) { + checked.focus(); + return; + } + + this.$('input[type=radio]').first().focus(); + }, + + blur: function() { + if (!this.hasFocus) return; + + this.$('input[type=radio]:focus').blur(); + }, + + /** + * Create the radio list HTML + * @param {Array} Options as a simple array e.g. ['option1', 'option2'] + * or as an array of objects e.g. [{val: 543, label: 'Title for object 543'}] + * @return {String} HTML + */ + _arrayToHtml: function (array) { + var html = []; + var self = this; + + _.each(array, function(option, index) { + var itemHtml = '<li>'; + if (_.isObject(option)) { + var val = (option.val || option.val === 0) ? option.val : ''; + itemHtml += ('<input type="radio" name="'+self.getName()+'" value="'+val+'" id="'+self.id+'-'+index+'" />'); + itemHtml += ('<label for="'+self.id+'-'+index+'">'+option.label+'</label>'); + } + else { + itemHtml += ('<input type="radio" name="'+self.getName()+'" value="'+option+'" id="'+self.id+'-'+index+'" />'); + itemHtml += ('<label for="'+self.id+'-'+index+'">'+option+'</label>'); + } + itemHtml += '</li>'; + html.push(itemHtml); + }); + + return html.join(''); + } + +}); + +/** + * Checkboxes editor + * + * Renders a <ul> with given options represented as <li> objects containing checkboxes + * + * Requires an 'options' value on the schema. + * Can be an array of options, a function that calls back with the array of options, a string of HTML + * or a Backbone collection. If a collection, the models must implement a toString() method + */ +Form.editors.Checkboxes = Form.editors.Select.extend({ + + tagName: 'ul', + + groupNumber: 0, + + events: { + 'click input[type=checkbox]': function() { + this.trigger('change', this); + }, + 'focus input[type=checkbox]': function() { + if (this.hasFocus) return; + this.trigger('focus', this); + }, + 'blur input[type=checkbox]': function() { + if (!this.hasFocus) return; + var self = this; + setTimeout(function() { + if (self.$('input[type=checkbox]:focus')[0]) return; + self.trigger('blur', self); + }, 0); + } + }, + + getValue: function() { + var values = []; + this.$('input[type=checkbox]:checked').each(function() { + values.push($(this).val()); + }); + return values; + }, + + setValue: function(values) { + if (!_.isArray(values)) values = [values]; + this.$('input[type=checkbox]').val(values); + }, + + focus: function() { + if (this.hasFocus) return; + + this.$('input[type=checkbox]').first().focus(); + }, + + blur: function() { + if (!this.hasFocus) return; + + this.$('input[type=checkbox]:focus').blur(); + }, + + /** + * Create the checkbox list HTML + * @param {Array} Options as a simple array e.g. ['option1', 'option2'] + * or as an array of objects e.g. [{val: 543, label: 'Title for object 543'}] + * @return {String} HTML + */ + _arrayToHtml: function (array) { + var html = []; + var self = this; + + _.each(array, function(option, index) { + var itemHtml = '<li>'; + var close = true; + if (_.isObject(option)) { + if (option.group) { + var originalId = self.id; + self.id += "-" + self.groupNumber++; + itemHtml = ('<fieldset class="group"> <legend>'+option.group+'</legend>'); + itemHtml += (self._arrayToHtml(option.options)); + itemHtml += ('</fieldset>'); + self.id = originalId; + close = false; + }else{ + var val = (option.val || option.val === 0) ? option.val : ''; + itemHtml += ('<input type="checkbox" name="'+self.getName()+'" value="'+val+'" id="'+self.id+'-'+index+'" />'); + itemHtml += ('<label for="'+self.id+'-'+index+'">'+option.label+'</label>'); + } + } + else { + itemHtml += ('<input type="checkbox" name="'+self.getName()+'" value="'+option+'" id="'+self.id+'-'+index+'" />'); + itemHtml += ('<label for="'+self.id+'-'+index+'">'+option+'</label>'); + } + if(close){ + itemHtml += '</li>'; + } + html.push(itemHtml); + }); + + return html.join(''); + } + +}); + +/** + * Object editor + * + * Creates a child form. For editing Javascript objects + * + * @param {Object} options + * @param {Form} options.form The form this editor belongs to; used to determine the constructor for the nested form + * @param {Object} options.schema The schema for the object + * @param {Object} options.schema.subSchema The schema for the nested form + */ +Form.editors.Object = Form.editors.Base.extend({ + //Prevent error classes being set on the main control; they are internally on the individual fields + hasNestedForm: true, + + initialize: function(options) { + //Set default value for the instance so it's not a shared object + this.value = {}; + + //Init + Form.editors.Base.prototype.initialize.call(this, options); + + //Check required options + if (!this.form) throw new Error('Missing required option "form"'); + if (!this.schema.subSchema) throw new Error("Missing required 'schema.subSchema' option for Object editor"); + }, + + render: function() { + //Get the constructor for creating the nested form; i.e. the same constructor as used by the parent form + var NestedForm = this.form.constructor; + + //Create the nested form + this.nestedForm = new NestedForm({ + schema: this.schema.subSchema, + data: this.value, + idPrefix: this.id + '_', + Field: NestedForm.NestedField + }); + + this._observeFormEvents(); + + this.$el.html(this.nestedForm.render().el); + + if (this.hasFocus) this.trigger('blur', this); + + return this; + }, + + getValue: function() { + if (this.nestedForm) return this.nestedForm.getValue(); + + return this.value; + }, + + setValue: function(value) { + this.value = value; + + this.render(); + }, + + focus: function() { + if (this.hasFocus) return; + + this.nestedForm.focus(); + }, + + blur: function() { + if (!this.hasFocus) return; + + this.nestedForm.blur(); + }, + + remove: function() { + this.nestedForm.remove(); + + Backbone.View.prototype.remove.call(this); + }, + + validate: function() { + return this.nestedForm.validate(); + }, + + _observeFormEvents: function() { + if (!this.nestedForm) return; + + this.nestedForm.on('all', function() { + // args = ["key:change", form, fieldEditor] + var args = _.toArray(arguments); + args[1] = this; + // args = ["key:change", this=objectEditor, fieldEditor] + + this.trigger.apply(this, args); + }, this); + } + +}); + +/** + * NestedModel editor + * + * Creates a child form. For editing nested Backbone models + * + * Special options: + * schema.model: Embedded model constructor + */ +Form.editors.NestedModel = Form.editors.Object.extend({ + initialize: function(options) { + Form.editors.Base.prototype.initialize.call(this, options); + + if (!this.form) throw new Error('Missing required option "form"'); + if (!options.schema.model) throw new Error('Missing required "schema.model" option for NestedModel editor'); + }, + + render: function() { + //Get the constructor for creating the nested form; i.e. the same constructor as used by the parent form + var NestedForm = this.form.constructor; + + var data = this.value || {}, + key = this.key, + nestedModel = this.schema.model; + + //Wrap the data in a model if it isn't already a model instance + var modelInstance = (data.constructor === nestedModel) ? data : new nestedModel(data); + + this.nestedForm = new NestedForm({ + model: modelInstance, + idPrefix: this.id + '_', + fieldTemplate: 'nestedField' + }); + + this._observeFormEvents(); + + //Render form + this.$el.html(this.nestedForm.render().el); + + if (this.hasFocus) this.trigger('blur', this); + + return this; + }, + + /** + * Update the embedded model, checking for nested validation errors and pass them up + * Then update the main model if all OK + * + * @return {Error|null} Validation error or null + */ + commit: function() { + var error = this.nestedForm.commit(); + if (error) { + this.$el.addClass('error'); + return error; + } + + return Form.editors.Object.prototype.commit.call(this); + } + +}); + +/** + * Date editor + * + * Schema options + * @param {Number|String} [options.schema.yearStart] First year in list. Default: 100 years ago + * @param {Number|String} [options.schema.yearEnd] Last year in list. Default: current year + * + * Config options (if not set, defaults to options stored on the main Date class) + * @param {Boolean} [options.showMonthNames] Use month names instead of numbers. Default: true + * @param {String[]} [options.monthNames] Month names. Default: Full English names + */ +Form.editors.Date = Form.editors.Base.extend({ + + events: { + 'change select': function() { + this.updateHidden(); + this.trigger('change', this); + }, + 'focus select': function() { + if (this.hasFocus) return; + this.trigger('focus', this); + }, + 'blur select': function() { + if (!this.hasFocus) return; + var self = this; + setTimeout(function() { + if (self.$('select:focus')[0]) return; + self.trigger('blur', self); + }, 0); + } + }, + + initialize: function(options) { + options = options || {}; + + Form.editors.Base.prototype.initialize.call(this, options); + + var Self = Form.editors.Date, + today = new Date(); + + //Option defaults + this.options = _.extend({ + monthNames: Self.monthNames, + showMonthNames: Self.showMonthNames + }, options); + + //Schema defaults + this.schema = _.extend({ + yearStart: today.getFullYear() - 100, + yearEnd: today.getFullYear() + }, options.schema || {}); + + //Cast to Date + if (this.value && !_.isDate(this.value)) { + this.value = new Date(this.value); + } + + //Set default date + if (!this.value) { + var date = new Date(); + date.setSeconds(0); + date.setMilliseconds(0); + + this.value = date; + } + + //Template + this.template = options.template || this.constructor.template; + }, + + render: function() { + var options = this.options, + schema = this.schema; + + var datesOptions = _.map(_.range(1, 32), function(date) { + return '<option value="'+date+'">' + date + '</option>'; + }); + + var monthsOptions = _.map(_.range(0, 12), function(month) { + var value = (options.showMonthNames) + ? options.monthNames[month] + : (month + 1); + + return '<option value="'+month+'">' + value + '</option>'; + }); + + var yearRange = (schema.yearStart < schema.yearEnd) + ? _.range(schema.yearStart, schema.yearEnd + 1) + : _.range(schema.yearStart, schema.yearEnd - 1, -1); + + var yearsOptions = _.map(yearRange, function(year) { + return '<option value="'+year+'">' + year + '</option>'; + }); + + //Render the selects + var $el = $($.trim(this.template({ + dates: datesOptions.join(''), + months: monthsOptions.join(''), + years: yearsOptions.join('') + }))); + + //Store references to selects + this.$date = $el.find('[data-type="date"]'); + this.$month = $el.find('[data-type="month"]'); + this.$year = $el.find('[data-type="year"]'); + + //Create the hidden field to store values in case POSTed to server + this.$hidden = $('<input type="hidden" name="'+this.key+'" />'); + $el.append(this.$hidden); + + //Set value on this and hidden field + this.setValue(this.value); + + //Remove the wrapper tag + this.setElement($el); + this.$el.attr('id', this.id); + this.$el.attr('name', this.getName()); + + if (this.hasFocus) this.trigger('blur', this); + + return this; + }, + + /** + * @return {Date} Selected date + */ + getValue: function() { + var year = this.$year.val(), + month = this.$month.val(), + date = this.$date.val(); + + if (!year || !month || !date) return null; + + return new Date(year, month, date); + }, + + /** + * @param {Date} date + */ + setValue: function(date) { + this.$date.val(date.getDate()); + this.$month.val(date.getMonth()); + this.$year.val(date.getFullYear()); + + this.updateHidden(); + }, + + focus: function() { + if (this.hasFocus) return; + + this.$('select').first().focus(); + }, + + blur: function() { + if (!this.hasFocus) return; + + this.$('select:focus').blur(); + }, + + /** + * Update the hidden input which is maintained for when submitting a form + * via a normal browser POST + */ + updateHidden: function() { + var val = this.getValue(); + + if (_.isDate(val)) val = val.toISOString(); + + this.$hidden.val(val); + } + +}, { + //STATICS + template: _.template('\ + <div>\ + <select data-type="date"><%= dates %></select>\ + <select data-type="month"><%= months %></select>\ + <select data-type="year"><%= years %></select>\ + </div>\ + ', null, Form.templateSettings), + + //Whether to show month names instead of numbers + showMonthNames: true, + + //Month names to use if showMonthNames is true + //Replace for localisation, e.g. Form.editors.Date.monthNames = ['Janvier', 'Fevrier'...] + monthNames: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] +}); + +/** + * DateTime editor + * + * @param {Editor} [options.DateEditor] Date editor view to use (not definition) + * @param {Number} [options.schema.minsInterval] Interval between minutes. Default: 15 + */ +Form.editors.DateTime = Form.editors.Base.extend({ + + events: { + 'change select': function() { + this.updateHidden(); + this.trigger('change', this); + }, + 'focus select': function() { + if (this.hasFocus) return; + this.trigger('focus', this); + }, + 'blur select': function() { + if (!this.hasFocus) return; + var self = this; + setTimeout(function() { + if (self.$('select:focus')[0]) return; + self.trigger('blur', self); + }, 0); + } + }, + + initialize: function(options) { + options = options || {}; + + Form.editors.Base.prototype.initialize.call(this, options); + + //Option defaults + this.options = _.extend({ + DateEditor: Form.editors.DateTime.DateEditor + }, options); + + //Schema defaults + this.schema = _.extend({ + minsInterval: 15 + }, options.schema || {}); + + //Create embedded date editor + this.dateEditor = new this.options.DateEditor(options); + + this.value = this.dateEditor.value; + + //Template + this.template = options.template || this.constructor.template; + }, + + render: function() { + function pad(n) { + return n < 10 ? '0' + n : n; + } + + var schema = this.schema; + + //Create options + var hoursOptions = _.map(_.range(0, 24), function(hour) { + return '<option value="'+hour+'">' + pad(hour) + '</option>'; + }); + + var minsOptions = _.map(_.range(0, 60, schema.minsInterval), function(min) { + return '<option value="'+min+'">' + pad(min) + '</option>'; + }); + + //Render time selects + var $el = $($.trim(this.template({ + hours: hoursOptions.join(), + mins: minsOptions.join() + }))); + + //Include the date editor + $el.find('[data-date]').append(this.dateEditor.render().el); + + //Store references to selects + this.$hour = $el.find('select[data-type="hour"]'); + this.$min = $el.find('select[data-type="min"]'); + + //Get the hidden date field to store values in case POSTed to server + this.$hidden = $el.find('input[type="hidden"]'); + + //Set time + this.setValue(this.value); + + this.setElement($el); + this.$el.attr('id', this.id); + this.$el.attr('name', this.getName()); + + if (this.hasFocus) this.trigger('blur', this); + + return this; + }, + + /** + * @return {Date} Selected datetime + */ + getValue: function() { + var date = this.dateEditor.getValue(); + + var hour = this.$hour.val(), + min = this.$min.val(); + + if (!date || !hour || !min) return null; + + date.setHours(hour); + date.setMinutes(min); + + return date; + }, + + /** + * @param {Date} + */ + setValue: function(date) { + if (!_.isDate(date)) date = new Date(date); + + this.dateEditor.setValue(date); + + this.$hour.val(date.getHours()); + this.$min.val(date.getMinutes()); + + this.updateHidden(); + }, + + focus: function() { + if (this.hasFocus) return; + + this.$('select').first().focus(); + }, + + blur: function() { + if (!this.hasFocus) return; + + this.$('select:focus').blur(); + }, + + /** + * Update the hidden input which is maintained for when submitting a form + * via a normal browser POST + */ + updateHidden: function() { + var val = this.getValue(); + if (_.isDate(val)) val = val.toISOString(); + + this.$hidden.val(val); + }, + + /** + * Remove the Date editor before removing self + */ + remove: function() { + this.dateEditor.remove(); + + Form.editors.Base.prototype.remove.call(this); + } + +}, { + //STATICS + template: _.template('\ + <div class="bbf-datetime">\ + <div class="bbf-date-container" data-date></div>\ + <select data-type="hour"><%= hours %></select>\ + :\ + <select data-type="min"><%= mins %></select>\ + </div>\ + ', null, Form.templateSettings), + + //The date editor to use (constructor function, not instance) + DateEditor: Form.editors.Date +}); + + + + //Metadata + Form.VERSION = '0.13.0'; + + + //Exports + Backbone.Form = Form; + if (typeof exports !== 'undefined') exports = Form; + +})(window || global || this);
