diff --git a/.gitignore b/.gitignore index 7018fcb..614604d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules *.log .DS_Store npm-debug.log -examples-bundle.js \ No newline at end of file +examples-bundle.js +.metals diff --git a/.storybook/config.js b/.storybook/config.js new file mode 100644 index 0000000..848c501 --- /dev/null +++ b/.storybook/config.js @@ -0,0 +1,3 @@ +import { configure } from "@storybook/react"; + +configure(require.context("../src", true, /\.stories\.tsx$/), module); diff --git a/.storybook/esnet.js b/.storybook/esnet.js new file mode 100644 index 0000000..21887f1 --- /dev/null +++ b/.storybook/esnet.js @@ -0,0 +1,18 @@ +import { create } from "@storybook/theming/create"; + +export default create({ + base: "light", + + colorPrimary: "gray", + colorSecondary: "#4ec1e0", + + // UI + appBg: "#fdfdfd", + appContentBg: "#ffffff", + appBorderColor: "grey", + appBorderRadius: 4, + + brandTitle: "react-dynamic-forms", + brandUrl: "https://software.es.net", + brandImage: "http://software.es.net/react-dynamic-forms/static/media/forms.aec5c7b8.png" +}); diff --git a/docs/static/media/forms.aec5c7b8.png b/.storybook/logo.png similarity index 100% rename from docs/static/media/forms.aec5c7b8.png rename to .storybook/logo.png diff --git a/.storybook/main.js b/.storybook/main.js new file mode 100644 index 0000000..760d428 --- /dev/null +++ b/.storybook/main.js @@ -0,0 +1,4 @@ +module.exports = { + stories: ["../src/**/*.stories.(js|mdx)"], + addons: ["@storybook/addon-docs"] +}; diff --git a/.storybook/manager.js b/.storybook/manager.js new file mode 100644 index 0000000..6fb4af7 --- /dev/null +++ b/.storybook/manager.js @@ -0,0 +1,6 @@ +import { addons } from "@storybook/addons"; +import theme from "./esnet"; + +addons.setConfig({ + theme +}); diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 0000000..442cd45 --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,6 @@ + diff --git a/.storybook/webpack.config.js b/.storybook/webpack.config.js new file mode 100644 index 0000000..d0f1f2d --- /dev/null +++ b/.storybook/webpack.config.js @@ -0,0 +1,19 @@ +const path = require("path"); +const SRC_PATH = path.join(__dirname, "../src"); + +module.exports = ({ config }) => { + config.module.rules.push({ + test: /\.(ts|tsx)$/, + include: [SRC_PATH], + use: [ + { + loader: require.resolve("awesome-typescript-loader"), + options: { + configFileName: "./tsconfig.json" + } + } + ] + }); + config.resolve.extensions.push(".ts", ".tsx"); + return config; +}; diff --git a/.travis.yml b/.travis.yml index 66bf72e..36599a2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,27 @@ sudo: false + language: node_js -node_js: - - "0.12" + +node_js: 10 + notifications: - email: false + email: false + +cache: yarn + +install: + - yarn + +jobs: + include: + - stage: Chromatic + if: (branch != master) AND NOT type = pull_request + install: yarn --frozen-lockfile + script: + - yarn chromatic + + - stage: Chromatic Master + if: (branch = master) AND NOT type = pull_request AND NOT env(RELEASE) + install: yarn --frozen-lockfile + script: + - yarn chromatic --auto-accept-changes diff --git a/LICENSE b/LICENSE deleted file mode 100644 index aa35a34..0000000 --- a/LICENSE +++ /dev/null @@ -1,40 +0,0 @@ -"ESnet React Forms Library, Copyright (c) 2015-2017, The Regents of the -University of California, through Lawrence Berkeley National Laboratory -(subject to receipt of any required approvals from the U.S. Dept. of Energy). -All rights reserved." - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -(1) Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -(2) Redistributions in binary form must reproduce the above copyright notice, -this list of conditions and the following disclaimer in the documentation and/ -or other materials provided with the distribution. - -(3) Neither the name of the University of California, Lawrence Berkeley -National Laboratory, U.S. Dept. of Energy nor the names of its contributors may -be used to endorse or promote products derived from this software without -specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -You are under no obligation whatsoever to provide any bug fixes, patches, or -upgrades to the features, functionality or performance of the source code -("Enhancements") to anyone; however, if you choose to make your Enhancements -available either publicly, or directly to Lawrence Berkeley National -Laboratory, without imposing a separate written license agreement for such -Enhancements, then you hereby grant the following license: a non-exclusive, -royalty-free perpetual license to install, use, modify, prepare derivative -works, incorporate into other computer software, distribute, and sublicense -such enhancements or derivative works thereof, in binary and source code form. \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 513cca1..0000000 --- a/README.md +++ /dev/null @@ -1,187 +0,0 @@ -# React Dynamic Forms - -[![Build Status](https://travis-ci.org/esnet/react-dynamic-forms.svg)](https://travis-ci.org/esnet/react-dynamic-forms) [![npm version](https://badge.fury.io/js/react-dynamic-forms.svg)](https://badge.fury.io/js/react-dynamic-forms) - -**NOTE: v1.0 adds support for React 16.x, but since the way Mixins work in React 15, this is a fairly substantial rewrite to provide an API that doesn't use Mixins at all.** - -This repository contains a set of React based forms components which are used within ESnet -for our network database application (ESDB), but could be used by any React based project -needing to build complex forms. - -Our approach is to treat a form as a controlled input, essentially an input with many inputs -(which may have many inputs, and so on...) You maintain your form's state however you want, -you pass that state down into the form as its `value` prop. If the form is edited, a callback -is called and you can update your form state. When it comes time to save the form, that's up -to you, you always have your form's state. On top of this the form has a schema defining rules. -Therefore, you can also listen to changes in the count of either missing values or errors. -With this information it is simple to control if the user can submit the form as well. - -The library is built on Immutable.js, so form state should be passed into the form as an Immutable.Map. -This allows efficient operations on your form data, minimizing copying while ensuring safety as the -form state is mutated. - -While part of defining a form is to specify a schema for your form, you still maintain complete -control over the layout in the form in your `render()` method, just like any other react app. The -schema and presentation are entirely separate. This React friendly approach makes it easy to build -forms which dynamically change values or structure based on the current state of the form. - -This library contains: - - * Low level forms control wrappers that communicate errors and missing values to parent components and style themselves appropriately for errors and missing value state. You can write your own in the same way. Supplied standard form controls: - - Textedit - - TextArea - - Checkboxes - - RadioButtons - - Chooser (internally we use react-select) - - TagsEdit (again using react-select) - - DateEdit (react-datepicker) - * A component that lets you define the rules for each field. Each field is specified in a component - * A component that acts as a top level controlled input for all of the form state, to assemble controls together and track state change, errors and missing values and enabling dynamic forms with via a declarative schema - * Higher Order Components: - - for grouping of controls with their labels, required state and editing control - - building lists of forms - * Inline editing - * List editing - -The library is build on several other open source libraries, especially: - * react - * immutable.js - * revalidator - * react-bootstrap - * react-select - * react-virtualized - * react-datepicker - -Please browse the examples for a feel for the library, or read on to get started. - -Getting Started ---------------- - -Install the forms library with npm: - - npm install react-dynamic-forms --save - -Once installed, you can import the necessary components from the library: - - import {Form, Schema, Field, TextEdit, Chooser} from "react-dynamic-forms"; - - -Anatomy of a form ------------------ - -A form will contain: - - 1. Your form's state - 2. A schema describing the form's fields - 3. Implementation of render() that specified controls for each form field - 4. Handling of form changes, missing values and errors - 5. Submit logic - -Form State ----------- - -As the creator of the form, you bring the form's state to the table, either in the form of an initial value or previous state you're loaded up to be edited. The form state will be passed into the form via the `value` prop, and should be an Immutable.Map. In the examples, we just keep this on this.state, but a flux store or redux would be other options. - - const ContactForm = React.createClass({ - ... - getInitialState() { - return { - value: Immutable.fromJS({ - first_name: "Bill", - last_name: "Jones", - email: "bill@mail.com", - }), - }; - }, - ... - }); - -Schema ------- - -A schema is specified using JSX to define the rules and meta data for each form field. As an example, here is a form that will take the first name, last name and email of a contact. The name here is the key for each value, so there would be corresponding keys in the form state (see initialValue above) and in the render of the form controls (see below). We can define also that the email should be of format `email` and that the first_name and last_name fields are `required`: - - const schema = ( - - - - - - ); - -In ESDB, we actually derive the schema from information we get from our server. - -Implementation of render() --------------------------- - -We've found from experience that we want a separation between schema and presentation, so instead we lay out the form out in the form component's `render()` function, just like any other React component, but in a way that we refer to our schema attributes using an `field` prop on each control. - - - render() { - ... - return ( -
- this.setState({ value })} - onMissingCountChange={(fieldName, missing) => - this.setState({ hasMissing: missing > 0 })} - onErrorCountChange={(fieldName, errors) => - this.setState({ hasErrors: errors > 0 })} - > - - - -
- - - */ - - let formClass = this.props.formClassName; - if (this.props.inline) { - formClass += "form-inline"; - } - - if (this.props.edit === FormEditStates.TABLE) { - return ( - - {this.renderChildren(formState, this.props.children)} - - ); - } else { - if (inner) { - return ( -
{ - this.handleSubmit(e); - }} - noValidate - > - {this.renderChildren(formState, this.props.children)} -
- ); - } else { - return ( -
- {this.renderChildren(formState, this.props.children)} -
- ); - } - } - } -} - -Form.propTypes = { - value: PropTypes.object -}; - -Form.defaultProps = { - formStyle: {}, - formClass: "form-horizontal", - formKey: "form", - groupLayout: FormGroupLayout.ROW -}; \ No newline at end of file diff --git a/packages/react-dynamic-forms/src/components/List.js b/packages/react-dynamic-forms/src/components/List.js deleted file mode 100644 index a66defe..0000000 --- a/packages/react-dynamic-forms/src/components/List.js +++ /dev/null @@ -1,245 +0,0 @@ -/** - * Copyright (c) 2015 - present, The Regents of the University of California, - * through Lawrence Berkeley National Laboratory (subject to receipt - * of any required approvals from the U.S. Dept. of Energy). - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -import React from "react"; -import _ from "underscore"; -import Flexbox from "flexbox-react"; -import ReactCSSTransitionGroup from "react-transition-group/CSSTransitionGroup"; - -import "../css/list.css"; -import "../css/icon.css"; - -/** - * Editing of a list of widgets. This widgets themselves are passed in as 'items'. - * - * A ListEditView is created within the ListEditorMixin, so you do not generally need - * to use this component directly. - * - * The user of this component should supply event handlers to manage the list - * when items are added or removed: - * * `onAddItem()` - * * `onRemoveItem()` - * - * Each item passed in should have an id set (item.props.id). This is used to - * uniquely identify each row so that removing a row happens correctly. - * - * Finally - * * `canAddItems()` - lets you hide the [+] icon for instance if there's no - * possible items that can be added from a list). - */ -export default class List extends React.Component { - addItem() { - if (this.props.onAddItem) { - this.props.onAddItem(); - } - } - - removeItem(index) { - if (this.props.onRemoveItem) { - this.props.onRemoveItem(index); - } - } - - selectItem(index) { - if (this.props.onSelectItem) { - this.props.onSelectItem(index); - } - } - - handleDeselect() { - this.selectItem(null); - } - - render() { - const addPlus = this.props.canAddItems; - const addMinus = this.props.canRemoveItems; - const addEdit = this.props.canEditItems; - - // Plus [+] icon - let plus; - if (addPlus) { - plus = ( - this.addItem()} - /> - ); - } else { - plus =
; - } - - // Build the item list, which is a list of table rows, each row containing - // an item and a [-] icon used for removing that item. - let itemList = _.map(this.props.items, (item, index) => { - const minusActionKey = `minus-action-${item.key}`; - const itemKey = `item-${item.key}`; - const itemSpanKey = `item-span-${item.key}`; - const actionSpanKey = `action-span-${item.key}`; - const itemMinusHide = item.props.hideMinus ? item.props.hideMinus : false; - - let listEditItemClass = "esnet-forms-listeditview-edit-item"; - - const isBeingEdited = item.props.edit === true; - - // Item remove [-] icon - let minus; - let edit; - - let isEditable; - if (this.props.hideEditRemove) { - isEditable = this.props.hideEditRemove && index === this.props.items.length - 1; - } else { - isEditable = true; - } - if (isEditable) { - if (addMinus && !itemMinusHide) { - minus = ( - this.removeItem(index)} - /> - ); - } else { - listEditItemClass += " no-controls"; - minus =
; - } - - const flip = { - transform: "scaleX(-1)", - fontSize: 10 - }; - - // Edit item icon - if (addEdit) { - if (isBeingEdited) { - edit = ( - this.selectItem(index)} - /> - ); - } else { - edit = ( - this.selectItem(index)} - /> - ); - } - } - } - - const minusAction = addMinus ? ( - - - {minus} - - - ) : ( -
- ); - - const editAction = addEdit ? ( - - - {edit} - - - ) : ( -
- ); - - // JSX for each row, includes: UI Item and [x] remove item button - return ( -
  • - - {minusAction} - {editAction} - - - {item} - - - -
  • - ); - }); - - // Build the [+] elements - if (addPlus) { - if (this.props.plusElement) { - plus = this.props.plusElement; - } else { - plus = ( - - - - {plus} - - - - - ); - } - } else { - plus =
    ; - } - - // - // Build the table of item rows, with the [+] at the bottom if required - // - - return ( -
    -
      - - {itemList} - -
    - {plus} -
    - ); - } -} diff --git a/packages/react-dynamic-forms/src/components/RadioButtons.js b/packages/react-dynamic-forms/src/components/RadioButtons.js deleted file mode 100644 index f5d36d6..0000000 --- a/packages/react-dynamic-forms/src/components/RadioButtons.js +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Copyright (c) 2017 - present, The Regents of the University of California, - * through Lawrence Berkeley National Laboratory (subject to receipt - * of any required approvals from the U.S. Dept. of Energy). - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -import React from "react"; -import _ from "underscore"; - -import formGroup from "../js/formGroup"; - -class RadioButtons extends React.Component { - handleChange(v) { - // Callbacks - if (this.props.onChange) { - this.props.onChange(this.props.name, v); - } - if (this.props.onBlur) { - this.props.onBlur(this.props.name); - } - } - - getCurrentChoiceLabel() { - const choiceItem = this.props.optionList.find(item => { - return item.get("id") === this.props.value; - }); - return choiceItem ? choiceItem.get("label") : ""; - } - - inlineStyle(hasError, isMissing) { - let color = "inherited"; - let background = "inherited"; - if (hasError) { - color = "#b94a48"; - background = "#fff0f3"; - } else if (isMissing) { - background = "floralwhite"; - } - return { - color, - background, - width: "100%", - paddingLeft: 3 - }; - } - - render() { - if (this.props.edit) { - const items = this.props.optionList.map((item, i) => { - const id = item.get("id"); - const label = item.get("label"); - return ( -
    - -
    - ); - }); - return ( -
    - {items} -
    - ); - } else { - let text = this.getCurrentChoiceLabel(); - return ( -
    - {text} -
    - ); - } - } -} - -RadioButtons.defaultProps = { - width: 300 -}; - -export default formGroup(RadioButtons); diff --git a/packages/react-dynamic-forms/src/components/TagsEdit.js b/packages/react-dynamic-forms/src/components/TagsEdit.js deleted file mode 100644 index d3d791e..0000000 --- a/packages/react-dynamic-forms/src/components/TagsEdit.js +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Copyright (c) 2015 - present, The Regents of the University of California, - * through Lawrence Berkeley National Laboratory (subject to receipt - * of any required approvals from the U.S. Dept. of Energy). - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -import React from "react"; -import _ from "underscore"; -import { Creatable } from "react-select"; -import Immutable from "immutable"; - -import formGroup from "../js/formGroup"; - -import "react-select/dist/react-select.css"; -import "../css/tagsedit.css"; - -/** - * Form control to select tags from a pull down list. - * You can also add a new tag with the Add tag button. - */ -class TagsEdit extends React.Component { - constructor(props) { - super(props); - this.state = { - touched: false - }; - } - - componentWillReceiveProps(nextProps) { - if (this.props.value !== nextProps.value) { - const missingCount = this.isMissing(nextProps.value) ? 1 : 0; - if (this.props.onMissingCountChange) { - this.props.onMissingCountChange(this.props.name, missingCount); - } - } - } - - handleChange(tags) { - const value = _.map(tags, tag => tag.label); - - let updatedTagList; - _.each(tags, tag => { - if (tag.className === "Select-create-option-placeholder") { - updatedTagList = this.props.tagList.push(tag.label); - } - }); - - if (updatedTagList && this.props.onTagListChange) { - this.props.onTagListChange(this.props.name, updatedTagList); - } - - if (this.props.onChange) { - this.props.onChange(this.props.name, Immutable.fromJS(value)); - } - } - - isEmpty(value) { - if (Immutable.List.isList(value)) { - return value.size === 0; - } - return _.isNull(value) || _.isUndefined(value); - } - - isMissing(value = this.props.value) { - return this.props.required && !this.props.disabled && this.isEmpty(value); - } - - render() { - const isMissing = this.isMissing(this.props.value); - if (this.props.edit) { - const options = []; - const value = []; - - this.props.tagList.forEach((tag, i) => { - if (this.props.value.contains(tag)) { - value.push({ value: i, label: tag }); - } else { - options.push({ value: i, label: tag }); - } - }); - - let className; - if (isMissing) { - className = "missing"; - } - - return ( -
    - this.handleChange(value)} - /> -
    -
    - ); - } else { - const tagStyle = { - cursor: "default", - paddingTop: 2, - paddingBottom: 2, - paddingLeft: 5, - paddingRight: 5, - background: "#ececec", - borderRadius: 2, - marginLeft: 2, - marginRight: 2 - }; - return ( -
    - {this.props.value.map((tag, i) => {tag})} -
    - ); - } - } -} - -export default formGroup(TagsEdit); diff --git a/packages/react-dynamic-forms/src/components/TextArea.js b/packages/react-dynamic-forms/src/components/TextArea.js deleted file mode 100644 index b49c999..0000000 --- a/packages/react-dynamic-forms/src/components/TextArea.js +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Copyright (c) 2015 - present, The Regents of the University of California, - * through Lawrence Berkeley National Laboratory (subject to receipt - * of any required approvals from the U.S. Dept. of Energy). - * All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. - */ - -import React from "react"; -import _ from "underscore"; -import { validate } from "revalidator"; - -import formGroup from "../js/formGroup"; - -import "../css/textarea.css"; - -/** - * Form control to edit a Text Area field - */ -class TextArea extends React.Component { - constructor(props) { - super(props); - this.state = { - touched: false - }; - } - - isEmpty(value) { - return _.isNull(value) || _.isUndefined(value) || value === ""; - } - - isMissing(v) { - return this.props.required && !this.props.disabled && this.isEmpty(v); - } - - getError(value) { - let result = { validationError: false, validationErrorMessage: null }; - - // If the user has a field blank then that is never an error - // Likewise if this item is disabled it can't be called an error - if (this.isEmpty(value) || this.props.disabled) { - return result; - } - - // Validate the value with Revalidator, given the rules in this.props.rules - let obj = {}; - obj[this.props.name] = value; - - let properties = {}; - properties[this.props.name] = this.props.rules; - - const rules = this.props.rules ? { properties } : null; - if (obj && rules) { - const validation = validate(obj, rules, { cast: true }); - const name = this.props.name || "Value"; - - let msg; - if (!validation.valid) { - msg = `${name} ${validation.errors[0].message}`; - result.validationError = true; - result.validationErrorMessage = msg; - } - } - return result; - } - - componentWillReceiveProps(nextProps) { - if (this.props.value !== nextProps.value) { - const missing = this.isMissing(nextProps.value); - const { validationError } = this.getError(nextProps.value); - - // Broadcast error and missing states up to the owner - if (this.props.onErrorCountChange) { - this.props.onErrorCountChange(this.props.name, validationError ? 1 : 0); - } - - if (this.props.onMissingCountChange) { - this.props.onMissingCountChange(this.props.name, missing ? 1 : 0); - } - } - } - - componentDidMount() { - const missing = this.isMissing(this.props.value); - const { validationError } = this.getError(this.props.value); - - // Initial error and missing states are fed up to the owner - if (this.props.onErrorCountChange) { - this.props.onErrorCountChange(this.props.name, validationError ? 1 : 0); - } - - if (this.props.onMissingCountChange) { - this.props.onMissingCountChange(this.props.name, missing ? 1 : 0); - } - } - - onBlur() { - const { value } = this.textInput; - const missing = this.props.required && this.isEmpty(value); - const { validationError } = this.getError(value); - - // Callbacks - if (this.props.onChange) { - this.props.onChange(this.props.name, value); - } - if (this.props.onErrorCountChange) { - this.props.onErrorCountChange(this.props.name, validationError ? 1 : 0); - } - if (this.props.onMissingCountChange) { - this.props.onMissingCountChange(this.props.name, missing ? 1 : 0); - } - - this.setState({ touched: true }); - } - - inlineStyle(hasError, isMissing) { - let color = ""; - let background = ""; - if (hasError) { - color = "#b94a48"; - background = "#fff0f3"; - } else if (isMissing) { - background = "floralwhite"; - } - return { - color, - background, - height: "100%", - width: "100%", - paddingLeft: 3 - }; - } - - render() { - // Control state - const isMissing = this.isMissing(this.props.value); - const { validationError, validationErrorMessage } = this.getError(this.props.value); - - if (this.props.edit) { - // Error style/message - let className = ""; - const msg = validationError ? validationErrorMessage : ""; - let helpClassName = "help-block"; - if (validationError && this.state.touched) { - helpClassName += " has-error"; - className = "has-error"; - } - - // Warning style - const style = isMissing ? { background: "floralwhite" } : {}; - - return ( -
    -