├── .babelrc ├── .eslintrc ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── GUIDE.md ├── ISSUE_TEMPLATE.md ├── LICENSE ├── README.md ├── docs └── example.png ├── karma.conf.js ├── package.json ├── src ├── components.js ├── i18n │ ├── de.js │ ├── en.js │ ├── es.js │ ├── fr.js │ ├── it.js │ └── uk.js ├── index.js ├── main.js └── util.js ├── test ├── components │ ├── Checkbox.js │ ├── Component.js │ ├── Datetime.js │ ├── List.js │ ├── Radio.js │ ├── Select.js │ ├── Struct.js │ ├── Textbox.js │ ├── index.js │ └── util.js ├── index.js └── util.js └── webpack.dist.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | stage: 0, 3 | loose: true 4 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "eslint-config-airbnb", 4 | "env": { 5 | "browser": true, 6 | "node": true 7 | }, 8 | "plugins": [ 9 | "react" 10 | ], 11 | "rules": { 12 | "semi": [2, "never"], 13 | "comma-dangle": 0, 14 | "react/sort-comp": 0, 15 | "react/no-multi-comp": 0, 16 | "react/prop-types": 0 17 | } 18 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | dev 3 | dist 4 | node_modules 5 | lib 6 | .idea 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "9" 4 | before_script: 5 | - export DISPLAY=:99.0 6 | - sh -e /etc/init.d/xvfb start 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | > **Tags:** 4 | > - [New Feature] 5 | > - [Bug Fix] 6 | > - [Breaking Change] 7 | > - [Documentation] 8 | > - [Internal] 9 | > - [Polish] 10 | > - [Experimental] 11 | 12 | **Note**: Gaps between patch versions are faulty/broken releases. 13 | **Note**: A feature tagged as Experimental is in a high state of flux, you're at risk of it changing without notice. 14 | 15 | ## 0.9.21 16 | 17 | - **Polish** 18 | - Possibility to use flexible sorting, PR #422 (@yasaricli) 19 | 20 | ## 0.9.20 21 | 22 | - **Bug Fix** 23 | - remove deleted refs keys from childRefs registry (@gbiryukov) 24 | 25 | ## 0.9.18 26 | 27 | - **Internal** 28 | - replace string refs with callback refs (@gbiryukov) 29 | - add React 16 to `peerDependencies` (@gcanti) 30 | 31 | ## 0.9.17 32 | 33 | - **Bug Fix** 34 | - Respect path changes in Component `shouldComponentUpdate`, fix #388 (@gbiryukov) 35 | 36 | ## 0.9.16 37 | 38 | - **New Feature** 39 | - German translation (@jpJuni0r) 40 | 41 | ## 0.9.15 42 | 43 | - **New Feature** 44 | - French translation (@jebarjonet) 45 | 46 | ## v0.9.14 47 | 48 | - **Bug Fix** 49 | - make `numberTransformer` resistant to minification (UglifyJS with `unsafe_comps` option) (@gbiryukov) 50 | 51 | ## v0.9.13 52 | 53 | - **Bug Fix** 54 | - revert last commit 55 | 56 | ## v0.9.12 57 | 58 | - **New Feature** 59 | - Add item parameter to List.addItem function (@karlguillotte) 60 | 61 | ## v0.9.11 62 | 63 | - **Experimental** 64 | - optional `getTcombFormOptions` function on types 65 | 66 | ## v0.9.10 67 | 68 | - **Bug Fix** 69 | - struct's and list's validate() now set hasError to true when there are errors, fix #350 (@gcanti, thanks @volkanunsal) 70 | 71 | ## v0.9.9 72 | 73 | - **Bug Fix** 74 | - retain static functions when constructing concrete type from union (@minedeljkovic) 75 | 76 | ## v0.9.8 77 | 78 | - **Bug Fix** 79 | - `_nativeContainerInfo` no longer exists in React v15.2.0, use `_hostContainerInfo` instead, fix #345 (thanks @kikoanis) 80 | - `transformer.parse` was not called for structs and lists (@gcanti) 81 | - **Experimental** 82 | - add support for interfaces (tcomb ^3.1.0), fix #341 (@gcanti) 83 | 84 | ## v0.9.7 85 | 86 | - **New Feature** 87 | - add support for union options (thanks @minedeljkovic) 88 | 89 | ## v0.9.6 90 | 91 | - **Bug Fix** 92 | - Broken with jspm, fix #331 (thanks @abhishiv) 93 | 94 | ## v0.9.5 95 | 96 | - **Bug Fix** 97 | - check for existing index in `List`'s `getValue` method, fix #322 (thanks @rajeshps) 98 | 99 | ## v0.9.4 100 | 101 | - **Bug Fix** 102 | - fix broken server side rendering with React v15.0.0, fix https://github.com/gcanti/tcomb-form-templates-bootstrap/issues/7 103 | - Optional or subtyped unions, fix #319 104 | 105 | ## v0.9.3 106 | 107 | - **Internal** 108 | - Move React to peerDependecies and devDependencies, fix #313 (thanks @maksis) 109 | 110 | ## v0.9.2 111 | 112 | - **Internal** 113 | - use empty string instead of null in textbox format, fix #308 (thanks @snadn). Reference https://facebook.github.io/react/blog/#new-deprecations-introduced-with-a-warning 114 | 115 | ## v0.9.1 116 | 117 | - **Bug Fix** 118 | - upgrade to `tcomb-form-templates-bootstrap` v0.2, ref https://github.com/gcanti/tcomb-json-schema/issues/22 119 | 120 | ## v0.9.0 121 | 122 | **Warning**. If you don't rely in your codebase on the property `maybe(MyType)(undefined) === null` this **is not a breaking change** for you. 123 | 124 | - **Breaking Change** 125 | - upgrade to `tcomb-validation` v3.0.0 126 | - **Polish** 127 | - remove `evt.preventDefault()` calls 128 | 129 | ## v0.8.2 130 | 131 | - **New Feature** 132 | - now `options` can also be a function `(value: any) -> object` 133 | - support for unions, fix #297 134 | - add new `isPristine` field to components `state` 135 | - **Documentation** 136 | - add issue template (new GitHub feature) 137 | 138 | ## v0.8.1 139 | 140 | - **New Feature** 141 | - add dist configuration for [unpkg](https://unpkg.com/) 142 | 143 | ## v0.8.0 144 | 145 | - **Breaking Change** 146 | - drop `uvdom`, `uvdom-bootstrap` dependencies 147 | - bootstrap templates in its own repo [tcomb-form-templates-bootstrap](https://github.com/gcanti/tcomb-form-templates-bootstrap) 148 | - semantic templates in its own repo [tcomb-form-templates-semantic](https://github.com/gcanti/tcomb-form-templates-semantic) 149 | 150 | **Migration guide** 151 | 152 | `tcomb-form` follows semver and technically this is a breaking change (hence the minor version bump). 153 | However, if you are using the default bootstrap templates, the default language (english) and you are not relying on the `uvdom` and `uvdom-bootstrap` modules, this is **not a breaking change** for you. 154 | 155 | ### How to 156 | 157 | **I'm using the default bootstrap templates and the default language (english)** 158 | 159 | This is easy: nothing changed for you. 160 | 161 | **I'm using the default bootstrap templates but I override the language** 162 | 163 | ```diff 164 | var t = require('tcomb-form/lib'); 165 | -var templates = require('tcomb-form/lib/templates/bootstrap'); 166 | +var templates = require('tcomb-form/node_modules/tcomb-form-templates-bootstrap'); 167 | 168 | t.form.Form.templates = templates; 169 | t.form.Form.i18n = { 170 | ... 171 | }; 172 | ``` 173 | 174 | (contributions to `src/i18n` folder welcome!) 175 | 176 | **I'm using the default language (english) but I override the templates** 177 | 178 | ```sh 179 | npm install tcomb-form-templates-semantic --save 180 | ``` 181 | 182 | ```diff 183 | var t = require('tcomb-form/lib'); 184 | var i18n = require('tcomb-form/lib/i18n/en'); 185 | -var templates = require('tcomb-form/lib/templates/semantic'); 186 | +var templates = require('tcomb-form-templates-semantic'); 187 | 188 | t.form.Form.i18n = i18n; 189 | t.form.Form.templates = templates; 190 | ``` 191 | 192 | ## v0.7.10 193 | 194 | - **Bug Fix** 195 | - IE8 issue, 'this.refs.input' is null or not and object, fix #268 196 | 197 | ## v0.7.9 198 | 199 | - **Bug Fix** 200 | - use keys returned from getTypeProps as refs, #269 201 | 202 | ## v0.7.8 203 | 204 | **Warning**. `uvdom` dependency is deprecated and will be removed in the next releases. If you are using custom templates based on `uvdom`, please add a static function `toReactElement` before upgrading to v0.8: 205 | 206 | ```js 207 | const Type = t.struct({ 208 | name: t.String 209 | }) 210 | 211 | import { compile } from 'uvdom/react' 212 | 213 | function myTemplate(locals) { 214 | return {tag: 'input', attrs: { value: locals.value }} 215 | } 216 | 217 | myTemplate.toReactElement = compile // <= here 218 | 219 | const options = { 220 | fields: { 221 | name: { 222 | template: myTemplate 223 | } 224 | } 225 | } 226 | ``` 227 | 228 | - **New Feature** 229 | - complete refactoring of bootstrap templates, fix #254 230 | - add a type property to button locals 231 | - one file for each template 232 | - every template own a series of render* function that can be overridden 233 | 234 | **Example** 235 | 236 | ```js 237 | const Type = t.struct({ 238 | name: t.String 239 | }) 240 | 241 | const myTemplate = t.form.Form.templates.textbox.clone({ 242 | // override default implementation 243 | renderInput: (locals) => { 244 | return 245 | } 246 | }) 247 | 248 | const options = { 249 | fields: { 250 | name: { 251 | template: myTemplate 252 | } 253 | } 254 | } 255 | ``` 256 | 257 | - more style classes for styling purposes, fix #171 258 | 259 | **Example** 260 | 261 | ```js 262 | const Type = t.struct({ 263 | name: t.String, 264 | rememberMe: t.Boolean 265 | }) 266 | ``` 267 | 268 | outputs 269 | 270 | ```html 271 | 272 |
273 | 274 |
275 | ... 276 |
277 |
278 | ... 279 |
280 |
281 | ``` 282 | 283 | - complete refactoring of semantic templates 284 | - add a type property to button locals 285 | - one file for each template 286 | - every template own a series of render* function that can be overridden 287 | - more style classes for styling purposes, fix #171 288 | - add `context` prop to template `locals` 289 | - **Bug Fix** 290 | - Incosistent calling of tcomb-validation `validate` function in `getTypeInfo` and components for struct and list types, fix #253 291 | - avoid useless re-renderings of Datetime when the value is undefined 292 | - **Experimental** 293 | - if a type owns a `getTcombFormFactory(options)` static function, it will be used to retrieve the suitable factory 294 | 295 | **Example** 296 | 297 | ```js 298 | // instead of 299 | const Country = t.enums.of(['IT', 'US'], 'Country'); 300 | 301 | const Type = t.struct({ 302 | country: Country 303 | }); 304 | 305 | const options = { 306 | fields: { 307 | country: { 308 | factory: t.form.Radio 309 | } 310 | } 311 | }; 312 | 313 | // you can write 314 | const Country = t.enums.of(['IT', 'US'], 'Country'); 315 | 316 | Country.getTcombFormFactory = function (/*options*/) { 317 | return t.form.Radio; 318 | }; 319 | 320 | const Type = t.struct({ 321 | country: Country 322 | }); 323 | 324 | const options = {}; 325 | ``` 326 | 327 | - **Internal** 328 | - remove `raw` param in `getValue` API (use `validate()` API instead) 329 | - remove deprecated types short alias from tests 330 | - factor out UIDGenerator from `Form` render method 331 | - optimize `getError()` return an error message only if `hasError === true` 332 | 333 | ## v0.7.6 334 | 335 | - **Bug Fix** 336 | - de-optimise structs / lists onChange, fix #235 337 | - **Experimental** 338 | - add support for maybe structs and maybe lists, fix #236 339 | 340 | ## v0.7.5 341 | 342 | - **Bug Fix** 343 | - optional refinement with custom error message not passing locals.error, fix #230 344 | - Kind is undefined in onChange for nested List, fix #231 345 | - **Internal** 346 | - custom error function now takes a parsed value 347 | 348 | ## v0.7.4 349 | 350 | - **New Feature** 351 | - pass the component options to the error option function, fix #222 352 | - **Bug Fix** 353 | - Inconsistent error message creation process between `validate(val, type)` and form validation, fix #221 354 | - Radio component does not have a transformer in IE10-, fix #226 355 | - **Internal** 356 | - upgrade to react v0.14 357 | 358 | ## v0.7.3 359 | 360 | - **New Feature** 361 | - add the type to template locals, fix #210 362 | - **Bug Fix** 363 | - Fields are wrapped in a form-group, fix #215 364 | 365 | ## v0.7.2 366 | 367 | - **Bug Fix** 368 | - Add className to locals of Struct and List (thanks @jsor) 369 | 370 | ## v0.7.1 371 | 372 | - **Internal** 373 | - upgrade to latest version of tcomb-validation (2.2.0) 374 | - removed react-dom dependency 375 | - removed debug dependency 376 | - **New Feature** 377 | - added argument `context` to `error` options that are functions (new signature: `error(value, path, context)`) 378 | - added `error` option default if the type constructor owns a `getValidationErrorMessage(value, path, context)` function 379 | - added `context` prop to all components (passed into `error` as `context` argument) 380 | 381 | ## v0.7.0 382 | 383 | - **Breaking Change** 384 | - upgrade to react v0.14.0-rc1, fix #194 385 | 386 | ## v0.6.4 387 | 388 | - **New Feature** 389 | - added a `required` field to i18n, fix #181 390 | 391 | ## v0.6.3 392 | 393 | - **Bug Fix** 394 | - `help` option for `t.Dat` gives bootstrap error #164 395 | 396 | ## v0.6.2 397 | 398 | - **Internal** 399 | + upgrade to latest version of tcomb-validation (2.0.0) 400 | 401 | ## v0.6.1 402 | 403 | - **Internal** 404 | + memoise `t.form.Component::getId()` 405 | - **Bug Fix** 406 | + Encountered two children with the same key fix #152 407 | 408 | ## v0.6.0 409 | 410 | - **Breaking Change** 411 | + upgrade to tcomb-validation v2.0.0-beta 412 | 413 | ## v0.5.5 414 | 415 | - **Internal** 416 | + Relax Bootstrap columns constraint #149 417 | 418 | ## v0.5.4 419 | 420 | - **Bug Fix** 421 | + Unable to disable t.Dat field #137 422 | - **New Feature** 423 | + t.Dat basic template (semantic skin) 424 | 425 | ## v0.5.3 426 | 427 | - **New Feature** 428 | + Add buttonBefore support (bootstrap skin) #126 429 | - **Bug Fix** 430 | + fix server-side rendering markup differences #124 431 | - **Internal** 432 | + tcomb-validation: upgrade to latest version 433 | 434 | ## v0.5.2 435 | 436 | - **New Feature** 437 | + Add buttonAfter support (bootstrap skin) #126 438 | - **Documentation** 439 | + Add `attrs` option 440 | + Add Date input 441 | + Fix wrong imports in Customizations section 442 | - **Internal** 443 | + Optimise stuct and list re-rendering on value change 444 | + fix server-side rendering markup differences #124 445 | 446 | ## v0.5.1 447 | 448 | - **Bug Fix** 449 | + Remove wrong label id attribute in date template (bootstrap skin) 450 | 451 | ## v0.5 452 | 453 | - **New Feature** 454 | + Add attributes and events (`attrs` option) #76, #91, #53, #67 455 | + Add bootstrap static control, #92 456 | + Set class on form-group div indicating its depth within form, #64 457 | + Added `kind` param to `onChange` handler 458 | - **Breaking Change** 459 | + Drop support for React v0.12.x 460 | + Moved `placeholder` option within `attrs` option 461 | + Moved `id` option within `attrs` option 462 | - **Bug Fix** 463 | + Remove class "has-error" from empty optional numeric field #113 464 | - **Documentation** 465 | + Add GUIDE.md 466 | - **Internal** 467 | + Complete code refactoring 468 | 469 | ## v0.4.10 470 | 471 | - **New Feature** 472 | + Add experimental semantic ui skin 473 | - **Bug Fix** 474 | + Fix `nullOption` was incorrectly added to multiple selects 475 | 476 | ## v0.4.9 477 | 478 | - **New Feature** 479 | + added `template` option to structs and lists 480 | + added `Component.extend` API 481 | + added `getComponent(path)` API 482 | + added basic date component 483 | - **Internal** 484 | + complete code refactoring 485 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | Contributions are welcome and are greatly appreciated! Every little bit helps, and credit will 4 | always be given. 5 | 6 | ## Issues Guidelines 7 | 8 | Before you submit an issue, check that it meets these guidelines: 9 | 10 | - specify the version of `tcomb-form` you are using 11 | - specify the version of `react` you are using 12 | - if the issue regards a bug, please provide a minimal failing example / test (see "How to setup an example on codepen.io") 13 | 14 | ## How to setup an example on [codepen.io](http://codepen.io/) 15 | 16 | Configuration: 17 | 18 | **CSS** 19 | 20 | - https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css 21 | 22 | **JS** 23 | 24 | JavaScript Preprocessor: Babel 25 | 26 | - //cdnjs.cloudflare.com/ajax/libs/react/0.14.3/react.js 27 | - //cdnjs.cloudflare.com/ajax/libs/react/0.14.3/react-dom.js 28 | - https://unpkg.com/tcomb-form/dist/tcomb-form.js (or https://unpkg.com/tcomb-form/dist/tcomb-form.min.js) 29 | 30 | ## Pull Request Guidelines 31 | 32 | Before you submit a pull request from your forked repo, check that it meets these guidelines: 33 | 34 | 1. If the pull request fixes a bug, it should include tests that fail without the changes, and pass 35 | with them. 36 | 2. If the pull request adds functionality, the docs should be updated as part of the same PR. 37 | 3. Please rebase and resolve all conflicts before submitting. 38 | 39 | ## Setting up your environment 40 | 41 | After forking tcomb-form to your own github org, do the following steps to get started: 42 | 43 | ```sh 44 | # clone your fork to your local machine 45 | git clone https://github.com/gcanti/tcomb-form.git 46 | 47 | # step into local repo 48 | cd tcomb-form 49 | 50 | # install dependencies 51 | npm install 52 | 53 | # build lib folder (optional) 54 | npm run watch 55 | ``` 56 | 57 | ### Running Tests 58 | 59 | ```sh 60 | # run tests 61 | npm test 62 | ``` 63 | 64 | ### Style & Linting 65 | 66 | This codebase adheres to the [Airbnb Styleguide](https://github.com/airbnb/javascript) with a few tweaks and is 67 | enforced using [ESLint](http://eslint.org/). 68 | 69 | It is recommended that you install an eslint plugin for your editor of choice when working on this 70 | codebase, however you can always check to see if the source code is compliant by running: 71 | 72 | ```sh 73 | npm run lint 74 | ``` 75 | 76 | -------------------------------------------------------------------------------- /GUIDE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | **Table of Contents** 4 | 5 | - [Get started](#get-started) 6 | - [Setup](#setup) 7 | - [Working example](#working-example) 8 | - [API](#api) 9 | - [`getValue()`](#getvalue) 10 | - [`validate()`](#validate) 11 | - [How to](#how-to) 12 | - [Adding a default value and listening to changes](#adding-a-default-value-and-listening-to-changes) 13 | - [Reset the form](#reset-the-form) 14 | - [Accessing fields](#accessing-fields) 15 | - [Submitting the form](#submitting-the-form) 16 | - [Customised error messages](#customised-error-messages) 17 | - [List with Dynamic Items (Different structs based on selected value)](#list-with-dynamic-items-different-structs-based-on-selected-value) 18 | - [Types](#types) 19 | - [Required field](#required-field) 20 | - [Optional field](#optional-field) 21 | - [Numbers](#numbers) 22 | - [Refinements](#refinements) 23 | - [Booleans](#booleans) 24 | - [Dates](#dates) 25 | - [Enums](#enums) 26 | - [Lists](#lists) 27 | - [Nested structures](#nested-structures) 28 | - [Rendering options](#rendering-options) 29 | - [Struct options](#struct-options) 30 | - [Automatically generated placeholders](#automatically-generated-placeholders) 31 | - [Fields order](#fields-order) 32 | - [Legend](#legend) 33 | - [Help message](#help-message) 34 | - [Error messages](#error-messages) 35 | - [Disabled](#disabled) 36 | - [Fields configuration](#fields-configuration) 37 | - [Look and feel](#look-and-feel) 38 | - [List options](#list-options) 39 | - [Items configuration](#items-configuration) 40 | - [Internationalization](#internationalization) 41 | - [Buttons configuration](#buttons-configuration) 42 | - [Union options](#union-options) 43 | - [Textbox options](#textbox-options) 44 | - [Type attribute](#type-attribute) 45 | - [Label](#label) 46 | - [Attributes and events](#attributes-and-events) 47 | - [Styling](#styling) 48 | - [Checkbox options](#checkbox-options) 49 | - [Select options](#select-options) 50 | - [Null option](#null-option) 51 | - [Options order](#options-order) 52 | - [Custom options](#custom-options) 53 | - [Render as a radio group](#render-as-a-radio-group) 54 | - [Multiple select](#multiple-select) 55 | - [Date options](#date-options) 56 | - [Fields order](#fields-order-1) 57 | - [Customizations](#customizations) 58 | - [Templates](#templates) 59 | - [Clone default templates](#clone-default-templates) 60 | - [Transformers](#transformers) 61 | - [Custom factories](#custom-factories) 62 | - [getTcombFormFactory](#gettcombformfactory) 63 | - [getTcombFormOptions](#gettcombformoptions) 64 | - [Bootstrap extras](#bootstrap-extras) 65 | - [Textbox](#textbox) 66 | - [Addons](#addons) 67 | - [Size](#size) 68 | - [Select](#select) 69 | - [Struct](#struct) 70 | - [General configuration](#general-configuration) 71 | - [Changing the default language](#changing-the-default-language) 72 | - [Changing the default skin](#changing-the-default-skin) 73 | 74 | 75 | 76 | # Get started 77 | 78 | ## Setup 79 | 80 | ```sh 81 | $ npm install tcomb-form 82 | ``` 83 | Note: Use tcomb-form@0.6.x with react@0.13.x. See [#200](https://github.com/gcanti/tcomb-form/issues/200). 84 | 85 | ## Working example 86 | 87 | ```js 88 | import React from 'react'; 89 | import { render } from 'react-dom'; 90 | import t from 'tcomb-form'; 91 | 92 | const Form = t.form.Form; 93 | 94 | // define your domain model with tcomb 95 | // https://github.com/gcanti/tcomb 96 | const Person = t.struct({ 97 | name: t.String, 98 | surname: t.String 99 | }); 100 | 101 | const App = React.createClass({ 102 | 103 | save() { 104 | // call getValue() to get the values of the form 105 | var value = this.refs.form.getValue(); 106 | // if validation fails, value will be null 107 | if (value) { 108 | // value here is an instance of Person 109 | console.log(value); 110 | } 111 | }, 112 | 113 | render() { 114 | return ( 115 |
116 |
120 | 121 |
122 | ); 123 | } 124 | 125 | }); 126 | 127 | render(, document.getElementById('app')); 128 | ``` 129 | 130 | > **Note**. Labels are automatically generated. 131 | 132 | ## API 133 | 134 | ### `getValue()` 135 | 136 | Returns `null` if the validation failed; otherwise returns an instance of your model. 137 | 138 | > **Note**. Calling `getValue` will cause the validation of all the fields of the form, including some side effects like highlighting the errors. 139 | 140 | ### `validate()` 141 | 142 | Returns a `ValidationResult` (see [tcomb-validation](https://github.com/gcanti/tcomb-validation) for a reference documentation). 143 | 144 | ## How to 145 | 146 | ### Adding a default value and listening to changes 147 | 148 | The `Form` component behaves like a [controlled component](https://facebook.github.io/react/docs/forms.html): 149 | 150 | ```js 151 | const App = React.createClass({ 152 | 153 | getInitialState() { 154 | return { 155 | value: { 156 | name: 'Giulio', 157 | surname: 'Canti' 158 | } 159 | }; 160 | }, 161 | 162 | onChange(value) { 163 | this.setState({value}); 164 | }, 165 | 166 | save() { 167 | var value = this.refs.form.getValue(); 168 | if (value) { 169 | console.log(value); 170 | } 171 | }, 172 | 173 | render() { 174 | return ( 175 |
176 | 182 | 183 |
184 | ); 185 | } 186 | 187 | }); 188 | ``` 189 | 190 | The `onChange` handler has the following signature: 191 | 192 | ``` 193 | (raw: any, path: Array, kind?) => void 194 | ``` 195 | 196 | where 197 | 198 | - `raw` contains the current raw value of the form (can be an invalid value for your model) 199 | - `path` is the path to the field triggering the change 200 | - `kind` specify the kind of change (when `undefined` means a value change) 201 | + `'add'` list item added 202 | + `'remove'` list item removed 203 | + `'moveUp'` list item moved up 204 | + `'moveDown'` list item moved down 205 | 206 | ### Reset the form 207 | 208 | In the previous example, just pass `null` to the `value` prop: 209 | 210 | ```js 211 | const App = React.createClass({ 212 | 213 | ... 214 | 215 | resetForm() { 216 | this.setState({value: null}); 217 | }, 218 | 219 | save() { 220 | var value = this.refs.form.getValue(); 221 | if (value) { 222 | console.log(value); 223 | // clear all fields after submit 224 | this.resetForm(); 225 | } 226 | }, 227 | 228 | ... 229 | 230 | }); 231 | ``` 232 | 233 | ### Accessing fields 234 | 235 | You can get access to a field with the `getComponent(path)` API: 236 | 237 | ```js 238 | const Person = t.struct({ 239 | name: t.String, 240 | surname: t.String 241 | }); 242 | 243 | const App = React.createClass({ 244 | 245 | onChange(value, path) { 246 | // validate a field on every change 247 | this.refs.form.getComponent(path).validate(); 248 | }, 249 | 250 | render() { 251 | return ( 252 |
253 | 257 |
258 | ); 259 | } 260 | 261 | }); 262 | ``` 263 | 264 | ### Submitting the form 265 | 266 | The output of the `Form` component is a `fieldset` tag containing your fields. You can submit the form by wrapping the output with a `form` tag: 267 | 268 | ```js 269 | const App = React.createClass({ 270 | 271 | onSubmit(evt) { 272 | const value = this.refs.form.getValue(); 273 | if (!value) { 274 | // there are errors, don't send the form 275 | evt.preventDefault(); 276 | } else { 277 | // everything ok, let the form fly... 278 | // ...or handle the values contained in the 279 | // `value` variable with js 280 | } 281 | }, 282 | 283 | render() { 284 | return ( 285 | 286 | 290 | 291 | 292 | ); 293 | } 294 | 295 | }); 296 | ``` 297 | 298 | ### Customised error messages 299 | 300 | See [Error messages](#error-messages) section. 301 | 302 | ### List with Dynamic Items (Different structs based on selected value) 303 | 304 | Lists of different types are not supported. This is because a `tcomb`'s list, by definition, contains only values of the same type. You can define a union though: 305 | 306 | ```js 307 | const AccountType = t.enums.of([ 308 | 'type 1', 309 | 'type 2', 310 | 'other' 311 | ], 'AccountType') 312 | 313 | const KnownAccount = t.struct({ 314 | type: AccountType 315 | }, 'KnownAccount') 316 | 317 | // UnknownAccount extends KnownAccount so it owns also the type field 318 | const UnknownAccount = KnownAccount.extend({ 319 | label: t.String, 320 | }, 'UnknownAccount') 321 | 322 | // the union 323 | const Account = t.union([KnownAccount, UnknownAccount], 'Account') 324 | 325 | // the final form type 326 | const Type = t.list(Account) 327 | ``` 328 | 329 | Generally `tcomb`'s unions require a `dispatch` implementation in order to select the suitable type constructor for a given value and this would be the key in this use case: 330 | 331 | ```js 332 | // if account type is 'other' return the UnknownAccount type 333 | Account.dispatch = value => value && value.type === 'other' ? UnknownAccount : KnownAccount 334 | ``` 335 | 336 | The complete example: 337 | 338 | ```js 339 | import React from 'react' 340 | import t from 'tcomb-form' 341 | 342 | const AccountType = t.enums.of([ 343 | 'type 1', 344 | 'type 2', 345 | 'other' 346 | ], 'AccountType') 347 | 348 | const KnownAccount = t.struct({ 349 | type: AccountType 350 | }, 'KnownAccount') 351 | 352 | const UnknownAccount = KnownAccount.extend({ 353 | label: t.String, 354 | }, 'UnknownAccount') 355 | 356 | const Account = t.union([KnownAccount, UnknownAccount], 'Account') 357 | 358 | Account.dispatch = value => value && value.type === 'other' ? UnknownAccount : KnownAccount 359 | 360 | const Type = t.list(Account) 361 | 362 | const App = React.createClass({ 363 | 364 | onSubmit(evt) { 365 | evt.preventDefault() 366 | const v = this.refs.form.getValue() 367 | if (v) { 368 | console.log(v) 369 | } 370 | }, 371 | 372 | render() { 373 | return ( 374 |
375 | 379 |
380 | 381 |
382 | 383 | ) 384 | } 385 | 386 | }) 387 | ``` 388 | 389 | # Types 390 | 391 | Models are defined with [tcomb](https://github.com/gcanti/tcomb). tcomb is a library for Node.js and the browser which allows you to check the types of JavaScript values at runtime with a simple syntax. It's great for Domain Driven Design, for testing, and for adding safety to your internal code. 392 | 393 | ## Required field 394 | 395 | By default fields are required: 396 | 397 | ```js 398 | const Person = t.struct({ 399 | name: t.String, // a required string 400 | surname: t.String // a required string 401 | }); 402 | ``` 403 | 404 | ## Optional field 405 | 406 | In order to create an optional field, wrap the field type with the `t.maybe` combinator: 407 | 408 | ```js 409 | const Person = t.struct({ 410 | name: t.String, 411 | surname: t.String, 412 | email: t.maybe(t.String) // an optional string 413 | }); 414 | ``` 415 | 416 | The suffix `" (optional)"` is automatically added to optional fields. 417 | 418 | You can customise the suffix value, or set a suffix for required fields (see the "Internationalization" section). 419 | 420 | ## Numbers 421 | 422 | In order to create a numeric field, use the `t.Number` type: 423 | 424 | ```js 425 | const Person = t.struct({ 426 | name: t.String, 427 | surname: t.String, 428 | email: t.maybe(t.String), 429 | age: t.Number // a numeric field 430 | }); 431 | ``` 432 | 433 | tcomb-form will automatically convert numbers to / from strings. 434 | 435 | ## Refinements 436 | 437 | A *predicate* is a function with the following signature: 438 | 439 | ``` 440 | (x: any) => boolean 441 | ``` 442 | 443 | You can refine a type with the `t.refinement(type, predicate)` combinator: 444 | 445 | ```js 446 | // a type representing positive numbers 447 | const Positive = t.refinement(t.Number, (n) => n >= 0}); 448 | 449 | const Person = t.struct({ 450 | name: t.String, 451 | surname: t.String, 452 | email: t.maybe(t.String), 453 | age: Positive 454 | }); 455 | ``` 456 | 457 | Refinements allow you to express any custom validation with a simple predicate. 458 | 459 | ## Booleans 460 | 461 | In order to create a boolean field, use the `t.Boolean` type: 462 | 463 | ```js 464 | const Person = t.struct({ 465 | name: t.String, 466 | surname: t.String, 467 | email: t.maybe(t.String), 468 | age: t.Number, 469 | rememberMe: t.Boolean // a boolean field 470 | }); 471 | ``` 472 | 473 | Booleans are displayed as checkboxes. 474 | 475 | ## Dates 476 | 477 | In order to create a date field, use the `t.Date` type: 478 | 479 | ```js 480 | const Person = t.struct({ 481 | name: t.String, 482 | surname: t.String, 483 | email: t.maybe(t.String), 484 | age: t.Number, 485 | rememberMe: t.Boolean, 486 | birthDate: t.Date // a date field 487 | }); 488 | ``` 489 | 490 | ## Enums 491 | 492 | In order to create an enum field, use the `t.enums` combinator: 493 | 494 | ```js 495 | const Gender = t.enums({ 496 | M: 'Male', 497 | F: 'Female' 498 | }); 499 | 500 | const Person = t.struct({ 501 | name: t.String, 502 | surname: t.String, 503 | email: t.maybe(t.String), 504 | age: t.Number, 505 | rememberMe: t.Boolean, 506 | birthDate: t.Date, 507 | gender: Gender // enum 508 | }); 509 | ``` 510 | 511 | By default enums are displayed as selects. 512 | 513 | ## Lists 514 | 515 | You can handle a list with the `t.list` combinator: 516 | 517 | ```js 518 | const Person = t.struct({ 519 | name: t.String, 520 | surname: t.String, 521 | email: t.maybe(t.String), 522 | age: Positive, // refinement 523 | rememberMe: t.Boolean, 524 | birthDate: t.Date, 525 | gender: Gender, 526 | tags: t.list(t.String) // a list of strings 527 | }); 528 | ``` 529 | 530 | ## Nested structures 531 | 532 | You can nest lists and structs at an arbitrary level: 533 | 534 | ```js 535 | const Person = t.struct({ 536 | name: t.String, 537 | surname: t.String 538 | }); 539 | 540 | const Persons = t.list(Person); 541 | 542 | ... 543 | 544 |
548 | ``` 549 | 550 | # Rendering options 551 | 552 | In order to customise the look and feel, use an `options` prop: 553 | 554 | ```js 555 | 556 | ``` 557 | 558 | > **Warning**. tcomb-form uses shouldComponentUpdate aggressively. In order to ensure that tcomb-form detect any change to `type`, `options` or `value` props you have to change references: 559 | 560 | Example: disable a field based on another field's value 561 | 562 | ```js 563 | const Type = t.struct({ 564 | disable: t.Boolean, // if true, name field will be disabled 565 | name: t.String 566 | }); 567 | 568 | const options = { 569 | fields: { 570 | name: {} 571 | } 572 | }; 573 | 574 | const App = React.createClass({ 575 | 576 | getInitialState() { 577 | return { 578 | options: options, 579 | value: null 580 | }; 581 | }, 582 | 583 | onSubmit(evt) { 584 | evt.preventDefault(); 585 | var value = this.refs.form.getValue(); 586 | if (value) { 587 | console.log(value); 588 | } 589 | }, 590 | 591 | onChange(value) { 592 | // tcomb immutability helpers 593 | // https://github.com/gcanti/tcomb/blob/master/GUIDE.md#updating-immutable-instances 594 | var options = t.update(this.state.options, { 595 | fields: { 596 | name: { 597 | disabled: {'$set': value.disable} 598 | } 599 | } 600 | }); 601 | this.setState({options: options, value: value}); 602 | }, 603 | 604 | render() { 605 | return ( 606 | 607 | 613 | 614 |
615 | ); 616 | } 617 | 618 | }); 619 | ``` 620 | 621 | ## Struct options 622 | 623 | ### Automatically generated placeholders 624 | 625 | In order to generate default placeholders use the option `auto: 'placeholders'`: 626 | 627 | ```js 628 | const options = { 629 | auto: 'placeholders' 630 | }; 631 | 632 |
633 | ``` 634 | 635 | Or `auto: 'none'` if you don't want neither labels nor placeholders: 636 | 637 | ```js 638 | const options = { 639 | auto: 'none' 640 | }; 641 | ``` 642 | 643 | ### Fields order 644 | 645 | You can sort the fields with the `order` option: 646 | 647 | ```js 648 | const options = { 649 | order: ['name', 'surname', 'rememberMe', 'gender', 'age', 'email'] 650 | }; 651 | ``` 652 | 653 | > **Warning**: Any field that is not in this array will be omitted from the form 654 | 655 | ### Legend 656 | 657 | You can add a fieldset legend with the `legend` option: 658 | 659 | ```js 660 | const options = { 661 | // you can use strings or JSX 662 | legend: My form legend 663 | }; 664 | ``` 665 | 666 | ### Help message 667 | 668 | You can add an help message with the `help` option: 669 | 670 | ```js 671 | const options = { 672 | // you can use strings or JSX 673 | help: My form help 674 | }; 675 | ``` 676 | 677 | ### Error messages 678 | 679 | You can add a custom error message with the `error` option: 680 | 681 | ```js 682 | const options = { 683 | error: A custom error message // use strings or JSX 684 | }; 685 | ``` 686 | 687 | `error` can also be a function with the following signature: 688 | 689 | ``` 690 | type getValidationErrorMessage = (value, path, context) => ?(string | ReactElement) 691 | ``` 692 | 693 | where 694 | 695 | - `value` is the (parsed) current value of the component. 696 | - `path` is the path of the value being validated 697 | - `context` is the value of the `context` prop. Also it contains a reference to the component options. 698 | 699 | The value returned by the function will be used as error message. 700 | 701 | If you want to show the error message onload, add the `hasError` option: 702 | 703 | ```js 704 | const options = { 705 | hasError: true, 706 | error: A custom error message 707 | }; 708 | ``` 709 | 710 | Another (advanced) way to customize the error message is to add a: 711 | 712 | ``` 713 | getValidationErrorMessage(value, path, context) => ?(string | ReactElement) 714 | ``` 715 | 716 | static function to the type, where the arguments are the same as above: 717 | 718 | ```js 719 | const Age = t.refinement(t.Number, (n) => return n >= 18); 720 | 721 | // if you define a getValidationErrorMessage function, it will be called on validation errors 722 | Age.getValidationErrorMessage = (value, path, context) => { 723 | return 'bad age, locale: ' + context.locale; 724 | }; 725 | 726 | const Schema = t.struct({ 727 | age: Age 728 | }); 729 | 730 | const App = React.createClass({ 731 | 732 | onSubmit(evt) { 733 | evt.preventDefault(); 734 | const value = this.refs.form.getValue(); 735 | if (value) { 736 | console.log(value); 737 | } 738 | }, 739 | 740 | render() { 741 | return ( 742 | 743 | 748 | 749 | 750 | ); 751 | } 752 | 753 | }); 754 | ``` 755 | 756 | You can even define `getValidationErrorMessage` on the supertype in order to be DRY: 757 | 758 | ```js 759 | t.Number.getValidationErrorMessage = (value, path, context) => { 760 | return 'bad number'; 761 | }; 762 | 763 | Age.getValidationErrorMessage = (value, path, context) => { 764 | return 'bad age, locale: ' + context.locale; 765 | }; 766 | ``` 767 | 768 | ### Disabled 769 | 770 | You can disable the whole fieldset with the `disabled` option: 771 | 772 | ```js 773 | const options = { 774 | disabled: true 775 | }; 776 | ``` 777 | 778 | ### Fields configuration 779 | 780 | You can configure each field with the `fields` option: 781 | 782 | ```js 783 | const options = { 784 | fields: { 785 | name: { 786 | // name field configuration here.. 787 | }, 788 | surname: { 789 | // surname field configuration here.. 790 | }, 791 | ... 792 | } 793 | }); 794 | ``` 795 | 796 | `fields` is a hash containing a key for each field you want to configure. 797 | 798 | ### Look and feel 799 | 800 | You can customise the look and feel with the `template` option: 801 | 802 | ```js 803 | const options = { 804 | template: mytemplate // see Templates section for documentation 805 | } 806 | ``` 807 | 808 | ## List options 809 | 810 | The following options are similar to the Struct ones: 811 | 812 | - `auto` 813 | - `disabled` 814 | - `help` 815 | - `hasError` 816 | - `error` 817 | - `legend` 818 | - `template` 819 | 820 | ### Items configuration 821 | 822 | To configure all the items in a list, set the `item` option: 823 | 824 | ```js 825 | const Colors = t.list(t.String); 826 | 827 | const options = { 828 | item: { 829 | type: 'color' // HTML5 type attribute 830 | } 831 | }); 832 | 833 |
834 | ``` 835 | 836 | ### Internationalization 837 | 838 | You can override the default language (english) with the `i18n` option: 839 | 840 | ```js 841 | const options = { 842 | i18n: { 843 | add: 'Nuovo', // add button 844 | down: 'Giù', // move down button 845 | optional: ' (opzionale)', // suffix added to optional fields 846 | required: '', // suffix added to required fields 847 | remove: 'Elimina', // remove button 848 | up: 'Su' // move up button 849 | } 850 | }; 851 | ``` 852 | 853 | ### Buttons configuration 854 | 855 | You can prevent operations on lists with the following options: 856 | 857 | - `disableAdd`: (default `false`) prevents adding new items 858 | - `disableRemove`: (default `false`) prevents removing existing items 859 | - `disableOrder`: (default `false`) prevents sorting existing items 860 | 861 | ```js 862 | const options = { 863 | disableOrder: true 864 | }; 865 | ``` 866 | 867 | ## Union options 868 | 869 | ```js 870 | const AccountType = t.enums.of([ 871 | 'type 1', 872 | 'type 2', 873 | 'other' 874 | ], 'AccountType') 875 | 876 | const KnownAccount = t.struct({ 877 | type: AccountType 878 | }, 'KnownAccount') 879 | 880 | // UnknownAccount extends KnownAccount so it owns also the type field 881 | const UnknownAccount = KnownAccount.extend({ 882 | label: t.String, 883 | }, 'UnknownAccount') 884 | 885 | // the union 886 | const Account = t.union([KnownAccount, UnknownAccount], 'Account') 887 | 888 | // the final form type 889 | const Type = t.list(Account) 890 | 891 | const options = { 892 | item: [ // one options object for each concrete type of the union 893 | { 894 | label: 'KnownAccount' 895 | }, 896 | { 897 | label: 'UnknownAccount' 898 | } 899 | ] 900 | } 901 | ``` 902 | 903 | ## Textbox options 904 | 905 | > **Tech note**. Values containing only white spaces are converted to null. 906 | 907 | The following options are similar to the fieldset ones: 908 | 909 | - `disabled` 910 | - `help` 911 | - `hasError` 912 | - `error` 913 | - `template` 914 | 915 | ### Type attribute 916 | 917 | You can set the type attribute with the `type` option. The following values are allowed: 918 | 919 | - 'text' (default) 920 | - 'password' 921 | - 'hidden' 922 | - 'textarea' (outputs a textarea instead of a textbox) 923 | - 'static' (outputs a static value) 924 | - all the HTML5 type values 925 | 926 | ### Label 927 | 928 | You can override the default label with the `label` option: 929 | 930 | ```js 931 | const options = { 932 | fields: { 933 | name: { 934 | // you can use strings or JSX 935 | label: My label 936 | } 937 | } 938 | }; 939 | ``` 940 | 941 | ### Attributes and events 942 | 943 | You can add attributes and events with the `attrs` option: 944 | 945 | ```js 946 | const options = { 947 | fields: { 948 | name: { 949 | attrs: { 950 | autoFocus: true, 951 | placeholder: 'Type your name here', 952 | onBlur: () => { 953 | console.log('onBlur'); 954 | } 955 | } 956 | } 957 | } 958 | }; 959 | ``` 960 | 961 | 962 | ### Styling 963 | 964 | You can a style class with the `className` or the `style` attribute: 965 | 966 | ```js 967 | const options = { 968 | fields: { 969 | name: { 970 | attrs: { 971 | className: 'myClassName' 972 | } 973 | } 974 | } 975 | }; 976 | ``` 977 | 978 | `className` can be a string, an array of strings or a dictionary `string -> boolean`. 979 | 980 | ## Checkbox options 981 | 982 | The following options are similar to the textbox ones: 983 | 984 | - `attrs` 985 | - `label` 986 | - `help` 987 | - `disabled` 988 | - `hasError` 989 | - `error` 990 | - `template` 991 | 992 | ## Select options 993 | 994 | The following options are similar to the textbox ones: 995 | 996 | - `attrs` 997 | - `label` 998 | - `help` 999 | - `disabled` 1000 | - `hasError` 1001 | - `error` 1002 | - `template` 1003 | 1004 | ### Null option 1005 | 1006 | You can customise the null option with the `nullOption` option: 1007 | 1008 | ```js 1009 | const options = { 1010 | fields: { 1011 | gender: { 1012 | nullOption: {value: '', text: 'Choose your gender'} 1013 | } 1014 | } 1015 | }; 1016 | ``` 1017 | 1018 | You can remove the null option by setting the `nullOption` option to `false`. 1019 | 1020 | > **Warning**: when you set `nullOption = false` you must also set the Form's `value` prop for the select field. 1021 | 1022 | > **Tech note**. A value equal to `nullOption.value` (default `''`) is converted to `null`. 1023 | 1024 | ### Options order 1025 | 1026 | You can sort the options with the `order` option: 1027 | 1028 | ```js 1029 | const options = { 1030 | fields: { 1031 | gender: { 1032 | order: 'asc' // or 'desc' 1033 | } 1034 | } 1035 | }; 1036 | ``` 1037 | 1038 | ### Custom options 1039 | 1040 | You can customise the options with the `options` option: 1041 | 1042 | ```js 1043 | const options = { 1044 | fields: { 1045 | gender: { 1046 | options: [ 1047 | {value: 'M', text: 'Maschio'}, 1048 | // use `disabled: true` to disable an option 1049 | {value: 'F', text: 'Femmina', disabled: true} 1050 | ] 1051 | } 1052 | } 1053 | }; 1054 | ``` 1055 | 1056 | An option is an object with the following structure: 1057 | 1058 | ```js 1059 | { 1060 | value: string, // required 1061 | text: string, // required 1062 | disabled: ?boolean // optional, default = false 1063 | } 1064 | ``` 1065 | 1066 | You can also add optgroups: 1067 | 1068 | ```js 1069 | const Car = t.enums.of('Audi Chrysler Ford Renault Peugeot'); 1070 | 1071 | const Select = t.struct({ 1072 | car: Car 1073 | }); 1074 | 1075 | const options = { 1076 | fields: { 1077 | car: { 1078 | options: [ 1079 | {value: 'Audi', text: 'Audi'}, // an option 1080 | {label: 'US', options: [ // a group of options 1081 | {value: 'Chrysler', text: 'Chrysler'}, 1082 | {value: 'Ford', text: 'Ford'} 1083 | ]}, 1084 | {label: 'France', options: [ // another group of options 1085 | {value: 'Renault', text: 'Renault'}, 1086 | {value: 'Peugeot', text: 'Peugeot'} 1087 | ], disabled: true} // use `disabled: true` to disable an optgroup 1088 | ] 1089 | } 1090 | } 1091 | }; 1092 | ``` 1093 | 1094 | ### Render as a radio group 1095 | 1096 | You can render the select as a radio group by using the `factory` option to override the default: 1097 | 1098 | ```js 1099 | const options = { 1100 | factory: t.form.Radio 1101 | }; 1102 | ``` 1103 | 1104 | ### Multiple select 1105 | 1106 | You can turn the select into a multiple select by passing a `list` as type and using the `factory` option to override the default: 1107 | 1108 | ```js 1109 | const Car = t.enums.of('Audi Chrysler Ford Renault Peugeot'); 1110 | 1111 | const Select = t.struct({ 1112 | car: t.list(Car) 1113 | }); 1114 | 1115 | const options = { 1116 | fields: { 1117 | car: { 1118 | factory: t.form.Select 1119 | } 1120 | } 1121 | }; 1122 | ``` 1123 | 1124 | ## Date options 1125 | 1126 | The following options are similar to the textbox ones: 1127 | 1128 | - `label` 1129 | - `help` 1130 | - `disabled` 1131 | - `hasError` 1132 | - `error` 1133 | - `template` 1134 | 1135 | ### Fields order 1136 | 1137 | You can sort the fields with the `order` option: 1138 | 1139 | ```js 1140 | const Type = t.struct({ 1141 | date: t.Date 1142 | }); 1143 | 1144 | const options = { 1145 | fields: { 1146 | date: { 1147 | order: ['D', 'M', 'YY'] 1148 | } 1149 | } 1150 | }; 1151 | ``` 1152 | 1153 | # Customizations 1154 | 1155 | ## Templates 1156 | 1157 | To customise the "skin" of tcomb-form you have to write a *template*. A template is simply a function with the following signature: 1158 | 1159 | ``` 1160 | (locals: any) => ReactElement 1161 | ``` 1162 | 1163 | where `locals` is an object contaning the "recipe" for rendering the input, and is built by tcomb-form for you. 1164 | 1165 | For example, this is the recipe for a textbox: 1166 | 1167 | ```js 1168 | { 1169 | attrs: Object // should render attributes and events 1170 | config: Object // custom options, see Template addons section 1171 | context: Any // a reference to the context prop 1172 | disabled: maybe(Boolean), // should be disabled 1173 | error: maybe(Label), // should show an error 1174 | hasError: maybe(Boolean), // if true should show an error state 1175 | help: maybe(Label), // should show a help message 1176 | label: maybe(Label), // should show a label 1177 | onChange: Function, // should call this function with the changed value 1178 | path: Array, // the path of this field with respect to the form root 1179 | type: String, // should use this as type attribute 1180 | typeInfo: Object // an object containing info on the current type 1181 | value: Any // the current value of the textbox 1182 | } 1183 | ``` 1184 | 1185 | You can set a custom template using the `template` option: 1186 | 1187 | ```js 1188 | const Animal = t.enums({ 1189 | dog: "Dog", 1190 | cat: "Cat" 1191 | }); 1192 | 1193 | const Pet = t.struct({ 1194 | name: t.String, 1195 | type: Animal 1196 | }); 1197 | 1198 | const Person = t.struct({ 1199 | name: t.String, 1200 | pets: t.list(Pet) 1201 | }); 1202 | 1203 | const formLayout = (locals) => { 1204 | return ( 1205 |
1206 |

formLayout

1207 |
{locals.inputs.name}
1208 |
{locals.inputs.pets}
1209 |
1210 | ); 1211 | }; 1212 | 1213 | const petLayout = (locals) => { 1214 | return ( 1215 |
1216 |

petLayout

1217 |
{locals.inputs.name}
1218 |
{locals.inputs.type}
1219 |
1220 | ); 1221 | }; 1222 | 1223 | const options = { 1224 | template: formLayout, 1225 | fields: { 1226 | pets: { // <- pets is a list, you can customise the elements with the `item` option 1227 | item: { 1228 | template: petLayout 1229 | } 1230 | } 1231 | } 1232 | }; 1233 | 1234 | const value = { 1235 | name: 'myname', 1236 | pets: [ 1237 | {name: 'pet1', type: 'dog'}, 1238 | {name: 'pet2', type: 'cat'} 1239 | ] 1240 | }; 1241 | 1242 | const App = React.createClass({ 1243 | 1244 | save() { 1245 | const value = this.refs.form.getValue(); 1246 | if (value) { 1247 | console.log(value); 1248 | } 1249 | }, 1250 | 1251 | render() { 1252 | 1253 | return ( 1254 |
1255 | 1260 |
1261 | 1262 |
1263 | ); 1264 | } 1265 | 1266 | }); 1267 | ``` 1268 | 1269 | ### Clone default templates 1270 | 1271 | In order to keep the majority of the implementation a template can be cloned. Every template own a series of `render*` function that can be overridden: 1272 | 1273 | ```js 1274 | const Type = t.struct({ 1275 | name: t.String 1276 | }) 1277 | 1278 | const myTemplate = t.form.Form.templates.textbox.clone({ 1279 | // override just the input default implementation (labels, help, error will be preserved) 1280 | renderInput: (locals) => { 1281 | return 1282 | } 1283 | }) 1284 | 1285 | const options = { 1286 | fields: { 1287 | name: { 1288 | template: myTemplate 1289 | } 1290 | } 1291 | } 1292 | ``` 1293 | 1294 | ## Transformers 1295 | 1296 | Say you want a search textbox which accepts a list of keywords separated by spaces: 1297 | 1298 | ```js 1299 | const Search = t.struct({ 1300 | search: t.list(t.String) 1301 | }); 1302 | ``` 1303 | 1304 | tcomb-form by default will render the `search` field as a list. In order to render a textbox you have to override the default behaviour with the `factory` option: 1305 | 1306 | ```js 1307 | const options = { 1308 | fields: { 1309 | search: { 1310 | factory: t.form.Textbox 1311 | } 1312 | } 1313 | }; 1314 | ``` 1315 | 1316 | There is a problem though: a textbox handles only strings, so we need a way to transform a list to a string and a string to a list. A `Transformer` deals with serialization / deserialization of data and has the following interface: 1317 | 1318 | ```js 1319 | const Transformer = t.struct({ 1320 | format: t.Function, // from value to string, it must be idempotent 1321 | parse: t.Function // from string to value 1322 | }); 1323 | ``` 1324 | 1325 | **Important**. the `format` function SHOULD BE idempotent. 1326 | 1327 | A basic transformer implementation for the search textbox: 1328 | 1329 | ```js 1330 | const listTransformer = { 1331 | format: (value) => { 1332 | return Array.isArray(value) ? value.join(' ') : value; 1333 | }, 1334 | parse: (str) => { 1335 | return str ? str.split(' ') : []; 1336 | } 1337 | }; 1338 | ``` 1339 | 1340 | Now you can handle lists using the transformer option: 1341 | 1342 | ```js 1343 | // example of initial value 1344 | const value = { 1345 | search: ['climbing', 'yosemite'] 1346 | }; 1347 | 1348 | const options = { 1349 | fields: { 1350 | search: { 1351 | factory: t.form.Textbox, 1352 | transformer: listTransformer, 1353 | help: 'Keywords are separated by spaces' 1354 | } 1355 | } 1356 | }; 1357 | ``` 1358 | 1359 | ## Custom factories 1360 | 1361 | The easisiest way to define a custom factory is to extend `t.form.Component` and define the `getTemplate` method: 1362 | 1363 | ```js 1364 | // 1365 | // a custom factory representing a tags input 1366 | // 1367 | 1368 | import React from 'react'; 1369 | import t from 'tcomb-form'; 1370 | import TagsInput from 'react-tagsinput'; // I'm using this but you can build your own (and reusable!) tagsinput 1371 | 1372 | class TagsComponent extends t.form.Component { // extend the base class 1373 | 1374 | getTemplate() { 1375 | return (locals) => { 1376 | return ( 1377 | 1378 | ); 1379 | }; 1380 | } 1381 | 1382 | } 1383 | 1384 | export default TagsComponent; 1385 | ``` 1386 | 1387 | Usage 1388 | 1389 | ```js 1390 | const Type = t.struct({ 1391 | tags: t.list(t.String) 1392 | }); 1393 | 1394 | const options = { 1395 | fields: { 1396 | tags: { 1397 | factory: TagsComponent 1398 | } 1399 | } 1400 | }; 1401 | 1402 | const value = { 1403 | tags: [] // react-tagsinput requires an initial value 1404 | } 1405 | ... 1406 | 1407 | 1413 | ``` 1414 | 1415 | ## getTcombFormFactory 1416 | 1417 | If a type owns a `getTcombFormFactory(options)` static function, it will be used to retrieve the suitable factory 1418 | 1419 | **Example** 1420 | 1421 | ```js 1422 | // instead of 1423 | const Country = t.enums.of(['IT', 'US'], 'Country'); 1424 | 1425 | const Type = t.struct({ 1426 | country: Country 1427 | }); 1428 | 1429 | const options = { 1430 | fields: { 1431 | country: { 1432 | factory: t.form.Radio 1433 | } 1434 | } 1435 | }; 1436 | 1437 | // you can write 1438 | const Country = t.enums.of(['IT', 'US'], 'Country'); 1439 | 1440 | Country.getTcombFormFactory = (/*options*/) => { 1441 | return t.form.Radio; 1442 | }; 1443 | 1444 | const Type = t.struct({ 1445 | country: Country 1446 | }); 1447 | 1448 | const options = {}; 1449 | ``` 1450 | 1451 | ## getTcombFormOptions 1452 | 1453 | If a type owns a `getTcombFormOptions` static function, tcomb-form will call it and use the result to populate the rendering options. 1454 | 1455 | For example, the following two code snippets behave the same: 1456 | 1457 | ```js 1458 | const Message = t.maybe(t.String); 1459 | const Textbox = t.struct({ 1460 | message: Message 1461 | }); 1462 | 1463 | Message.getTcombFormOptions = formOptions => ({ 1464 | help: Enter text here! 1465 | }); 1466 | 1467 | 1468 | ``` 1469 | 1470 | ```js 1471 | const Textbox = t.struct({ 1472 | message: t.maybe(t.String) 1473 | }); 1474 | 1475 | const options = { 1476 | fields: { 1477 | message: { 1478 | help: Enter text here! 1479 | } 1480 | } 1481 | }; 1482 | 1483 | 1484 | ``` 1485 | 1486 | You can mix and match `Form`'s `options` prop and `getTcombFormOptions` as you see fit. The `formOptions` parameter passed to `getTcombFormOptions` contains the rendering options for this type as passed in via `Form`'s `options` prop, if any. 1487 | 1488 | `getTcombFormOptions` is particularly helpful to keep code easy to follow when using tcomb-form on deeply nested data structures. 1489 | 1490 | # Bootstrap extras 1491 | 1492 | ## Textbox 1493 | 1494 | ### Addons 1495 | 1496 | You can set an addon before or an addon after with the `config.addonBefore` and `config.addonAfter` options: 1497 | 1498 | ```js 1499 | const Textbox = t.struct({ 1500 | mytext: t.String 1501 | }); 1502 | 1503 | const options = { 1504 | fields: { 1505 | mytext: { 1506 | config: { 1507 | // you can use strings or JSX 1508 | addonBefore: before, 1509 | addonAfter: after 1510 | } 1511 | } 1512 | } 1513 | }; 1514 | 1515 | 1516 | ``` 1517 | 1518 | You can set a button after (before) with the `config.buttonAfter` (`config.buttonBefore`) option: 1519 | 1520 | ```js 1521 | const options = { 1522 | fields: { 1523 | mytext: { 1524 | config: { 1525 | buttonAfter: 1526 | } 1527 | } 1528 | } 1529 | }; 1530 | 1531 | 1532 | ``` 1533 | 1534 | ### Size 1535 | 1536 | You can set the textbox size with the `config.size` option: 1537 | 1538 | ```js 1539 | const Textbox = t.struct({ 1540 | mytext: t.String 1541 | }); 1542 | 1543 | const options = { 1544 | fields: { 1545 | mytext: { 1546 | config: { 1547 | size: 'lg' // xs sm md lg are allowed 1548 | } 1549 | } 1550 | } 1551 | }; 1552 | 1553 | 1554 | ``` 1555 | 1556 | ## Select 1557 | 1558 | Same as Textbox extras. 1559 | 1560 | ## Struct 1561 | 1562 | You can render the form horizontal with the `config.horizontal` option: 1563 | 1564 | ```js 1565 | const Person = t.struct({ 1566 | name: t.String, 1567 | notifyMe: t.Boolean, 1568 | email: t.maybe(t.String) 1569 | }); 1570 | 1571 | const options = { 1572 | config: { 1573 | // for each of lg md sm xs you can specify the columns width 1574 | horizontal: { 1575 | md: [3, 9], 1576 | sm: [6, 6] 1577 | } 1578 | } 1579 | }; 1580 | 1581 | // remember to add the proper bootstrap style class 1582 | // to a wrapping div (or form) tag in order 1583 | // to get a nice layout 1584 |
1585 | 1586 |
1587 | ``` 1588 | 1589 | # General configuration 1590 | 1591 | tcomb-form uses the following default settings: 1592 | 1593 | - English as language 1594 | - Bootstrap as theme ([tcomb-form-templates-bootstrap](https://github.com/gcanti/tcomb-form-templates-bootstrap)) 1595 | 1596 | ## Changing the default language 1597 | 1598 | ```js 1599 | import t from 'tcomb-form/lib'; // load tcomb-form without templates and i18n 1600 | import templates from 'tcomb-form-templates-bootstrap'; 1601 | 1602 | t.form.Form.templates = templates; 1603 | t.form.Form.i18n = { 1604 | optional: ' (opzionale)', 1605 | required: '', 1606 | add: 'Nuovo', 1607 | remove: 'Elimina', 1608 | up: 'Su', 1609 | down: 'Giù' 1610 | }; 1611 | ``` 1612 | 1613 | Or pick one in the `i18n` folder (constributions welcome!): 1614 | 1615 | ```js 1616 | import t from 'tcomb-form/lib'; // load tcomb-form without templates and i18n 1617 | import templates from 'tcomb-form-templates-bootstrap'; 1618 | import i18n from 'tcomb-form/lib/i18n/it'; 1619 | 1620 | t.form.Form.templates = templates; 1621 | t.form.Form.i18n = i18n; 1622 | ``` 1623 | 1624 | ## Changing the default skin 1625 | 1626 | ```js 1627 | import t from 'tcomb-form/lib'; // load tcomb-form without templates and i18n 1628 | import i18n from 'tcomb-form/lib/i18n/en'; 1629 | import semantic from 'tcomb-form-templates-semantic'; 1630 | 1631 | t.form.Form.i18n = i18n; 1632 | t.form.Form.templates = semantic; 1633 | ``` 1634 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Version 2 | 3 | Tell us which versions you are using: 4 | 5 | - tcomb-form v0.8.? 6 | - react v0.14.? 7 | 8 | ### Expected behaviour 9 | 10 | Tell us what should happen 11 | 12 | ### Actual behaviour 13 | 14 | Tell us what happens instead 15 | 16 | ### Steps to reproduce 17 | 18 | 1. 19 | 2. 20 | 3. 21 | 22 | ### Stack trace and console log 23 | 24 | Hint: it would help a lot if you enable the debugger ("Pause on exceptions" in the "Source" panel of Chrome dev tools) and spot the place where the error is thrown 25 | 26 | ``` 27 | ``` 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Giulio Canti 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![build status](https://img.shields.io/travis/gcanti/tcomb-form/master.svg?style=flat-square)](https://travis-ci.org/gcanti/tcomb-form) 2 | [![dependency status](https://img.shields.io/david/gcanti/tcomb-form.svg?style=flat-square)](https://david-dm.org/gcanti/tcomb-form) 3 | ![npm downloads](https://img.shields.io/npm/dm/tcomb-form.svg) 4 | 5 | > "Simplicity is the ultimate sophistication" (Leonardo da Vinci) 6 | 7 | # Notice 8 | 9 | `tcomb-form` is looking for maintainers. If you're interested in helping a great way to get started would just be to start weighing-in on GitHub issues, reviewing and testing some PRs. 10 | 11 | # Domain Driven Forms 12 | 13 | The [tcomb library](https://github.com/gcanti/tcomb) provides a concise but expressive way to define domain models in JavaScript. 14 | 15 | The [tcomb-validation library](https://github.com/gcanti/tcomb-validation) builds on tcomb, providing validation functions for tcomb domain models. 16 | 17 | This library builds on those two and **realizes an old dream of mine**. 18 | 19 | # Playground 20 | 21 | This [playground](https://gcanti.github.io/resources/tcomb-form/playground/playground.html), while a bit outdated, gives you the general idea. 22 | 23 | # Benefits 24 | 25 | With tcomb-form you simply call `` to generate a form based on that domain model. What does this get you? 26 | 27 | 1. Write a lot less HTML 28 | 2. Usability and accessibility for free (automatic labels, inline validation, etc) 29 | 3. No need to update forms when domain model changes 30 | 31 | # Flexibility 32 | 33 | - tcomb-forms lets you override automatic features or add additional information to forms. 34 | - You often don't want to use your domain model directly for a form. You can easily create a form specific model with tcomb that captures the details of a particular feature, and then define a function that uses that model to process the main domain model. 35 | 36 | # Example 37 | 38 | ```js 39 | import t from 'tcomb-form' 40 | 41 | const FormSchema = t.struct({ 42 | name: t.String, // a required string 43 | age: t.maybe(t.Number), // an optional number 44 | rememberMe: t.Boolean // a boolean 45 | }) 46 | 47 | const App = React.createClass({ 48 | 49 | onSubmit(evt) { 50 | evt.preventDefault() 51 | const value = this.refs.form.getValue() 52 | if (value) { 53 | console.log(value) 54 | } 55 | }, 56 | 57 | render() { 58 | return ( 59 | 60 | 61 |
62 | 63 |
64 | 65 | ) 66 | } 67 | 68 | }) 69 | ``` 70 | 71 | **Output**. Labels are automatically generated. 72 | 73 | ![](docs/example.png) 74 | 75 | # Documentation 76 | 77 | [GUIDE.md](GUIDE.md) 78 | 79 | **Browser compatibility**: same as React >=0.13.0 80 | 81 | # Contributions 82 | 83 | Thanks so much to [Chris Pearce](https://github.com/Chrisui) for pointing me in the right direction 84 | and for supporting me in the v0.4 rewrite. 85 | 86 | Special thanks to [William Lubelski](https://github.com/lubelski) ([@uiwill](https://twitter.com/uiwill)), without him this library would be less magic. 87 | 88 | Thanks to [Esa-Matti Suuronen](https://github.com/epeli) for the excellent `humanize()` function. 89 | 90 | Thanks to [Andrey Popp](https://github.com/andreypopp) for writing [react-forms](https://github.com/prometheusresearch/react-forms), great inspiration for list management. 91 | 92 | # Contributing 93 | 94 | [CONTRIBUTING.md](CONTRIBUTING.md) 95 | -------------------------------------------------------------------------------- /docs/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gcanti/tcomb-form/94ae91864b6379dae8e517d9a57c2e2be5b86a0f/docs/example.png -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack') // eslint-disable-line 2 | var path = require('path') // eslint-disable-line 3 | 4 | module.exports = function getConfig(config) { 5 | config.set({ 6 | browserNoActivityTimeout: 30000, 7 | browsers: [process.env.CONTINUOUS_INTEGRATION ? 'Firefox' : 'Chrome'], 8 | singleRun: true, 9 | frameworks: ['tap'], 10 | files: [ 11 | 'test/index.js' 12 | ], 13 | preprocessors: { 14 | 'test/index.js': ['webpack'] 15 | }, 16 | webpack: { 17 | node: { 18 | fs: 'empty' 19 | }, 20 | module: { 21 | preLoaders: [ 22 | { 23 | test: /\.js$/, 24 | exclude: [ 25 | path.resolve('src/'), 26 | path.resolve('node_modules/') 27 | ], 28 | loader: 'babel' 29 | }, 30 | { 31 | test: /\.js$/, 32 | include: path.resolve('src/'), 33 | loader: 'isparta' 34 | } 35 | ] 36 | }, 37 | plugins: [ 38 | new webpack.DefinePlugin({ 39 | 'process.env.NODE_ENV': JSON.stringify('test') 40 | }) 41 | ] 42 | }, 43 | webpackMiddleware: { 44 | noInfo: true 45 | }, 46 | reporters: ['dots', 'coverage'], 47 | coverageReporter: { 48 | type: 'text' 49 | } 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tcomb-form", 3 | "version": "0.9.21", 4 | "description": "React.js powered UI library for developing forms writing less code", 5 | "main": "lib/main.js", 6 | "files": [ 7 | "lib", 8 | "src", 9 | "dist" 10 | ], 11 | "scripts": { 12 | "dev": "webpack --config=dev/webpack.config.js --watch --progress", 13 | "lint": "eslint src test", 14 | "test:node": "babel-node test", 15 | "test": "npm run lint && karma start", 16 | "watch": "rm -rf lib/* && babel src -d lib -w", 17 | "build": "rm -rf lib/* && babel src -d lib", 18 | "dist": "webpack --config webpack.dist.config.js --progress" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/gcanti/tcomb-form.git" 23 | }, 24 | "author": "Giulio Canti ", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/gcanti/tcomb-form/issues" 28 | }, 29 | "homepage": "https://github.com/gcanti/tcomb-form", 30 | "dependencies": { 31 | "tcomb-form-templates-bootstrap": "^0.2.0", 32 | "tcomb-validation": "^3.0.0" 33 | }, 34 | "peerDependencies": { 35 | "react": "^0.14.0 || ^15.0.0 || ^16.0.0" 36 | }, 37 | "devDependencies": { 38 | "babel": "5.8.38", 39 | "babel-core": "5.8.38", 40 | "babel-eslint": "4.1.8", 41 | "babel-loader": "5.3.2", 42 | "eslint": "1.10.3", 43 | "eslint-config-airbnb": "1.0.0", 44 | "eslint-plugin-react": "3.10.0", 45 | "isparta-loader": "1.0.0", 46 | "karma": "2.0.0", 47 | "karma-chrome-launcher": "2.2.0", 48 | "karma-coverage": "1.1.1", 49 | "karma-firefox-launcher": "1.1.0", 50 | "karma-tap": "3.1.1", 51 | "karma-webpack": "1.7.0", 52 | "react": "^15.0.0", 53 | "react-dom": "^15.0.0", 54 | "react-test-renderer": "^15.5.4", 55 | "tape": "4.0.0", 56 | "webpack": "1.12.8" 57 | }, 58 | "tags": [ 59 | "form", 60 | "forms", 61 | "validation", 62 | "generation", 63 | "react", 64 | "react-component" 65 | ], 66 | "keywords": [ 67 | "form", 68 | "forms", 69 | "validation", 70 | "generation", 71 | "react", 72 | "react-component" 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /src/components.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import t from 'tcomb-validation' 3 | import { 4 | humanize, 5 | merge, 6 | getTypeInfo, 7 | getOptionsOfEnum, 8 | move, 9 | UIDGenerator, 10 | getTypeFromUnion, 11 | getComponentOptions, 12 | isArraysShallowDiffers 13 | } from './util' 14 | 15 | const Nil = t.Nil 16 | const assert = t.assert 17 | const SOURCE = 'tcomb-form' 18 | const noobj = Object.freeze({}) 19 | const noarr = Object.freeze([]) 20 | const noop = () => {} 21 | 22 | function getFormComponent(type, options) { 23 | if (options.factory) { 24 | return options.factory 25 | } 26 | if (type.getTcombFormFactory) { 27 | return type.getTcombFormFactory(options) 28 | } 29 | const name = t.getTypeName(type) 30 | switch (type.meta.kind) { 31 | case 'irreducible' : 32 | if (type === t.Boolean) { 33 | return Checkbox // eslint-disable-line no-use-before-define 34 | } else if (type === t.Date) { 35 | return Datetime // eslint-disable-line no-use-before-define 36 | } 37 | return Textbox // eslint-disable-line no-use-before-define 38 | case 'struct' : 39 | case 'interface' : 40 | return Struct // eslint-disable-line no-use-before-define 41 | case 'list' : 42 | return List // eslint-disable-line no-use-before-define 43 | case 'enums' : 44 | return Select // eslint-disable-line no-use-before-define 45 | case 'maybe' : 46 | case 'subtype' : 47 | return getFormComponent(type.meta.type, options) 48 | default : 49 | t.fail(`[${SOURCE}] unsupported kind ${type.meta.kind} for type ${name}`) 50 | } 51 | } 52 | 53 | exports.getComponent = getFormComponent 54 | 55 | function sortByText(a, b) { 56 | return a.text.localeCompare(b.text) 57 | } 58 | 59 | function getComparator(order) { 60 | return { 61 | asc: sortByText, 62 | desc: (a, b) => -sortByText(a, b) 63 | }[order] 64 | } 65 | 66 | export const decorators = { 67 | 68 | template(name) { 69 | return (Component) => { 70 | Component.prototype.getTemplate = function getTemplate() { 71 | return this.props.options.template || this.props.ctx.templates[name] 72 | } 73 | } 74 | }, 75 | 76 | attrs(Component) { 77 | Component.prototype.getAttrs = function getAttrs() { 78 | const attrs = t.mixin({}, this.props.options.attrs) 79 | attrs.id = this.getId() 80 | attrs.name = this.getName() 81 | return attrs 82 | } 83 | }, 84 | 85 | templates(Component) { 86 | Component.prototype.getTemplates = function getTemplates() { 87 | return merge(this.props.ctx.templates, this.props.options.templates) 88 | } 89 | } 90 | 91 | } 92 | 93 | export class Component extends React.Component { 94 | 95 | static transformer = { 96 | format: value => Nil.is(value) ? null : value, 97 | parse: value => value 98 | } 99 | 100 | constructor(props) { 101 | super(props) 102 | this.typeInfo = getTypeInfo(props.type) 103 | this.state = { 104 | isPristine: true, 105 | hasError: false, 106 | value: this.getTransformer().format(props.value) 107 | } 108 | } 109 | 110 | getTransformer() { 111 | return this.props.options.transformer || this.constructor.transformer 112 | } 113 | 114 | shouldComponentUpdate(nextProps, nextState) { 115 | const { props, state } = this 116 | const nextPath = Boolean(nextProps.ctx) && nextProps.ctx.path 117 | const curPath = Boolean(props.ctx) && props.ctx.path 118 | 119 | const should = ( 120 | nextState.value !== state.value || 121 | nextState.hasError !== state.hasError || 122 | nextProps.options !== props.options || 123 | nextProps.type !== props.type || 124 | isArraysShallowDiffers(nextPath, curPath) 125 | ) 126 | 127 | return should 128 | } 129 | 130 | componentWillReceiveProps(props) { 131 | if (props.type !== this.props.type) { 132 | this.typeInfo = getTypeInfo(props.type) 133 | } 134 | const value = this.getTransformer().format(props.value) 135 | this.setState({ value }) 136 | } 137 | 138 | onChange = (value) => { 139 | this.setState({ value, isPristine: false }, () => { 140 | this.props.onChange(value, this.props.ctx.path) 141 | }) 142 | } 143 | 144 | getValidationOptions() { 145 | const context = this.props.context || this.props.ctx.context 146 | return { 147 | path: this.props.ctx.path, 148 | context: t.mixin(t.mixin({}, context), { options: this.props.options }) 149 | } 150 | } 151 | 152 | getValue() { 153 | return this.getTransformer().parse(this.state.value) 154 | } 155 | 156 | isValueNully() { 157 | return Nil.is(this.getValue()) 158 | } 159 | 160 | removeErrors() { 161 | this.setState({ hasError: false }) 162 | } 163 | 164 | validate() { 165 | const result = t.validate(this.getValue(), this.props.type, this.getValidationOptions()) 166 | this.setState({ hasError: !result.isValid() }) 167 | return result 168 | } 169 | 170 | getAuto() { 171 | return this.props.options.auto || this.props.ctx.auto 172 | } 173 | 174 | getI18n() { 175 | return this.props.options.i18n || this.props.ctx.i18n 176 | } 177 | 178 | getDefaultLabel() { 179 | const label = this.props.ctx.label 180 | if (label) { 181 | const suffix = this.typeInfo.isMaybe ? this.getI18n().optional : this.getI18n().required 182 | return label + suffix 183 | } 184 | } 185 | 186 | getLabel() { 187 | let label = this.props.options.label || this.props.options.legend 188 | if (Nil.is(label) && this.getAuto() === 'labels') { 189 | label = this.getDefaultLabel() 190 | } 191 | return label 192 | } 193 | 194 | getError() { 195 | if (this.hasError()) { 196 | const error = this.props.options.error || this.typeInfo.getValidationErrorMessage 197 | if (t.Function.is(error)) { 198 | const { path, context } = this.getValidationOptions() 199 | return error(this.getValue(), path, context) 200 | } 201 | return error 202 | } 203 | } 204 | 205 | hasError() { 206 | return this.props.options.hasError || this.state.hasError 207 | } 208 | 209 | getConfig() { 210 | return merge(this.props.ctx.config, this.props.options.config) 211 | } 212 | 213 | getId() { 214 | const attrs = this.props.options.attrs || noobj 215 | if (attrs.id) { 216 | return attrs.id 217 | } 218 | if (!this.uid) { 219 | this.uid = this.props.ctx.uidGenerator.next() 220 | } 221 | return this.uid 222 | } 223 | 224 | getName() { 225 | return this.props.options.name || this.props.ctx.name || this.getId() 226 | } 227 | 228 | getLocals() { 229 | const options = this.props.options 230 | const value = this.state.value 231 | return { 232 | typeInfo: this.typeInfo, 233 | path: this.props.ctx.path, 234 | isPristine: this.state.isPristine, 235 | error: this.getError(), 236 | hasError: this.hasError(), 237 | label: this.getLabel(), 238 | onChange: this.onChange, 239 | config: this.getConfig(), 240 | value, 241 | disabled: options.disabled, 242 | help: options.help, 243 | context: this.props.ctx.context 244 | } 245 | } 246 | 247 | render() { 248 | const locals = this.getLocals() 249 | if (process.env.NODE_ENV !== 'production') { 250 | // getTemplate is the only required implementation when extending Component 251 | assert(t.Function.is(this.getTemplate), `[${SOURCE}] missing getTemplate method of component ${this.constructor.name}`) 252 | } 253 | const template = this.getTemplate() 254 | return template(locals) 255 | } 256 | 257 | } 258 | 259 | function toNull(value) { 260 | return (t.String.is(value) && value.trim() === '') || Nil.is(value) ? null : value 261 | } 262 | 263 | function parseNumber(value) { 264 | const n = parseFloat(value) 265 | const isNumeric = value == n // eslint-disable-line eqeqeq 266 | return isNumeric ? n : toNull(value) 267 | } 268 | 269 | @decorators.attrs 270 | @decorators.template('textbox') 271 | export class Textbox extends Component { 272 | 273 | static transformer = { 274 | format: value => Nil.is(value) ? '' : value, 275 | parse: toNull 276 | } 277 | 278 | static numberTransformer = { 279 | format: value => Nil.is(value) ? '' : String(value), 280 | parse: parseNumber 281 | } 282 | 283 | getTransformer() { 284 | const options = this.props.options 285 | if (options.transformer) { 286 | return options.transformer 287 | } else if (this.typeInfo.innerType === t.Number) { 288 | return Textbox.numberTransformer 289 | } 290 | return Textbox.transformer 291 | } 292 | 293 | getPlaceholder() { 294 | const attrs = this.props.options.attrs || noobj 295 | let placeholder = attrs.placeholder 296 | if (Nil.is(placeholder) && this.getAuto() === 'placeholders') { 297 | placeholder = this.getDefaultLabel() 298 | } 299 | return placeholder 300 | } 301 | 302 | getLocals() { 303 | const locals = super.getLocals() 304 | locals.attrs = this.getAttrs() 305 | locals.attrs.placeholder = this.getPlaceholder() 306 | locals.type = this.props.options.type || 'text' 307 | return locals 308 | } 309 | 310 | } 311 | 312 | @decorators.attrs 313 | @decorators.template('checkbox') 314 | export class Checkbox extends Component { 315 | 316 | static transformer = { 317 | format: value => Nil.is(value) ? false : value, 318 | parse: value => value 319 | } 320 | 321 | getLocals() { 322 | const locals = super.getLocals() 323 | locals.attrs = this.getAttrs() 324 | // checkboxes must always have a label 325 | locals.label = locals.label || this.getDefaultLabel() 326 | return locals 327 | } 328 | 329 | } 330 | 331 | @decorators.attrs 332 | @decorators.template('select') 333 | export class Select extends Component { 334 | 335 | static transformer = (nullOption) => { 336 | return { 337 | format: value => Nil.is(value) && nullOption ? nullOption.value : value, 338 | parse: value => nullOption && nullOption.value === value ? null : value 339 | } 340 | } 341 | 342 | static multipleTransformer = { 343 | format: value => Nil.is(value) ? noarr : value, 344 | parse: value => value 345 | } 346 | 347 | getTransformer() { 348 | const options = this.props.options 349 | if (options.transformer) { 350 | return options.transformer 351 | } 352 | if (this.isMultiple()) { 353 | return Select.multipleTransformer 354 | } 355 | return Select.transformer(this.getNullOption()) 356 | } 357 | 358 | getNullOption() { 359 | return this.props.options.nullOption || {value: '', text: '-'} 360 | } 361 | 362 | isMultiple() { 363 | return this.typeInfo.innerType.meta.kind === 'list' 364 | } 365 | 366 | getEnum() { 367 | return this.isMultiple() ? getTypeInfo(this.typeInfo.innerType.meta.type).innerType : this.typeInfo.innerType 368 | } 369 | 370 | getOptions() { 371 | const options = this.props.options 372 | const items = options.options ? options.options.slice() : getOptionsOfEnum(this.getEnum()) 373 | if (options.order) { 374 | items.sort(getComparator(options.order)) 375 | } 376 | const nullOption = this.getNullOption() 377 | if (!this.isMultiple() && options.nullOption !== false) { 378 | items.unshift(nullOption) 379 | } 380 | return items 381 | } 382 | 383 | getLocals() { 384 | const locals = super.getLocals() 385 | locals.attrs = this.getAttrs() 386 | locals.options = this.getOptions() 387 | locals.isMultiple = this.isMultiple() 388 | return locals 389 | } 390 | 391 | } 392 | 393 | @decorators.attrs 394 | @decorators.template('radio') 395 | export class Radio extends Component { 396 | 397 | static transformer = { 398 | format: value => Nil.is(value) ? null : value, 399 | parse: value => value 400 | } 401 | 402 | getOptions() { 403 | const options = this.props.options 404 | const items = options.options ? options.options.slice() : getOptionsOfEnum(this.typeInfo.innerType) 405 | if (options.order) { 406 | items.sort(getComparator(options.order)) 407 | } 408 | return items 409 | } 410 | 411 | getLocals() { 412 | const locals = super.getLocals() 413 | locals.attrs = this.getAttrs() 414 | locals.options = this.getOptions() 415 | return locals 416 | } 417 | 418 | } 419 | 420 | const defaultDatetimeValue = Object.freeze([null, null, null]) 421 | 422 | @decorators.attrs 423 | @decorators.template('date') 424 | export class Datetime extends Component { 425 | 426 | static transformer = { 427 | format: (value) => { 428 | if (t.Array.is(value)) { 429 | return value 430 | } else if (t.Date.is(value)) { 431 | return [value.getFullYear(), value.getMonth(), value.getDate()].map(String) 432 | } 433 | return defaultDatetimeValue 434 | }, 435 | parse: (value) => { 436 | const numbers = value.map(parseNumber) 437 | if (numbers.every(t.Number.is)) { 438 | return new Date(numbers[0], numbers[1], numbers[2]) 439 | } else if (numbers.every(Nil.is)) { 440 | return null 441 | } 442 | return numbers 443 | } 444 | } 445 | 446 | getOrder() { 447 | return this.props.options.order || ['M', 'D', 'YY'] 448 | } 449 | 450 | getLocals() { 451 | const locals = super.getLocals() 452 | locals.attrs = this.getAttrs() 453 | locals.order = this.getOrder() 454 | return locals 455 | } 456 | 457 | } 458 | 459 | class ComponentWithChildRefs extends Component { 460 | 461 | childRefs = {}; 462 | 463 | setChildRefFor = prop => ref => { 464 | if (ref) { 465 | this.childRefs[prop] = ref 466 | } else { 467 | delete this.childRefs[prop] 468 | } 469 | } 470 | 471 | } 472 | 473 | @decorators.templates 474 | export class Struct extends ComponentWithChildRefs { 475 | 476 | static transformer = { 477 | format: value => Nil.is(value) ? noobj : value, 478 | parse: value => value 479 | } 480 | 481 | isValueNully() { 482 | return Object.keys(this.childRefs).every((key) => this.childRefs[key].isValueNully()) 483 | } 484 | 485 | removeErrors() { 486 | this.setState({ hasError: false }) 487 | Object.keys(this.childRefs).forEach((key) => this.childRefs[key].removeErrors()) 488 | } 489 | 490 | validate() { 491 | let value = {} 492 | let errors = [] 493 | let result 494 | 495 | if (this.typeInfo.isMaybe && this.isValueNully()) { 496 | this.removeErrors() 497 | return new t.ValidationResult({errors: [], value: null}) 498 | } 499 | 500 | const props = this.getTypeProps() 501 | for (const ref in props) { 502 | if (this.childRefs.hasOwnProperty(ref)) { 503 | result = this.childRefs[ref].validate() 504 | errors = errors.concat(result.errors) 505 | value[ref] = result.value 506 | } 507 | } 508 | 509 | if (errors.length === 0) { 510 | const InnerType = this.typeInfo.innerType 511 | value = this.getTransformer().parse(value) 512 | value = new InnerType(value) 513 | if (this.typeInfo.isSubtype) { 514 | result = t.validate(value, this.props.type, this.getValidationOptions()) 515 | errors = result.errors 516 | } 517 | } 518 | 519 | this.setState({ hasError: errors.length > 0 }) 520 | return new t.ValidationResult({errors, value}) 521 | } 522 | 523 | onChange = (fieldName, fieldValue, path, kind) => { 524 | const value = t.mixin({}, this.state.value) 525 | value[fieldName] = fieldValue 526 | this.setState({ value, isPristine: false }, () => { 527 | this.props.onChange(value, path, kind) 528 | }) 529 | } 530 | 531 | getTemplate() { 532 | return this.props.options.template || this.getTemplates().struct 533 | } 534 | 535 | getTypeProps() { 536 | return this.typeInfo.innerType.meta.props 537 | } 538 | 539 | getOrder() { 540 | return this.props.options.order || Object.keys(this.getTypeProps()) 541 | } 542 | 543 | getInputs() { 544 | const { options, ctx } = this.props 545 | const props = this.getTypeProps() 546 | const auto = this.getAuto() 547 | const i18n = this.getI18n() 548 | const config = this.getConfig() 549 | const templates = this.getTemplates() 550 | const value = this.state.value 551 | const inputs = {} 552 | 553 | for (const prop in props) { 554 | if (props.hasOwnProperty(prop)) { 555 | const type = props[prop] 556 | const propValue = value[prop] 557 | const propType = getTypeFromUnion(type, propValue) 558 | const fieldsOptions = options.fields || noobj 559 | const propOptions = getComponentOptions(fieldsOptions[prop], noobj, propValue, type) 560 | inputs[prop] = React.createElement(getFormComponent(propType, propOptions), { 561 | key: prop, 562 | ref: this.setChildRefFor(prop), 563 | type: propType, 564 | options: propOptions, 565 | value: propValue, 566 | onChange: this.onChange.bind(this, prop), 567 | ctx: { 568 | context: ctx.context, 569 | uidGenerator: ctx.uidGenerator, 570 | auto, 571 | config, 572 | name: ctx.name ? `${ctx.name}[${prop}]` : prop, 573 | label: humanize(prop), 574 | i18n, 575 | templates, 576 | path: ctx.path.concat(prop) 577 | } 578 | }) 579 | } 580 | } 581 | return inputs 582 | } 583 | 584 | getLocals() { 585 | const options = this.props.options 586 | const locals = super.getLocals() 587 | locals.order = this.getOrder() 588 | locals.inputs = this.getInputs() 589 | locals.className = options.className 590 | return locals 591 | } 592 | 593 | } 594 | 595 | function toSameLength(value, keys, uidGenerator) { 596 | if (value.length === keys.length) { 597 | return keys 598 | } 599 | const ret = [] 600 | for (let i = 0, len = value.length; i < len; i++ ) { 601 | ret[i] = keys[i] || uidGenerator.next() 602 | } 603 | return ret 604 | } 605 | 606 | @decorators.templates 607 | export class List extends ComponentWithChildRefs { 608 | 609 | static transformer = { 610 | format: value => Nil.is(value) ? noarr : value, 611 | parse: value => value 612 | } 613 | 614 | constructor(props) { 615 | super(props) 616 | this.state.keys = this.state.value.map(() => props.ctx.uidGenerator.next()) 617 | } 618 | 619 | componentWillReceiveProps(props) { 620 | if (props.type !== this.props.type) { 621 | this.typeInfo = getTypeInfo(props.type) 622 | } 623 | const value = this.getTransformer().format(props.value) 624 | this.setState({ 625 | value, 626 | keys: toSameLength(value, this.state.keys, props.ctx.uidGenerator) 627 | }) 628 | } 629 | 630 | isValueNully() { 631 | return this.state.value.length === 0 632 | } 633 | 634 | removeErrors() { 635 | this.setState({ hasError: false }) 636 | Object.keys(this.childRefs).forEach((key) => this.childRefs[key].removeErrors()) 637 | } 638 | 639 | validate() { 640 | let value = [] 641 | let errors = [] 642 | let result 643 | 644 | if (this.typeInfo.isMaybe && this.isValueNully()) { 645 | this.removeErrors() 646 | return new t.ValidationResult({errors: [], value: null}) 647 | } 648 | 649 | for (let i = 0, len = this.state.value.length; i < len; i++ ) { 650 | result = this.childRefs[i].validate() 651 | errors = errors.concat(result.errors) 652 | value.push(result.value) 653 | } 654 | 655 | // handle subtype 656 | if (this.typeInfo.isSubtype && errors.length === 0) { 657 | value = this.getTransformer().parse(value) 658 | result = t.validate(value, this.props.type, this.getValidationOptions()) 659 | errors = result.errors 660 | } 661 | 662 | this.setState({ hasError: errors.length > 0 }) 663 | return new t.ValidationResult({errors: errors, value: value}) 664 | } 665 | 666 | onChange = (value, keys, path, kind) => { 667 | const allkeys = toSameLength(value, keys, this.props.ctx.uidGenerator) 668 | this.setState({ value, keys: allkeys, isPristine: false }, () => { 669 | this.props.onChange(value, path, kind) 670 | }) 671 | } 672 | 673 | addItem = () => { 674 | const value = this.state.value.concat(undefined) 675 | const keys = this.state.keys.concat(this.props.ctx.uidGenerator.next()) 676 | this.onChange(value, keys, this.props.ctx.path.concat(value.length - 1), 'add') 677 | } 678 | 679 | onItemChange(itemIndex, itemValue, path, kind) { 680 | const value = this.state.value.slice() 681 | value[itemIndex] = itemValue 682 | this.onChange(value, this.state.keys, path, kind) 683 | } 684 | 685 | removeItem(i) { 686 | const value = this.state.value.slice() 687 | value.splice(i, 1) 688 | const keys = this.state.keys.slice() 689 | keys.splice(i, 1) 690 | this.onChange(value, keys, this.props.ctx.path.concat(i), 'remove') 691 | } 692 | 693 | moveUpItem(i) { 694 | if (i > 0) { 695 | this.onChange( 696 | move(this.state.value.slice(), i, i - 1), 697 | move(this.state.keys.slice(), i, i - 1), 698 | this.props.ctx.path.concat(i), 699 | 'moveUp' 700 | ) 701 | } 702 | } 703 | 704 | moveDownItem(i) { 705 | if (i < this.state.value.length - 1) { 706 | this.onChange( 707 | move(this.state.value.slice(), i, i + 1), 708 | move(this.state.keys.slice(), i, i + 1), 709 | this.props.ctx.path.concat(i), 710 | 'moveDown' 711 | ) 712 | } 713 | } 714 | 715 | getTemplate() { 716 | return this.props.options.template || this.getTemplates().list 717 | } 718 | 719 | getItems() { 720 | const { options, ctx } = this.props 721 | const auto = this.getAuto() 722 | const i18n = this.getI18n() 723 | const config = this.getConfig() 724 | const templates = this.getTemplates() 725 | const value = this.state.value 726 | return value.map((itemValue, i) => { 727 | const type = this.typeInfo.innerType.meta.type 728 | const itemType = getTypeFromUnion(type, itemValue) 729 | const itemOptions = getComponentOptions(options.item, noobj, itemValue, type) 730 | const ItemComponent = getFormComponent(itemType, itemOptions) 731 | const buttons = [] 732 | if (!options.disableRemove) { 733 | buttons.push({ 734 | type: 'remove', 735 | label: i18n.remove, 736 | click: this.removeItem.bind(this, i) 737 | }) 738 | } 739 | if (!options.disableOrder) { 740 | buttons.push({ 741 | type: 'move-up', 742 | label: i18n.up, 743 | click: this.moveUpItem.bind(this, i) 744 | }) 745 | } 746 | if (!options.disableOrder) { 747 | buttons.push({ 748 | type: 'move-down', 749 | label: i18n.down, 750 | click: this.moveDownItem.bind(this, i) 751 | }) 752 | } 753 | return { 754 | input: React.createElement(ItemComponent, { 755 | ref: this.setChildRefFor(i), 756 | type: itemType, 757 | options: itemOptions, 758 | value: itemValue, 759 | onChange: this.onItemChange.bind(this, i), 760 | ctx: { 761 | context: ctx.context, 762 | uidGenerator: ctx.uidGenerator, 763 | auto, 764 | config, 765 | i18n, 766 | name: ctx.name ? `${ctx.name}[${i}]` : String(i), 767 | templates, 768 | path: ctx.path.concat(i) 769 | } 770 | }), 771 | key: this.state.keys[i], 772 | buttons: buttons 773 | } 774 | }) 775 | } 776 | 777 | getLocals() { 778 | const options = this.props.options 779 | const i18n = this.getI18n() 780 | const locals = super.getLocals() 781 | locals.add = options.disableAdd ? null : { 782 | type: 'add', 783 | label: i18n.add, 784 | click: this.addItem 785 | } 786 | locals.items = this.getItems() 787 | locals.className = options.className 788 | return locals 789 | } 790 | 791 | } 792 | 793 | export class Form extends React.Component { 794 | inputRef = null 795 | 796 | setInputRef = ref => { 797 | this.inputRef = ref 798 | } 799 | 800 | validate() { 801 | return this.inputRef.validate() 802 | } 803 | 804 | getValue() { 805 | const result = this.validate() 806 | return result.isValid() ? result.value : null 807 | } 808 | 809 | getComponent(path) { 810 | const points = t.String.is(path) ? path.split('.') : path 811 | return points.reduce((input, name) => input.childRefs[name], this.inputRef) 812 | } 813 | 814 | getSeed() { 815 | const rii = this._reactInternalInstance 816 | if (rii) { 817 | if (rii._hostContainerInfo) { 818 | return rii._hostContainerInfo._idCounter 819 | } 820 | if (rii._nativeContainerInfo) { 821 | return rii._nativeContainerInfo._idCounter 822 | } 823 | if (rii._rootNodeID) { 824 | return rii._rootNodeID 825 | } 826 | } 827 | return '0' 828 | } 829 | 830 | getUIDGenerator() { 831 | this.uidGenerator = this.uidGenerator || new UIDGenerator(this.getSeed()) 832 | return this.uidGenerator 833 | } 834 | 835 | render() { 836 | const { i18n, templates } = Form 837 | 838 | if (process.env.NODE_ENV !== 'production') { 839 | assert(t.isType(this.props.type), `[${SOURCE}] missing required prop type`) 840 | assert(t.maybe(t.Object).is(this.props.options) || t.Function.is(this.props.options) || t.list(t.maybe(t.Object)).is(this.props.options), `[${SOURCE}] prop options, if specified, must be an object, a function returning the options or a list of options for unions`) 841 | assert(t.Object.is(templates), `[${SOURCE}] missing templates config`) 842 | assert(t.Object.is(i18n), `[${SOURCE}] missing i18n config`) 843 | } 844 | 845 | const value = this.props.value 846 | const type = getTypeFromUnion(this.props.type, value) 847 | const options = getComponentOptions(this.props.options, noobj, value, this.props.type) 848 | 849 | // this is in the render method because I need this._reactInternalInstance._rootNodeID in React ^0.14.0 850 | // and this._reactInternalInstance._nativeContainerInfo._idCounter in React ^15.0.0 851 | const uidGenerator = this.getUIDGenerator() 852 | 853 | return React.createElement(getFormComponent(type, options), { 854 | ref: this.setInputRef, 855 | type: type, 856 | options, 857 | value: value, 858 | onChange: this.props.onChange || noop, 859 | ctx: this.props.ctx || { 860 | context: this.props.context, 861 | uidGenerator, 862 | auto: 'labels', 863 | templates, 864 | i18n, 865 | path: [] 866 | } 867 | }) 868 | } 869 | 870 | } 871 | -------------------------------------------------------------------------------- /src/i18n/de.js: -------------------------------------------------------------------------------- 1 | export default { 2 | optional: ' (optional)', 3 | required: '', 4 | add: 'Hinzufügen', 5 | remove: 'Entfernen', 6 | up: 'Hoch', 7 | down: 'Runter' 8 | } 9 | -------------------------------------------------------------------------------- /src/i18n/en.js: -------------------------------------------------------------------------------- 1 | export default { 2 | optional: ' (optional)', 3 | required: '', 4 | add: 'Add', 5 | remove: 'Remove', 6 | up: 'Up', 7 | down: 'Down' 8 | } 9 | -------------------------------------------------------------------------------- /src/i18n/es.js: -------------------------------------------------------------------------------- 1 | export default { 2 | optional: ' (opcional)', 3 | required: '', 4 | add: 'Añadir', 5 | remove: 'Eliminar', 6 | up: 'Arriba', 7 | down: 'Abajo' 8 | } 9 | -------------------------------------------------------------------------------- /src/i18n/fr.js: -------------------------------------------------------------------------------- 1 | export default { 2 | optional: ' (optionnel)', 3 | required: '', 4 | add: 'Ajouter', 5 | remove: 'Supprimer', 6 | up: 'Monter', 7 | down: 'Descendre' 8 | } 9 | -------------------------------------------------------------------------------- /src/i18n/it.js: -------------------------------------------------------------------------------- 1 | export default { 2 | optional: ' (opzionale)', 3 | required: '', 4 | add: 'Nuovo', 5 | remove: 'Elimina', 6 | up: 'Su', 7 | down: 'Giù' 8 | } 9 | -------------------------------------------------------------------------------- /src/i18n/uk.js: -------------------------------------------------------------------------------- 1 | export default { 2 | optional: ' (необов\'язкове)', 3 | required: '', 4 | add: 'Додати', 5 | remove: 'Видалити', 6 | up: 'Вверх', 7 | down: 'Вниз' 8 | } 9 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* global File */ 2 | import t from 'tcomb-validation' 3 | import * as components from './components' 4 | 5 | t.form = components 6 | t.form.File = t.irreducible('File', x => x instanceof File) 7 | 8 | module.exports = t 9 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | /*! @preserve 2 | * 3 | * The MIT License (MIT) 4 | * 5 | * Copyright (c) 2014 Giulio Canti 6 | * 7 | */ 8 | import t from './index.js' 9 | import templates from 'tcomb-form-templates-bootstrap' 10 | import i18n from './i18n/en' 11 | 12 | t.form.Form.templates = templates 13 | t.form.Form.i18n = i18n 14 | 15 | export default t 16 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | import t, { mixin } from 'tcomb-validation' 2 | 3 | export function getOptionsOfEnum(type) { 4 | const enums = type.meta.map 5 | return Object.keys(enums).map(value => { 6 | return { 7 | value, 8 | text: enums[value] 9 | } 10 | }) 11 | } 12 | 13 | export function getTypeInfo(type) { 14 | let innerType = type 15 | let isMaybe = false 16 | let isSubtype = false 17 | let kind 18 | let innerGetValidationErrorMessage 19 | 20 | while (innerType) { 21 | kind = innerType.meta.kind 22 | if (t.Function.is(innerType.getValidationErrorMessage)) { 23 | innerGetValidationErrorMessage = innerType.getValidationErrorMessage 24 | } 25 | if (kind === 'maybe') { 26 | isMaybe = true 27 | innerType = innerType.meta.type 28 | continue 29 | } 30 | if (kind === 'subtype') { 31 | isSubtype = true 32 | innerType = innerType.meta.type 33 | continue 34 | } 35 | break 36 | } 37 | 38 | const getValidationErrorMessage = innerGetValidationErrorMessage ? (value, path, context) => { 39 | const result = t.validate(value, type, {path, context}) 40 | if (!result.isValid()) { 41 | for (let i = 0, len = result.errors.length; i < len; i++ ) { 42 | if (t.Function.is(result.errors[i].expected.getValidationErrorMessage)) { 43 | return result.errors[i].message 44 | } 45 | } 46 | return innerGetValidationErrorMessage(value, path, context) 47 | } 48 | } : undefined 49 | 50 | return { 51 | type, 52 | isMaybe, 53 | isSubtype, 54 | innerType, 55 | getValidationErrorMessage 56 | } 57 | } 58 | 59 | // thanks to https://github.com/epeli/underscore.string 60 | 61 | function underscored(s) { 62 | return s.trim().replace(/([a-z\d])([A-Z]+)/g, '$1_$2').replace(/[-\s]+/g, '_').toLowerCase() 63 | } 64 | 65 | function capitalize(s) { 66 | return s.charAt(0).toUpperCase() + s.slice(1) 67 | } 68 | 69 | export function humanize(s) { 70 | return capitalize(underscored(s).replace(/_id$/, '').replace(/_/g, ' ')) 71 | } 72 | 73 | export function merge(a, b) { 74 | return mixin(mixin({}, a), b, true) 75 | } 76 | 77 | export function move(arr, fromIndex, toIndex) { 78 | const element = arr.splice(fromIndex, 1)[0] 79 | arr.splice(toIndex, 0, element) 80 | return arr 81 | } 82 | 83 | export class UIDGenerator { 84 | 85 | constructor(seed) { 86 | this.seed = 'tfid-' + seed + '-' 87 | this.counter = 0 88 | } 89 | 90 | next() { 91 | return this.seed + (this.counter++) 92 | } 93 | 94 | } 95 | 96 | function containsUnion(type) { 97 | switch (type.meta.kind) { 98 | case 'union' : 99 | return true 100 | case 'maybe' : 101 | case 'subtype' : 102 | return containsUnion(type.meta.type) 103 | default : 104 | return false 105 | } 106 | } 107 | 108 | function getUnionConcreteType(type, value) { 109 | const kind = type.meta.kind 110 | if (kind === 'union') { 111 | const concreteType = type.dispatch(value) 112 | if (process.env.NODE_ENV !== 'production') { 113 | t.assert(t.isType(concreteType), () => 'Invalid value ' + t.assert.stringify(value) + ' supplied to ' + t.getTypeName(type) + ' (no constructor returned by dispatch)' ) 114 | } 115 | return concreteType 116 | } else if (kind === 'maybe') { 117 | const maybeConcrete = t.maybe(getUnionConcreteType(type.meta.type, value), type.meta.name) 118 | maybeConcrete.getValidationErrorMessage = type.getValidationErrorMessage 119 | maybeConcrete.getTcombFormFactory = type.getTcombFormFactory 120 | return maybeConcrete 121 | } else if (kind === 'subtype') { 122 | const subtypeConcrete = t.subtype(getUnionConcreteType(type.meta.type, value), type.meta.predicate, type.meta.name) 123 | subtypeConcrete.getValidationErrorMessage = type.getValidationErrorMessage 124 | subtypeConcrete.getTcombFormFactory = type.getTcombFormFactory 125 | return subtypeConcrete 126 | } 127 | } 128 | 129 | export function getTypeFromUnion(type, value) { 130 | if (containsUnion(type)) { 131 | return getUnionConcreteType(type, value) 132 | } 133 | return type 134 | } 135 | 136 | function getUnion(type) { 137 | if (type.meta.kind === 'union') { 138 | return type 139 | } 140 | return getUnion(type.meta.type) 141 | } 142 | 143 | function findIndex(arr, element) { 144 | for (let i = 0, len = arr.length; i < len; i++ ) { 145 | if (arr[i] === element) { 146 | return i 147 | } 148 | } 149 | return -1 150 | } 151 | 152 | export function getBaseComponentOptions(options, defaultOptions, value, type) { 153 | if (t.Nil.is(options)) { 154 | return defaultOptions 155 | } 156 | if (t.Function.is(options)) { 157 | return options(value) 158 | } 159 | if (t.Array.is(options) && containsUnion(type)) { 160 | const union = getUnion(type) 161 | const concreteType = union.dispatch(value) 162 | const index = findIndex(union.meta.types, concreteType) 163 | // recurse 164 | return getComponentOptions(options[index], defaultOptions, value, concreteType) // eslint-disable-line no-use-before-define 165 | } 166 | return options 167 | } 168 | 169 | export function getComponentOptions(options, defaultOptions, value, type) { 170 | const opts = getBaseComponentOptions(options, defaultOptions, value, type) 171 | if (t.Function.is(type.getTcombFormOptions)) { 172 | return type.getTcombFormOptions(opts) 173 | } 174 | return opts 175 | } 176 | 177 | export function isArraysShallowDiffers(array, other) { 178 | if (array === other) { 179 | return false 180 | } 181 | 182 | const { length } = array 183 | if (length !== other.length) { 184 | return true 185 | } 186 | 187 | let index = -1 188 | while (++index < length) { 189 | if (array[index] !== other[index]) { 190 | return true 191 | } 192 | } 193 | 194 | return false 195 | } 196 | -------------------------------------------------------------------------------- /test/components/Checkbox.js: -------------------------------------------------------------------------------- 1 | import tape from 'tape' 2 | import t from 'tcomb-validation' 3 | import bootstrap from 'tcomb-form-templates-bootstrap' 4 | import React from 'react' 5 | import { Checkbox } from '../../src/components' 6 | import { ctx, ctxPlaceholders, getRenderComponent } from './util' 7 | const renderComponent = getRenderComponent(Checkbox) 8 | 9 | const transformer = { 10 | format: (value) => { 11 | if (t.String.is(value)) { 12 | return value 13 | } else if (value === true) { 14 | return '1' 15 | } 16 | return '0' 17 | }, 18 | parse: (value) => value === '1' 19 | } 20 | 21 | tape('Checkbox', ({ test }) => { 22 | test('label', (assert) => { 23 | assert.plan(5) 24 | 25 | assert.strictEqual( 26 | new Checkbox({ 27 | type: t.Bool, 28 | options: {}, 29 | ctx: ctx 30 | }).getLocals().label, 31 | 'Default label', 32 | 'should have a default label') 33 | 34 | assert.strictEqual( 35 | new Checkbox({ 36 | type: t.Bool, 37 | options: {}, 38 | ctx: ctxPlaceholders 39 | }).getLocals().label, 40 | 'Default label', 41 | 'should have a default label even if auto !== labels') 42 | 43 | assert.strictEqual( 44 | new Checkbox({ 45 | type: t.Bool, 46 | options: {label: 'mylabel'}, 47 | ctx: ctx 48 | }).getLocals().label, 49 | 'mylabel', 50 | 'should handle label option as string') 51 | 52 | const actual = new Checkbox({ 53 | type: t.Bool, 54 | options: {label: React.DOM.i(null, 'JSX label')}, 55 | ctx: ctx 56 | }).getLocals().label 57 | assert.equal(actual.type, 'i') 58 | assert.equal(actual.props.children, 'JSX label') 59 | }) 60 | 61 | test('help', (assert) => { 62 | assert.plan(3) 63 | 64 | assert.strictEqual( 65 | new Checkbox({ 66 | type: t.Bool, 67 | options: {help: 'myhelp'}, 68 | ctx: ctx 69 | }).getLocals().help, 70 | 'myhelp', 71 | 'should handle help option as string') 72 | 73 | const actual = new Checkbox({ 74 | type: t.Bool, 75 | options: {help: React.DOM.i(null, 'JSX help')}, 76 | ctx: ctx 77 | }).getLocals().help 78 | assert.equal(actual.type, 'i') 79 | assert.equal(actual.props.children, 'JSX help') 80 | }) 81 | 82 | test('value', (assert) => { 83 | assert.plan(3) 84 | 85 | assert.strictEqual( 86 | new Checkbox({ 87 | type: t.Bool, 88 | options: {}, 89 | ctx: ctx 90 | }).getLocals().value, 91 | false, 92 | 'default value should be false') 93 | 94 | assert.strictEqual( 95 | new Checkbox({ 96 | type: t.Bool, 97 | options: {}, 98 | ctx: ctx, 99 | value: false 100 | }).getLocals().value, 101 | false, 102 | 'should handle value option') 103 | 104 | assert.strictEqual( 105 | new Checkbox({ 106 | type: t.Bool, 107 | options: {}, 108 | ctx: ctx, 109 | value: true 110 | }).getLocals().value, 111 | true, 112 | 'should handle value option') 113 | }) 114 | 115 | test('transformer', (assert) => { 116 | assert.plan(1) 117 | 118 | assert.strictEqual( 119 | new Checkbox({ 120 | type: t.Bool, 121 | options: {transformer: transformer}, 122 | ctx: ctx, 123 | value: true 124 | }).getLocals().value, 125 | '1', 126 | 'should handle transformer option (format)') 127 | }) 128 | 129 | test('hasError', (assert) => { 130 | assert.plan(2) 131 | 132 | const True = t.subtype(t.Bool, (value) => value === true) 133 | 134 | assert.strictEqual( 135 | new Checkbox({ 136 | type: True, 137 | options: {}, 138 | ctx: ctx 139 | }).getLocals().hasError, 140 | false, 141 | 'default hasError should be false') 142 | 143 | assert.strictEqual( 144 | new Checkbox({ 145 | type: True, 146 | options: {hasError: true}, 147 | ctx: ctx 148 | }).getLocals().hasError, 149 | true, 150 | 'should handle hasError option') 151 | }) 152 | 153 | test('error', (assert) => { 154 | assert.plan(3) 155 | 156 | assert.strictEqual( 157 | new Checkbox({ 158 | type: t.Bool, 159 | options: {}, 160 | ctx: ctx 161 | }).getLocals().error, 162 | undefined, 163 | 'default error should be undefined') 164 | 165 | assert.strictEqual( 166 | new Checkbox({ 167 | type: t.Bool, 168 | options: {error: 'myerror', hasError: true}, 169 | ctx: ctx 170 | }).getLocals().error, 171 | 'myerror', 172 | 'should handle error option') 173 | 174 | assert.strictEqual( 175 | new Checkbox({ 176 | type: t.Bool, 177 | options: { 178 | error: (value) => 'error: ' + value, 179 | hasError: true 180 | }, 181 | ctx: ctx, 182 | value: 'a' 183 | }).getLocals().error, 184 | 'error: a', 185 | 'should handle error option as a function') 186 | }) 187 | 188 | test('template', (assert) => { 189 | assert.plan(2) 190 | 191 | assert.strictEqual( 192 | new Checkbox({ 193 | type: t.Bool, 194 | options: {}, 195 | ctx: ctx 196 | }).getTemplate(), 197 | bootstrap.checkbox, 198 | 'default template should be bootstrap.checkbox') 199 | 200 | const template = () => {} 201 | 202 | assert.strictEqual( 203 | new Checkbox({ 204 | type: t.Bool, 205 | options: {template: template}, 206 | ctx: ctx 207 | }).getTemplate(), 208 | template, 209 | 'should handle template option') 210 | }) 211 | 212 | if (typeof window !== 'undefined') { 213 | test('validate', (assert) => { 214 | assert.plan(6) 215 | 216 | let result 217 | 218 | // required type, default value 219 | result = renderComponent({ 220 | type: t.Bool 221 | }).validate() 222 | 223 | assert.strictEqual(result.isValid(), true) 224 | assert.strictEqual(result.value, false) 225 | 226 | // required type, setting a value 227 | result = renderComponent({ 228 | type: t.Bool, 229 | value: true 230 | }).validate() 231 | 232 | assert.strictEqual(result.isValid(), true) 233 | assert.strictEqual(result.value, true) 234 | 235 | result = renderComponent({ 236 | type: t.Bool, 237 | options: {transformer: transformer}, 238 | value: true 239 | }).validate() 240 | 241 | // 'should handle transformer option (parse)' 242 | assert.strictEqual(result.isValid(), true) 243 | assert.strictEqual(result.value, true) 244 | }) 245 | } 246 | }) 247 | -------------------------------------------------------------------------------- /test/components/Component.js: -------------------------------------------------------------------------------- 1 | import tape from 'tape' 2 | import React from 'react' 3 | import t from 'tcomb-validation' 4 | import ShallowRenderer from 'react-test-renderer/shallow' 5 | import { Component, getComponent } from '../../src/components' 6 | import { ctx } from './util' 7 | 8 | tape('Component', ({ test }) => { 9 | test('getComponent public API', (assert) => { 10 | assert.plan(1) 11 | assert.ok(t.Function.is(getComponent)) 12 | }) 13 | 14 | test('getValidationErrorMessage', (assert) => { 15 | assert.plan(16) 16 | 17 | let component = new Component({ 18 | type: t.String, 19 | options: {hasError: true}, 20 | ctx: ctx 21 | }) 22 | 23 | assert.strictEqual(component.getError(), undefined, '#1') 24 | 25 | component = new Component({ 26 | type: t.maybe(t.String), 27 | options: {hasError: true}, 28 | ctx: ctx 29 | }) 30 | 31 | assert.strictEqual(component.getError(), undefined, '#2') 32 | 33 | const Password = t.refinement(t.String, (s) => s.length > 6) 34 | 35 | component = new Component({ 36 | type: Password, 37 | options: {hasError: true}, 38 | ctx: ctx 39 | }) 40 | 41 | assert.strictEqual(component.getError(), undefined, '#3') 42 | 43 | Password.getValidationErrorMessage = () => 'bad password' 44 | 45 | component = new Component({ 46 | type: Password, 47 | options: {hasError: false}, 48 | ctx: ctx 49 | }) 50 | 51 | assert.strictEqual(component.getError(), undefined, '#4.1') 52 | 53 | component = new Component({ 54 | type: Password, 55 | options: {hasError: true}, 56 | ctx: ctx 57 | }) 58 | 59 | assert.strictEqual(component.getError(), 'bad password', '#4.2') 60 | 61 | component = new Component({ 62 | type: t.maybe(Password), 63 | options: {hasError: true}, 64 | ctx: ctx 65 | }) 66 | 67 | assert.strictEqual(component.getError(), undefined, '#5') 68 | 69 | component = new Component({ 70 | type: t.maybe(Password), 71 | options: {hasError: true}, 72 | ctx: ctx, 73 | value: 'a' 74 | }) 75 | 76 | assert.strictEqual(component.getError(), 'bad password', '#6') 77 | 78 | t.String.getValidationErrorMessage = () => 'bad string' 79 | 80 | component = new Component({ 81 | type: Password, 82 | options: {hasError: true}, 83 | ctx: ctx 84 | }) 85 | 86 | assert.strictEqual(component.getError(), 'bad string', '#7') 87 | 88 | delete t.String.getValidationErrorMessage 89 | 90 | const Age = t.refinement(t.Number, (n) => n > 6) 91 | 92 | component = new Component({ 93 | type: Age, 94 | options: {hasError: true}, 95 | ctx: ctx 96 | }) 97 | 98 | assert.strictEqual(component.getError(), undefined, '#8') 99 | 100 | component = new Component({ 101 | type: t.maybe(Age), 102 | options: {hasError: true}, 103 | ctx: ctx 104 | }) 105 | 106 | assert.strictEqual(component.getError(), undefined, '#9') 107 | 108 | Age.getValidationErrorMessage = () => 'bad age' 109 | 110 | component = new Component({ 111 | type: Age, 112 | options: {hasError: true}, 113 | ctx: ctx 114 | }) 115 | 116 | assert.strictEqual(component.getError(), 'bad age', '#10') 117 | 118 | component = new Component({ 119 | type: t.maybe(Age), 120 | options: {hasError: true}, 121 | ctx: ctx 122 | }) 123 | 124 | assert.strictEqual(component.getError(), undefined, '#11') 125 | 126 | component = new Component({ 127 | type: t.maybe(Age), 128 | options: {hasError: true}, 129 | ctx: ctx, 130 | value: 'a' 131 | }) 132 | 133 | assert.strictEqual(component.getError(), 'bad age', '#12') 134 | 135 | t.Number.getValidationErrorMessage = () => 'bad number' 136 | 137 | component = new Component({ 138 | type: Age, 139 | options: {hasError: true}, 140 | ctx: ctx, 141 | value: 'a' 142 | }) 143 | 144 | assert.strictEqual(component.getError(), 'bad number', '#13') 145 | 146 | component = new Component({ 147 | type: Age, 148 | options: {hasError: true}, 149 | ctx: ctx, 150 | value: 1 151 | }) 152 | 153 | assert.strictEqual(component.getError(), 'bad age', '#14') 154 | 155 | component = new Component({ 156 | type: t.maybe(Age), 157 | options: {hasError: true}, 158 | ctx: ctx 159 | }) 160 | 161 | assert.strictEqual(component.getError(), undefined, '#15') 162 | 163 | delete t.Number.getValidationErrorMessage 164 | }) 165 | 166 | test('typeInfo', (assert) => { 167 | assert.plan(3) 168 | 169 | const MaybeString = t.maybe(t.String) 170 | const Subtype = t.subtype(t.String, () => true ) 171 | 172 | let component = new Component({ 173 | type: t.String, 174 | options: {}, 175 | ctx: ctx 176 | }) 177 | 178 | assert.deepEqual(component.typeInfo, { 179 | type: t.String, 180 | isMaybe: false, 181 | isSubtype: false, 182 | innerType: t.Str, 183 | getValidationErrorMessage: undefined 184 | }) 185 | 186 | component = new Component({ 187 | type: MaybeString, 188 | options: {}, 189 | ctx: ctx 190 | }) 191 | 192 | assert.deepEqual(component.typeInfo, { 193 | type: MaybeString, 194 | isMaybe: true, 195 | isSubtype: false, 196 | innerType: t.Str, 197 | getValidationErrorMessage: undefined 198 | }) 199 | 200 | component = new Component({ 201 | type: Subtype, 202 | options: {}, 203 | ctx: ctx 204 | }) 205 | 206 | assert.deepEqual(component.typeInfo, { 207 | type: Subtype, 208 | isMaybe: false, 209 | isSubtype: true, 210 | innerType: t.Str, 211 | getValidationErrorMessage: undefined 212 | }) 213 | }) 214 | 215 | test('getId()', (assert) => { 216 | assert.plan(1) 217 | 218 | assert.strictEqual( 219 | new Component({ 220 | type: t.Str, 221 | options: {attrs: {id: 'myid'}}, 222 | ctx: ctx 223 | }).getId(), 224 | 'myid', 225 | 'should return the provided id') 226 | }) 227 | 228 | test('re-render on path change', (assert) => { 229 | assert.plan(2) 230 | 231 | let renderCallsNum = 0 232 | 233 | class TestComponent extends Component { 234 | render() { 235 | renderCallsNum++ 236 | return null 237 | } 238 | } 239 | 240 | const renderer = new ShallowRenderer() 241 | 242 | const baseProps = { 243 | options: {}, 244 | } 245 | const elementA = React.createElement(TestComponent, { 246 | ...baseProps, 247 | ctx: { 248 | ...ctx, 249 | path: ['foo'] 250 | } 251 | }) 252 | const elementB = React.createElement(TestComponent, { 253 | ...baseProps, 254 | ctx: { 255 | ...ctx, 256 | path: ['bar'] 257 | } 258 | }) 259 | 260 | renderer.render(elementA) 261 | assert.strictEqual(renderCallsNum, 1, '#1.1') 262 | 263 | renderer.render(elementB) 264 | assert.strictEqual(renderCallsNum, 2, '#1.2') 265 | }) 266 | }) 267 | -------------------------------------------------------------------------------- /test/components/Datetime.js: -------------------------------------------------------------------------------- 1 | import tape from 'tape' 2 | import t from 'tcomb-validation' 3 | import bootstrap from 'tcomb-form-templates-bootstrap' 4 | import React from 'react' 5 | import { Datetime } from '../../src/components' 6 | import { ctx, getRenderComponent } from './util' 7 | const renderComponent = getRenderComponent(Datetime) 8 | 9 | tape('Datetime', ({ test }) => { 10 | test('label', (assert) => { 11 | assert.plan(4) 12 | 13 | assert.strictEqual( 14 | new Datetime({ 15 | type: t.Dat, 16 | options: {}, 17 | ctx: ctx 18 | }).getLocals().label, 19 | 'Default label', 20 | 'should have a default label') 21 | 22 | assert.strictEqual( 23 | new Datetime({ 24 | type: t.Dat, 25 | options: {label: 'mylabel'}, 26 | ctx: ctx 27 | }).getLocals().label, 28 | 'mylabel', 29 | 'should handle label option as string') 30 | 31 | const actual = new Datetime({ 32 | type: t.Dat, 33 | options: {label: React.DOM.i(null, 'JSX label')}, 34 | ctx: ctx 35 | }).getLocals().label 36 | assert.equal(actual.type, 'i') 37 | assert.equal(actual.props.children, 'JSX label') 38 | }) 39 | 40 | test('help', (assert) => { 41 | assert.plan(3) 42 | 43 | assert.strictEqual( 44 | new Datetime({ 45 | type: t.Dat, 46 | options: {help: 'myhelp'}, 47 | ctx: ctx 48 | }).getLocals().help, 49 | 'myhelp', 50 | 'should handle help option as string') 51 | 52 | const actual = new Datetime({ 53 | type: t.Dat, 54 | options: {help: React.DOM.i(null, 'JSX help')}, 55 | ctx: ctx 56 | }).getLocals().help 57 | assert.equal(actual.type, 'i') 58 | assert.equal(actual.props.children, 'JSX help') 59 | }) 60 | 61 | test('value', (assert) => { 62 | assert.plan(2) 63 | 64 | assert.deepEqual( 65 | new Datetime({ 66 | type: t.Dat, 67 | options: {}, 68 | ctx: ctx 69 | }).getLocals().value, 70 | [null, null, null], 71 | 'default value should be [null, null, null]') 72 | 73 | assert.deepEqual( 74 | new Datetime({ 75 | type: t.Dat, 76 | options: {}, 77 | ctx: ctx, 78 | value: new Date(1973, 10, 30) 79 | }).getLocals().value, 80 | ['1973', '10', '30'], 81 | 'should handle value option') 82 | }) 83 | 84 | test('hasError', (assert) => { 85 | assert.plan(2) 86 | 87 | assert.strictEqual( 88 | new Datetime({ 89 | type: t.Dat, 90 | options: {}, 91 | ctx: ctx 92 | }).getLocals().hasError, 93 | false, 94 | 'default hasError should be false') 95 | 96 | assert.strictEqual( 97 | new Datetime({ 98 | type: t.Dat, 99 | options: {hasError: true}, 100 | ctx: ctx 101 | }).getLocals().hasError, 102 | true, 103 | 'should handle hasError option') 104 | }) 105 | 106 | test('error', (assert) => { 107 | assert.plan(3) 108 | 109 | assert.strictEqual( 110 | new Datetime({ 111 | type: t.Dat, 112 | options: {}, 113 | ctx: ctx 114 | }).getLocals().error, 115 | undefined, 116 | 'default error should be undefined') 117 | 118 | assert.strictEqual( 119 | new Datetime({ 120 | type: t.Dat, 121 | options: {error: 'myerror', hasError: true}, 122 | ctx: ctx 123 | }).getLocals().error, 124 | 'myerror', 125 | 'should handle error option') 126 | 127 | assert.deepEqual( 128 | new Datetime({ 129 | type: t.Dat, 130 | options: { 131 | error: (value) => 'error: ' + value.getFullYear(), 132 | hasError: true 133 | }, 134 | ctx: ctx, 135 | value: new Date(1973, 10, 30) 136 | }).getLocals().error, 137 | 'error: 1973', 138 | 'should handle error option as a function') 139 | }) 140 | 141 | test('template', (assert) => { 142 | assert.plan(2) 143 | 144 | assert.strictEqual( 145 | new Datetime({ 146 | type: t.Dat, 147 | options: {}, 148 | ctx: ctx 149 | }).getTemplate(), 150 | bootstrap.date, 151 | 'default template should be bootstrap.date') 152 | 153 | const template = () => {} 154 | 155 | assert.strictEqual( 156 | new Datetime({ 157 | type: t.Dat, 158 | options: {template: template}, 159 | ctx: ctx 160 | }).getTemplate(), 161 | template, 162 | 'should handle template option') 163 | }) 164 | 165 | if (typeof window !== 'undefined') { 166 | test('validate', (assert) => { 167 | assert.plan(4) 168 | 169 | let result 170 | 171 | // required type, default value 172 | result = renderComponent({ 173 | type: t.Dat 174 | }).validate() 175 | 176 | assert.strictEqual(result.isValid(), false) 177 | assert.deepEqual(result.value, null) 178 | 179 | // required type, setting a value 180 | result = renderComponent({ 181 | type: t.Dat, 182 | value: new Date(1973, 10, 30) 183 | }).validate() 184 | 185 | assert.strictEqual(result.isValid(), true) 186 | assert.strictEqual(result.value.getFullYear(), 1973) 187 | }) 188 | } 189 | }) 190 | -------------------------------------------------------------------------------- /test/components/List.js: -------------------------------------------------------------------------------- 1 | import tape from 'tape' 2 | import t from 'tcomb-validation' 3 | import { List } from '../../src/components' 4 | import { ctx } from './util' 5 | 6 | tape('List', ({ test }) => { 7 | test('should support unions', (assert) => { 8 | assert.plan(2) 9 | 10 | const AccountType = t.enums.of([ 11 | 'type 1', 12 | 'type 2', 13 | 'other' 14 | ], 'AccountType') 15 | 16 | const KnownAccount = t.struct({ 17 | type: AccountType 18 | }, 'KnownAccount') 19 | 20 | const UnknownAccount = KnownAccount.extend({ 21 | label: t.String, 22 | }, 'UnknownAccount') 23 | 24 | const Account = t.union([KnownAccount, UnknownAccount], 'Account') 25 | 26 | Account.dispatch = value => value && value.type === 'other' ? UnknownAccount : KnownAccount 27 | 28 | let component = new List({ 29 | type: t.list(Account), 30 | ctx: ctx, 31 | options: {}, 32 | value: [ 33 | { type: 'type 1' } 34 | ] 35 | }) 36 | 37 | assert.strictEqual(component.getItems()[0].input.props.type, KnownAccount) 38 | 39 | component = new List({ 40 | type: t.list(Account), 41 | ctx: ctx, 42 | options: {}, 43 | value: [ 44 | { type: 'other' } 45 | ] 46 | }) 47 | 48 | assert.strictEqual(component.getItems()[0].input.props.type, UnknownAccount) 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /test/components/Radio.js: -------------------------------------------------------------------------------- 1 | import tape from 'tape' 2 | import t from 'tcomb-validation' 3 | import bootstrap from 'tcomb-form-templates-bootstrap' 4 | import React from 'react' 5 | import { Radio } from '../../src/components' 6 | import { ctx, getRenderComponent } from './util' 7 | const renderComponent = getRenderComponent(Radio) 8 | 9 | const transformer = { 10 | format: (value) => { 11 | if (t.String.is(value)) { 12 | return value 13 | } else if (value === true) { 14 | return '1' 15 | } 16 | return '0' 17 | }, 18 | parse: (value) => value === '1' 19 | } 20 | 21 | tape('Radio', ({ test }) => { 22 | const Country = t.enums({ 23 | 'IT': 'Italy', 24 | 'FR': 'France', 25 | 'US': 'United States' 26 | }) 27 | 28 | test('label', (assert) => { 29 | assert.plan(5) 30 | 31 | assert.strictEqual( 32 | new Radio({ 33 | type: Country, 34 | options: {}, 35 | ctx: ctx 36 | }).getLocals().label, 37 | 'Default label', 38 | 'should have a default label') 39 | 40 | assert.strictEqual( 41 | new Radio({ 42 | type: Country, 43 | options: {label: 'mylabel'}, 44 | ctx: ctx 45 | }).getLocals().label, 46 | 'mylabel', 47 | 'should handle label option as string') 48 | 49 | const actual = new Radio({ 50 | type: Country, 51 | options: {label: React.DOM.i(null, 'JSX label')}, 52 | ctx: ctx 53 | }).getLocals().label 54 | assert.equal(actual.type, 'i') 55 | assert.equal(actual.props.children, 'JSX label') 56 | 57 | assert.strictEqual( 58 | new Radio({ 59 | type: t.maybe(Country), 60 | options: {}, 61 | ctx: ctx 62 | }).getLocals().label, 63 | 'Default label (optional)', 64 | 'should handle optional types') 65 | }) 66 | 67 | test('help', (assert) => { 68 | assert.plan(3) 69 | 70 | assert.strictEqual( 71 | new Radio({ 72 | type: Country, 73 | options: {help: 'myhelp'}, 74 | ctx: ctx 75 | }).getLocals().help, 76 | 'myhelp', 77 | 'should handle help option as string') 78 | 79 | const actual = new Radio({ 80 | type: Country, 81 | options: {help: React.DOM.i(null, 'JSX help')}, 82 | ctx: ctx 83 | }).getLocals().help 84 | assert.equal(actual.type, 'i') 85 | assert.equal(actual.props.children, 'JSX help') 86 | }) 87 | 88 | test('value', (assert) => { 89 | assert.plan(1) 90 | 91 | assert.strictEqual( 92 | new Radio({ 93 | type: Country, 94 | options: {}, 95 | ctx: ctx, 96 | value: 'a' 97 | }).getLocals().value, 98 | 'a', 99 | 'should handle value option') 100 | }) 101 | 102 | test('transformer', (assert) => { 103 | assert.plan(1) 104 | 105 | assert.strictEqual( 106 | new Radio({ 107 | type: t.maybe(t.Bool), 108 | options: { 109 | transformer: transformer, 110 | options: [ 111 | {value: '0', text: 'No'}, 112 | {value: '1', text: 'Yes'} 113 | ] 114 | }, 115 | ctx: ctx, 116 | value: true 117 | }).getLocals().value, 118 | '1', 119 | 'should handle transformer option (format)') 120 | }) 121 | 122 | test('hasError', (assert) => { 123 | assert.plan(2) 124 | 125 | assert.strictEqual( 126 | new Radio({ 127 | type: Country, 128 | options: {}, 129 | ctx: ctx 130 | }).getLocals().hasError, 131 | false, 132 | 'default hasError should be false') 133 | 134 | assert.strictEqual( 135 | new Radio({ 136 | type: Country, 137 | options: {hasError: true}, 138 | ctx: ctx 139 | }).getLocals().hasError, 140 | true, 141 | 'should handle hasError option') 142 | }) 143 | 144 | test('error', (assert) => { 145 | assert.plan(3) 146 | 147 | assert.strictEqual( 148 | new Radio({ 149 | type: Country, 150 | options: {}, 151 | ctx: ctx 152 | }).getLocals().error, 153 | undefined, 154 | 'default error should be undefined') 155 | 156 | assert.strictEqual( 157 | new Radio({ 158 | type: Country, 159 | options: {error: 'myerror', hasError: true}, 160 | ctx: ctx 161 | }).getLocals().error, 162 | 'myerror', 163 | 'should handle error option') 164 | 165 | assert.strictEqual( 166 | new Radio({ 167 | type: Country, 168 | options: { 169 | error: (value) => 'error: ' + value, 170 | hasError: true 171 | }, 172 | ctx: ctx, 173 | value: 'a' 174 | }).getLocals().error, 175 | 'error: a', 176 | 'should handle error option as a function') 177 | }) 178 | 179 | test('template', (assert) => { 180 | assert.plan(2) 181 | 182 | assert.strictEqual( 183 | new Radio({ 184 | type: Country, 185 | options: {}, 186 | ctx: ctx 187 | }).getTemplate(), 188 | bootstrap.radio, 189 | 'default template should be bootstrap.eadio') 190 | 191 | const template = () => {} 192 | 193 | assert.strictEqual( 194 | new Radio({ 195 | type: Country, 196 | options: {template: template}, 197 | ctx: ctx 198 | }).getTemplate(), 199 | template, 200 | 'should handle template option') 201 | }) 202 | 203 | test('options', (assert) => { 204 | assert.plan(1) 205 | 206 | assert.deepEqual( 207 | new Radio({ 208 | type: Country, 209 | options: { 210 | options: [ 211 | {value: 'IT', text: 'Italia'}, 212 | {value: 'US', text: 'Stati Uniti'} 213 | ] 214 | }, 215 | ctx: ctx 216 | }).getLocals().options, 217 | [ 218 | { text: 'Italia', value: 'IT' }, 219 | { text: 'Stati Uniti', value: 'US' } 220 | ], 221 | 'should handle options option') 222 | }) 223 | 224 | test('order', (assert) => { 225 | assert.plan(2) 226 | 227 | assert.deepEqual( 228 | new Radio({ 229 | type: Country, 230 | options: {order: 'asc'}, 231 | ctx: ctx 232 | }).getLocals().options, 233 | [ 234 | { text: 'France', value: 'FR' }, 235 | { text: 'Italy', value: 'IT' }, 236 | { text: 'United States', value: 'US' } 237 | ], 238 | 'should handle order = asc option') 239 | 240 | assert.deepEqual( 241 | new Radio({ 242 | type: Country, 243 | options: {order: 'desc'}, 244 | ctx: ctx 245 | }).getLocals().options, 246 | [ 247 | { text: 'United States', value: 'US' }, 248 | { text: 'Italy', value: 'IT' }, 249 | { text: 'France', value: 'FR' } 250 | ], 251 | 'should handle order = desc option') 252 | }) 253 | 254 | if (typeof window !== 'undefined') { 255 | test('validate', (assert) => { 256 | assert.plan(8) 257 | 258 | let result 259 | 260 | // required type, default value 261 | result = renderComponent({ 262 | type: Country 263 | }).validate() 264 | 265 | assert.strictEqual(result.isValid(), false) 266 | assert.strictEqual(result.value, null) 267 | 268 | // required type, setting a value 269 | result = renderComponent({ 270 | type: Country, 271 | value: 'IT' 272 | }).validate() 273 | 274 | assert.strictEqual(result.isValid(), true) 275 | assert.strictEqual(result.value, 'IT') 276 | 277 | // optional type 278 | result = renderComponent({ 279 | type: t.maybe(Country) 280 | }).validate() 281 | 282 | assert.strictEqual(result.isValid(), true) 283 | assert.strictEqual(result.value, null) 284 | 285 | result = renderComponent({ 286 | type: t.maybe(t.Bool), 287 | options: { 288 | transformer: transformer, 289 | options: [ 290 | {value: '0', text: 'No'}, 291 | {value: '1', text: 'Yes'} 292 | ] 293 | }, 294 | value: true 295 | }).validate() 296 | 297 | assert.strictEqual(result.isValid(), true) 298 | assert.strictEqual(result.value, true) 299 | }) 300 | } 301 | }) 302 | -------------------------------------------------------------------------------- /test/components/Select.js: -------------------------------------------------------------------------------- 1 | import tape from 'tape' 2 | import t from 'tcomb-validation' 3 | import bootstrap from 'tcomb-form-templates-bootstrap' 4 | import React from 'react' 5 | import { Select } from '../../src/components' 6 | import { ctx, getRenderComponent } from './util' 7 | const renderComponent = getRenderComponent(Select) 8 | 9 | const transformer = { 10 | format: (value) => { 11 | if (t.String.is(value)) { 12 | return value 13 | } else if (value === true) { 14 | return '1' 15 | } 16 | return '0' 17 | }, 18 | parse: (value) => value === '1' 19 | } 20 | 21 | tape('Select', ({ test }) => { 22 | const Country = t.enums({ 23 | 'IT': 'Italy', 24 | 'FR': 'France', 25 | 'US': 'United States' 26 | }) 27 | 28 | test('label', (assert) => { 29 | assert.plan(5) 30 | 31 | assert.strictEqual( 32 | new Select({ 33 | type: Country, 34 | options: {}, 35 | ctx: ctx 36 | }).getLocals().label, 37 | 'Default label', 38 | 'should have a default label') 39 | 40 | assert.strictEqual( 41 | new Select({ 42 | type: Country, 43 | options: {label: 'mylabel'}, 44 | ctx: ctx 45 | }).getLocals().label, 46 | 'mylabel', 47 | 'should handle label option as string') 48 | 49 | const actual = new Select({ 50 | type: Country, 51 | options: {label: React.DOM.i(null, 'JSX label')}, 52 | ctx: ctx 53 | }).getLocals().label 54 | assert.equal(actual.type, 'i') 55 | assert.equal(actual.props.children, 'JSX label') 56 | 57 | assert.strictEqual( 58 | new Select({ 59 | type: t.maybe(Country), 60 | options: {}, 61 | ctx: ctx 62 | }).getLocals().label, 63 | 'Default label (optional)', 64 | 'should handle optional types') 65 | }) 66 | 67 | test('help', (assert) => { 68 | assert.plan(3) 69 | 70 | assert.strictEqual( 71 | new Select({ 72 | type: Country, 73 | options: {help: 'myhelp'}, 74 | ctx: ctx 75 | }).getLocals().help, 76 | 'myhelp', 77 | 'should handle help option as string') 78 | 79 | const actual = new Select({ 80 | type: Country, 81 | options: {help: React.DOM.i(null, 'JSX help')}, 82 | ctx: ctx 83 | }).getLocals().help 84 | assert.equal(actual.type, 'i') 85 | assert.equal(actual.props.children, 'JSX help') 86 | }) 87 | 88 | test('value', (assert) => { 89 | assert.plan(2) 90 | 91 | assert.strictEqual( 92 | new Select({ 93 | type: Country, 94 | options: {}, 95 | ctx: ctx 96 | }).getLocals().value, 97 | '', 98 | 'default value should be nullOption.value') 99 | 100 | assert.strictEqual( 101 | new Select({ 102 | type: Country, 103 | options: {}, 104 | ctx: ctx, 105 | value: 'a' 106 | }).getLocals().value, 107 | 'a', 108 | 'should handle value option') 109 | }) 110 | 111 | test('transformer', (assert) => { 112 | assert.plan(1) 113 | 114 | assert.strictEqual( 115 | new Select({ 116 | type: t.maybe(t.Bool), 117 | options: { 118 | transformer: transformer, 119 | options: [ 120 | {value: '0', text: 'No'}, 121 | {value: '1', text: 'Yes'} 122 | ] 123 | }, 124 | ctx: ctx, 125 | value: true 126 | }).getLocals().value, 127 | '1', 128 | 'should handle transformer option (format)') 129 | }) 130 | 131 | test('hasError', (assert) => { 132 | assert.plan(2) 133 | 134 | assert.strictEqual( 135 | new Select({ 136 | type: Country, 137 | options: {}, 138 | ctx: ctx 139 | }).getLocals().hasError, 140 | false, 141 | 'default hasError should be false') 142 | 143 | assert.strictEqual( 144 | new Select({ 145 | type: Country, 146 | options: {hasError: true}, 147 | ctx: ctx 148 | }).getLocals().hasError, 149 | true, 150 | 'should handle hasError option') 151 | }) 152 | 153 | test('error', (assert) => { 154 | assert.plan(3) 155 | 156 | assert.strictEqual( 157 | new Select({ 158 | type: Country, 159 | options: {}, 160 | ctx: ctx 161 | }).getLocals().error, 162 | undefined, 163 | 'default error should be undefined') 164 | 165 | assert.strictEqual( 166 | new Select({ 167 | type: Country, 168 | options: {error: 'myerror', hasError: true}, 169 | ctx: ctx 170 | }).getLocals().error, 171 | 'myerror', 172 | 'should handle error option') 173 | 174 | assert.strictEqual( 175 | new Select({ 176 | type: Country, 177 | options: { 178 | error: (value) => 'error: ' + value, 179 | hasError: true 180 | }, 181 | ctx: ctx, 182 | value: 'a' 183 | }).getLocals().error, 184 | 'error: a', 185 | 'should handle error option as a function') 186 | }) 187 | 188 | test('template', (assert) => { 189 | assert.plan(2) 190 | 191 | assert.strictEqual( 192 | new Select({ 193 | type: Country, 194 | options: {}, 195 | ctx: ctx 196 | }).getTemplate(), 197 | bootstrap.select, 198 | 'default template should be bootstrap.select') 199 | 200 | const template = () => {} 201 | 202 | assert.strictEqual( 203 | new Select({ 204 | type: Country, 205 | options: {template: template}, 206 | ctx: ctx 207 | }).getTemplate(), 208 | template, 209 | 'should handle template option') 210 | }) 211 | 212 | test('options', (assert) => { 213 | assert.plan(1) 214 | 215 | assert.deepEqual( 216 | new Select({ 217 | type: Country, 218 | options: { 219 | options: [ 220 | {value: 'IT', text: 'Italia'}, 221 | {value: 'US', text: 'Stati Uniti'} 222 | ] 223 | }, 224 | ctx: ctx 225 | }).getLocals().options, 226 | [ 227 | { text: '-', value: '' }, 228 | { text: 'Italia', value: 'IT' }, 229 | { text: 'Stati Uniti', value: 'US' } 230 | ], 231 | 'should handle options option') 232 | }) 233 | 234 | test('order', (assert) => { 235 | assert.plan(2) 236 | 237 | assert.deepEqual( 238 | new Select({ 239 | type: Country, 240 | options: {order: 'asc'}, 241 | ctx: ctx 242 | }).getLocals().options, 243 | [ 244 | { text: '-', value: '' }, 245 | { text: 'France', value: 'FR' }, 246 | { text: 'Italy', value: 'IT' }, 247 | { text: 'United States', value: 'US' } 248 | ], 249 | 'should handle order = asc option') 250 | 251 | assert.deepEqual( 252 | new Select({ 253 | type: Country, 254 | options: {order: 'desc'}, 255 | ctx: ctx 256 | }).getLocals().options, 257 | [ 258 | { text: '-', value: '' }, 259 | { text: 'United States', value: 'US' }, 260 | { text: 'Italy', value: 'IT' }, 261 | { text: 'France', value: 'FR' } 262 | ], 263 | 'should handle order = desc option') 264 | }) 265 | 266 | test('nullOption', (assert) => { 267 | assert.plan(2) 268 | 269 | assert.deepEqual( 270 | new Select({ 271 | type: Country, 272 | options: { 273 | nullOption: {value: '', text: 'Select a country'} 274 | }, 275 | ctx: ctx 276 | }).getLocals().options, 277 | [ 278 | { value: '', text: 'Select a country' }, 279 | { text: 'Italy', value: 'IT' }, 280 | { text: 'France', value: 'FR' }, 281 | { text: 'United States', value: 'US' } 282 | ], 283 | 'should handle nullOption option') 284 | 285 | assert.deepEqual( 286 | new Select({ 287 | type: Country, 288 | options: { 289 | nullOption: false 290 | }, 291 | ctx: ctx, 292 | value: 'US' 293 | }).getLocals().options, 294 | [ 295 | { text: 'Italy', value: 'IT' }, 296 | { text: 'France', value: 'FR' }, 297 | { text: 'United States', value: 'US' } 298 | ], 299 | 'should skip the nullOption if nullOption = false') 300 | }) 301 | 302 | if (typeof window !== 'undefined') { 303 | test('validate', (assert) => { 304 | assert.plan(16) 305 | 306 | let result 307 | 308 | // required type, default value 309 | result = renderComponent({ 310 | type: Country 311 | }).validate() 312 | 313 | assert.strictEqual(result.isValid(), false) 314 | assert.strictEqual(result.value, null) 315 | 316 | // required type, setting a value 317 | result = renderComponent({ 318 | type: Country, 319 | value: 'IT' 320 | }).validate() 321 | 322 | assert.strictEqual(result.isValid(), true) 323 | assert.strictEqual(result.value, 'IT') 324 | 325 | // optional type 326 | result = renderComponent({ 327 | type: t.maybe(Country) 328 | }).validate() 329 | 330 | assert.strictEqual(result.isValid(), true) 331 | assert.strictEqual(result.value, null) 332 | 333 | // option groups 334 | const Car = t.enums.of('Audi Chrysler Ford Renault Peugeot') 335 | result = renderComponent({ 336 | type: Car, 337 | options: { 338 | options: [ 339 | {value: 'Audi', text: 'Audi'}, // an option 340 | {label: 'US', options: [ // a group of options 341 | {value: 'Chrysler', text: 'Chrysler'}, 342 | {value: 'Ford', text: 'Ford'} 343 | ]}, 344 | {label: 'France', options: [ // another group of options 345 | {value: 'Renault', text: 'Renault'}, 346 | {value: 'Peugeot', text: 'Peugeot'} 347 | ], disabled: true} // use `disabled: true` to disable an optgroup 348 | ] 349 | }, 350 | value: 'Ford' 351 | }).validate() 352 | 353 | assert.strictEqual(result.isValid(), true) 354 | assert.strictEqual(result.value, 'Ford') 355 | 356 | // 357 | // multiple select 358 | // 359 | 360 | // default value should be [] 361 | result = renderComponent({ 362 | type: t.list(Country) 363 | }).validate() 364 | 365 | assert.strictEqual(result.isValid(), true) 366 | assert.deepEqual(result.value, []) 367 | 368 | // setting a value 369 | result = renderComponent({ 370 | type: t.list(Country), 371 | value: ['IT', 'US'] 372 | }).validate() 373 | 374 | assert.strictEqual(result.isValid(), true) 375 | assert.deepEqual(result.value, ['IT', 'US']) 376 | 377 | // subtyped multiple select 378 | result = renderComponent({ 379 | type: t.subtype(t.list(Country), (x) => x.length >= 2), 380 | value: ['IT'] 381 | }).validate() 382 | 383 | assert.strictEqual(result.isValid(), false) 384 | assert.deepEqual(result.value, ['IT']) 385 | 386 | // should handle transformer option (parse) 387 | result = renderComponent({ 388 | type: t.maybe(t.Bool), 389 | options: { 390 | transformer: transformer, 391 | options: [ 392 | {value: '0', text: 'No'}, 393 | {value: '1', text: 'Yes'} 394 | ] 395 | }, 396 | value: true 397 | }).validate() 398 | 399 | assert.strictEqual(result.isValid(), true) 400 | assert.deepEqual(result.value, true) 401 | }) 402 | } 403 | }) 404 | -------------------------------------------------------------------------------- /test/components/Struct.js: -------------------------------------------------------------------------------- 1 | import tape from 'tape' 2 | import React from 'react' 3 | import ReactTestRenderer from 'react-test-renderer' 4 | import t from '../../src/main' 5 | import { Struct, Form } from '../../src/components' 6 | import { ctx } from './util' 7 | 8 | tape('Struct', ({ test }) => { 9 | test('options can be a function', (assert) => { 10 | assert.plan(2) 11 | 12 | let component = new Struct({ 13 | type: t.struct({ 14 | name: t.String 15 | }), 16 | options: { 17 | fields: { 18 | name: value => ({ disabled: value === 'a' }) 19 | } 20 | }, 21 | ctx: ctx 22 | }) 23 | 24 | assert.strictEqual(component.getInputs().name.props.options.disabled, false) 25 | 26 | component = new Struct({ 27 | type: t.struct({ 28 | name: t.String 29 | }), 30 | options: { 31 | fields: { 32 | name: value => ({ disabled: value === 'a' }) 33 | } 34 | }, 35 | ctx: ctx, 36 | value: { name: 'a' } 37 | }) 38 | 39 | assert.strictEqual(component.getInputs().name.props.options.disabled, true) 40 | }) 41 | 42 | test('manages refs correctly', (assert) => { 43 | assert.plan(4) 44 | 45 | const modelA = t.struct({ 46 | name: t.String 47 | }) 48 | const modelB = modelA.extend({ 49 | age: t.Number 50 | }) 51 | 52 | let formRef = null 53 | const setRef = ref => { 54 | formRef = ref 55 | } 56 | 57 | const formProps = { 58 | key: 'form', 59 | ref: setRef, 60 | value: { 61 | name: 'test', 62 | age: 5 63 | } 64 | } 65 | 66 | const formB = React.createElement(Form, { 67 | ...formProps, 68 | type: modelB, 69 | }) 70 | 71 | const renderer = ReactTestRenderer.create(formB) 72 | 73 | assert.strictEqual(Object.keys(formRef.inputRef.childRefs).length, 2, 'should have 2 ref keys') 74 | assert.strictEqual(formRef.validate().errors.length, 0, 'validation works') 75 | 76 | const formA = React.createElement(Form, { 77 | ...formProps, 78 | type: modelA, 79 | }) 80 | 81 | renderer.update(formA) 82 | 83 | assert.strictEqual(Object.keys(formRef.inputRef.childRefs).length, 1, 'should have 1 ref keys') 84 | assert.strictEqual(formRef.validate().errors.length, 0, 'validation works') 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /test/components/Textbox.js: -------------------------------------------------------------------------------- 1 | import tape from 'tape' 2 | import t from 'tcomb-validation' 3 | import bootstrap from 'tcomb-form-templates-bootstrap' 4 | import React from 'react' 5 | import { Textbox } from '../../src/components' 6 | import { ctx, ctxPlaceholders, ctxNone, getRenderComponent } from './util' 7 | const renderComponent = getRenderComponent(Textbox) 8 | 9 | const transformer = { 10 | format: (value) => Array.isArray(value) ? value : value.split(' '), 11 | parse: (value) => value.join(' ') 12 | } 13 | 14 | tape('Textbox', ({ test }) => { 15 | test('path', (assert) => { 16 | assert.plan(1) 17 | 18 | assert.deepEqual( 19 | new Textbox({ 20 | type: t.Str, 21 | options: {}, 22 | ctx: ctx 23 | }).getLocals().path, 24 | [ 'defaultPath' ], 25 | 'should handle the path') 26 | }) 27 | 28 | test('attrs', (assert) => { 29 | assert.plan(1) 30 | 31 | assert.strictEqual( 32 | new Textbox({ 33 | type: t.Num, 34 | options: { 35 | type: 'number', 36 | attrs: { 37 | min: 0 38 | } 39 | }, 40 | ctx: ctx 41 | }).getLocals().attrs.min, 42 | 0, 43 | 'should handle attrs option') 44 | }) 45 | 46 | test('attrs.events', (assert) => { 47 | assert.plan(1) 48 | 49 | function onBlur() {} 50 | 51 | assert.deepEqual( 52 | new Textbox({ 53 | type: t.Str, 54 | options: { 55 | attrs: { 56 | id: 'myid', 57 | onBlur: onBlur 58 | } 59 | }, 60 | ctx: ctx 61 | }).getLocals().attrs, 62 | { 63 | name: 'defaultName', 64 | id: 'myid', 65 | onBlur: onBlur, 66 | placeholder: undefined 67 | }, 68 | 'should handle events') 69 | }) 70 | 71 | test('label', (assert) => { 72 | assert.plan(6) 73 | 74 | assert.strictEqual( 75 | new Textbox({ 76 | type: t.Str, 77 | options: {}, 78 | ctx: ctx 79 | }).getLocals().label, 80 | 'Default label', 81 | 'should have a default label') 82 | 83 | ctx.i18n.required = ' (required)' 84 | assert.strictEqual( 85 | new Textbox({ 86 | type: t.Str, 87 | options: {}, 88 | ctx: ctx 89 | }).getLocals().label, 90 | 'Default label (required)', 91 | 'should have a default label') 92 | ctx.i18n.required = '' 93 | 94 | assert.strictEqual( 95 | new Textbox({ 96 | type: t.Str, 97 | options: {label: 'mylabel'}, 98 | ctx: ctx 99 | }).getLocals().label, 100 | 'mylabel', 101 | 'should handle label option as string') 102 | 103 | const actual = new Textbox({ 104 | type: t.Str, 105 | options: {label: React.DOM.i(null, 'JSX label')}, 106 | ctx: ctx 107 | }).getLocals().label 108 | assert.equal(actual.type, 'i') 109 | assert.equal(actual.props.children, 'JSX label') 110 | 111 | assert.strictEqual( 112 | new Textbox({ 113 | type: t.maybe(t.Str), 114 | options: {}, 115 | ctx: ctx 116 | }).getLocals().label, 117 | 'Default label (optional)', 118 | 'should handle optional types') 119 | }) 120 | 121 | test('attrs.placeholder', (assert) => { 122 | assert.plan(6) 123 | 124 | assert.strictEqual( 125 | new Textbox({ 126 | type: t.Str, 127 | options: {}, 128 | ctx: ctx 129 | }).getLocals().attrs.placeholder, 130 | undefined, 131 | 'default placeholder should be undefined') 132 | 133 | assert.strictEqual( 134 | new Textbox({ 135 | type: t.Str, 136 | options: {attrs: {placeholder: 'myplaceholder'}}, 137 | ctx: ctx 138 | }).getLocals().attrs.placeholder, 139 | 'myplaceholder', 140 | 'should handle placeholder option') 141 | 142 | assert.strictEqual( 143 | new Textbox({ 144 | type: t.Str, 145 | options: {label: 'mylabel', attrs: {placeholder: 'myplaceholder'}}, 146 | ctx: ctx 147 | }).getLocals().attrs.placeholder, 148 | 'myplaceholder', 149 | 'should handle placeholder option even if a label is specified') 150 | 151 | assert.strictEqual( 152 | new Textbox({ 153 | type: t.Str, 154 | options: {}, 155 | ctx: ctxPlaceholders 156 | }).getLocals().attrs.placeholder, 157 | 'Default label', 158 | 'should have a default placeholder if auto = placeholders') 159 | 160 | assert.strictEqual( 161 | new Textbox({ 162 | type: t.maybe(t.Str), 163 | options: {}, 164 | ctx: ctxPlaceholders 165 | }).getLocals().attrs.placeholder, 166 | 'Default label (optional)', 167 | 'should handle optional types if auto = placeholders') 168 | 169 | assert.strictEqual( 170 | new Textbox({ 171 | type: t.Str, 172 | options: {attrs: {placeholder: 'myplaceholder'}}, 173 | ctx: ctxNone 174 | }).getLocals().attrs.placeholder, 175 | 'myplaceholder', 176 | 'should handle placeholder option even if auto === none') 177 | }) 178 | 179 | test('disabled', (assert) => { 180 | assert.plan(3) 181 | 182 | assert.strictEqual( 183 | new Textbox({ 184 | type: t.Str, 185 | options: {}, 186 | ctx: ctx 187 | }).getLocals().disabled, 188 | undefined, 189 | 'default disabled should be undefined') 190 | 191 | assert.strictEqual( 192 | new Textbox({ 193 | type: t.Str, 194 | options: {disabled: true}, 195 | ctx: ctx 196 | }).getLocals().disabled, 197 | true, 198 | 'should handle disabled = true') 199 | 200 | assert.strictEqual( 201 | new Textbox({ 202 | type: t.Str, 203 | options: {disabled: false}, 204 | ctx: ctx 205 | }).getLocals().disabled, 206 | false, 207 | 'should handle disabled = false') 208 | }) 209 | 210 | test('help', (assert) => { 211 | assert.plan(3) 212 | 213 | assert.strictEqual( 214 | new Textbox({ 215 | type: t.Str, 216 | options: {help: 'myhelp'}, 217 | ctx: ctx 218 | }).getLocals().help, 219 | 'myhelp', 220 | 'should handle help option as string') 221 | 222 | const actual = new Textbox({ 223 | type: t.Str, 224 | options: {help: React.DOM.i(null, 'JSX help')}, 225 | ctx: ctx 226 | }).getLocals().help 227 | assert.equal(actual.type, 'i') 228 | assert.equal(actual.props.children, 'JSX help') 229 | }) 230 | 231 | test('value', (assert) => { 232 | assert.plan(3) 233 | 234 | assert.strictEqual( 235 | new Textbox({ 236 | type: t.Str, 237 | options: {}, 238 | ctx: ctx 239 | }).getLocals().value, 240 | '', 241 | 'default value should be \'\'') 242 | 243 | assert.strictEqual( 244 | new Textbox({ 245 | type: t.Str, 246 | options: {}, 247 | ctx: ctx, 248 | value: 'a' 249 | }).getLocals().value, 250 | 'a', 251 | 'should handle value option') 252 | 253 | assert.strictEqual( 254 | new Textbox({ 255 | type: t.Num, 256 | options: {}, 257 | ctx: ctx, 258 | value: 1.1 259 | }).getLocals().value, 260 | '1.1', 261 | 'should handle numeric values') 262 | }) 263 | 264 | test('transformer', (assert) => { 265 | assert.plan(13) 266 | 267 | assert.deepEqual( 268 | new Textbox({ 269 | type: t.Str, 270 | options: {transformer: transformer}, 271 | ctx: ctx, 272 | value: 'a b' 273 | }).getLocals().value, 274 | ['a', 'b'], 275 | 'should handle transformer option (format)') 276 | 277 | const { parse } = Textbox.numberTransformer 278 | assert.equal(parse(''), null) 279 | assert.equal(parse(' \r\n\t'), null) 280 | assert.equal(parse('a'), 'a') 281 | assert.equal(parse('1'), 1) 282 | assert.equal(parse('1.2a'), '1.2a') 283 | assert.equal(parse('1.a2'), '1.a2') 284 | assert.equal(parse('1a2'), '1a2') 285 | assert.equal(parse('1 2'), '1 2') 286 | assert.equal(parse(true), true) 287 | assert.equal(parse(null), null) 288 | assert.equal(parse(undefined), null) 289 | const objValue = { foo: 'bar' } 290 | assert.equal(parse(objValue), objValue) 291 | }) 292 | 293 | test('hasError', (assert) => { 294 | assert.plan(2) 295 | 296 | assert.strictEqual( 297 | new Textbox({ 298 | type: t.Str, 299 | options: {}, 300 | ctx: ctx 301 | }).getLocals().hasError, 302 | false, 303 | 'default hasError should be false') 304 | 305 | assert.strictEqual( 306 | new Textbox({ 307 | type: t.Str, 308 | options: {hasError: true}, 309 | ctx: ctx 310 | }).getLocals().hasError, 311 | true, 312 | 'should handle hasError option') 313 | }) 314 | 315 | test('error', (assert) => { 316 | assert.plan(3) 317 | 318 | assert.strictEqual( 319 | new Textbox({ 320 | type: t.Str, 321 | options: {}, 322 | ctx: ctx 323 | }).getLocals().error, 324 | undefined, 325 | 'default error should be undefined') 326 | 327 | assert.strictEqual( 328 | new Textbox({ 329 | type: t.Str, 330 | options: {error: 'myerror', hasError: true}, 331 | ctx: ctx 332 | }).getLocals().error, 333 | 'myerror', 334 | 'should handle error option') 335 | 336 | assert.strictEqual( 337 | new Textbox({ 338 | type: t.Str, 339 | options: { 340 | error: (value) => 'error: ' + value, 341 | hasError: true 342 | }, 343 | ctx: ctx, 344 | value: 'a' 345 | }).getLocals().error, 346 | 'error: a', 347 | 'should handle error option as a function') 348 | }) 349 | 350 | test('template', (assert) => { 351 | assert.plan(2) 352 | 353 | assert.strictEqual( 354 | new Textbox({ 355 | type: t.Str, 356 | options: {}, 357 | ctx: ctx 358 | }).getTemplate(), 359 | bootstrap.textbox, 360 | 'default template should be bootstrap.textbox') 361 | 362 | const template = () => {} 363 | 364 | assert.strictEqual( 365 | new Textbox({ 366 | type: t.Str, 367 | options: {template: template}, 368 | ctx: ctx 369 | }).getTemplate(), 370 | template, 371 | 'should handle template option') 372 | }) 373 | 374 | if (typeof window !== 'undefined') { 375 | test('validate', (assert) => { 376 | assert.plan(20) 377 | 378 | let result 379 | 380 | // required type, default value 381 | result = renderComponent({ 382 | type: t.Str 383 | }).validate() 384 | 385 | assert.strictEqual(result.isValid(), false) 386 | assert.strictEqual(result.value, null) 387 | 388 | // required type, setting a value 389 | result = renderComponent({ 390 | type: t.Str, 391 | value: 'a' 392 | }).validate() 393 | 394 | assert.strictEqual(result.isValid(), true) 395 | assert.strictEqual(result.value, 'a') 396 | 397 | // string type with numeric value 398 | result = renderComponent({ 399 | type: t.Str, 400 | value: '123' 401 | }).validate() 402 | 403 | assert.strictEqual(result.isValid(), true) 404 | assert.strictEqual(result.value, '123') 405 | 406 | // optional type 407 | result = renderComponent({ 408 | type: t.maybe(t.Str) 409 | }).validate() 410 | 411 | assert.strictEqual(result.isValid(), true) 412 | assert.strictEqual(result.value, null) 413 | 414 | // numeric type 415 | result = renderComponent({ 416 | type: t.Num, 417 | value: 1 418 | }).validate() 419 | 420 | assert.strictEqual(result.isValid(), true) 421 | assert.strictEqual(result.value, 1) 422 | 423 | // optional numeric type 424 | result = renderComponent({ 425 | type: t.maybe(t.Num), 426 | value: '' 427 | }).validate() 428 | 429 | assert.strictEqual(result.isValid(), true) 430 | assert.strictEqual(result.value, null) 431 | 432 | // numeric type with stringy value 433 | result = renderComponent({ 434 | type: t.Num, 435 | value: '1.01' 436 | }).validate() 437 | 438 | assert.strictEqual(result.isValid(), true) 439 | assert.strictEqual(result.value, 1.01) 440 | 441 | // subtype, setting a valid value 442 | result = renderComponent({ 443 | type: t.subtype(t.Num, (n) => n >= 0), 444 | value: 1 445 | }).validate() 446 | 447 | assert.strictEqual(result.isValid(), true) 448 | assert.strictEqual(result.value, 1) 449 | 450 | // subtype, setting an invalid value 451 | result = renderComponent({ 452 | type: t.subtype(t.Num, (n) => n >= 0), 453 | value: -1 454 | }).validate() 455 | 456 | assert.strictEqual(result.isValid(), false) 457 | assert.strictEqual(result.value, -1) 458 | 459 | // should handle transformer option (parse) 460 | result = renderComponent({ 461 | type: t.Str, 462 | options: {transformer: transformer}, 463 | value: ['a', 'b'] 464 | }).validate() 465 | 466 | assert.strictEqual(result.isValid(), true) 467 | assert.strictEqual(result.value, 'a b') 468 | }) 469 | } 470 | }) 471 | -------------------------------------------------------------------------------- /test/components/index.js: -------------------------------------------------------------------------------- 1 | import './Component' 2 | import './Textbox' 3 | import './Checkbox' 4 | import './Select' 5 | import './Radio' 6 | import './Datetime' 7 | import './Struct' 8 | import './List' 9 | -------------------------------------------------------------------------------- /test/components/util.js: -------------------------------------------------------------------------------- 1 | import t from 'tcomb-validation' 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | import bootstrap from 'tcomb-form-templates-bootstrap' 5 | import { UIDGenerator } from '../../src/util' 6 | 7 | const ctx = { 8 | uidGenerator: new UIDGenerator('root'), 9 | auto: 'labels', 10 | config: {}, 11 | name: 'defaultName', 12 | label: 'Default label', 13 | i18n: { 14 | optional: ' (optional)', 15 | required: '', 16 | add: 'Add', 17 | remove: 'Remove', 18 | up: 'Up', 19 | down: 'Down' 20 | }, 21 | templates: bootstrap, 22 | path: ['defaultPath'] 23 | } 24 | 25 | function getContext(options) { 26 | return t.mixin(t.mixin({}, ctx), options, true) 27 | } 28 | 29 | const ctxPlaceholders = getContext({auto: 'placeholders'}) 30 | const ctxNone = getContext({auto: 'none'}) 31 | 32 | function getRenderComponent(Component) { 33 | return (props) => { 34 | props.options = props.options || {} 35 | props.ctx = props.ctx || ctx 36 | const node = document.createElement('div') 37 | document.body.appendChild(node) 38 | return ReactDOM.render(React.createElement(Component, props), node) 39 | } 40 | } 41 | 42 | export default { 43 | ctx, 44 | ctxPlaceholders, 45 | ctxNone, 46 | getRenderComponent 47 | } 48 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import './util' 2 | import './components' 3 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | import tape from 'tape' 2 | import { humanize, move, isArraysShallowDiffers } from '../src/util' 3 | 4 | tape('util', ({ test }) => { 5 | test('humanize', (assert) => { 6 | assert.plan(1) 7 | assert.strictEqual(humanize('birthDate'), 'Birth date') 8 | }) 9 | 10 | test('move', (assert) => { 11 | assert.plan(2) 12 | const initial = [1, 2, 3] 13 | const actual = move(initial, 1, 2) 14 | const expected = [1, 3, 2] 15 | assert.strictEqual(initial, actual) 16 | assert.deepEqual(actual, expected) 17 | }) 18 | 19 | test('isArraysShallowDiffers', (assert) => { 20 | assert.plan(4) 21 | 22 | const array = ['foo', 1] 23 | let other = ['bar', 1] 24 | assert.equal(isArraysShallowDiffers(array, other), true) 25 | 26 | other[0] = 'foo' 27 | assert.equal(isArraysShallowDiffers(array, other), false) 28 | 29 | other.push('baz') 30 | assert.equal(isArraysShallowDiffers(array, other), true) 31 | 32 | other = array 33 | assert.equal(isArraysShallowDiffers(array, other), false) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /webpack.dist.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var webpack = require('webpack'); 3 | var path = require('path'); 4 | var library = 'TcombForm'; 5 | 6 | module.exports = [ 7 | { 8 | devtool: 'source-map', 9 | entry: './lib/main.js', 10 | output: { 11 | path: path.resolve(__dirname, './dist'), 12 | filename: 'tcomb-form.js', 13 | library: library, 14 | libraryTarget: 'umd' 15 | }, 16 | externals: { 17 | 'react': 'React' 18 | }, 19 | module: { 20 | loaders: [ 21 | { 22 | test: /\.js?$/, 23 | loader: 'babel', 24 | query: { 25 | stage: 0, 26 | loose: true 27 | }, 28 | exclude: [/node_modules/] 29 | } 30 | ] 31 | }, 32 | plugins: [ 33 | new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify('development') }) 34 | ] 35 | }, 36 | { 37 | devtool: 'source-map', 38 | entry: './lib/main.js', 39 | output: { 40 | path: path.resolve(__dirname, './dist'), 41 | filename: 'tcomb-form.min.js', 42 | library: library, 43 | libraryTarget: 'umd' 44 | }, 45 | externals: { 46 | 'react': 'React' 47 | }, 48 | module: { 49 | loaders: [ 50 | { 51 | test: /\.js?$/, 52 | loader: 'babel', 53 | query: { 54 | stage: 0, 55 | loose: true 56 | }, 57 | exclude: [/node_modules/] 58 | } 59 | ] 60 | }, 61 | plugins: [ 62 | new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify('production') }), 63 | new webpack.optimize.UglifyJsPlugin({ compress: { warnings: false } }) 64 | ] 65 | } 66 | ]; 67 | --------------------------------------------------------------------------------