├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── devServer.js ├── dist └── react-jsonschema-form.js.map ├── formbuilder ├── actions │ ├── dragndrop.js │ ├── fieldlist.js │ ├── notifications.js │ └── server.js ├── app.js ├── assets │ └── background.jpg ├── bootswatch.less ├── components │ ├── AdminView.js │ ├── CSVDownloader.js │ ├── Check.js │ ├── FAQ.js │ ├── FormCreated.js │ ├── Header.js │ ├── NotificationList.js │ ├── RecordCreated.js │ ├── URLDisplay.js │ ├── UserForm.js │ ├── Welcome.js │ ├── XLSDownloader.js │ └── builder │ │ ├── DescriptionField.js │ │ ├── EditableField.js │ │ ├── FieldListDropdown.js │ │ ├── Form.js │ │ ├── FormActions.js │ │ ├── JsonSchemaDownloader.js │ │ ├── JsonView.js │ │ └── TitleField.js ├── config.js ├── containers │ ├── AdminViewContainer.js │ ├── App.js │ ├── FormCreatedContainer.js │ ├── NotificationContainer.js │ ├── RecordCreatedContainer.js │ ├── UserFormContainer.js │ ├── WelcomeContainer.js │ └── builder │ │ ├── FormActionsContainer.js │ │ ├── FormContainer.js │ │ ├── FormEdit.js │ │ ├── FormEditContainer.js │ │ ├── JsonSchemaDownloaderContainer.js │ │ └── JsonViewContainer.js ├── index.html ├── index.prod.html ├── reducers │ ├── dragndrop.js │ ├── form.js │ ├── index.js │ ├── notifications.js │ ├── records.js │ └── serverStatus.js ├── routes.js ├── store │ └── configureStore.js ├── styles.css └── utils.js ├── package.json ├── scalingo.json ├── test ├── .eslintrc ├── actions │ └── notifications_test.js ├── components │ ├── AdminView_test.js │ ├── EditableField_test.js │ └── UserForm_test.js ├── reducers │ └── form_test.js ├── setup-jsdom.js └── test-utils.js ├── webpack.config.dev.js ├── webpack.config.github.js └── webpack.config.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0 3 | } 4 | 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "rules": { 4 | "react/jsx-uses-react": 2, 5 | "react/jsx-uses-vars": 2, 6 | "react/react-in-jsx-scope": 2, 7 | 8 | "curly": [2], 9 | "indent": [2, 2], 10 | "quotes": [2, "double"], 11 | "linebreak-style": [2, "unix"], 12 | "semi": [2, "always"], 13 | "comma-dangle": [0], 14 | "no-unused-vars": [2, {"vars": "all", "args": "none"}], 15 | "no-console": [0] 16 | }, 17 | "env": { 18 | "es6": true, 19 | "browser": true, 20 | "node": true 21 | }, 22 | "extends": "eslint:recommended", 23 | "ecmaFeatures": { 24 | "modules": true, 25 | "jsx": true, 26 | "experimentalObjectRestSpread": true 27 | }, 28 | "plugins": [ 29 | "react" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules 3 | build 4 | lib 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: 3 | - node_js 4 | node_js: 5 | - "4" 6 | env: 7 | - ACTION=test 8 | - ACTION="run lint" 9 | script: 10 | - npm $ACTION 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Formbuilder 2 | =========== 3 | 4 | [![Build Status](https://travis-ci.org/Kinto/formbuilder.svg?branch=master)](https://travis-ci.org/Kinto/formbuilder) 5 | 6 | If you want to try it out, have a look [at the demo 7 | page](https://kinto.github.io/formbuilder/) 8 | 9 | Or deploy it on Scalingo in a single click on this button: 10 | 11 | [![Deploy to Scalingo](https://cdn.scalingo.com/deploy/button.svg)](https://my.scalingo.com/deploy) 12 | 13 | Scalingo offer a 1 month free trial then 7.20€ / month. 14 | 15 | # Installation 16 | 17 | To run the formbuilder locally, you can issue the following commands: 18 | 19 | ``` 20 | $ git clone https://github.com/Kinto/formbuilder.git 21 | $ cd formbuilder 22 | $ npm install 23 | $ npm run start 24 | ``` 25 | 26 | You also need to have a [Kinto](https://kinto.readthedocs.io) server **greater 27 | than 4.3.1** in order to store your data and **less than 11.0.0** for an out-of-the-box 28 | experience (when basicauth was removed by default). The latest version of Kinto 29 | at the time was **9.2.3**. If you don't already have Kinto, follow 30 | [the installation instructions](http://kinto.readthedocs.io/en/stable/tutorials/install.html). 31 | Also, see the [changelog](https://github.com/Kinto/kinto/blob/master/CHANGELOG.rst#1100-2018-10-09). 32 | 33 | 34 | # Configuration 35 | 36 | It's possible to configure a few things, using environment variables: 37 | 38 | - `PROJECT_NAME` is the name of the project. Defaults to "formbuilder". 39 | - `SERVER_URL` is the URL of the kinto server. It's default value depends on 40 | the environment that's being used (development, production, etc.) 41 | 42 | # How can I get my data back? 43 | 44 | All the data generated by the formbuilder is stored in a 45 | [Kinto](https://kinto.readthedocs.io) instance. As such, you can request the 46 | data stored in there. 47 | 48 | When you generate a form, you're actually generating two *tokens*: 49 | - the *adminToken*, that you need to keep secret, giving you access to all the 50 | submitted data; 51 | - the *userToken*, that's used by users to find back the proper form. 52 | 53 | One interesting property of the *userToken* is that it is actually half of the 54 | admin token ! 55 | 56 | With that in mind, let's say we've generated a form with an *adminToken* of `152e3b0af1e14cb186894980ecac95de`. The *userToken* is then `152e3b0af1e14cb1`. 57 | 58 | So if we want to have access to the data on the server, using curl, we need to authenticate as the admin (using [BasicAuth](https://en.wikipedia.org/wiki/Basic_access_authentication) with `form:{adminToken}`): 59 | 60 | ``` 61 | $ SERVER_URL="http://localhost:8888/v1" 62 | $ ADMIN_TOKEN="152e3b0af1e14cb186894980ecac95de" 63 | $ FORM_ID="152e3b0af1e14cb1" 64 | $ curl $SERVER_URL/buckets/formbuilder/collections/$FORM_ID/records \ 65 | -u form:$ADMIN_TOKEN | python -m json.tool 66 | { 67 | "data": [ 68 | { 69 | "how_are_you_feeling_today": "I don't know", 70 | "id": "7785a0bb-cf75-4da4-a757-faefb30e47ae", 71 | "last_modified": 1464788211487, 72 | "name": "Clark Kent" 73 | }, 74 | { 75 | "how_are_you_feeling_today": "Quite bad", 76 | "id": "23b00a31-6acc-4ad2-894c-e208fb9d38bc", 77 | "last_modified": 1464788201181, 78 | "name": "Garfield" 79 | }, 80 | { 81 | "how_are_you_feeling_today": "Happy", 82 | "id": "aedfb695-b22c-433d-a104-60a0cee8cb55", 83 | "last_modified": 1464788192427, 84 | "name": "Lucky Luke" 85 | } 86 | ] 87 | } 88 | ``` 89 | 90 | # Architecture of the project 91 | 92 | The formbuilder is based on top of React and the [react-jsonschema-form (rjsf)](https://github.com/mozilla-services/react-jsonschema-form) 93 | library. 94 | 95 | It is also using [redux](https://github.com/reactjs/react-redux) to handle 96 | the state and dispatch actions. If you're not familiar with it, don't worry, 97 | here is an explanation of how it works. 98 | 99 | ## A quick theory tour 100 | 101 | With react and redux, components are rendering themselves depending of some *state*. The state is passed to a component and becomes a set of `props`. 102 | 103 | States aren't all stored at the same place and are grouped in so called *stores* (which are containers for the different states). 104 | 105 | Now, if one needs to update the props of a component, it will be done via an *action*. 106 | 107 | An *action* will eventually trigger some changes to the state, by the way of *reducers*: they are simple functions which take a original state, an action and return a new version of the state. 108 | 109 | Then, the state will be given to the components which will re-render with the new values. As all components don't want to have access to all stores, the mapping is defined in a "container". 110 | 111 | Yeah, I know, it's a bunch of concepts to understand. Here is a short recap: 112 | 113 | state 114 | : The state of the application. It's stored in different stores. 115 | 116 | actions 117 | : triggered from the components, they usually do something and then are relayed 118 | to reducers in order to update the state. 119 | 120 | reducers 121 | : Make the state of a store evolve depending a specified action 122 | 123 | containers 124 | : Containers define which stores and actions should be available from a react 125 | component. 126 | 127 | ## How are components organised ? 128 | 129 | At the very root, there is an "App" component, which either: 130 | - renders a "mainComponent" 131 | - renders a sidebar (customisable), the notifications and a content (customisable) 132 | 133 | It is useful to have such a root component for the styling of the application: 134 | header is always in place and the menu always remains at the same location as 135 | well, if there is a need for such a menu. 136 | 137 | There are multiple "screens" that the user navigates into, which are summarized 138 | here. 139 | 140 | This might not be completely up to date, but is here so you can grasp easily how things are put together in the project. 141 | 142 | ### Welcome screen 143 | 144 | There is a `Welcome` component which is rendered as a mainComponent. 145 | 146 | ``` 147 | 148 | 149 | 150 | ``` 151 | 152 | ### Form edition 153 | 154 | This is the "builder" part of the formbuilder. 155 | 156 | On the left side, you have a FieldList component and on the 157 | right side you have the components. It's possible to 158 | dragndrop from the fieldlist to the form. 159 | 160 | Each time a field is added to the form, the `insertfield` action is called. 161 | 162 | ``` 163 | 164 |
165 | 166 | 167 | 168 | 169 | ... 170 | 171 |
172 | 173 | or 174 | 175 | 176 | 177 | 178 | ``` 179 | 180 | The form is actually rendered by the rjsf library using a custom SchemaField, defined in `components/builder/EditableField.js`. 181 | 182 | ### Form created confirmation 183 | 184 | Once the form is created, the user is shown the FormCreated view, with a checkbox on the left side. This has two links to the AdminView and the UserForm views. 185 | 186 | ``` 187 | 188 |
189 | 190 | 191 | 192 | 193 | ``` 194 | 195 | ### Form administration 196 | 197 | The form admin is where the answers to the forms can be viewed. It should be viewable only by the people with the administration 198 | link. 199 | 200 | ``` 201 | 202 | 203 | 204 | 205 | 206 | ``` 207 | 208 | ### Form user 209 | 210 | The UserForm is what the people that will fill the form will see. 211 | 212 | ``` 213 | 214 | 215 | 216 | ``` 217 | -------------------------------------------------------------------------------- /devServer.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | var express = require("express"); 3 | var webpack = require("webpack"); 4 | 5 | var port = process.env.PORT || 8080; 6 | var host = process.env.HOST || "localhost"; 7 | 8 | var env = "dev"; 9 | 10 | var webpackConfig = require("./webpack.config." + env); 11 | var compiler = webpack(webpackConfig); 12 | var app = express(); 13 | 14 | app.use(require("webpack-dev-middleware")(compiler, { 15 | publicPath: webpackConfig.output.publicPath, 16 | noInfo: true 17 | })); 18 | 19 | app.use(require("webpack-hot-middleware")(compiler)); 20 | 21 | app.get("/", function(req, res) { 22 | res.sendFile(path.join(__dirname, "formbuilder", "index.html")); 23 | }); 24 | 25 | app.get("/react-jsonschema-form.css", function(req, res) { 26 | res.sendFile(path.join(__dirname, "css", "react-jsonschema-form.css")); 27 | }); 28 | 29 | app.listen(port, host, function(err) { 30 | if (err) { 31 | console.log(err); 32 | return; 33 | } 34 | 35 | console.log("Listening at http://" + host + ":" + port); 36 | }); 37 | -------------------------------------------------------------------------------- /formbuilder/actions/dragndrop.js: -------------------------------------------------------------------------------- 1 | export const SET_DRAG_STATUS = "SET_DRAG_STATUS"; 2 | 3 | 4 | export function setDragStatus(status) { 5 | return {type: SET_DRAG_STATUS, status}; 6 | } 7 | -------------------------------------------------------------------------------- /formbuilder/actions/fieldlist.js: -------------------------------------------------------------------------------- 1 | export const FIELD_ADD = "FIELD_ADD"; 2 | export const FIELD_SWITCH = "FIELD_SWITCH"; 3 | export const FIELD_REMOVE = "FIELD_REMOVE"; 4 | export const FIELD_UPDATE = "FIELD_UPDATE"; 5 | export const FIELD_INSERT = "FIELD_INSERT"; 6 | export const FIELD_SWAP = "FIELD_SWAP"; 7 | export const FORM_RESET = "FORM_RESET"; 8 | export const FORM_UPDATE_TITLE = "FORM_UPDATE_TITLE"; 9 | export const FORM_UPDATE_DESCRIPTION = "FORM_UPDATE_DESCRIPTION"; 10 | 11 | 12 | export function addField(field) { 13 | return {type: FIELD_ADD, field}; 14 | } 15 | 16 | export function switchField(property, newField) { 17 | return {type: FIELD_SWITCH, property, newField}; 18 | } 19 | 20 | export function insertField(field, before) { 21 | return {type: FIELD_INSERT, field, before}; 22 | } 23 | 24 | export function removeField(name) { 25 | return {type: FIELD_REMOVE, name}; 26 | } 27 | 28 | export function updateField(name, schema, required, newName) { 29 | return {type: FIELD_UPDATE, name, schema, required, newName}; 30 | } 31 | 32 | export function swapFields(source, target) { 33 | return {type: FIELD_SWAP, source, target}; 34 | } 35 | 36 | export function updateFormTitle(title) { 37 | return {type: FORM_UPDATE_TITLE, title}; 38 | } 39 | 40 | export function updateFormDescription(description) { 41 | return {type: FORM_UPDATE_DESCRIPTION, description}; 42 | } 43 | 44 | export function resetForm(callback) { 45 | return (dispatch, getState) => { 46 | dispatch({type: FORM_RESET}); 47 | if (callback) { 48 | callback(); 49 | } 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /formbuilder/actions/notifications.js: -------------------------------------------------------------------------------- 1 | import uuid from "uuid"; 2 | 3 | export const NOTIFICATION_ADD = "NOTIFICATION_ADD"; 4 | export const NOTIFICATION_REMOVE = "NOTIFICATION_REMOVE"; 5 | 6 | 7 | export function addNotification(message, options) { 8 | const defaultOptions = { 9 | type: "info", 10 | autoDismiss: null, 11 | dismissAfter: 5000 12 | }; 13 | let {type, autoDismiss, dismissAfter} = { 14 | ...defaultOptions, 15 | ...options 16 | }; 17 | if (autoDismiss == null) { 18 | autoDismiss = type !== "error"; 19 | } 20 | return (dispatch) => { 21 | const id = uuid.v4(); 22 | dispatch({type: NOTIFICATION_ADD, notification: {id, message, type}}); 23 | if (autoDismiss === true) { 24 | setTimeout(() => { 25 | dispatch({type: NOTIFICATION_REMOVE, id}); 26 | }, dismissAfter); 27 | } 28 | }; 29 | } 30 | 31 | export function removeNotification(id) { 32 | return {type: NOTIFICATION_REMOVE, id}; 33 | } 34 | -------------------------------------------------------------------------------- /formbuilder/actions/server.js: -------------------------------------------------------------------------------- 1 | import KintoClient from "kinto-http"; 2 | import btoa from "btoa"; 3 | import uuid from "uuid"; 4 | 5 | import {addNotification} from "./notifications"; 6 | import {getFormID} from "../utils"; 7 | import config from "../config"; 8 | 9 | 10 | export const FORM_PUBLISH = "FORM_PUBLISH"; 11 | 12 | export const FORM_PUBLICATION_PENDING = "FORM_PUBLICATION_PENDING"; 13 | export const FORM_PUBLICATION_DONE = "FORM_PUBLICATION_DONE"; 14 | export const FORM_PUBLICATION_FAILED = "FORM_PUBLICATION_FAILED"; 15 | 16 | export const FORM_RECORD_CREATION_PENDING = "FORM_RECORD_CREATION_PENDING"; 17 | export const FORM_RECORD_CREATION_DONE = "FORM_RECORD_CREATION_DONE"; 18 | 19 | export const SCHEMA_RETRIEVAL_PENDING = "SCHEMA_RETRIEVAL_PENDING"; 20 | export const SCHEMA_RETRIEVAL_DONE = "SCHEMA_RETRIEVAL_DONE"; 21 | 22 | export const RECORDS_RETRIEVAL_PENDING = "RECORDS_RETRIEVAL_PENDING"; 23 | export const RECORDS_RETRIEVAL_DONE = "RECORDS_RETRIEVAL_DONE"; 24 | 25 | const CONNECTIVITY_ISSUES = "This is usually due to an unresponsive server or some connectivity issues."; 26 | 27 | function connectivityIssues(dispatch, message) { 28 | const msg = message + " " + CONNECTIVITY_ISSUES; 29 | dispatch(addNotification(msg, {type: "error"})); 30 | } 31 | 32 | /** 33 | * Return HTTP authentication headers from a given token. 34 | **/ 35 | function getAuthenticationHeaders(token) { 36 | return {Authorization: "Basic " + btoa(`form:${token}`)}; 37 | } 38 | 39 | /** 40 | * Initializes the bucket used to store all the forms and answers. 41 | * 42 | * - All authenticated users can create new collections 43 | * - The credentials used to create this bucket aren't useful anymore after 44 | * this function as the user is removed from the permissions. 45 | **/ 46 | function initializeBucket() { 47 | const api = new KintoClient( 48 | config.server.remote, 49 | {headers: getAuthenticationHeaders(uuid.v4())} 50 | ); 51 | return api.createBucket(config.server.bucket, { 52 | safe: true, 53 | permissions: { 54 | "collection:create": ["system.Authenticated",] 55 | } 56 | }).then(() => { 57 | api.bucket(config.server.bucket).setPermissions({ 58 | "write": [] 59 | }, 60 | {patch: true}); // Do a PATCH request to prevent everyone to be an admin. 61 | }) 62 | .catch(() => { 63 | console.debug("Skipping bucket creation, it probably already exist."); 64 | }); 65 | } 66 | 67 | /** 68 | * Publishes a new form and give the credentials to the callback function 69 | * when it's done. 70 | * 71 | * In case a 403 is retrieved, initialisation of the bucket is triggered. 72 | **/ 73 | export function publishForm(callback) { 74 | const thunk = (dispatch, getState, retry = true) => { 75 | 76 | const form = getState().form; 77 | const schema = form.schema; 78 | const uiSchema = form.uiSchema; 79 | 80 | // Remove the "required" property if it's empty. 81 | if (schema.required && schema.required.length === 0) { 82 | delete schema.required; 83 | } 84 | 85 | dispatch({type: FORM_PUBLICATION_PENDING}); 86 | const adminToken = uuid.v4().replace(/-/g, ""); 87 | const formID = getFormID(adminToken); 88 | 89 | // Create a client authenticated as the admin. 90 | const bucket = new KintoClient( 91 | config.server.remote, 92 | {headers: getAuthenticationHeaders(adminToken)} 93 | ).bucket(config.server.bucket); 94 | 95 | // The name of the collection is the user token so the user deals with 96 | // less different concepts. 97 | bucket.createCollection(formID, { 98 | data: {schema, uiSchema}, 99 | permissions: { 100 | "record:create": ["system.Authenticated"] 101 | } 102 | }) 103 | .then(({data}) => { 104 | dispatch({ 105 | type: FORM_PUBLICATION_DONE, 106 | collection: data.id, 107 | }); 108 | if (callback) { 109 | callback({ 110 | collection: data.id, 111 | adminToken, 112 | }); 113 | } 114 | }) 115 | .catch((error) => { 116 | if (error.response === undefined) { 117 | throw error; 118 | } 119 | // If the bucket doesn't exist, try to create it. 120 | if (error.response.status === 403 && retry === true) { 121 | return initializeBucket().then(() => { 122 | thunk(dispatch, getState, false); 123 | }); 124 | } 125 | connectivityIssues(dispatch, "We were unable to publish your form."); 126 | dispatch({type: FORM_PUBLICATION_FAILED}); 127 | }); 128 | }; 129 | return thunk; 130 | } 131 | 132 | /** 133 | * Submit a new form answer. 134 | * New credentials are created for each answer. 135 | **/ 136 | export function submitRecord(record, collection, callback) { 137 | return (dispatch, getState) => { 138 | dispatch({type: FORM_RECORD_CREATION_PENDING}); 139 | 140 | // Submit all form answers under a different users. 141 | // Later-on, we could persist these userid to let users change their 142 | // answers (but we're not quite there yet). 143 | new KintoClient(config.server.remote, { 144 | headers: getAuthenticationHeaders(uuid.v4()) 145 | }) 146 | .bucket(config.server.bucket) 147 | .collection(collection) 148 | .createRecord(record).then(({data}) => { 149 | dispatch({type: FORM_RECORD_CREATION_DONE}); 150 | if (callback) { 151 | callback(); 152 | } 153 | }) 154 | .catch((error) => { 155 | connectivityIssues(dispatch, "We were unable to publish your answers"); 156 | }); 157 | }; 158 | } 159 | 160 | export function loadSchema(formID, callback, adminId) { 161 | return (dispatch, getState) => { 162 | dispatch({type: SCHEMA_RETRIEVAL_PENDING}); 163 | new KintoClient(config.server.remote, { 164 | headers: getAuthenticationHeaders(adminId ? adminId : "EVERYONE") 165 | }) 166 | .bucket(config.server.bucket) 167 | .collection(formID) 168 | .getData().then((data) => { 169 | dispatch({ 170 | type: SCHEMA_RETRIEVAL_DONE, 171 | data, 172 | }); 173 | if (callback) { 174 | callback(data); 175 | } 176 | }) 177 | .catch((error) => { 178 | connectivityIssues(dispatch, "We were unable to load your form"); 179 | }); 180 | }; 181 | } 182 | 183 | /** 184 | * Retrieve all the answers to a specific form. 185 | * 186 | * The formID is derived from the the adminToken. 187 | **/ 188 | export function getRecords(adminToken, callback) { 189 | return (dispatch, getState) => { 190 | const formID = getFormID(adminToken); 191 | dispatch({type: RECORDS_RETRIEVAL_PENDING}); 192 | new KintoClient(config.server.remote, { 193 | headers: getAuthenticationHeaders(adminToken) 194 | }) 195 | .bucket(config.server.bucket) 196 | .collection(formID) 197 | .listRecords().then(({data}) => { 198 | dispatch({ 199 | type: RECORDS_RETRIEVAL_DONE, 200 | records: data 201 | }); 202 | if (callback) { 203 | callback(data); 204 | } 205 | }) 206 | .catch((error) => { 207 | connectivityIssues( 208 | dispatch, 209 | "We were unable to retrieve the list of records for your form." 210 | ); 211 | }); 212 | }; 213 | } 214 | -------------------------------------------------------------------------------- /formbuilder/app.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | import { Provider } from "react-redux"; 4 | import { Router } from "react-router"; 5 | const createHashHistory = require("history/lib/createHashHistory"); 6 | 7 | import routes from "./routes"; 8 | import configureStore from "./store/configureStore"; 9 | import "./bootswatch.less"; 10 | import "./styles.css"; 11 | 12 | const store = configureStore({ 13 | notifications: [], 14 | }); 15 | const history = createHashHistory({queryKey: false}); 16 | 17 | render(( 18 | 19 | 20 | {routes} 21 | 22 | 23 | ), document.getElementById("app")); 24 | -------------------------------------------------------------------------------- /formbuilder/assets/background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kinto/formbuilder/d85d12226ac7ac4af6459ab59147cafdf21f3687/formbuilder/assets/background.jpg -------------------------------------------------------------------------------- /formbuilder/bootswatch.less: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/less/bootstrap.less"; 2 | @import "~bootswatch/paper/variables.less"; 3 | @import "~bootswatch/paper/bootswatch.less"; 4 | -------------------------------------------------------------------------------- /formbuilder/components/AdminView.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import CSVDownloader from "./CSVDownloader"; 3 | import XLSDownloader from "./XLSDownloader"; 4 | import URLDisplay from "./URLDisplay"; 5 | import {getFormID, getFormURL} from "../utils"; 6 | 7 | import {DropdownButton, MenuItem} from "react-bootstrap"; 8 | 9 | export default class AdminView extends Component { 10 | componentDidMount() { 11 | const adminToken = this.props.params.adminToken; 12 | this.formID = getFormID(adminToken); 13 | this.props.getRecords(adminToken); 14 | this.props.loadSchema(this.formID); 15 | } 16 | render() { 17 | const properties = this.props.schema.properties; 18 | const title = this.props.schema.title; 19 | const ready = Object.keys(properties).length !== 0; 20 | const schemaFields = this.props.uiSchema["ui:order"]; 21 | const formUrl = getFormURL(this.formID); 22 | 23 | let content = "loading"; 24 | if (ready) { 25 | content = ( 26 |
27 |

Results for {title}

28 | 29 |
  • 30 | 34 |
  • 35 |
  • 36 | 40 |
  • 41 |
    42 | 43 | 44 | 45 | { 46 | schemaFields.map((key) => { 47 | return ; 48 | }) 49 | } 50 | 51 | 52 | {this.props.records.map((record, idx) => { 53 | return ({ 54 | schemaFields.map((key) => { 55 | return ; 56 | } 57 | )} 58 | ); 59 | })} 60 | 61 |
    {properties[key].title}
    {String(record[key])}
    62 |
    ); 63 | } 64 | return
    {content}
    ; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /formbuilder/components/CSVDownloader.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import json2csv from "json2csv"; 3 | import btoa from "btoa"; 4 | 5 | export default function CSVDownloader(props) { 6 | const filename = props.schema.title + ".csv"; 7 | const fields = props.fields; 8 | const fieldNames = props.fields.map((key) => { 9 | return props.schema.properties[key].title; 10 | }); 11 | var csv = json2csv({ data: props.records, fields: fields, fieldNames: fieldNames}); 12 | const fileContent = "data:text/plain;base64," + btoa(csv); 13 | 14 | return CSV; 16 | } 17 | -------------------------------------------------------------------------------- /formbuilder/components/Check.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Check(props) { 4 | return (
    ); 5 | } 6 | -------------------------------------------------------------------------------- /formbuilder/components/FAQ.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import config from "../config"; 3 | 4 | export default function FAQ(props) { 5 | 6 | return ( 7 |
    8 |

    Where is the data stored?

    9 |

    We believe to the separation of application logic and data. 10 | In the future, we plan to let the form creator chose the location for the data, 11 | but for now {config.projectName} stores the data in a Kinto instance it hosts. 12 |

    13 | 14 |

    I found a bug, where can I report it?

    15 |

    We are currently using Github to manage issues. 16 | If you want, you can open an issue on our bugtracker. 17 | You can also send us an email or come talk with us on our IRC channel #kinto on irc.freenode.net — 18 | (Click here to access the web client).

    19 |
    20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /formbuilder/components/FormCreated.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import ClipboardButton from "react-clipboard.js"; 3 | import {getFormID, getFormURL, getFormEditURL, getAdminURL} from "../utils"; 4 | import URLDisplay from "./URLDisplay"; 5 | 6 | export default class FormCreated extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | copied: false 11 | }; 12 | } 13 | 14 | onClipboardCopied() { 15 | this.setState({copied: true}); 16 | } 17 | 18 | render() { 19 | const adminToken = this.props.params.adminToken; 20 | const formID = getFormID(adminToken); 21 | 22 | const userformURL = getFormURL(formID); 23 | const userformEditURL = getFormEditURL(adminToken); 24 | const adminURL = getAdminURL(adminToken); 25 | 26 | const twitterText = `I've just created a form, it is at ${userformURL}!`; 27 | const twitterUrl = `https://twitter.com/intent/tweet?text=${twitterText}`; 28 | 29 | const emailSubject = `Hey, I just created a new form`; 30 | const emailBody = `Hi folks, 31 | 32 | I just created a new form and it's available at: 33 | 34 | ${userformURL} 35 | 36 | Please, take some time to fill it, 37 | `; 38 | 39 | const emailUrl = `mailto:?subject=${emailSubject}&body=${encodeURIComponent(emailBody)}`; 40 | return ( 41 | 42 |

    Neat, your form is now ready!

    43 |
    44 | 60 | 61 | 62 | 63 |
    64 | 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /formbuilder/components/Header.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Link } from "react-router"; 3 | 4 | 5 | export default function Header(props) { 6 | return ( 7 |
    8 |
    9 |
    10 | Kinto formbuilder 11 |
    12 |
    13 | 17 |
    18 |
    19 |
    ); 20 | } 21 | -------------------------------------------------------------------------------- /formbuilder/components/NotificationList.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const ERROR_CLASSES = { 4 | error: "danger", 5 | info: "info", 6 | }; 7 | 8 | export default function NotificationList(props) { 9 | const {notifications, removeNotification} = props; 10 | if (notifications.length === 0) { 11 | return
    ; 12 | } 13 | return ( 14 |
    { 15 | notifications.map((notif) => { 16 | const {message, type, id} = notif; 17 | const classes = `alert alert-${ERROR_CLASSES[type]}`; 18 | return ( 19 |
    20 | removeNotification(id)}>× 22 | {message} 23 |
    24 | ); 25 | }) 26 | }
    27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /formbuilder/components/RecordCreated.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import config from "../config"; 3 | 4 | export default function RecordCreated(props) { 5 | 6 | // Issue #130 - Change title back to project name after submitting the form 7 | document.title = config.projectName; 8 | 9 | return ( 10 |
    11 |

    Submitted!

    12 | Thanks, your data has been submitted! 13 |
    14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /formbuilder/components/URLDisplay.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function URLDisplay(props) { 4 | const onClick = (e) => { 5 | e.target.select(); 6 | }; 7 | 8 | const icon = props.type === "admin" ? "eye-close" : "bullhorn"; 9 | const label = props.type == "admin" ? "Admin link" : "Form link"; 10 | const glyphicon = `glyphicon glyphicon-${icon}`; 11 | 12 | return ( 13 |
    14 |
    15 | {label} 16 | 17 |
    18 |
    19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /formbuilder/components/UserForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import Form from "react-jsonschema-form"; 3 | import config from "../config"; 4 | 5 | export default class UserForm extends Component { 6 | componentDidMount() { 7 | // If the schema properties is empty, then try to load the schema from the 8 | if (Object.keys(this.props.schema.properties).length === 0) { 9 | this.props.loadSchema(this.props.params.id, (data) => { 10 | document.title = data.schema.title; 11 | }); 12 | } 13 | } 14 | 15 | render() { 16 | const origin = window.location.origin + window.location.pathname; 17 | const onSubmit = ({formData}) => { 18 | this.props.submitRecord(formData, this.props.params.id, () => { 19 | this.props.history.pushState(null, "/form/data-sent"); 20 | }); 21 | }; 22 | return (
    23 |
    25 |

    This form was created with the {config.projectName}.

    26 |
    27 | 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /formbuilder/components/Welcome.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | 4 | export default function Welcome(props) { 5 | const createNewForm = () => { 6 | props.resetForm(() => { 7 | props.history.pushState(null, "/builder"); 8 | }); 9 | }; 10 | 11 | return ( 12 |
    13 |
    14 |
    15 |

    Create your own forms

    16 |

    17 | This is the Kinto formbuilder, a tool to help 18 | you create online forms easily. 19 |

    20 |

    21 |
    22 |
    23 |
    24 |
    25 |
    26 |

    Privacy matters

    27 |

    With Kinto, you are not giving Google or any other giants your data.

    28 |

    Our goal is not to host all the forms of the world, so we try to make it easy for you to host your own servers.

    29 |
    30 |
    31 |

    Open source

    32 |

    All the code we write is written in the open and we try to be 33 | the most inclusive as we can to welcome your ideas.

    34 |

    Kinto and the formbuilder are released under Apache 2.0 licenses

    35 |
    36 |
    37 |

    Account-less

    38 |

    You don't need an account to create a new form: just create and give it to your friends, it's a matter of minutes!

    39 |
    40 |
    41 |
    42 |
    43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /formbuilder/components/XLSDownloader.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import json2xls from "json2xls"; 3 | import btoa from "btoa"; 4 | 5 | export default function XLSDownloader(props) { 6 | const filename = props.schema.title + ".xls"; 7 | const fields = props.fields; 8 | const fieldNames = props.fields.map((key) => { 9 | return props.schema.properties[key].title; 10 | }); 11 | 12 | // Pre-processing of data 13 | // json2xls takes json in the following format 14 | // json_data = [ 15 | // { 16 | // col1: row1value1, 17 | // col2: row1value2 18 | // }, 19 | // { 20 | // col1: row2value1, 21 | // col2: row2value2 22 | // } 23 | // ] 24 | // After the pre-processing is done, we will have data in 25 | // above mentioned format in `formattedData` which can be 26 | // then easily fed to `json2xls`. 27 | var tempData; 28 | var formattedData = props.records.map((record) => { 29 | tempData = {}; 30 | fieldNames.map((fieldName, index) => { 31 | tempData[fieldName] = record[fields[index]]; 32 | }); 33 | return tempData; 34 | }); 35 | // END of pre-processing 36 | 37 | var xls = json2xls(formattedData); 38 | const fileContent = "data:text/plain;base64," + btoa(xls); 39 | 40 | return XLS; 42 | } 43 | -------------------------------------------------------------------------------- /formbuilder/components/builder/DescriptionField.js: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from "react"; 2 | import {RIEInput} from "riek"; 3 | 4 | function DescriptionField(props) { 5 | const onUpdate = function(formData) { 6 | props.updateFormDescription(formData); 7 | }; 8 | 9 | const {id, description=""} = props; 10 | return ( 11 |

    12 | 18 |

    19 | ); 20 | } 21 | 22 | if (process.env.NODE_ENV !== "production") { 23 | DescriptionField.propTypes = { 24 | id: PropTypes.string, 25 | title: PropTypes.string, 26 | required: PropTypes.bool, 27 | }; 28 | } 29 | 30 | export default DescriptionField; 31 | -------------------------------------------------------------------------------- /formbuilder/components/builder/EditableField.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Draggable, Droppable } from "react-drag-and-drop"; 3 | import Form from "react-jsonschema-form"; 4 | import SchemaField from "react-jsonschema-form/lib/components/fields/SchemaField"; 5 | import { ButtonToolbar, Button } from "react-bootstrap"; 6 | import FieldListDropdown from "./FieldListDropdown"; 7 | 8 | /** 9 | * Recopies the keys listed in "source" using the values in the "target" 10 | * object, excluding keys listed in the "excludedKey" argument. 11 | **/ 12 | function pickKeys(source, target, excludedKeys) { 13 | const result = {}; 14 | 15 | let isExcluded; 16 | for (let key in source) { 17 | isExcluded = excludedKeys.indexOf(key) !== -1; 18 | if (isExcluded) { 19 | continue; 20 | } 21 | result[key] = target[key]; 22 | } 23 | return result; 24 | } 25 | 26 | function shouldHandleDoubleClick(node) { 27 | // disable doubleclick on number input, so people can use inc/dec arrows 28 | if (node.tagName === "INPUT" && 29 | node.getAttribute("type") === "number") { 30 | return false; 31 | } 32 | return true; 33 | } 34 | 35 | class FieldPropertiesEditor extends Component { 36 | constructor(props) { 37 | super(props); 38 | this.state = {editedSchema: props.schema}; 39 | } 40 | 41 | onChange({formData}) { 42 | this.setState({editedSchema: formData}); 43 | } 44 | 45 | render() { 46 | const {schema, name, required, uiSchema, onCancel, onUpdate, onDelete} = this.props; 47 | const formData = { 48 | ...schema, 49 | required, 50 | ...this.state.editedSchema, 51 | name: this.state.name 52 | }; 53 | 54 | return ( 55 |
    56 |
    57 | Edit {name} 58 | 59 | 60 | 61 | change field 62 | 63 | 66 | 69 | 70 |
    71 |
    72 | 77 | 78 | 79 |
    80 |
    81 | ); 82 | } 83 | } 84 | 85 | function DraggableFieldContainer(props) { 86 | const { 87 | children, 88 | dragData, 89 | onEdit, 90 | onDelete, 91 | onDoubleClick, 92 | onDrop 93 | } = props; 94 | return ( 95 | 96 | 98 |
    99 |
    100 | {children} 101 |
    102 |
    103 | 106 | 109 |
    110 |
    111 |
    112 |
    113 | ); 114 | } 115 | 116 | export default class EditableField extends Component { 117 | constructor(props) { 118 | super(props); 119 | this.state = {edit: true, schema: props.schema}; 120 | } 121 | 122 | componentWillReceiveProps(nextProps) { 123 | this.setState({schema: nextProps.schema}); 124 | } 125 | 126 | handleEdit(event) { 127 | event.preventDefault(); 128 | if (shouldHandleDoubleClick(event.target)) { 129 | this.setState({edit: true}); 130 | } 131 | } 132 | 133 | handleUpdate({formData}) { 134 | // Exclude the "type" key when picking the keys as it is handled by the 135 | // SWITCH_FIELD action. 136 | const updated = pickKeys(this.props.schema, formData, ["type"]); 137 | const schema = {...this.props.schema, ...updated}; 138 | this.setState({edit: false, schema}); 139 | this.props.updateField( 140 | this.props.name, schema, formData.required, formData.title); 141 | } 142 | 143 | handleDelete(event) { 144 | event.preventDefault(); 145 | if (confirm("Are you sure you want to delete this field?")) { 146 | this.props.removeField(this.props.name); 147 | } 148 | } 149 | 150 | handleCancel(event) { 151 | event.preventDefault(); 152 | this.setState({edit: false}); 153 | } 154 | 155 | handleDrop(data) { 156 | const {name, swapFields, insertField} = this.props; 157 | if ("moved-field" in data && data["moved-field"]) { 158 | if (data["moved-field"] !== name) { 159 | swapFields(data["moved-field"], name); 160 | } 161 | } else if ("field" in data && data.field) { 162 | insertField(JSON.parse(data.field), name); 163 | } 164 | } 165 | 166 | render() { 167 | const props = this.props; 168 | 169 | if (this.state.edit) { 170 | return ( 171 | 176 | ); 177 | } 178 | 179 | if (props.schema.type === "object") { 180 | if (!props.name) { 181 | // This can only be the root form object, returning a regular SchemaField. 182 | return ; 183 | } 184 | } 185 | 186 | return ( 187 | 195 | 198 | 199 | ); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /formbuilder/components/builder/FieldListDropdown.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import {Dropdown, MenuItem} from "react-bootstrap"; 4 | 5 | import config from "../../config"; 6 | 7 | export default class FieldListDropdown extends Component { 8 | constructor(props) { 9 | super(props); 10 | 11 | let fieldListAction = "add_field"; 12 | if (typeof(this.props.name) !== "undefined") { 13 | // By default FieldListDropdown adds a new field, but in this case 14 | // we want to switch from a field to a other one (ex: "input" to 15 | // "checkbox"). 16 | fieldListAction = "switch_field"; 17 | } 18 | this.state = { 19 | fieldList: config.fieldList, 20 | fieldListAction: fieldListAction 21 | }; 22 | } 23 | 24 | handleFieldListAction(fieldIndex, event) { 25 | const fieldList = this.state.fieldList; 26 | fieldIndex = parseInt(fieldIndex, 10); 27 | 28 | if (typeof fieldList[fieldIndex] !== "undefined") { 29 | const field = fieldList[fieldIndex]; 30 | 31 | if (this.state.fieldListAction === "switch_field") { 32 | this.props.switchField(this.props.name, field); 33 | } else { 34 | this.props.addField(field); 35 | } 36 | 37 | } 38 | } 39 | 40 | render () { 41 | return ( 42 | 43 | 44 | {this.props.children} 45 | 46 | 47 | 48 | {this.state.fieldList.map((field, index) => { 49 | return 53 | {field.label} 54 | ; 55 | })} 56 | 57 | 58 | ); 59 | } 60 | } 61 | 62 | FieldListDropdown.defaultProps = { 63 | bsStyle: "default" 64 | }; 65 | -------------------------------------------------------------------------------- /formbuilder/components/builder/Form.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import FormActionsContainer from "./../../containers/builder/FormActionsContainer"; 4 | import SchemaField from "react-jsonschema-form/lib/components/fields/SchemaField"; 5 | 6 | export default function Form(props) { 7 | const {error, dragndropStatus} = props; 8 | console.log("dragndropstatus", dragndropStatus); 9 | 10 | const registry = { 11 | ...SchemaField.defaultProps.registry, 12 | fields: { 13 | ...SchemaField.defaultProps.registry.fields, 14 | SchemaField: props.SchemaField, 15 | TitleField: props.TitleField, 16 | DescriptionField: props.DescriptionField, 17 | } 18 | }; 19 | 20 | return ( 21 |
    22 | {error ?
    {error}
    :
    } 23 |
    24 | 25 |
    26 | 27 | 28 |
    29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /formbuilder/components/builder/FormActions.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import FieldListDropdown from "./FieldListDropdown"; 3 | import {Button, ButtonToolbar, ButtonGroup} from "react-bootstrap"; 4 | 5 | export default function FormActions(props) { 6 | const onClick = (event) => { 7 | props.publishForm(({collection, adminToken}) => { 8 | props.history.pushState(null, `/builder/published/${adminToken}`); 9 | }); 10 | }; 11 | 12 | let saveIconName; 13 | if (props.status == "pending") { 14 | saveIconName = "refresh spin"; 15 | } else { 16 | saveIconName = "save"; 17 | } 18 | 19 | return ( 20 |
    21 | 22 | 23 | 24 | Add a field 25 | 26 | 27 | 28 | 32 | 36 | 37 |
    38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /formbuilder/components/builder/JsonSchemaDownloader.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import btoa from "btoa"; 4 | 5 | export default function JsonSchemaDownloader(props) { 6 | const filename = props.schema.title + ".json"; 7 | const schemaFileContent = "data:application/json;base64," + btoa(JSON.stringify(props.schema)); 8 | const uiSchemaFileContent = "data:application/json;base64," + btoa(JSON.stringify(props.uiSchema)); 9 | 10 | return ( 11 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /formbuilder/components/builder/JsonView.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | 4 | export default function JSONView(props) { 5 | return ( 6 |
    7 |

    JSONSchema

    8 |
    9 |