diff --git a/package.json b/package.json index d217319e1a486296432fc0071546c99bcbe92c78..807b7759c7418539b1702214e8017096def58f4e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "author": "AMIV IT team", "license": "ISC", "dependencies": { + "ajv": "^5.5.0", + "email-validator": "^1.1.1", "babel-core": "^6.26.0", "babel-cli": "^6.26.0", "babel-loader": "^7.1.2", @@ -30,4 +32,4 @@ "webpack": "^3.8.1", "webpack-dev-server": "^2.9.3" } -} \ No newline at end of file +} diff --git a/src/models/event.js b/src/models/events.js similarity index 78% rename from src/models/event.js rename to src/models/events.js index 53b46845c9b576b2a3efbfbed27b686d41520845..197931b89bc7bf4cf036c6801ffe44845be93f8e 100644 --- a/src/models/event.js +++ b/src/models/events.js @@ -45,11 +45,18 @@ export function checkCurrentSignup() { }).then((result) => { [this.currentSignup] = result._items; this.currentSignupLoaded = true; - log(this.currentSignup); }); } -export function signupCurrent(email = '') { +export function signupCurrent(additionalFields, email = '') { + let additionalFieldsString; + if (this.current.additional_fields === undefined || + additionalFields === null || typeof additionalFields !== 'object') { + additionalFieldsString = undefined; + } else { + additionalFieldsString = JSON.stringify(additionalFields); + } + if (isLoggedIn()) { log(`UserId: ${getUserId()}`); m.request({ @@ -57,6 +64,7 @@ export function signupCurrent(email = '') { url: `${apiUrl}/eventsignups`, data: { event: this.current._id, + additional_fields: additionalFieldsString, user: getUserId(), }, headers: getToken() ? { @@ -64,19 +72,19 @@ export function signupCurrent(email = '') { } : {}, }).then(() => { this.checkCurrentSignup(); }); } else if (this.current.allow_email_signup) { - if (email.length > 0) { - m.request({ - method: 'POST', - url: `${apiUrl}/eventsignups`, - data: { - event: this.current._id, - email, - }, - headers: getToken() ? { - Authorization: `Token ${getToken()}`, - } : {}, - }).then(() => { this.checkCurrentSignup(); }); - } + log(`Email: ${email}`); + m.request({ + method: 'POST', + url: `${apiUrl}/eventsignups`, + data: { + event: this.current._id, + additional_fields: additionalFieldsString, + email, + }, + headers: getToken() ? { + Authorization: `Token ${getToken()}`, + } : {}, + }).then(() => { this.checkCurrentSignup(); }); } } diff --git a/src/views/eventDetails.js b/src/views/eventDetails.js index d873f8e32aec764fa720feaf4653551378673aff..996290b761139fce556dc5a0ae62bc77c97ea941 100644 --- a/src/views/eventDetails.js +++ b/src/views/eventDetails.js @@ -1,39 +1,83 @@ -import * as events from '../models/event'; +import * as EmailValidator from 'email-validator'; +import * as events from '../models/events'; +import { log } from '../models/log'; import { isLoggedIn } from '../models/auth'; +import { inputGroup, submitButton } from './formFields'; +import JSONSchemaForm from './jsonSchemaForm'; const m = require('mithril'); -let signupEmail = ''; - -class EventSignupForm { - static oninit() { +class EventSignupForm extends JSONSchemaForm { + oninit(vnode) { + super.oninit(vnode); + this.emailErrors = []; + this.emailValid = false; if (isLoggedIn()) { events.checkCurrentSignup(); } } - static view() { - if (typeof events.getCurrent() === 'undefined') { - return m('div'); + submit() { + if (isLoggedIn()) { + events.signupCurrent(super.getValue()); + } else { + events.signupCurrent(super.getValue(), this.email); } + } + + view() { + // do not render anything if there is no data yet + if (typeof events.getCurrent() === 'undefined') return m(); + if (isLoggedIn()) { - if (!events.currentSignupHasLoaded()) { - return m('span', 'Loading...'); - } else if (typeof events.getCurrentSignup() === 'undefined') { - return m('button', { onclick() { events.signupCurrent(); } }, 'signup'); + // do not render form if there is no signup data of the current user + if (!events.currentSignupHasLoaded()) return m('span', 'Loading...'); + if (typeof events.getCurrentSignup() === 'undefined') { + const elements = this.renderFormElements(); + elements.push(m(submitButton, { + active: super.isValid(), + args: { + onclick: () => this.submit(), + }, + text: 'Signup', + })); + return m('form', elements); } + return m('div', 'You have already signed up for this event.'); } else if (events.getCurrent().allow_email_signup) { - return m('div', [ - m('input', { + const elements = this.renderFormElements(); + elements.push(m(inputGroup, { + name: 'email', + title: 'Email', + args: { type: 'text', - placeholder: 'Email', - oninput: m.withAttr('value', (value) => { signupEmail = value; }), - value: signupEmail, - }), - m('button', { onclick() { events.signupCurrent(signupEmail); } }, 'signup'), - ]); + }, + onchange: (e) => { + // bind changed data + this.email = e.target.value; + + // validate if email address has the right structure + if (EmailValidator.validate(this.email)) { + this.emailValid = true; + this.emailErrors = []; + } else { + this.emailValid = false; + this.emailErrors = ['Not a valid email address']; + } + }, + getErrors: () => this.emailErrors, + value: this.email, + })); + elements.push(m(submitButton, { + active: this.emailValid && super.isValid(), + args: { + onclick: () => this.submit(), + }, + text: 'Signup', + })); + return m('form', elements); } - return m('div'); + return m('div', 'This event is for AMIV members only.'); } } @@ -44,7 +88,27 @@ export default class EventDetails { static view() { if (typeof events.getCurrent() === 'undefined') { - return m('div'); + return m(); + } + log(events.getCurrent()); + let eventSignupForm; + const now = new Date(); + const registerStart = new Date(events.getCurrent().time_register_start); + const registerEnd = new Date(events.getCurrent().time_register_end); + log(`Now: ${now}`); + log(`Start: ${registerStart}`); + log(`End: ${registerEnd}`); + if (registerStart <= now) { + if (registerEnd >= now) { + eventSignupForm = m(EventSignupForm, { + schema: events.getCurrent().additional_fields === undefined ? + undefined : JSON.parse(events.getCurrent().additional_fields), + }); + } else { + eventSignupForm = m('div', 'The registration period is over.'); + } + } else { + eventSignupForm = m('div', `The registration starts at ${registerStart}`); } return m('div', [ m('h1', events.getCurrent().title_de), @@ -52,7 +116,7 @@ export default class EventDetails { m('span', events.getCurrent().signup_count), m('span', events.getCurrent().spots), m('p', events.getCurrent().description_de), - m(EventSignupForm), + eventSignupForm, ]); } } diff --git a/src/views/eventList.js b/src/views/eventList.js index 30024e0a6b221234dde8687a6f7fd13e85c8b468..6943b18352b60335ef9edcbb855b07ce802e6948 100644 --- a/src/views/eventList.js +++ b/src/views/eventList.js @@ -1,4 +1,4 @@ -import * as events from '../models/event'; +import * as events from '../models/events'; const m = require('mithril'); @@ -16,6 +16,13 @@ export default class EventList { }); } + static onbeforeupdate(vnode, old) { + // when attrs are different it means we changed route + if (vnode.attrs.id !== old.attrs.id) { + events.reload(); + } + } + static view() { return m('table', [ m('thead', [ diff --git a/src/views/formFields.js b/src/views/formFields.js new file mode 100644 index 0000000000000000000000000000000000000000..b411a78ebb30f0f759e4bf306115ced56334d380 --- /dev/null +++ b/src/views/formFields.js @@ -0,0 +1,133 @@ +const m = require('mithril'); + +export class inputGroup { + constructor(vnode) { + // Link the error-getting function from the binding + this.getErrors = () => []; + if (vnode.attrs.getErrors) { + this.getErrors = vnode.attrs.getErrors; + } + } + + view(vnode) { + // set display-settings accoridng to error-state + let errorField = null; + let groupClasses = vnode.attrs.classes ? vnode.attrs.classes : ''; + const errors = this.getErrors(); + if (errors.length > 0) { + errorField = m('span', `Error: ${errors.join(', ')}`); + groupClasses += ' has-error'; + } + + let { args } = vnode.attrs; + if (args === undefined) { + args = {}; + } + args.value = vnode.attrs.value; + args.onchange = vnode.attrs.onchange; + + if (['radio', 'checkbox'].includes(args.type)) { + return m('div', { class: groupClasses }, [ + m(`input[name=${vnode.attrs.name}][id=${vnode.attrs.name}]`, args), + m(`label[for=${vnode.attrs.name}]`, vnode.attrs.title), + errorField, + ]); + } + return m('div', { class: groupClasses }, [ + m(`label[for=${vnode.attrs.name}]`, vnode.attrs.title), + m(`input[name=${vnode.attrs.name}][id=${vnode.attrs.name}]`, args), + errorField, + ]); + } +} + +export class selectGroup { + oninit() { + this.value = []; + } + + view(vnode) { + switch (vnode.attrs.args.type) { + case 'buttons': { + if (vnode.attrs.args.multipleSelect) { + return m('div', { class: vnode.attrs.classes }, [ + m(`label[for=${vnode.attrs.name}]`, vnode.attrs.title), + m('div', vnode.attrs.args.options.map(option => + m(inputGroup, { + name: vnode.attrs.name, + title: option, + value: option, + onchange: (e) => { + if (e.target.checked) { + this.value.push(e.target.value); + } else { + this.value = this.value.filter(item => item !== e.target.value); + } + vnode.attrs.onchange({ target: { name: e.target.name, value: this.value } }); + }, + args: { type: 'checkbox' }, + }))), + ]); + } + return m('div', { class: vnode.attrs.classes }, [ + m('div', vnode.attrs.options.map(option => + m(inputGroup, { + name: vnode.attrs.name, + title: option, + onchange: vnode.attrs.onchange, + args: { type: 'radio' }, + }))), + m(`label[for=${vnode.attrs.name}]`, vnode.attrs.title), + ]); + } + case 'select': + default: { + if (vnode.attrs.args.multipleSelect) { + return m('div', { class: vnode.attrs.classes }, [ + m(`label[for=${vnode.attrs.name}]`, vnode.attrs.title), + m( + `select[name=${vnode.attrs.name}][id=${vnode.attrs.name}]`, + { + onchange: (e) => { + const value = []; + let opt; + for (let i = 0; i < e.target.options.length; i += 1) { + opt = e.target.options[i]; + if (opt.selected) { + value.push(opt); + } + } + vnode.attrs.onchange(e); + }, + multiple: true, + }, + vnode.attrs.options.map(option => m('option', option)), + ), + ]); + } + return m('div', { class: vnode.attrs.classes }, [ + m(`label[for=${vnode.attrs.name}]`, vnode.attrs.title), + m( + `select[name=${vnode.attrs.name}][id=${vnode.attrs.name}]`, + { + value: vnode.attrs.value, + onchange: vnode.attrs.onchange, + multiple: false, + }, + vnode.attrs.args.options.map(option => m('option', option)), + ), + ]); + } + } + } +} + +export class submitButton { + static view(vnode) { + const { args } = vnode.attrs; + if (!vnode.attrs.active) { + args.disabled = 'disabled'; + } + return m('button[type=button]', args, vnode.attrs.text); + } +} diff --git a/src/views/jsonSchemaForm.js b/src/views/jsonSchemaForm.js new file mode 100644 index 0000000000000000000000000000000000000000..aa07e8f31cf1391845b447261742bba72d146faf --- /dev/null +++ b/src/views/jsonSchemaForm.js @@ -0,0 +1,129 @@ +import Ajv from 'ajv'; +import { inputGroup, selectGroup } from './formFields'; + +const m = require('mithril'); +const jsonSchemaDraft04 = require('ajv/lib/refs/json-schema-draft-04.json'); + +export default class JSONSchemaForm { + constructor() { + this.data = {}; + this.errors = {}; + + this.ajv = new Ajv({ + missingRefs: 'ignore', + errorDataPath: 'property', + allErrors: true, + }); + this.ajv.addMetaSchema(jsonSchemaDraft04); + } + + oninit(vnode) { + this.schema = vnode.attrs.schema; + if (this.schema === null || typeof this.schema !== 'object') { + this.schema = undefined; + } else { + this.ajv.addSchema(this.schema, 'schema'); + } + } + + // bind form-fields to the object data and validation + bind(attrs) { + // initialize error-list for every bound field + if (!this.errors[attrs.name]) this.errors[attrs.name] = []; + + const boundFormelement = { + onchange: (e) => { + // bind changed data + this.data[e.target.name] = e.target.value; + + // validate against schema + const validate = this.ajv.getSchema('schema'); + this.valid = validate(this.data); + + if (this.valid) { + Object.keys(this.errors).forEach((field) => { + this.errors[field] = []; + }); + } else { + // get errors for respective fields + Object.keys(this.errors).forEach((field) => { + const errors = validate.errors.filter(error => + `.${field}` === error.dataPath); + this.errors[field] = errors.map(error => error.message); + }); + } + }, + getErrors: () => this.errors[attrs.name], + value: this.data[attrs.name], + }; + // add the given attributes + Object.keys(attrs).forEach((key) => { boundFormelement[key] = attrs[key]; }); + + return boundFormelement; + } + + view() { + const elements = this.renderFormElements(); + return m('form', elements); + } + + isValid() { + return this.schema === undefined || this.valid; + } + + getValue() { + return this.data; + } + + // render all schema properties to an array of form-fields + renderFormElements() { + const elements = []; + if (this.schema !== undefined) { + Object.keys(this.schema.properties).forEach((key) => { + elements.push(this._renderProperty(key, this.schema.properties[key])); + }); + } + return elements; + } + + // render schema property to form-fields + _renderProperty(key, item) { + if ('enum' in item) { + return m(selectGroup, this.bind({ + name: key, + title: item.description, + args: { + options: item.enum, + type: 'select', + multipleSelect: false, + }, + })); + } + switch (item.type) { + case 'integer': { + return m(inputGroup, this.bind({ name: key, title: item.description, args: { type: 'number', step: 1 } })); + } + case 'number': { + return m(inputGroup, this.bind({ name: key, title: item.description, args: { type: 'number' } })); + } + case 'boolean': { + return m(inputGroup, this.bind({ name: key, title: item.description, args: { type: 'checkbox' } })); + } + case 'array': { + return m(selectGroup, this.bind({ + name: key, + title: item.description, + args: { + options: item.items.enum, + type: item.items.enum.length > 8 ? 'select' : 'buttons', + multipleSelect: true, + }, + })); + } + case 'string': + default: { + return m(inputGroup, this.bind({ name: key, title: item.description, args: { type: 'text' } })); + } + } + } +}