Skip to content
Snippets Groups Projects
editEvent.js 22.4 KiB
Newer Older
Hermann's avatar
Hermann committed
import m from 'mithril';
import { styler } from 'polythene-core-css';
boian's avatar
boian committed
import {
  RadioGroup, Switch, Dialog, Button, Tabs, Icon, TextField,
} from 'polythene-mithril';
import { FileInput, ListSelect, DatalistController } from 'amiv-web-ui-components';
Hermann's avatar
Hermann committed
import { TabsCSS, ButtonCSS } from 'polythene-css';
// eslint-disable-next-line import/extensions
import { apiUrl, ownUrl } from 'networkConfig';
boian's avatar
boian committed
import { ResourceHandler } from '../auth';
Hermann's avatar
Hermann committed
import { colors } from '../style';
import { icons } from '../views/elements';
Hermann's avatar
Hermann committed
import EditView from '../views/editView';
Hermann's avatar
Hermann committed
ButtonCSS.addStyle('.nav-button', {
  color_light_border: 'rgba(0, 0, 0, 0.09)',
  color_light_disabled_background: 'rgba(0, 0, 0, 0.09)',
  color_light_disabled_border: 'transparent',
});

TabsCSS.addStyle('.edit-tabs', {
  color_light: '#555555',
  // no hover effect
  color_light_hover: '#555555',
  color_light_selected: colors.amiv_blue,
  color_light_tab_indicator: colors.amiv_blue,
});
const styles = [
  {
    '.imgPlaceholder': {
      background: '#999',
      position: 'relative',
    },
    '.imgPlaceholder > div': {
      position: 'absolute',
      top: 0,
      bottom: 0,
      left: 0,
      right: 0,
      'font-size': '16px',
      display: 'flex',
      'justify-content': 'center',
      'align-items': 'center',
    },
    '.imgBackground': {
      'background-size': 'contain',
      'background-position': 'center',
      'background-repeat': 'no-repeat',
    },
  },
];
styler.add('eventEdit', styles);

