├── .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 | [](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 | [](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 |
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 {properties[key].title} ;
48 | })
49 | }
50 |
51 |
52 | {this.props.records.map((record, idx) => {
53 | return ({
54 | schemaFields.map((key) => {
55 | return {String(record[key])} ;
56 | }
57 | )}
58 | );
59 | })}
60 |
61 |
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 |
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 | );
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 |
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 (
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 |
Start a new form
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 |
64 | delete
65 |
66 |
67 | close
68 |
69 |
70 |
71 |
72 |
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 |
104 | edit
105 |
106 |
107 | delete
108 |
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 | confirm("This action will reset all unsaved changes, Are you sure?") && props.resetForm()}>
29 |
30 | Reset form
31 |
32 |
33 |
34 | Save your form
35 |
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 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/formbuilder/components/builder/TitleField.js:
--------------------------------------------------------------------------------
1 | import React, {PropTypes} from "react";
2 | import {RIEInput} from "riek";
3 |
4 | function TitleField(props) {
5 | const onUpdate = function(formData) {
6 | props.updateFormTitle(formData);
7 | };
8 |
9 | const {id, title=""} = props;
10 | return (
11 |
12 |
17 |
18 | );
19 | }
20 |
21 | if (process.env.NODE_ENV !== "production") {
22 | TitleField.propTypes = {
23 | id: PropTypes.string,
24 | title: PropTypes.string,
25 | required: PropTypes.bool,
26 | };
27 | }
28 |
29 | export default TitleField;
30 |
--------------------------------------------------------------------------------
/formbuilder/config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | projectName: process.env.PROJECT_NAME || "Formbuilder",
3 | server: {
4 | remote: process.env.SERVER_URL,
5 | bucket: "formbuilder",
6 | },
7 | appURL: process.env.APP_URL || window.location.origin + window.location.pathname,
8 | fieldList: [
9 | {
10 | id: "text",
11 | icon: "text-color",
12 | label: "Short text",
13 | jsonSchema: {
14 | type: "string",
15 | title: "Edit me",
16 | description: "",
17 | default: ""
18 | },
19 | uiSchema: {
20 | editSchema: {
21 | type: "object",
22 | properties: {
23 | title: {type: "string", title: "Label"},
24 | description: {type: "string", title: "Example value"},
25 | required: {type: "boolean"},
26 | }
27 | },
28 | },
29 | formData: {}
30 | },
31 | {
32 | id: "multilinetext",
33 | icon: "align-left",
34 | label: "Long text",
35 | jsonSchema: {
36 | type: "string",
37 | title: "Edit me",
38 | description: "",
39 | default: ""
40 | },
41 | uiSchema: {
42 | "ui:widget": "textarea",
43 | editSchema: {
44 | type: "object",
45 | properties: {
46 | title: {type: "string", title: "Label"},
47 | description: {type: "string", title: "Example value"},
48 | required: {type: "boolean"},
49 | }
50 | },
51 | },
52 | formData: {}
53 | },
54 | {
55 | id: "checkbox",
56 | icon: "check",
57 | label: "Checkbox",
58 | jsonSchema: {
59 | type: "boolean",
60 | title: "Edit me",
61 | default: false,
62 | },
63 | uiSchema: {
64 | editSchema: {
65 | type: "object",
66 | properties: {
67 | title: {type: "string", title: "Label"},
68 | required: {type: "boolean"},
69 | }
70 | },
71 | },
72 | formData: {}
73 | },
74 | {
75 | id: "multiple-checkbox",
76 | icon: "check",
77 | label: "Multiple choices",
78 | jsonSchema: {
79 | type: "array",
80 | title: "A multiple choices list",
81 | items: {
82 | type: "string",
83 | enum: ["choice 1", "choice 2", "choice 3"],
84 | },
85 | uniqueItems: true,
86 | },
87 | uiSchema: {
88 | "ui:widget": "checkboxes",
89 | editSchema: {
90 | type: "object",
91 | properties: {
92 | title: {type: "string", title: "Label"},
93 | required: {type: "boolean"},
94 | items: {
95 | type: "object",
96 | title: "Choices",
97 | properties: {
98 | enum: {
99 | title: null,
100 | type: "array",
101 | items: {
102 | type: "string"
103 | },
104 | default: ["choice 1", "choice 2", "choice 3"],
105 | }
106 | }
107 | }
108 | }
109 | },
110 | },
111 | formData: {}
112 | },
113 | {
114 | id: "radiobuttonlist",
115 | icon: "list",
116 | label: "Choice list",
117 | jsonSchema: {
118 | type: "string",
119 | title: "Edit me",
120 | enum: ["option 1", "option 2", "option 3"],
121 | },
122 | uiSchema: {
123 | "ui:widget": "radio",
124 | editSchema: {
125 | type: "object",
126 | properties: {
127 | title: {type: "string", title: "Label"},
128 | required: {type: "boolean"},
129 | enum: {
130 | type: "array",
131 | title: "Options",
132 | items: {
133 | type: "string"
134 | }
135 | }
136 | }
137 | },
138 | },
139 | formData: {}
140 | },
141 | {
142 | id: "select",
143 | icon: "chevron-down",
144 | label: "Select List",
145 | jsonSchema: {
146 | type: "string",
147 | format: "string",
148 | title: "Edit me",
149 | enum: ["option 1", "option 2", "option 3"],
150 | },
151 | uiSchema: {
152 | "ui:widget": "select",
153 | editSchema: {
154 | type: "object",
155 | properties: {
156 | title: {type: "string", title: "Label"},
157 | required: {type: "boolean"},
158 | enum: {
159 | type: "array",
160 | title: "Options",
161 | items: {
162 | type: "string"
163 | }
164 | }
165 | }
166 | },
167 | },
168 | formData: {}
169 | },
170 | {
171 | id: "date",
172 | icon: "calendar",
173 | label: "Date",
174 | jsonSchema: {
175 | type: "string",
176 | format: "date",
177 | title: "Edit me",
178 | },
179 | uiSchema: {
180 | "ui:widget": "alt-date",
181 | editSchema: {
182 | type: "object",
183 | properties: {
184 | title: {type: "string", title: "Label"},
185 | required: {type: "boolean"}
186 | }
187 | },
188 | },
189 | formData: {}
190 | },
191 | ],
192 | };
193 |
--------------------------------------------------------------------------------
/formbuilder/containers/AdminViewContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 |
3 | import AdminView from "../components/AdminView";
4 | import { bindActionCreators } from "redux";
5 | import * as ServerActions from "../actions/server";
6 |
7 |
8 | function mapDispatchToProps(dispatch) {
9 | return bindActionCreators(ServerActions, dispatch);
10 | }
11 |
12 | function mapStateToProps(state) {
13 | return {
14 | records: state.records,
15 | schema: state.form.schema,
16 | uiSchema: state.form.uiSchema,
17 | };
18 | }
19 |
20 | export default connect(
21 | mapStateToProps,
22 | mapDispatchToProps
23 | )(AdminView);
24 |
--------------------------------------------------------------------------------
/formbuilder/containers/App.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function App(props) {
4 | const {mainComponent, sidebarComponent, content, notifications, header} = props;
5 | const contentClassName = sidebarComponent? "col-sm-9" : "col-sm-9 center";
6 |
7 | if (mainComponent) {
8 | return {mainComponent}
;
9 | }
10 |
11 | return (
12 |
13 | {header}
14 |
15 |
16 | {sidebarComponent ?
{sidebarComponent}
:
}
17 |
18 | {notifications}
19 | {content ||
Nothing to render
}
20 |
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/formbuilder/containers/FormCreatedContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 |
3 | import FormCreated from "../components/FormCreated";
4 |
5 |
6 | function mapStateToProps(state) {
7 | return {
8 | schema: state.form.schema,
9 | uiSchema: state.form.uiSchema,
10 | formData: state.form.formData,
11 | publicationStatus: state.publicationStatus
12 | };
13 | }
14 |
15 | export default connect(
16 | mapStateToProps,
17 | )(FormCreated);
18 |
--------------------------------------------------------------------------------
/formbuilder/containers/NotificationContainer.js:
--------------------------------------------------------------------------------
1 | import { bindActionCreators } from "redux";
2 | import { connect } from "react-redux";
3 | import NotificationList from "../components/NotificationList";
4 | import * as NotificationsActions from "../actions/notifications";
5 |
6 | function mapStateToProps(state) {
7 | return {
8 | notifications: state.notifications,
9 | };
10 | }
11 |
12 | function mapDispatchToProps(dispatch) {
13 | return bindActionCreators(NotificationsActions, dispatch);
14 | }
15 |
16 | export default connect(
17 | mapStateToProps,
18 | mapDispatchToProps
19 | )(NotificationList);
20 |
--------------------------------------------------------------------------------
/formbuilder/containers/RecordCreatedContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 |
3 | import RecordCreated from "../components/RecordCreated";
4 |
5 |
6 | function mapStateToProps(state) {
7 | return {
8 | schema: state.form.schema,
9 | uiSchema: state.form.uiSchema,
10 | formData: state.form.formData,
11 | };
12 | }
13 |
14 | export default connect(
15 | mapStateToProps,
16 | )(RecordCreated);
17 |
--------------------------------------------------------------------------------
/formbuilder/containers/UserFormContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 |
3 | import UserForm from "../components/UserForm";
4 | import { bindActionCreators } from "redux";
5 | import * as ServerActions from "../actions/server";
6 |
7 |
8 | function mapDispatchToProps(dispatch) {
9 | return bindActionCreators(ServerActions, dispatch);
10 | }
11 |
12 | function mapStateToProps(state) {
13 | return {
14 | schema: state.form.schema,
15 | uiSchema: state.form.uiSchema,
16 | formData: state.form.formData,
17 | };
18 | }
19 |
20 | export default connect(
21 | mapStateToProps,
22 | mapDispatchToProps
23 | )(UserForm);
24 |
--------------------------------------------------------------------------------
/formbuilder/containers/WelcomeContainer.js:
--------------------------------------------------------------------------------
1 | import { bindActionCreators } from "redux";
2 | import { connect } from "react-redux";
3 | import Welcome from "../components/Welcome";
4 | import * as FieldListActions from "../actions/fieldlist";
5 |
6 | function mapStateToProps(state) {
7 | return {};
8 | }
9 |
10 | function mapDispatchToProps(dispatch) {
11 | return bindActionCreators(FieldListActions, dispatch);
12 | }
13 |
14 | export default connect(
15 | mapStateToProps,
16 | mapDispatchToProps
17 | )(Welcome);
18 |
--------------------------------------------------------------------------------
/formbuilder/containers/builder/FormActionsContainer.js:
--------------------------------------------------------------------------------
1 | import { bindActionCreators } from "redux";
2 | import { connect } from "react-redux";
3 | import FormActions from "../../components/builder/FormActions";
4 | import * as FieldListActions from "../../actions/fieldlist";
5 | import config from "../../config";
6 |
7 | function mapStateToProps(state) {
8 | return {
9 | fieldList: config.fieldList,
10 | schema: state.form.schema,
11 | };
12 | }
13 |
14 | function mapDispatchToProps(dispatch) {
15 | const actionCreators = {...FieldListActions};
16 | return bindActionCreators(actionCreators, dispatch);
17 | }
18 |
19 | export default connect(
20 | mapStateToProps,
21 | mapDispatchToProps
22 | )(FormActions);
23 |
--------------------------------------------------------------------------------
/formbuilder/containers/builder/FormContainer.js:
--------------------------------------------------------------------------------
1 | import { bindActionCreators } from "redux";
2 | import { connect } from "react-redux";
3 |
4 | import * as FieldListActions from "../../actions/fieldlist";
5 | import * as ServerActions from "../../actions/server";
6 |
7 | import Form from "../../components/builder/Form";
8 | import EditableField from "../../components/builder/EditableField";
9 | import TitleField from "../../components/builder/TitleField";
10 | import DescriptionField from "../../components/builder/DescriptionField";
11 |
12 | function mapStateToProps(state) {
13 | return {
14 | error: state.form.error,
15 | schema: state.form.schema,
16 | uiSchema: state.form.uiSchema,
17 | formData: state.form.formData,
18 | status: state.serverStatus.status,
19 | dragndropStatus: state.dragndrop.dragndropStatus
20 | };
21 | }
22 |
23 | function mapDispatchToProps(dispatch) {
24 | const actionCreators = {...FieldListActions, ...ServerActions};
25 | const actions = bindActionCreators(actionCreators, dispatch);
26 | // Side effect: attaching action creators as EditableField props, so they're
27 | // available from within the Form fields hierarchy.
28 | EditableField.defaultProps = Object.assign(
29 | {}, EditableField.defaultProps || {}, actions);
30 | TitleField.defaultProps = Object.assign(
31 | {}, TitleField.defaultProps || {}, actions);
32 | DescriptionField.defaultProps = Object.assign(
33 | {}, DescriptionField.defaultProps || {}, actions);
34 | return actions;
35 | }
36 |
37 | function mergeProps(stateProps, dispatchProps, ownProps) {
38 | return {
39 | ...stateProps,
40 | ...dispatchProps,
41 | ...ownProps,
42 | SchemaField: EditableField,
43 | TitleField,
44 | DescriptionField,
45 | onChange: () => {}
46 | };
47 | }
48 |
49 | export default connect(
50 | mapStateToProps,
51 | mapDispatchToProps,
52 | mergeProps
53 | )(Form);
54 |
--------------------------------------------------------------------------------
/formbuilder/containers/builder/FormEdit.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from "react";
2 | import FormContainer from "../../containers/builder/FormContainer";
3 | import {getFormID} from "../../utils";
4 |
5 | export default class FormEdit extends Component {
6 |
7 | componentDidMount() {
8 | // If the schema is empty, then load the schema into the state
9 | if (!this.state) {
10 | const formId = getFormID(this.props.params.adminId);
11 | const callback = (data) => {
12 | document.title = data.schema.title;
13 | // see publishForm for what gets saved
14 | this.setState({
15 | schema: data.schema,
16 | uiSchema: data.uiSchema
17 | });
18 | };
19 | this.props.loadSchema(formId, callback, this.props.params.adminId);
20 | }
21 | }
22 |
23 | render() {
24 | return (
25 |
26 |
27 | );
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/formbuilder/containers/builder/FormEditContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 |
3 | import FormEdit from "./FormEdit";
4 | import { bindActionCreators } from "redux";
5 | import * as ServerActions from "../../actions/server";
6 |
7 |
8 | function mapDispatchToProps(dispatch) {
9 | return bindActionCreators(ServerActions, dispatch);
10 | }
11 |
12 | function mapStateToProps(state) {
13 | return {};
14 | }
15 |
16 | export default connect(
17 | mapStateToProps,
18 | mapDispatchToProps
19 | )(FormEdit);
20 |
--------------------------------------------------------------------------------
/formbuilder/containers/builder/JsonSchemaDownloaderContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 |
3 | import JsonSchemaDownloader from "../../components/builder/JsonSchemaDownloader";
4 |
5 |
6 | function mapStateToProps(state) {
7 | return {
8 | schema: state.form.schema,
9 | uiSchema: state.form.uiSchema
10 | };
11 | }
12 |
13 | export default connect(
14 | mapStateToProps,
15 | )(JsonSchemaDownloader);
16 |
--------------------------------------------------------------------------------
/formbuilder/containers/builder/JsonViewContainer.js:
--------------------------------------------------------------------------------
1 | import { connect } from "react-redux";
2 |
3 | import FormOptions from "../../components/builder/JsonView";
4 |
5 |
6 | function mapStateToProps(state) {
7 | return {
8 | schema: state.form.schema,
9 | uiSchema: state.form.uiSchema,
10 | formData: state.form.formData,
11 | };
12 | }
13 |
14 | export default connect(
15 | mapStateToProps,
16 | )(FormOptions);
17 |
--------------------------------------------------------------------------------
/formbuilder/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Formbuilder
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/formbuilder/index.prod.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Formbuilder
8 |
9 |
10 |
11 |
12 |
13 |
14 | We're sorry, but you need JavaScript to access this website. JavaScript
15 | is used to communicate with an external Kinto
16 | server where the form data is stored.
17 | Without it, we cannot either save or retrieve the data on this external server.
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/formbuilder/reducers/dragndrop.js:
--------------------------------------------------------------------------------
1 | import {
2 | SET_DRAG_STATUS,
3 | } from "../actions/dragndrop";
4 |
5 | const INITIAL_STATE = {
6 | dragndropStatus: false
7 | };
8 |
9 | export default function form(state = INITIAL_STATE, action) {
10 | switch(action.type) {
11 | case SET_DRAG_STATUS:
12 | return {
13 | dragndropStatus: action.status
14 | };
15 | default:
16 | return state;
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/formbuilder/reducers/form.js:
--------------------------------------------------------------------------------
1 | import S from "string";
2 |
3 | import {
4 | FIELD_ADD,
5 | FIELD_SWITCH,
6 | FIELD_REMOVE,
7 | FIELD_UPDATE,
8 | FIELD_INSERT,
9 | FIELD_SWAP,
10 | FORM_RESET,
11 | FORM_UPDATE_TITLE,
12 | FORM_UPDATE_DESCRIPTION,
13 | } from "../actions/fieldlist";
14 |
15 | import {SCHEMA_RETRIEVAL_DONE} from "../actions/server";
16 |
17 | const INITIAL_STATE = {
18 | error: null,
19 | schema: {
20 | type: "object",
21 | title: "Untitled form",
22 | description: "Enter some description for your form here",
23 | properties: {}
24 | },
25 | uiSchema: {
26 | "ui:order": []
27 | },
28 | formData: {},
29 | currentIndex: 0,
30 | };
31 |
32 | function slugify(string) {
33 | return S(string).slugify().replace("-", "_").s;
34 | }
35 |
36 | function clone(obj) {
37 | return JSON.parse(JSON.stringify(obj));
38 | }
39 |
40 | function unique(array) {
41 | return Array.from(new Set(array));
42 | }
43 |
44 | function addField(state, field) {
45 | // Generating a usually temporary random, unique field name.
46 | state.currentIndex += 1;
47 | const name = `Question ${state.currentIndex}`;
48 | const _slug = slugify(name);
49 | state.schema.properties[_slug] = {...field.jsonSchema, title: name};
50 | state.uiSchema[_slug] = field.uiSchema;
51 | state.uiSchema["ui:order"] = (state.uiSchema["ui:order"] || []).concat(_slug);
52 | return state;
53 | }
54 |
55 | function switchField(state, propertyName, newField) {
56 | state.schema.properties[propertyName] = {...newField.jsonSchema};
57 | state.uiSchema[propertyName] = newField.uiSchema;
58 |
59 | return state;
60 | }
61 |
62 | function removeField(state, name) {
63 | const requiredFields = state.schema.required || [];
64 | delete state.schema.properties[name];
65 | delete state.uiSchema[name];
66 | state.uiSchema["ui:order"] = state.uiSchema["ui:order"].filter(
67 | (field) => field !== name);
68 | state.schema.required = requiredFields
69 | .filter(requiredFieldName => name !== requiredFieldName);
70 | if (state.schema.required.length === 0) {
71 | delete state.schema.required;
72 | }
73 | return {...state, error: null};
74 | }
75 |
76 | function updateField(state, name, schema, required, newLabel) {
77 | const existing = Object.keys(state.schema.properties);
78 | const newName = slugify(newLabel);
79 | if (name !== newName && existing.indexOf(newName) !== -1) {
80 | // Field name already exists, we can't update state
81 | const error = `Duplicate field name "${newName}", operation aborted.`;
82 | return {...state, error};
83 | }
84 | const requiredFields = state.schema.required || [];
85 | state.schema.properties[name] = schema;
86 | if (required) {
87 | // Ensure uniquely required field names
88 | state.schema.required = unique(requiredFields.concat(name));
89 | } else {
90 | state.schema.required = requiredFields
91 | .filter(requiredFieldName => name !== requiredFieldName);
92 | }
93 | if (newName !== name) {
94 | return renameField(state, name, newName);
95 | }
96 | return {...state, error: null};
97 | }
98 |
99 | function renameField(state, name, newName) {
100 | const schema = clone(state.schema.properties[name]);
101 | const uiSchema = clone(state.uiSchema[name]);
102 | const order = state.uiSchema["ui:order"];
103 | const required = state.schema.required;
104 | delete state.schema.properties[name];
105 | delete state.uiSchema[name];
106 | state.schema.properties[newName] = schema;
107 | state.schema.required = required.map(fieldName => {
108 | return fieldName === name ? newName : fieldName;
109 | });
110 | state.uiSchema[newName] = uiSchema;
111 | state.uiSchema["ui:order"] = order.map(fieldName => {
112 | return fieldName === name ? newName : fieldName;
113 | });
114 | return {...state, error: null};
115 | }
116 |
117 | function insertField(state, field, before) {
118 | const insertedState = addField(state, field);
119 | const order = insertedState.uiSchema["ui:order"];
120 | const added = order[order.length - 1];
121 | const idxBefore = order.indexOf(before);
122 | const newOrder = [].concat(
123 | order.slice(0, idxBefore),
124 | added,
125 | order.slice(idxBefore, order.length - 1)
126 | );
127 | insertedState.uiSchema["ui:order"] = newOrder;
128 | return {...insertedState, error: null};
129 | }
130 |
131 | function swapFields(state, source, target) {
132 | const order = state.uiSchema["ui:order"];
133 | const idxSource = order.indexOf(source);
134 | const idxTarget = order.indexOf(target);
135 | order[idxSource] = target;
136 | order[idxTarget] = source;
137 | return {...state, error: null};
138 | }
139 |
140 | function updateFormTitle(state, {title}) {
141 | state.schema.title = title;
142 | return {...state, error: null};
143 | }
144 |
145 | function updateFormDescription(state, {description}) {
146 | state.schema.description = description;
147 | return {...state, error: null};
148 | }
149 |
150 | function setSchema(state, data) {
151 | state.schema = data.schema;
152 | state.uiSchema = data.uiSchema;
153 | return {...state, error: null};
154 | }
155 |
156 | export default function form(state = INITIAL_STATE, action) {
157 | switch(action.type) {
158 | case FIELD_ADD:
159 | return addField(clone(state), action.field);
160 | case FIELD_SWITCH:
161 | return switchField(clone(state), action.property, action.newField);
162 | case FIELD_REMOVE:
163 | return removeField(clone(state), action.name);
164 | case FIELD_UPDATE:
165 | const {name, schema, required, newName} = action;
166 | return updateField(clone(state), name, schema, required, newName);
167 | case FIELD_INSERT:
168 | return insertField(clone(state), action.field, action.before);
169 | case FIELD_SWAP:
170 | return swapFields(clone(state), action.source, action.target);
171 | case FORM_RESET:
172 | return INITIAL_STATE;
173 | case FORM_UPDATE_TITLE:
174 | return updateFormTitle(clone(state), action.title);
175 | case FORM_UPDATE_DESCRIPTION:
176 | return updateFormDescription(clone(state), action.description);
177 | case SCHEMA_RETRIEVAL_DONE:
178 | return setSchema(clone(state), action.data);
179 | default:
180 | return state;
181 | }
182 | }
183 |
--------------------------------------------------------------------------------
/formbuilder/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from "redux";
2 |
3 | import form from "./form";
4 | import notifications from "./notifications";
5 | import serverStatus from "./serverStatus";
6 | import records from "./records";
7 | import dragndrop from "./dragndrop";
8 |
9 |
10 | const rootReducer = combineReducers({
11 | notifications,
12 | form,
13 | serverStatus,
14 | records,
15 | dragndrop
16 | });
17 |
18 | export default rootReducer;
19 |
--------------------------------------------------------------------------------
/formbuilder/reducers/notifications.js:
--------------------------------------------------------------------------------
1 | import {
2 | NOTIFICATION_ADD,
3 | NOTIFICATION_REMOVE,
4 | } from "../actions/notifications";
5 |
6 | const INITIAL_STATE = [];
7 |
8 | export default function collections(state = INITIAL_STATE, action) {
9 | switch(action.type) {
10 |
11 | case NOTIFICATION_ADD:
12 | return [...state, action.notification];
13 |
14 | case NOTIFICATION_REMOVE:
15 | return state.filter(({id}) => action.id !== id);
16 |
17 | default:
18 | return state;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/formbuilder/reducers/records.js:
--------------------------------------------------------------------------------
1 | import {
2 | RECORDS_RETRIEVAL_DONE,
3 | } from "../actions/server";
4 |
5 | const INITIAL_STATE = [];
6 |
7 | export default function collections(state = INITIAL_STATE, action) {
8 | switch(action.type) {
9 |
10 | case RECORDS_RETRIEVAL_DONE:
11 | return action.records;
12 |
13 | default:
14 | return state;
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/formbuilder/reducers/serverStatus.js:
--------------------------------------------------------------------------------
1 | import {
2 | FORM_PUBLICATION_PENDING,
3 | FORM_PUBLICATION_DONE,
4 | FORM_PUBLICATION_FAILED
5 | } from "../actions/server";
6 |
7 | const INITIAL_STATE = {
8 | status: "init",
9 | collection: null,
10 | };
11 |
12 | export default function serverStatus(state = INITIAL_STATE, action) {
13 | switch(action.type) {
14 |
15 | case FORM_PUBLICATION_FAILED:
16 | return {...state, status: "failed"};
17 |
18 | case FORM_PUBLICATION_PENDING:
19 | return {...state, status: "pending"};
20 |
21 | case FORM_PUBLICATION_DONE:
22 | return {
23 | ...state,
24 | status: "done",
25 | collection: action.collection,
26 | };
27 | default:
28 | return state;
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/formbuilder/routes.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Route, IndexRoute, Link } from "react-router";
3 |
4 | import App from "./containers/App";
5 | import FormContainer from "./containers/builder/FormContainer";
6 | import JsonViewContainer from "./containers/builder/JsonViewContainer";
7 |
8 | import NotificationContainer from "./containers/NotificationContainer";
9 | import FormCreatedContainer from "./containers/FormCreatedContainer";
10 | import FormEditContainer from "./containers/builder/FormEditContainer";
11 | import UserFormContainer from "./containers/UserFormContainer";
12 | import RecordCreatedContainer from "./containers/RecordCreatedContainer";
13 | import AdminViewContainer from "./containers/AdminViewContainer";
14 | import WelcomeContainer from "./containers/WelcomeContainer";
15 | import JsonSchemaDownloaderContainer from "./containers/builder/JsonSchemaDownloaderContainer";
16 | import Header from "./components/Header";
17 | import Check from "./components/Check";
18 | import FAQ from "./components/FAQ";
19 |
20 |
21 | const common = {
22 | notifications: NotificationContainer,
23 | header: Header
24 | };
25 |
26 | const LinkToBuilder = (props) => {
27 | const {children} = props;
28 | const browserHistory = props.history;
29 |
30 | return (
31 |
32 | {browserHistory.goBack();}}>
33 |
34 | {props.text || "Back"}
35 |
36 | {children}
37 |
38 | );
39 | };
40 |
41 | const BackAndCheck = (props) => {
42 | return (
43 |
44 |
45 |
46 | View as JSON
47 |
48 |
49 |
50 |
51 | );
52 | };
53 |
54 | const BackAndDownloadJSONSchema = (props) => {
55 | return (
56 |
62 | );
63 | };
64 |
65 | const LinkToHome = () => {
66 | return (
67 |
68 |
69 |
70 | "Home"
71 |
72 |
73 | );
74 | };
75 |
76 | export default (
77 |
78 |
79 |
81 |
83 |
85 |
87 |
89 |
91 |
93 |
95 | Page not found.
98 | }}/>
99 |
100 | );
101 |
--------------------------------------------------------------------------------
/formbuilder/store/configureStore.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, compose } from "redux";
2 | import thunk from "redux-thunk";
3 | import rootReducer from "../reducers";
4 |
5 | const finalCreateStore = compose(
6 | applyMiddleware(thunk)
7 | )(createStore);
8 |
9 | export default function configureStore(initialState) {
10 | return finalCreateStore(rootReducer, initialState);
11 | }
12 |
--------------------------------------------------------------------------------
/formbuilder/styles.css:
--------------------------------------------------------------------------------
1 | .spin {
2 | -webkit-animation: spin 1s infinite linear;
3 | -moz-animation: spin 1s infinite linear;
4 | -o-animation: spin 1s infinite linear;
5 | animation: spin 1s infinite linear;
6 | -webkit-transform-origin: 50% 50%;
7 | transform-origin: 50% 50%;
8 | -ms-transform-origin: 50% 50%; /* IE 9 */
9 | }
10 |
11 | @-moz-keyframes spin {
12 | from {
13 | -moz-transform: rotate(0deg);
14 | }
15 | to {
16 | -moz-transform: rotate(360deg);
17 | }
18 | }
19 |
20 | @-webkit-keyframes spin {
21 | from {
22 | -webkit-transform: rotate(0deg);
23 | }
24 | to {
25 | -webkit-transform: rotate(360deg);
26 | }
27 | }
28 |
29 | @keyframes spin {
30 | from {
31 | transform: rotate(0deg);
32 | }
33 | to {
34 | transform: rotate(360deg);
35 | }
36 | }
37 |
38 | /* Override default navbar
39 | Prevent first form element dropdown menu to get out of the viewport */
40 | .navbar {
41 | min-height: 8em;
42 | }
43 |
44 | .check {
45 | font-size: 13em;
46 | color: #455;
47 | margin-left: 50px;
48 | }
49 |
50 |
51 | .editable-field {
52 | padding: .5em 0;
53 | }
54 |
55 | .editable-field:hover {
56 | background: #fafafa;
57 | cursor: move;
58 | }
59 |
60 | .editable-field label:hover {
61 | cursor: move;
62 | }
63 |
64 | .editable-field-actions {
65 | text-align: right;
66 | padding-left: 0;
67 | }
68 |
69 | .editable-field-actions button,
70 | button.close-btn {
71 | background: none;
72 | border: none;
73 | }
74 |
75 | .editable-field-actions button:last-child {
76 | padding-right: 0;
77 | }
78 |
79 | form > p {
80 | padding: 1em 0;
81 | }
82 |
83 | .field-editor .panel-heading {
84 | padding: 0;
85 | }
86 |
87 | .field-editor .btn,
88 | .editable-field-actions .btn {
89 | text-transform: none;
90 | }
91 |
92 | .editable-field-actions .btn {
93 | padding: 0;
94 | margin-left: 6px;
95 | }
96 |
97 | .editable-field-actions .btn:last {
98 | margin-left: 0px;
99 | }
100 |
101 | .panel-heading .panel-title {
102 | display: inline-block;
103 | padding: 4px 16px;
104 | }
105 |
106 | textarea.json-viewer {
107 | width: 100%;
108 | height: 350px;
109 | font-family: Consolas, "Ubuntu Mono", "Monaco", monospace;
110 | }
111 |
112 | .field-list-entry:hover {
113 | background: #fafafa;
114 | cursor: move;
115 | }
116 |
117 | .field-list-entry:active {
118 | background: #F7F7F7;
119 | box-shadow: inset 0 -2px 2px -2px rgba(0, 0, 0, .3);
120 | }
121 |
122 | .glyphicon + span {
123 | margin-left: .5em;
124 | }
125 |
126 | .dropzone-active {
127 | padding-bottom: 5em;
128 | background-color: white;
129 | outline: 2px dashed black;
130 | outline-offset: -10px;
131 | font-size: 1.25rem;
132 | position: relative;
133 | padding: 50px 20px;
134 | }
135 |
136 | .dropzone-active p{
137 | text-align: center;
138 | margin: auto;
139 | height: 100px;
140 | }
141 |
142 | span.edit-in-place:hover {
143 | cursor: pointer;
144 | }
145 |
146 | .edit-in-place:hover:not(:focus) {
147 | text-decoration: underline;
148 | }
149 |
150 | .edit-in-place-active {
151 | width: 500px;
152 | }
153 |
154 | .narrow {
155 | max-width: 730px;
156 | margin-right: auto;
157 | margin-left: auto;
158 | }
159 |
160 | .builder-form .panel {
161 | margin-bottom: 0;
162 | }
163 |
164 | .builder-inner-actions {
165 | margin-bottom: 30px;
166 | }
167 |
168 | .background {
169 | background-image: url(assets/background.jpg);
170 | background-position: bottom;
171 | color: white !important;
172 | }
173 |
174 | .background h1 {
175 | color: white !important;
176 | }
177 |
--------------------------------------------------------------------------------
/formbuilder/utils.js:
--------------------------------------------------------------------------------
1 | import config from "./config";
2 |
3 |
4 | /**
5 | * Returns the form unique identifier from the administration token.
6 | *
7 | * The form ID is used as the name of the collection where the records
8 | * are stored. This is useful to always have one ID to pass to the clients,
9 | * and they can figure out what the collection name is.
10 | **/
11 | export function getFormID(adminToken) {
12 | return adminToken.slice(0, adminToken.length / 2);
13 | }
14 |
15 | /**
16 | * Returns the form URL from the form identifier.
17 | *
18 | * This function relies on the globally available "window" object, which might
19 | * be something we want to pass rather than relying on it being globally
20 | * available.
21 | **/
22 | export function getFormURL(formID) {
23 | return `${config.appURL}#/form/${formID}`;
24 | }
25 |
26 | export function getFormEditURL(adminID) {
27 | return `${config.appURL}#/builder/edit/${adminID}`;
28 | }
29 |
30 | /**
31 | * Returns the admin URL from the admin token.
32 | *
33 | * This function relies on the globally available "window" object, which might
34 | * be something we want to pass rather than relying on it being globally
35 | * available.
36 | **/
37 | export function getAdminURL(adminToken) {
38 | return `${config.appURL}#/admin/${adminToken}`;
39 | }
40 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "formbuilder",
3 | "version": "0.0.1",
4 | "description": "A tool to build forms on the web",
5 | "scripts": {
6 | "build:formbuilder": "NODE_ENV=production rimraf build && webpack --config webpack.config.prod.js --optimize-minimize && cp formbuilder/index.prod.html build/index.html",
7 | "build:formbuilder-gh": "rimraf build && webpack --config webpack.config.github.js && cp formbuilder/index.prod.html build/index.html",
8 | "lint": "eslint src test",
9 | "publish-to-gh-pages": "npm run build:formbuilder-gh && gh-pages --dist build/",
10 | "publish-to-npm": "npm run dist && npm publish",
11 | "start": "node devServer.js",
12 | "tdd": "npm run test -- -w",
13 | "test": "SERVER_URL=http://localhost:8888/v1 NODE_ENV=test mocha --compilers js:babel/register --recursive --require ./test/setup-jsdom.js $(find test -name '*_test.js')"
14 | },
15 | "main": "lib/index.js",
16 | "files": [
17 | "dist",
18 | "lib"
19 | ],
20 | "dependencies": {
21 | "bootstrap": "^3.3.7",
22 | "bootswatch": "^3.3.7",
23 | "btoa": "^1.1.2",
24 | "history": "^1.17.0",
25 | "isomorphic-fetch": "^2.2.1",
26 | "json2csv": "^3.7.0",
27 | "json2xls": "^0.1.2",
28 | "kinto-http": "^4.3.4",
29 | "react": "^15.3.2",
30 | "react-bootstrap": "^0.30.3",
31 | "react-clipboard.js": "^0.2.5",
32 | "react-dom": "^15.3.2",
33 | "react-drag-and-drop": "^2.0.1",
34 | "react-jsonschema-form": "^0.40.0",
35 | "react-redux": "^4.0.6",
36 | "react-router": "^1.0.3",
37 | "redux": "^3.0.5",
38 | "redux-thunk": "^1.0.3",
39 | "riek": "^1.0.2",
40 | "string": "^3.3.1",
41 | "urlencode": "^1.1.0",
42 | "uuid": "^2.0.2"
43 | },
44 | "devDependencies": {
45 | "babel": "^5.8.20",
46 | "babel-eslint": "^4.1.6",
47 | "babel-loader": "^5.3.2",
48 | "babel-plugin-react-transform": "^1.1.1",
49 | "babel-polyfill": "^6.9.1",
50 | "btoa": "^1.1.2",
51 | "chai": "^3.3.0",
52 | "css-loader": "^0.23.1",
53 | "eslint": "^1.8.0",
54 | "eslint-plugin-react": "^3.6.3",
55 | "express": "^4.13.3",
56 | "extract-text-webpack-plugin": "^0.9.1",
57 | "file-loader": "^0.9.0",
58 | "gh-pages": "^0.4.0",
59 | "html": "0.0.10",
60 | "jsdom": "^7.2.1",
61 | "less": "^2.7.1",
62 | "less-loader": "^2.2.3",
63 | "mocha": "^2.3.0",
64 | "react-addons-test-utils": "^15.3.2",
65 | "react-transform-hmr": "^1.0.1",
66 | "rimraf": "^2.4.4",
67 | "sinon": "^1.17.2",
68 | "style-loader": "^0.13.0",
69 | "url-loader": "^0.5.7",
70 | "webpack": "^1.10.5",
71 | "webpack-dev-middleware": "^1.4.0",
72 | "webpack-hot-middleware": "^2.6.0"
73 | },
74 | "directories": {
75 | "test": "test"
76 | },
77 | "repository": {
78 | "type": "git",
79 | "url": "git+https://github.com/Kinto/formbuilder.git"
80 | },
81 | "author": "Kinto team ",
82 | "keywords": [
83 | "react",
84 | "form",
85 | "json-schema"
86 | ],
87 | "license": "Apache-2.0",
88 | "homepage": "https://github.com/Kinto/formbuilder#readme"
89 | }
90 |
--------------------------------------------------------------------------------
/scalingo.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Formbuilder",
3 | "description": "Create your own forms and surveys, get your data back.",
4 | "repository": "https://github.com/Kinto/formbuilder",
5 | "website": "https://github.com/Kinto/formbuilder#readme",
6 | "logo": "https://avatars0.githubusercontent.com/u/13413813",
7 | "env": {
8 | "PROJECT_NAME": {
9 | "description": "Name of the project. Defaults to \"formbuilder\".",
10 | "value": "$APP"
11 | },
12 | "SERVER_URL": {
13 | "description": "URL of the Kinto server. It's default value depends on the environment that's being used (development, production, etc.)",
14 | "value": ""
15 | },
16 | "HOST": {
17 | "description": "Listens connection on the specified host. Defaults to \"localhost\".",
18 | "value": "0.0.0.0"
19 | },
20 | "NPM_CONFIG_PRODUCTION": {
21 | "description": "Install dev dependencies",
22 | "value": "false"
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/test/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "mocha": true
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/test/actions/notifications_test.js:
--------------------------------------------------------------------------------
1 | /*eslint no-unused-vars: [2, { "varsIgnorePattern": "^d$" }]*/
2 |
3 | import { expect } from "chai";
4 | import sinon from "sinon";
5 |
6 | import {
7 | NOTIFICATION_ADD,
8 | addNotification
9 | } from "../../formbuilder/actions/notifications";
10 |
11 | describe("notifications actions", () => {
12 | let sandbox;
13 | let dispatch;
14 |
15 | beforeEach(() => {
16 | sandbox = sinon.sandbox.create();
17 | dispatch = sandbox.stub();
18 | });
19 |
20 | afterEach(() => {
21 | sandbox.restore();
22 | });
23 |
24 | describe("addNotification", () => {
25 | it("should send info notifications by default", (done) => {
26 | addNotification("Some info message")(({type, notification}) => {
27 | expect(type).to.eql(NOTIFICATION_ADD);
28 | expect(notification.message).to.eql("Some info message");
29 | expect(notification.type).to.eql("info");
30 | done();
31 | });
32 | });
33 |
34 | it("should send error notifications if specified", (done) => {
35 | const thunk = addNotification("Some error message", {type: "error"});
36 | thunk(({type, notification}) => {
37 | expect(type).to.eql(NOTIFICATION_ADD);
38 | expect(notification.message).to.eql("Some error message");
39 | expect(notification.type).to.eql("error");
40 | done();
41 | });
42 | });
43 | describe("With fake timers", () => {
44 | beforeEach(() => {
45 | sandbox.useFakeTimers();
46 | });
47 |
48 | it("should dismiss a message after a specific time", () => {
49 | addNotification("Some dismissable message", {
50 | type: "info",
51 | autoDismiss: true,
52 | dismissAfter: 2
53 | })(dispatch);
54 | sinon.assert.calledWithMatch(dispatch, {
55 | type: "NOTIFICATION_ADD",
56 | notification: {
57 | id: sinon.match.string,
58 | message: "Some dismissable message",
59 | type: "info"
60 | }
61 | });
62 | sandbox.clock.tick(3);
63 | sinon.assert.calledWithMatch(dispatch, {
64 | type: "NOTIFICATION_REMOVE",
65 | id: sinon.match.string
66 | });
67 | });
68 |
69 | it("should not dismiss an undismissable message", () => {
70 | addNotification("Some dismissable message", {
71 | type: "info",
72 | autoDismiss: false,
73 | dismissAfter: 2
74 | })(dispatch);
75 | sinon.assert.calledWithMatch(dispatch, {
76 | type: "NOTIFICATION_ADD",
77 | notification: {
78 | id: sinon.match.string,
79 | message: "Some dismissable message",
80 | type: "info"
81 | }
82 | });
83 | sandbox.clock.tick(3);
84 | expect(dispatch.calledOnce).to.be.true;
85 | });
86 |
87 | it("should dismiss messages by default", () => {
88 | addNotification("Some dismissable message")(dispatch);
89 | sinon.assert.calledWithMatch(dispatch, {
90 | type: "NOTIFICATION_ADD",
91 | notification: {
92 | id: sinon.match.string,
93 | message: "Some dismissable message",
94 | type: "info"
95 | }
96 | });
97 | sandbox.clock.tick(6000);
98 | sinon.assert.calledWithMatch(dispatch, {
99 | type: "NOTIFICATION_REMOVE",
100 | id: sinon.match.string
101 | });
102 | });
103 | });
104 | });
105 | });
106 |
--------------------------------------------------------------------------------
/test/components/AdminView_test.js:
--------------------------------------------------------------------------------
1 | /*eslint no-unused-vars: [2, { "varsIgnorePattern": "^d$" }]*/
2 |
3 | import { expect } from "chai";
4 | import sinon from "sinon";
5 |
6 | import { createComponent } from "../test-utils";
7 | import AdminView from "../../formbuilder/components/AdminView";
8 |
9 |
10 | describe("AdminView", () => {
11 | var sandbox, compProps, schema, uiSchema, records;
12 | const noop = () => {};
13 |
14 | beforeEach(() => {
15 | sandbox = sinon.sandbox.create();
16 | // Create stubs instead of doing real HTTP calls.
17 | uiSchema = {
18 | "ui:order": []
19 | };
20 | schema = {
21 | title: "My new form",
22 | properties: []
23 | };
24 | records = [];
25 |
26 | compProps = {
27 | getRecords: noop,
28 | loadSchema: noop,
29 | schema,
30 | uiSchema,
31 | records,
32 | params: {adminToken: "12345678910"}
33 | };
34 | });
35 |
36 | afterEach(() => {
37 | sandbox.restore();
38 | });
39 |
40 | describe("Default state", () => {
41 | it("should show the user a message if no data is present", () => {
42 | const comp = createComponent(AdminView, compProps);
43 | expect(comp.query().textContent).eql("loading");
44 | });
45 | });
46 |
47 | describe("Loaded records and schema", () => {
48 | beforeEach(() => {
49 | compProps.schema.properties = {
50 | "question-2": { title: "Question 2" },
51 | "question-1": { title: "Question 1" },
52 | };
53 | compProps.uiSchema["ui:order"] = ["question-1", "question-2"];
54 | compProps.records = [
55 | {"question-2": "Answer A2", "question-1": "Answer A1"},
56 | {"question-2": "Answer B2", "question-1": "Answer B1"},
57 | ];
58 | });
59 |
60 | it("should show the fields ordered properly", () => {
61 | const comp = createComponent(AdminView, compProps);
62 | let titles = Array.map(comp.queryAll("thead th"), (title) => {
63 | return title.textContent;
64 | });
65 | expect(titles).eql(["Question 1", "Question 2"]);
66 | });
67 |
68 | it("should show the results in a table", () => {
69 | const comp = createComponent(AdminView, compProps);
70 |
71 | // Retrieve all fields of each records.
72 | records = Array.map(comp.queryAll("tbody tr"), (tr) => {
73 | return Array.map(tr.querySelectorAll("td"), (td) => {
74 | return td.textContent;
75 | });
76 | });
77 | expect(records).eql([
78 | ["Answer A1", "Answer A2"],
79 | ["Answer B1", "Answer B2"],
80 | ]);
81 | });
82 |
83 | it("should stringify the values of the records", () => {
84 | compProps.records = [
85 | {"question-2": ["one", "two"], "question-1": "Answer A1"},
86 | {"question-2": ["three", "four"], "question-1": "Answer B1"},
87 | ];
88 |
89 | const comp = createComponent(AdminView, compProps);
90 |
91 | // Retrieve all fields of each records.
92 | records = Array.map(comp.queryAll("tbody tr"), (tr) => {
93 | return Array.map(tr.querySelectorAll("td"), (td) => {
94 | return td.textContent;
95 | });
96 | });
97 | expect(records).eql([
98 | ["Answer A1", "one,two"],
99 | ["Answer B1", "three,four"],
100 | ]);
101 | });
102 | });
103 | });
104 |
--------------------------------------------------------------------------------
/test/components/EditableField_test.js:
--------------------------------------------------------------------------------
1 | /*eslint no-unused-vars: [2, { "varsIgnorePattern": "^d$" }]*/
2 |
3 | import { expect } from "chai";
4 | import sinon from "sinon";
5 | import { Simulate } from "react-addons-test-utils";
6 |
7 | import { createComponent, d } from "../test-utils";
8 | import config from "../../formbuilder/config";
9 | import EditableField from "../../formbuilder/components/builder/EditableField";
10 |
11 |
12 | const {fieldList} = config;
13 | const textField = fieldList.find(x => x.id === "text");
14 |
15 | describe("EditableField", () => {
16 | var sandbox, compProps;
17 |
18 | const schema = textField.jsonSchema;
19 | const uiSchema = textField.uiSchema;
20 |
21 | beforeEach(() => {
22 | sandbox = sinon.sandbox.create();
23 | compProps = {
24 | name: "field_1234567",
25 | schema,
26 | uiSchema,
27 | addField: sandbox.spy(),
28 | switchField: sandbox.spy(),
29 | updateField: sandbox.spy(),
30 | onChange: sandbox.spy(),
31 | };
32 | });
33 |
34 | afterEach(() => {
35 | sandbox.restore();
36 | });
37 |
38 | describe("Default state", () => {
39 | let comp;
40 |
41 | beforeEach(() => {
42 | comp = createComponent(EditableField, compProps);
43 | });
44 |
45 | it("should render an properties edition form", () => {
46 | expect(comp.query().classList.contains("field-editor"))
47 | .eql(true);
48 | });
49 |
50 | it("should update field properties", () => {
51 | const value = "modified";
52 | Simulate.change(comp.query("[type=text][value='Edit me']"), {
53 | target: {value}
54 | });
55 | return new Promise((r) => setTimeout(r, 10)).then(() => {
56 | Simulate.submit(comp.query("form"));
57 | expect(comp.query("label").textContent).eql(value);
58 | });
59 | });
60 | });
61 |
62 | describe("Field properties edition", () => {
63 | var comp;
64 |
65 | beforeEach(() => {
66 | comp = createComponent(EditableField, compProps);
67 | Simulate.click(comp.query("[name=close-btn]"));
68 | });
69 |
70 | it("should render a EditableField in render mode", () => {
71 | expect(comp.queryAll(".editable-field"))
72 | .to.have.length.of(1);
73 | });
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/test/components/UserForm_test.js:
--------------------------------------------------------------------------------
1 | /*eslint no-unused-vars: [2, { "varsIgnorePattern": "^d$" }]*/
2 |
3 | import { expect } from "chai";
4 | import sinon from "sinon";
5 | import { Simulate } from "react-addons-test-utils";
6 |
7 | import { createComponent, d } from "../test-utils";
8 | import UserForm from "../../formbuilder/components/UserForm";
9 |
10 |
11 | describe("UserForm", () => {
12 | var sandbox, compProps, schema;
13 |
14 | beforeEach(() => {
15 | sandbox = sinon.sandbox.create();
16 | // Create stubs instead of doing real HTTP calls.
17 | schema = {
18 | "title": "A registration form",
19 | "type": "object",
20 | "properties": {
21 | "firstName": {
22 | "type": "string",
23 | "title": "First name"
24 | },
25 | "lastName": {
26 | "type": "string",
27 | "title": "Last name"
28 | }
29 | }
30 | };
31 |
32 | compProps = {
33 | params: {id: 1234},
34 | schema,
35 | uiSchema: {},
36 | loadSchema: sandbox.spy(),
37 | submitRecord: () => {},
38 | history: {
39 | pushState: sandbox.spy()
40 | }
41 | };
42 | });
43 |
44 | afterEach(() => {
45 | sandbox.restore();
46 | });
47 |
48 | it("should call loadSchema() when no schema is present", () => {
49 | compProps.schema.properties = [];
50 | createComponent(UserForm, compProps);
51 | expect(compProps.loadSchema.calledOnce).to.be.True;
52 | });
53 |
54 | it("should submit the new record and redirect on submission", (done) => {
55 | sinon.stub(compProps, "submitRecord", (formData, id, callback) => {
56 | expect(formData).to.eql({
57 | "firstName": "John",
58 | "lastName": "Doe"
59 | });
60 | expect(id).to.eql(1234);
61 | callback();
62 | expect(compProps.history.pushState.calledWith(null, "/form/data-sent")).to.be.True;
63 | done();
64 | });
65 | const comp = createComponent(UserForm, compProps);
66 | Simulate.change(comp.query("#root_firstName"), {
67 | target: {value: "John"}
68 | });
69 | Simulate.change(comp.query("#root_lastName"), {
70 | target: {value: "Doe"}
71 | });
72 | return new Promise(setImmediate).then(() => {
73 | Simulate.submit(comp.query("form"));
74 | expect(compProps.submitRecord.calledOnce).to.be.True;
75 | });
76 |
77 | });
78 | });
79 |
--------------------------------------------------------------------------------
/test/reducers/form_test.js:
--------------------------------------------------------------------------------
1 | /*eslint no-unused-vars: [2, { "varsIgnorePattern": "^d$" }]*/
2 |
3 | import { expect } from "chai";
4 |
5 | import form from "../../formbuilder/reducers/form";
6 | import * as actions from "../../formbuilder/actions/fieldlist";
7 | import config from "../../formbuilder/config";
8 |
9 |
10 | const {fieldList} = config;
11 | const textField = fieldList.find(x => x.id === "text");
12 | const multilineTextField = fieldList.find(x => x.id === "multilinetext");
13 | const radioButtonField = fieldList.find(x => x.id === "radiobuttonlist");
14 |
15 | describe("form reducer", () => {
16 | describe("FIELD_ADD action", () => {
17 | var state, firstFieldAdded;
18 |
19 | beforeEach(() => {
20 | state = form(undefined, actions.addField(textField));
21 | firstFieldAdded = Object.keys(state.schema.properties)[0];
22 | });
23 |
24 | describe("Empty form", () => {
25 | describe("schema", () => {
26 | it("should add a new field to the form schema", () => {
27 | expect(Object.keys(state.schema.properties))
28 | .to.have.length.of(1);
29 | });
30 |
31 | it("should generate a sluggified name for the added field", () => {
32 | expect(firstFieldAdded)
33 | .to.match(/^question_\d+/);
34 | });
35 |
36 | it("should assign the expected title to added field", () => {
37 | const fieldSchema = state.schema.properties[firstFieldAdded];
38 |
39 | expect(fieldSchema.title).eql("Question 1");
40 | });
41 |
42 | it("should assign the expected type to added field", () => {
43 | const fieldSchema = state.schema.properties[firstFieldAdded];
44 |
45 | expect(fieldSchema.type).eql("string");
46 | });
47 | });
48 |
49 | describe("uiSchema", () => {
50 | it("should have an entry for added field", () => {
51 | expect(state.uiSchema)
52 | .to.have.property(firstFieldAdded);
53 | });
54 |
55 | it("should provide an editSchema", () => {
56 | expect(state.uiSchema[firstFieldAdded].editSchema)
57 | .eql(textField.uiSchema.editSchema);
58 | });
59 |
60 | it("should initialize field order list", () => {
61 | expect(state.uiSchema["ui:order"])
62 | .eql([firstFieldAdded]);
63 | });
64 | });
65 | });
66 |
67 | describe("Existing form", () => {
68 | var newState, secondFieldAdded;
69 |
70 | beforeEach(() => {
71 | newState = form(state, actions.addField(multilineTextField));
72 | secondFieldAdded = Object.keys(newState.schema.properties)[1];
73 | });
74 |
75 | describe("schema", () => {
76 | it("should add a second field to the form schema", () => {
77 | expect(Object.keys(newState.schema.properties))
78 | .to.have.length.of(2);
79 | });
80 |
81 | it("should generate a sluggified name for the second field", () => {
82 | expect(secondFieldAdded)
83 | .to.match(/^question_\d+/);
84 | });
85 | });
86 |
87 | describe("uiSchema", () => {
88 | it("should have an entry for the newly added field", () => {
89 | expect(newState.uiSchema)
90 | .to.have.property(secondFieldAdded);
91 | });
92 |
93 | it("should provide an editSchema", () => {
94 | expect(newState.uiSchema[firstFieldAdded].editSchema)
95 | .eql(multilineTextField.uiSchema.editSchema);
96 | });
97 |
98 | it("should update the field order list", () => {
99 | expect(newState.uiSchema["ui:order"])
100 | .eql([firstFieldAdded, secondFieldAdded]);
101 | });
102 | });
103 | });
104 | });
105 |
106 | describe("FIELD_REMOVE action", () => {
107 | var state, firstFieldAdded;
108 |
109 | beforeEach(() => {
110 | state = form(undefined, actions.addField(textField));
111 | firstFieldAdded = Object.keys(state.schema.properties)[0];
112 | });
113 |
114 | it("should keep current index", () => {
115 | const previousIndex = state.currentIndex;
116 | expect(form(state, actions.removeField(firstFieldAdded)).currentIndex)
117 | .eql(previousIndex);
118 | });
119 |
120 | describe("Multiple items", () => {
121 | var removedState;
122 |
123 | beforeEach(() => {
124 | const intState = form(state, actions.addField(textField));
125 | const secondField = Object.keys(intState.schema.properties)[1];
126 | const secondFieldSchema = intState.schema.properties[secondField];
127 | const requiredState = form(intState, actions.updateField(
128 | secondField, secondFieldSchema, true, secondFieldSchema.title));
129 | removedState = form(requiredState,
130 | actions.removeField(secondField));
131 | });
132 |
133 | it("should remove a field from the required fields list", () => {
134 | expect(removedState.schema.required)
135 | .to.be.a("undefined");
136 | });
137 |
138 | it("should remove a field from the uiSchema order list", () => {
139 | expect(removedState.uiSchema["ui:order"])
140 | .eql([firstFieldAdded]);
141 | });
142 | });
143 | });
144 |
145 | describe("FIELD_UPDATE action", () => {
146 | var state, firstFieldAdded;
147 |
148 | const newSchema = {
149 | type: "string",
150 | title: "updated title",
151 | description: "updated description"
152 | };
153 |
154 | beforeEach(() => {
155 | state = form(undefined, actions.addField(textField));
156 | firstFieldAdded = Object.keys(state.schema.properties)[0];
157 | });
158 |
159 | it("should update the form schema with the updated one", () => {
160 | const action = actions.updateField(firstFieldAdded, newSchema, false,
161 | firstFieldAdded);
162 |
163 | expect(form(state, action).schema.properties[firstFieldAdded])
164 | .eql(newSchema);
165 | });
166 |
167 | it("should mark a field as required", () => {
168 | const action = actions.updateField(firstFieldAdded, newSchema, true,
169 | firstFieldAdded);
170 |
171 | expect(form(state, action).schema.required)
172 | .eql([firstFieldAdded]);
173 | });
174 |
175 | it("shouldn't touch uiSchema order", () => {
176 | const action = actions.updateField(firstFieldAdded, newSchema, false,
177 | firstFieldAdded);
178 |
179 | expect(form(state, action).uiSchema["ui:order"])
180 | .eql(state.uiSchema["ui:order"]);
181 | });
182 |
183 | describe("Successful Renaming", () => {
184 | const newFieldName = "renamed";
185 | var renamedState;
186 |
187 | beforeEach(() => {
188 | const action = actions.updateField(
189 | firstFieldAdded, newSchema, true, newFieldName);
190 | renamedState = form(state, action);
191 | });
192 |
193 | it("should expose new field name", () => {
194 | expect(renamedState.schema.properties[newFieldName])
195 | .eql(newSchema);
196 | });
197 |
198 | it("should discard previous name", () => {
199 | expect(renamedState.schema.properties[firstFieldAdded])
200 | .to.be.a("undefined");
201 | });
202 |
203 | it("should update required fields list", () => {
204 | expect(renamedState.schema.required)
205 | .eql([newFieldName]);
206 | });
207 |
208 | it("should update uiSchema order", () => {
209 | expect(renamedState.uiSchema["ui:order"])
210 | .eql([newFieldName]);
211 | });
212 | });
213 |
214 | describe("Failed Renaming", () => {
215 | var secondFieldAdded;
216 |
217 | beforeEach(() => {
218 | state = form(state, actions.addField(multilineTextField));
219 | secondFieldAdded = Object.keys(state.schema.properties)[1];
220 | });
221 |
222 | it("should notify renaming conflicts with an error", () => {
223 | state = form(state, actions.updateField(secondFieldAdded,
224 | state.schema.properties[secondFieldAdded], false, firstFieldAdded));
225 |
226 | expect(state.error)
227 | .eql(`Duplicate field name "${firstFieldAdded}", operation aborted.`);
228 | });
229 | });
230 | });
231 |
232 | describe("FIELD_INSERT action", () => {
233 | var state, firstField, secondField, insertedField;
234 |
235 | beforeEach(() => {
236 | state = form(undefined, actions.addField(textField));
237 | state = form(state, actions.addField(multilineTextField));
238 | firstField = Object.keys(state.schema.properties)[0];
239 | secondField = Object.keys(state.schema.properties)[1];
240 | state = form(state, actions.insertField(radioButtonField, secondField));
241 | insertedField = Object.keys(state.schema.properties)[2];
242 | });
243 |
244 | it("should insert the new field at the desired position", () => {
245 | expect(state.uiSchema["ui:order"])
246 | .eql([firstField, insertedField, secondField]);
247 | });
248 | });
249 |
250 | describe("FIELD_SWAP action", () => {
251 | var state, firstField, secondField;
252 |
253 | beforeEach(() => {
254 | state = form(undefined, actions.addField(textField));
255 | state = form(state, actions.addField(multilineTextField));
256 | firstField = Object.keys(state.schema.properties)[0];
257 | secondField = Object.keys(state.schema.properties)[1];
258 | state = form(state, actions.swapFields(secondField, firstField));
259 | });
260 |
261 | it("should swap two fields", () => {
262 | expect(state.uiSchema["ui:order"])
263 | .eql([secondField, firstField]);
264 | });
265 | });
266 |
267 | describe("FORM_RESET action", () => {
268 | it("should reset the form", () => {
269 | const initialState = form(undefined, {type: null});
270 | var state = form(undefined, actions.addField(textField));
271 | state = form(state, actions.resetForm(() => {
272 | expect(state).eql(initialState);
273 | }));
274 |
275 | });
276 | });
277 |
278 | describe("FORM_UPDATE_TITLE action", () => {
279 | it("should update form properties", () => {
280 | const state = form(undefined, actions.updateFormTitle({title: "title"}));
281 | expect(state.schema.title).eql("title");
282 | });
283 | });
284 |
285 | describe("FORM_UPDATE_DESCRIPTION action", () => {
286 | it("should update form properties", () => {
287 | const state = form(undefined, actions.updateFormDescription({description: "description"}));
288 | expect(state.schema.description).eql("description");
289 | });
290 | });
291 | });
292 |
--------------------------------------------------------------------------------
/test/setup-jsdom.js:
--------------------------------------------------------------------------------
1 | var jsdom = require("jsdom");
2 |
3 | // Setup the jsdom environment
4 | // @see https://github.com/facebook/react/issues/5046
5 | if (!global.hasOwnProperty("window")) {
6 | global.document = jsdom.jsdom("");
7 | global.window = document.defaultView;
8 | global.navigator = global.window.navigator;
9 | }
10 |
--------------------------------------------------------------------------------
/test/test-utils.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { renderIntoDocument } from "react-addons-test-utils";
3 | import { findDOMNode } from "react-dom";
4 |
5 |
6 | export function createComponent(Component, props) {
7 | const comp = renderIntoDocument( );
8 | const queryAll = (selector) => findDOMNode(comp).querySelectorAll(selector);
9 | const query = (selector) => {
10 | const element = findDOMNode(comp);
11 | return selector ? element.querySelector(selector) : element;
12 | };
13 | return {...comp, query, queryAll};
14 | }
15 |
16 | export function d(node) {
17 | console.log(require("html").prettyPrint(node.outerHTML, {indent_size: 2}));
18 | }
19 |
--------------------------------------------------------------------------------
/webpack.config.dev.js:
--------------------------------------------------------------------------------
1 | var path = require("path");
2 | var webpack = require("webpack");
3 |
4 | const serverURL = process.env.SERVER_URL || "http://localhost:8888/v1/";
5 |
6 | module.exports = {
7 | devtool: "eval",
8 | node: {
9 | fs: "empty"
10 | },
11 | entry: [
12 | "webpack-hot-middleware/client?reload=true",
13 | "./formbuilder/app"
14 | ],
15 | output: {
16 | path: path.join(__dirname, "build"),
17 | filename: "bundle.js",
18 | publicPath: "/static/"
19 | },
20 | plugins: [
21 | new webpack.IgnorePlugin(/^(buffertools)$/), // unwanted "deeper" dependency
22 | new webpack.HotModuleReplacementPlugin(),
23 | new webpack.NoErrorsPlugin(),
24 | new webpack.DefinePlugin({
25 | "process.env": {
26 | SERVER_URL: JSON.stringify(serverURL)
27 | }
28 | })
29 | ],
30 | module: {
31 | loaders: [
32 | {
33 | test: /\.jsx?$/,
34 | loader: "babel",
35 | include: [
36 | path.join(__dirname, "src"),
37 | path.join(__dirname, "formbuilder"),
38 | ],
39 | query: {
40 | plugins: ["react-transform"],
41 | extra: {
42 | "react-transform": {
43 | transforms: [{
44 | transform: "react-transform-hmr",
45 | imports: ["react"],
46 | locals: ["module"]
47 | }]
48 | }
49 | }
50 | }
51 | },
52 | {
53 | test: /\.css$/,
54 | loader: "style!css",
55 | include: [
56 | path.join(__dirname, "css"),
57 | path.join(__dirname, "formbuilder"),
58 | path.join(__dirname, "node_modules"),
59 | ],
60 | },
61 | {
62 | test: /\.less$/,
63 | loader: "style!css!less"
64 | },
65 | {test: /\.(woff|woff2)(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=application/font-woff"},
66 | {test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=application/octet-stream"},
67 | {test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: "file"},
68 | {test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=image/svg+xml"},
69 | {test: /\.jpg(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=image/jpeg"}
70 | ]
71 | }
72 | };
73 |
--------------------------------------------------------------------------------
/webpack.config.github.js:
--------------------------------------------------------------------------------
1 | var path = require("path");
2 | var webpack = require("webpack");
3 | var ExtractTextPlugin = require("extract-text-webpack-plugin");
4 |
5 | const serverURL = process.env.SERVER_URL || "https://kinto.notmyidea.org/v1/";
6 |
7 | module.exports = {
8 | devtool: "eval",
9 | node: {
10 | fs: "empty"
11 | },
12 | entry: "./formbuilder/app",
13 | output: {
14 | path: path.join(__dirname, "build"),
15 | filename: "bundle.js",
16 | publicPath: "/formbuilder/"
17 | },
18 | plugins: [
19 | new webpack.IgnorePlugin(/^(buffertools)$/), // unwanted "deeper" dependency
20 | new ExtractTextPlugin("styles.css", {allChunks: true}),
21 | new webpack.DefinePlugin({
22 | "process.env": {
23 | SERVER_URL: JSON.stringify(serverURL)
24 | }
25 | })
26 | ],
27 | resolve: {
28 | extensions: ["", ".js", ".jsx", ".css"]
29 | },
30 | module: {
31 | loaders: [
32 | {
33 | test: /\.jsx?$/,
34 | loader: "babel",
35 | include: [
36 | path.join(__dirname, "src"),
37 | path.join(__dirname, "formbuilder"),
38 | ],
39 | },
40 | {
41 | test: /\.css$/,
42 | loader: ExtractTextPlugin.extract("css-loader"),
43 | include: [
44 | path.join(__dirname, "css"),
45 | path.join(__dirname, "formbuilder"),
46 | path.join(__dirname, "node_modules"),
47 | ],
48 | },
49 | {
50 | test: /\.less$/,
51 | loader: "style!css!less"
52 | },
53 | {test: /\.(woff|woff2)(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=application/font-woff"},
54 | {test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=application/octet-stream"},
55 | {test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: "file"},
56 | {test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=image/svg+xml"},
57 | {test: /\.jpg(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=image/jpeg"}
58 | ]
59 | }
60 | };
61 |
--------------------------------------------------------------------------------
/webpack.config.prod.js:
--------------------------------------------------------------------------------
1 | var path = require("path");
2 | var webpack = require("webpack");
3 | var ExtractTextPlugin = require("extract-text-webpack-plugin");
4 |
5 | const serverURL = process.env.SERVER_URL || "https://kinto.notmyidea.org/v1/";
6 | const appURL = process.env.APP_URL || "https://www.fourmilieres.net/";
7 |
8 |
9 | module.exports = {
10 | entry: ["./formbuilder/app", "babel-polyfill"],
11 | node: {
12 | fs: "empty"
13 | },
14 | output: {
15 | path: path.join(__dirname, "build"),
16 | filename: "bundle.js",
17 | publicPath: "/"
18 | },
19 | plugins: [
20 | new webpack.IgnorePlugin(/^(buffertools)$/), // unwanted "deeper" dependency
21 | new ExtractTextPlugin("styles.css", {allChunks: true}),
22 | new webpack.DefinePlugin({
23 | "process.env": {
24 | NODE_ENV: JSON.stringify("production"),
25 | SERVER_URL: JSON.stringify(serverURL),
26 | APP_URL: JSON.stringify(appURL),
27 | },
28 | })
29 | ],
30 | resolve: {
31 | extensions: ["", ".js", ".jsx", ".css"]
32 | },
33 | module: {
34 | loaders: [
35 | {
36 | test: /\.jsx?$/,
37 | loader: "babel",
38 | include: [
39 | path.join(__dirname, "src"),
40 | path.join(__dirname, "formbuilder"),
41 | ],
42 | },
43 | {
44 | test: /\.css$/,
45 | loader: ExtractTextPlugin.extract("css-loader"),
46 | include: [
47 | path.join(__dirname, "css"),
48 | path.join(__dirname, "formbuilder"),
49 | path.join(__dirname, "node_modules"),
50 | ],
51 | },
52 | {
53 | test: /\.less$/,
54 | loader: "style!css!less"
55 | },
56 | {test: /\.(woff|woff2)(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=application/font-woff"},
57 | {test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=application/octet-stream"},
58 | {test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: "file"},
59 | {test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=image/svg+xml"},
60 | {test: /\.jpg(\?v=\d+\.\d+\.\d+)?$/, loader: "url?limit=10000&mimetype=image/jpeg"}
61 | ]
62 | }
63 | };
64 |
--------------------------------------------------------------------------------