Hermann's avatar
Hermann committed
export default class newEvent extends EditView {
  constructor(vnode) {
Hermann's avatar
Hermann committed
    super(vnode);
aneff's avatar
aneff committed
    this.currentpage = 1;
boian's avatar
boian committed
    // Create a usercontroller to handle the moderator field
    this.userHandler = new ResourceHandler('users', ['firstname', 'lastname', 'email', 'nethz']);
boian's avatar
boian committed
    this.userController = new DatalistController((query, search) => this.userHandler.get(
      { search, ...query },
    ));
boian's avatar
boian committed

    // check whether the user has the right to create events or can only propose
    this.rightSubmit = !m.route.get().startsWith('/proposeevent');

    // proposition URL-link decoder
    if (this.rightSubmit && m.route.param('proposition')) {
      const data = JSON.parse(window.atob(m.route.param('proposition')));
      this.form.data = data;
    }
maspect's avatar
maspect committed
    if (this.form.data.priority === 10) this.form.data.high_priority = true;

    // read additional_fields to make it editable
    if (this.form.data.additional_fields) {
      const copy = JSON.parse(this.form.data.additional_fields);
      this.form.data.add_fields_sbb = 'sbb_abo' in copy.properties;
      this.form.data.add_fields_food = 'food' in copy.properties;
      this.form.data.additional_fields = null;
      let i = 0;
      while (`text${i}` in copy.properties) {
        this.form.data[`add_fields_text${i}`] = copy.properties[`text${i}`].title;
        i += 1;
      }
      // TODO: find a better solution to keep track of the additional textfields
      this.add_fields_text_index = i;
    } else {
      this.add_fields_text_index = 0;
    // price can either not be set or set to null
    // if it is 0 however, that would mean that there actually is a price that
    // you can edit
    this.hasprice = 'price' in this.form.data && this.form.data.price !== null;
    this.hasregistration = 'spots' in this.form.data || 'time_registration_start' in this.form.data;
Hermann's avatar
Hermann committed
  beforeSubmit() {
    // Here comes all the processing from the state of our input form into data send to the api.
    // In particular, we have to:
    // - remove images from the patch that should not get changed, add images in right format that
    //   should be changed
    // - transfer states like add_fields_sbb etc. into actual additional_fields
    // - dependent on user rights, either submit to API or create an event proposal link

    const { data } = JSON.parse(JSON.stringify(this.form));

    // Images that should be changed have new_{key} set, this needs to get uploaded to the API
    // All the other images should be removed from the upload to not overwrite them.
    const images = {};
Hermann's avatar
Hermann committed
    ['thumbnail', 'infoscreen', 'poster'].forEach((key) => {
      if (this.form.data[`new_${key}`]) {
        images[`img_${key}`] = this.form.data[`new_${key}`];
        delete data[`new_${key}`];
      if (data[`img_${key}`] !== undefined && data[`img_${key}`] !== null) {
        delete data[`img_${key}`];
Hermann's avatar
Hermann committed
    });

    // Merge Options for additional fields
Hermann's avatar
Hermann committed
    const additionalFields = {
      $schema: 'http://json-schema.org/draft-04/schema#',
Hermann's avatar
Hermann committed
      additionalProperties: false,
      title: 'Additional Fields',
      type: 'object',
      properties: {},
      required: [],
    };
    if (data.add_fields_sbb) {
      additionalFields.properties.sbb_abo = {
Hermann's avatar
Hermann committed
        type: 'string',
        title: 'SBB Abonnement',
Hermann's avatar
Hermann committed
        enum: ['None', 'GA', 'Halbtax', 'Gleis 7', 'HT + Gleis 7'],
Hermann's avatar
Hermann committed
      };
      additionalFields.required.push('sbb_abo');
    if (data.add_fields_food) {
      additionalFields.properties.food = {
Hermann's avatar
Hermann committed
        type: 'string',
Hermann's avatar
Hermann committed
        enum: ['Omnivor', 'Vegi', 'Vegan', 'Other'],
      };
      additionalFields.properties.food_special = {
        title: 'Special Food Requirements',
Hermann's avatar
Hermann committed
      };
      additionalFields.required.push('food');
    // There can be an arbitrary number of text fields added.
    while (`add_fields_text${i}` in data) {
      const fieldName = `text${i}`;
      additionalFields.properties[fieldName] = {
        type: 'string',
        minLength: 1,
        title: data[`add_fields_text${i}`],
      };
      additionalFields.required.push(fieldName);
      delete data[`add_fields_text${i}`];
    // Remove our intermediate form states from the the data that is uploaded
    if ('add_fields_sbb' in data) delete data.add_fields_sbb;
    if ('add_fields_food' in data) delete data.add_fields_food;
    // If there are no additional_fields, the properties are empty, and we null the whole field,
    // otherwise we send a json string of the additional fields object
    if (Object.keys(additionalFields.properties).length > 0) {
      data.additional_fields = JSON.stringify(additionalFields);
      data.additional_fields = null;
    // Translate state high_priority into a priority for the event
    if (data.high_priority === true) data.priority = 10;
    else data.priority = 1;
    delete data.high_priority;
boian's avatar
boian committed

Hermann's avatar
Hermann committed
    // if spots is not set, also remove 'allow_email_signup'
    if (!('spots' in data) && 'allow_email_signup' in data
        && !data.allow_email_signup) {
      delete data.allow_email_signup;
    // Propose Event <=> Submit Changes dependent on the user rights
    if (this.rightSubmit) {
      // Submition tool

      // Change moderator from user object to user id
      if (data.moderator) data.moderator = data.moderator._id;
Hermann's avatar
Hermann committed
      // first upload the data as JSON, then the images as form data
      this.submit(data).then(({ _id, _etag }) => {
        if (Object.keys(images).length > 0) {
          // there are changed images to upload, they are added as an additional PATCH on this event
Hermann's avatar
Hermann committed
          const imageForm = new FormData();
          Object.keys(images).forEach(key => imageForm.append(key, images[key]));
Ian Boschung's avatar
Ian Boschung committed
          imageForm.append('_id', _id);
          imageForm.append('_etag', _etag);
          this.controller.patch(imageForm).then(() => this.controller.changeModus('view'));
Hermann's avatar
Hermann committed
        } else {
          this.controller.changeModus('view');
        }
      });
      // Propose tool
      Dialog.show({
        title: 'Congratulations!',
        body: [
          m(
            'div',
            'You sucessfuly setup an event.',
            'Please send this link to the respectiv board member for validation.',
          ),
          m('input', {
            type: 'text',
            style: { width: '335px' },
            value: `${ownUrl}/newevent?${m.buildQueryString({
              proposition: window.btoa(JSON.stringify(this.form.data)),
            })}`,
            id: 'textId',
          }),
        ],
        backdrop: true,
        footerButtons: [
          m(Button, {
            label: 'Copy',
            events: {
              onclick: () => {
                const copyText = document.getElementById('textId');
                copyText.select();
                document.execCommand('copy');
              },
            },
          }),
        ],
      });
Hermann's avatar
Hermann committed
  }

  view() {
    // load image urls from the API data
    ['thumbnail', 'poster', 'infoscreen'].forEach((key) => {
      const img = this.form.data[`img_${key}`];
      if (typeof (img) === 'object' && img !== null && 'file' in img) {
        // the data from the API has a weird format, we only need the url to display the image
        this.form.data[`img_${key}`] = { url: `${apiUrl}${img.file}` };
      }
    });

    // Define the number of Tabs and their titles
boian's avatar
boian committed
    const titles = ['Event Description', 'When and Where?', 'Signups', 'Internal Info'];
    if (this.rightSubmit) titles.push('Images');
    // Data fields of the event in the different tabs. Ordered the same way as the titles
    const keysPages = [[
      'title_en',
      'catchphrase_en',
      'description_en',
      'title_de',
      'catchphrase_de',
      'description_de',
    ],
    ['time_start', 'time_end', 'location'],
    ['price', 'spots', 'time_register_start', 'time_register_end'],
boian's avatar
boian committed
    ['moderator', 'time_advertising_start', 'time_advertising_end'],
    [],
    ];
    // Look which Tabs have errors
    const errorPages = keysPages.map(keysOfOnePage => keysOfOnePage.map((key) => {
      if (this.form.errors && key in this.form.errors) return this.form.errors[key].length > 0;
      return false;
    }).includes(true));
    // Navigation Buttons to go to next/previous page
Hermann's avatar
Hermann committed
    const buttonRight = m(Button, {
      label: m('div.pe-button__label', m(Icon, {
        svg: { content: m.trust(icons.ArrowRight) },
        style: { top: '-5px', float: 'right' },
      }), 'next'),
      disabled: this.currentpage === titles.length,
Christian H's avatar
Christian H committed
      ink: false,
Hermann's avatar
Hermann committed
      border: true,
      className: 'nav-button',
      events: { onclick: () => { this.currentpage = Math.min(this.currentpage + 1, 5); } },
Hermann's avatar
Hermann committed
    const buttonLeft = m(Button, {
      label: m('div.pe-button__label', m(Icon, {
        svg: { content: m.trust(icons.ArrowLeft) },
        style: { top: '-5px', float: 'left' },
      }), 'previous'),
      disabled: this.currentpage === 1,
Christian H's avatar
Christian H committed
      ink: false,
Hermann's avatar
Hermann committed
      border: true,
      className: 'nav-button',
      events: { onclick: () => { this.currentpage = Math.max(1, this.currentpage - 1); } },
aneff's avatar
aneff committed
    const radioButtonSelectionMode = m(RadioGroup, {
      name: 'Selection Mode',
      buttons: [
        {
          value: 'fcfs',
          label: 'First come, first serve',
        },
        {
          value: 'manual',
          label: 'Selection made by organizer',
        },
      ],
      onChange: (state) => {
        this.selection_strategy = state.value;
        this.form.data.selection_strategy = state.value;
    // Processing for additional text fields users have to fill in for the signup.
    const addFieldsText = [];
    let i = 0;
    while (`add_fields_text${i}` in this.form.data) {
      addFieldsText.push(this.form._renderField(`add_fields_text${i}`, {
        type: 'string',
        label: `Label for Textfield ${i}`,
      }));
      const fieldIndex = i;
      addFieldsText.push(m(Button, {
        label: `Remove Textfield ${i}`,
        className: 'red-row-button',
        events: {
          onclick: () => {
            let index = fieldIndex;
            while (`add_fields_text${index + 1}` in this.form.data) {
boian's avatar
boian committed
              this.form.data[`add_fields_text${index}`] = this.form.data[
                `add_fields_text${index + 1}`];
              index += 1;
            }
            delete this.form.data[`add_fields_text${index}`];
            this.add_fields_text_index = index;
          },
        },
      }));
      i += 1;
    }

aneff's avatar
aneff committed
    // checks currentPage and selects the fitting page
Hermann's avatar
Hermann committed
    return this.layout([
      // navigation bar
      // all pages are displayed, current is highlighted,
      // validation errors are shown per page by red icon-background
Hermann's avatar
Hermann committed
      m(Tabs, {
        className: 'edit-tabs',
        // it would be too easy if we could set the background color in the theme class
        style: { backgroundColor: colors.orange },
        onChange: ({ index }) => { this.currentpage = index + 1; },
        centered: true,
        selectedTabIndex: this.currentpage - 1,
      }, [...titles.entries()].map((numAndTitle) => {
        const buttonAttrs = { label: numAndTitle[1] };
        if (errorPages[numAndTitle[0]]) {
          // in case of an error, put an error icon before the tab label
          buttonAttrs.label = m('div', m(Icon, {
            svg: { content: m.trust(icons.error) },
            style: { top: '-2px', 'margin-right': '4px' },
          }), numAndTitle[1]);
        }
        return buttonAttrs;
      })),
Hermann's avatar
Hermann committed
      m('div.maincontainer', { style: { height: 'calc(100vh - 180px)', 'overflow-y': 'auto' } }, [
        // page 1: title & description
        m('div', {
          style: { display: (this.currentpage === 1) ? 'block' : 'none' },
        }, [
          ...this.form.renderSchema(['title_en', 'catchphrase_en']),
          this.form._renderField('description_en', {
            type: 'string',
Hermann's avatar
Hermann committed
            label: 'English Description',
            multiLine: true,
            rows: 5,
          }),
          ...this.form.renderSchema(['title_de', 'catchphrase_de']),
          this.form._renderField('description_de', {
            type: 'string',
Hermann's avatar
Hermann committed
            label: 'German Description',
            multiLine: true,
            rows: 5,
Hermann's avatar
Hermann committed
        // page 2: when & where
        m('div', {
          style: { display: (this.currentpage === 2) ? 'block' : 'none' },
        }, this.form.renderSchema(['time_start', 'time_end', 'location'])),
Hermann's avatar
Hermann committed
        // page 3: registration
        m('div', {
          style: { display: (this.currentpage === 3) ? 'block' : 'none' },
        }, [
          m(Switch, {
            label: 'people have to pay something to attend this event',
            style: { 'margin-bottom': '5px' },
            checked: this.hasprice,
            onChange: ({ checked }) => {
              this.hasprice = checked;
              if (!checked) {
                // if it originally had a price, set to null, otherwise delete
                if (this.controller.data.price) this.form.data.price = null;
                else delete this.form.data.price;
              }
            },
          }),
Hermann's avatar
Hermann committed
          ...(this.hasprice ? this.form.renderSchema(['price']) : []),
Hermann's avatar
Hermann committed
          m('br'),
          m(Switch, {
            label: 'people have to register to attend this event',
            checked: this.hasregistration,
            onChange: ({ checked }) => {
              this.hasregistration = checked;
              if (!checked) {
                // remove all the data connected to registration
Hermann's avatar
Hermann committed
                delete this.form.data.spots;
                delete this.form.data.time_register_start;
                delete this.form.data.time_register_end;
                delete this.form.data.add_fields_sbb;
                delete this.form.data.add_fields_food;
                delete this.form.data.allow_email_signup;
                delete this.form.data.selection_strategy;
              }
            },
          }),
Hermann's avatar
Hermann committed
          ...(this.hasregistration ? this.form.renderSchema([
            'spots', 'time_register_start', 'time_register_end']) : []),
          this.hasregistration && this.form._renderField('add_fields_food', {
            type: 'boolean',
            label: 'Food Limitations',
Hermann's avatar
Hermann committed
          }),
          this.hasregistration && this.form._renderField('add_fields_sbb', {
            type: 'boolean',
            label: 'SBB Abonnement',
          }),
Hermann's avatar
Hermann committed
          ...(this.hasregistration ? addFieldsText : []),
          m('br'),
          this.hasregistration && m(Button, {
            label: 'Additional Textfield',
            className: 'blue-button',
            border: true,
            events: {
              onclick: () => {
                this.form.data[`add_fields_text${this.add_fields_text_index}`] = '';
                this.add_fields_text_index += 1;
              },
            },
Hermann's avatar
Hermann committed
          }),
          m('br'),
Hermann's avatar
Hermann committed
          ...(this.hasregistration ? this.form.renderSchema(['allow_email_signup']) : []),
Hermann's avatar
Hermann committed
          this.hasregistration && radioButtonSelectionMode,
        ]),
boian's avatar
boian committed
        // PAGE 4: Internal Info
Hermann's avatar
Hermann committed
        m('div', {
          style: { display: (this.currentpage === 4) ? 'block' : 'none' },
        }, [
boian's avatar
boian committed
          m('div', { style: { display: 'flex', 'margin-top': '5px' } }, [
            m(TextField, {
              label: 'Moderator: ',
              disabled: true,
              style: { width: '200px' },
              help: 'Can edit the event and see signups.',
            }),
            m('div', { style: { 'flex-grow': 1 } }, m(ListSelect, {
              controller: this.userController,
              selection: this.form.data.moderator,
              listTileAttrs: user => Object.assign(
                {},
                { title: `${user.firstname} ${user.lastname}` },
              ),
              selectedText: user => `${user.firstname} ${user.lastname}`,
              onSelect: (data) => { this.form.data.moderator = data; },
            })),
          ]),
          ...this.form.renderSchema(['time_advertising_start', 'time_advertising_end']),
          ...this.form.renderSchema(['show_website', 'show_announce', 'show_infoscreen']),
maspect's avatar
maspect committed
          // pritority update
          this.form._renderField('high_priority', {
            type: 'boolean',
            label: 'Set high Priority',
          }),
boian's avatar
boian committed
          m('div', 'Please send your announce text additionally via email to info@amiv.ch '
          + 'until the new announce tool is ready.'),
          m('div', 'Please send an email to info@amiv.ch in order to show your event on'
          + 'the infoscreen until the new infoscreen tool is ready.'),
maspect's avatar
maspect committed

Hermann's avatar
Hermann committed
        ]),
maspect's avatar
maspect committed

Hermann's avatar
Hermann committed
        // page 5: images
        m('div', {
          style: { display: (this.currentpage === 5) ? 'block' : 'none' },
Hermann's avatar
Hermann committed
        }, [
          m('div', 'Formats for the files: Thumbnail: 1:1, Poster: Any DIN-A, Infoscreen: 16:9'),
          // All images and placeholders are placed next to each other in the following div:
          m('div', { style: { width: '90%', display: 'flex' } }, [
            // POSTER
            m('div', { style: { width: '30%' } }, [
              m('div', 'Poster'),
              // imgPlaceholder has exactly a 1:1 aspect ratio
              m('div.imgPlaceholder', { style: { width: '100%', 'padding-bottom': '141%' } }, [
                // inside, we display the image. if it has a wrong aspect ratio, grey areas
                // from the imgPlaceholder will be visible behind the image
                this.form.data.img_poster ? m('div.imgBackground', {
                  style: { 'background-image': `url(${this.form.data.img_poster.url})` },
                // Placeholder in case that there is no image
                }) : m('div', 'No Poster'),
              ]),
              m(Button, {
                className: 'red-row-button',
                borders: false,
                label: 'remove',
                events: { onclick: () => { this.form.data.img_poster = null; } },
              }),
            ]),
            // INFOSCREEN
            m('div', { style: { width: '50%', 'margin-left': '5%' } }, [
              m('div', 'Infoscreen'),
              // imgPlaceholder has exactly a 16:9 aspect ratio
              m('div.imgPlaceholder', { style: { width: '100%', 'padding-bottom': '56.25%' } }, [
                // inside, we display the image. if it has a wrong aspect ratio, grey areas
                // from the imgPlaceholder will be visible behind the image
                this.form.data.img_infoscreen ? m('div.imgBackground', {
                  style: { 'background-image': `url(${this.form.data.img_infoscreen.url})` },
                  // Placeholder in case that there is no image
                }) : m('div', 'No Infoscreen Image'),
              ]),
              m(Button, {
                className: 'red-row-button',
                borders: false,
                label: 'remove',
                events: { onclick: () => { this.form.data.img_infoscreen = null; } },
              }),
            ]),
            // THUMBNAIL
            m('div', { style: { width: '10%', 'margin-left': '5%' } }, [
              m('div', 'Thumbnail'),
              // imgPlaceholder has exactly a 16:9 aspect ratio
              m('div.imgPlaceholder', { style: { width: '100%', 'padding-bottom': '100%' } }, [
                // inside, we display the image. if it has a wrong aspect ratio, grey areas
                // from the imgPlaceholder will be visible behind the image
                this.form.data.img_thumbnail ? m('div.imgBackground', {
                  style: { 'background-image': `url(${this.form.data.img_thumbnail.url})` },
                  // Placeholder in case that there is no image
                }) : m('div', 'No Thumbnail'),
              ]),
              m(Button, {
                className: 'red-row-button',
                borders: false,
                label: 'remove',
                events: { onclick: () => { this.form.data.img_thumbnail = null; } },
              }),
            ]),
          ]),
          // old stuff, goes through all images
Hermann's avatar
Hermann committed
          ['thumbnail', 'poster', 'infoscreen'].map(key => [
Hermann's avatar
Hermann committed
            m(FileInput, this.form.bind({
              name: `new_${key}`,
              label: `New ${key} Image`,
              accept: 'image/png, image/jpeg',
              onChange: ({ value }) => {
                // if a new image file is selected, we display it using a data encoded url
                const reader = new FileReader();
                reader.onload = ({ target: { result } }) => {
                  this.form.data[`img_${key}`] = { url: result };
                  m.redraw();
                };
                reader.readAsDataURL(value);
                this.form.data[`new_${key}`] = value;
              },
Hermann's avatar
Hermann committed
            })),
          ]),
Hermann's avatar
Hermann committed
        ]),
Hermann's avatar
Hermann committed
        m('div', {
          style: {
            display: 'flex',
            'justify-content': 'space-between',
            padding: '35px',
            'padding-top': '20px',
          },
        }, [buttonLeft, buttonRight]),
Hermann's avatar
Hermann committed
    ], this.rightSubmit ? 'submit' : 'propose', false);