Răsfoiți Sursa

resolving merge conflicts

IgnatzHome 7 ani în urmă
părinte
comite
9518a5c11e

+ 1 - 0
.gitignore

@@ -15,3 +15,4 @@ package-lock.json
 **/prime
 **/*.snap
 snap/.snapcraft/
+.idea

+ 3 - 0
client/components/activities/activities.jade

@@ -53,6 +53,9 @@ template(name="boardActivities")
         if($eq activityType 'createCard')
           | {{{_ 'activity-added' cardLink boardLabel}}}.
 
+        if($eq activityType 'createCustomField')
+          | {{_ 'activity-customfield-created' customField}}.
+
         if($eq activityType 'createList')
           | {{_ 'activity-added' list.title boardLabel}}.
 

+ 5 - 0
client/components/activities/activities.js

@@ -91,6 +91,11 @@ BlazeComponent.extendComponent({
     }, attachment.name()));
   },
 
+  customField() {
+    const customField = this.currentData().customField();
+    return customField.name;
+  },
+
   events() {
     return [{
       // XXX We should use Popup.afterConfirmation here

+ 1 - 0
client/components/boards/boardHeader.jade

@@ -113,6 +113,7 @@ template(name="boardHeaderBar")
 
 template(name="boardMenuPopup")
   ul.pop-over-list
+    li: a.js-custom-fields {{_ 'custom-fields'}}
     li: a.js-open-archives {{_ 'archived-items'}}
     if currentUser.isBoardAdmin
       li: a.js-change-board-color {{_ 'board-change-color'}}

+ 4 - 0
client/components/boards/boardHeader.js

@@ -1,5 +1,9 @@
 Template.boardMenuPopup.events({
   'click .js-rename-board': Popup.open('boardChangeTitle'),
+  'click .js-custom-fields'() {
+    Sidebar.setView('customFields');
+    Popup.close();
+  },
   'click .js-open-archives'() {
     Sidebar.setView('archives');
     Popup.close();

+ 76 - 0
client/components/cards/cardCustomFields.jade

@@ -0,0 +1,76 @@
+template(name="cardCustomFieldsPopup")
+    ul.pop-over-list
+        each board.customFields
+            li.item(class="")
+                a.name.js-select-field(href="#")
+                    span.full-name
+                        = name
+                    if hasCustomField
+                        i.fa.fa-check
+    hr
+    a.quiet-button.full.js-settings
+        i.fa.fa-cog
+        span {{_ 'settings'}}
+
+template(name="cardCustomField")
+    +Template.dynamic(template=getTemplate)
+
+template(name="cardCustomField-text")
+    if canModifyCard
+        +inlinedForm(classNames="js-card-customfield-text")
+            +editor(autofocus=true)
+                = value
+            .edit-controls.clearfix
+                button.primary(type="submit") {{_ 'save'}}
+                a.fa.fa-times-thin.js-close-inlined-form
+        else
+            a.js-open-inlined-form
+                if value
+                    +viewer
+                        = value
+                else
+                    | {{_ 'edit'}}
+
+template(name="cardCustomField-number")
+    if canModifyCard
+        +inlinedForm(classNames="js-card-customfield-number")
+            input(type="number" value=data.value)
+            .edit-controls.clearfix
+                button.primary(type="submit") {{_ 'save'}}
+                a.fa.fa-times-thin.js-close-inlined-form
+        else
+            a.js-open-inlined-form
+                if value
+                    = value
+                else
+                    | {{_ 'edit'}}
+
+template(name="cardCustomField-date")
+    if canModifyCard
+        a.js-edit-date(title="{{showTitle}}" class="{{classes}}")
+            if value
+                div.card-date
+                    time(datetime="{{showISODate}}")
+                        | {{showDate}}
+            else
+                | {{_ 'edit'}}
+
+template(name="cardCustomField-dropdown")
+    if canModifyCard
+        +inlinedForm(classNames="js-card-customfield-dropdown")
+            select.inline
+                each items
+                    if($eq data.value this._id)
+                        option(value=_id selected="selected") {{name}}
+                    else
+                        option(value=_id) {{name}}
+            .edit-controls.clearfix
+                button.primary(type="submit") {{_ 'save'}}
+                a.fa.fa-times-thin.js-close-inlined-form
+        else
+            a.js-open-inlined-form
+                if value
+                    +viewer
+                        = selectedItem
+                else
+                    | {{_ 'edit'}}

+ 179 - 0
client/components/cards/cardCustomFields.js

@@ -0,0 +1,179 @@
+Template.cardCustomFieldsPopup.helpers({
+    hasCustomField() {
+        const card = Cards.findOne(Session.get('currentCard'));
+        const customFieldId = this._id;
+        return card.customFieldIndex(customFieldId) > -1;
+    },
+});
+
+Template.cardCustomFieldsPopup.events({
+    'click .js-select-field'(evt) {
+        const card = Cards.findOne(Session.get('currentCard'));
+        const customFieldId = this._id;
+        card.toggleCustomField(customFieldId);
+        evt.preventDefault();
+    },
+    'click .js-settings'(evt) {
+        EscapeActions.executeUpTo('detailsPane');
+        Sidebar.setView('customFields');
+        evt.preventDefault();
+    }
+});
+
+// cardCustomField
+const CardCustomField = BlazeComponent.extendComponent({
+
+    getTemplate() {
+        return 'cardCustomField-' + this.data().definition.type;
+    },
+
+    onCreated() {
+        const self = this;
+        self.card = Cards.findOne(Session.get('currentCard'));
+        self.customFieldId = this.data()._id;
+    },
+
+    canModifyCard() {
+        return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly();
+    },
+});
+CardCustomField.register('cardCustomField');
+
+// cardCustomField-text
+(class extends CardCustomField {
+
+    onCreated() {
+        super.onCreated();
+    }
+
+    events() {
+        return [{
+            'submit .js-card-customfield-text'(evt) {
+                evt.preventDefault();
+                const value = this.currentComponent().getValue();
+                this.card.setCustomField(this.customFieldId, value);
+            },
+        }];
+    }
+
+}).register('cardCustomField-text');
+
+// cardCustomField-number
+(class extends CardCustomField {
+
+    onCreated() {
+        super.onCreated();
+    }
+
+    events() {
+        return [{
+            'submit .js-card-customfield-number'(evt) {
+                evt.preventDefault();
+                const value = parseInt(this.find('input').value);
+                this.card.setCustomField(this.customFieldId, value);
+            },
+        }];
+    }
+
+}).register('cardCustomField-number');
+
+// cardCustomField-date
+(class extends CardCustomField {
+
+    onCreated() {
+        super.onCreated();
+        const self = this;
+        self.date = ReactiveVar();
+        self.now = ReactiveVar(moment());
+        window.setInterval(() => {
+            self.now.set(moment());
+        }, 60000);
+
+        self.autorun(() => {
+            self.date.set(moment(self.data().value));
+        });
+    }
+
+    showDate() {
+        // this will start working once mquandalle:moment
+        // is updated to at least moment.js 2.10.5
+        // until then, the date is displayed in the "L" format
+        return this.date.get().calendar(null, {
+            sameElse: 'llll',
+        });
+    }
+
+    showISODate() {
+        return this.date.get().toISOString();
+    }
+
+    classes() {
+        if (this.date.get().isBefore(this.now.get(), 'minute') &&
+            this.now.get().isBefore(this.data().value)) {
+            return 'current';
+        }
+        return '';
+    }
+
+    showTitle() {
+        return `${TAPi18n.__('card-start-on')} ${this.date.get().format('LLLL')}`;
+    }
+
+    events() {
+        return [{
+            'click .js-edit-date': Popup.open('cardCustomField-date'),
+        }];
+    }
+
+}).register('cardCustomField-date');
+
+// cardCustomField-datePopup
+(class extends DatePicker {
+    onCreated() {
+        super.onCreated();
+        const self = this;
+        self.card = Cards.findOne(Session.get('currentCard'));
+        self.customFieldId = this.data()._id;
+        this.data().value && this.date.set(moment(this.data().value));
+    }
+
+    _storeDate(date) {
+        this.card.setCustomField(this.customFieldId, date);
+    }
+
+    _deleteDate() {
+        this.card.setCustomField(this.customFieldId, '');
+    }
+}).register('cardCustomField-datePopup');
+
+// cardCustomField-dropdown
+(class extends CardCustomField {
+
+    onCreated() {
+        super.onCreated();
+        this._items = this.data().definition.settings.dropdownItems;
+        this.items = this._items.slice(0);
+        this.items.unshift({
+            _id: "",
+            name: TAPi18n.__('custom-field-dropdown-none')
+        });
+    }
+
+    selectedItem() {
+        const selected = this._items.find((item) => {
+            return item._id == this.data().value;
+        });
+        return (selected) ? selected.name : TAPi18n.__('custom-field-dropdown-unknown');
+    }
+
+    events() {
+        return [{
+            'submit .js-card-customfield-dropdown'(evt) {
+                evt.preventDefault();
+                const value = this.find('select').value;
+                this.card.setCustomField(this.customFieldId, value);
+            },
+        }];
+    }
+
+}).register('cardCustomField-dropdown');

+ 0 - 16
client/components/cards/cardDate.jade

@@ -1,19 +1,3 @@
-template(name="editCardDate")
-  .edit-card-date
-    form.edit-date
-      .fields
-        .left
-          label(for="date") {{_ 'date'}}
-          input.js-date-field#date(type="text" name="date" value=showDate placeholder=dateFormat autofocus)
-        .right
-          label(for="time") {{_ 'time'}}
-          input.js-time-field#time(type="text" name="time" value=showTime placeholder=timeFormat)
-      .js-datepicker
-      if error.get
-        .warning {{_ error.get}}
-      button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
-      button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
-
 template(name="dateBadge")
   if canModifyCard
     a.js-edit-date.card-date(title="{{showTitle}}" class="{{classes}}")

+ 2 - 2
client/components/cards/cardDate.js

@@ -110,7 +110,7 @@ Template.dateBadge.helpers({
 
 
 // editCardStartDatePopup
-(class extends EditCardDate {
+(class extends DatePicker {
   onCreated() {
     super.onCreated();
     this.data().startAt && this.date.set(moment(this.data().startAt));
@@ -133,7 +133,7 @@ Template.dateBadge.helpers({
 }).register('editCardStartDatePopup');
 
 // editCardDueDatePopup
-(class extends EditCardDate {
+(class extends DatePicker {
   onCreated() {
     super.onCreated();
     this.data().dueAt && this.date.set(moment(this.data().dueAt));

+ 0 - 19
client/components/cards/cardDate.styl

@@ -1,22 +1,3 @@
-.edit-card-date
-  .fields
-    .left
-      width: 56%
-    .right
-      width: 38%
-  .datepicker
-    width: 100%
-    table
-      width: 100%
-      border: none
-      border-spacing: 0
-      border-collapse: collapse
-      thead
-        background: none
-      td, th
-        box-sizing: border-box
-
-
 .card-date
   display: block
   border-radius: 4px

+ 17 - 0
client/components/cards/cardDetails.jade

@@ -65,6 +65,22 @@ template(name="cardDetails")
           a.card-label.add-label.js-add-labels(title="{{_ 'card-labels-title'}}")
             i.fa.fa-plus
 
+      if startAt
+        .card-details-item.card-details-item-start
+          h3.card-details-item-title {{_ 'card-start'}}
+          +cardStartDate
+
+      if dueAt
+        .card-details-item.card-details-item-due
+          h3.card-details-item-title {{_ 'card-due'}}
+          +cardDueDate
+
+      each customFieldsWD
+        .card-details-item.card-details-item-customfield
+          h3.card-details-item-title
+            = definition.name
+          +cardCustomField
+
     .card-details-items
       if spentTime
         .card-details-item.card-details-item-spent
@@ -144,6 +160,7 @@ template(name="cardDetailsActionsPopup")
       li: a.js-labels {{_ 'card-edit-labels'}}
       li: a.js-attachments {{_ 'card-edit-attachments'}}
       li: a.js-received-date {{_ 'editCardReceivedDatePopup-title'}}
+      li: a.js-custom-fields {{_ 'card-edit-custom-fields'}}
       li: a.js-start-date {{_ 'editCardStartDatePopup-title'}}
       li: a.js-due-date {{_ 'editCardDueDatePopup-title'}}
       li: a.js-end-date {{_ 'editCardEndDatePopup-title'}}

+ 1 - 0
client/components/cards/cardDetails.js

@@ -216,6 +216,7 @@ Template.cardDetailsActionsPopup.events({
   'click .js-labels': Popup.open('cardLabels'),
   'click .js-attachments': Popup.open('cardAttachments'),
   'click .js-received-date': Popup.open('editCardReceivedDate'),
+  'click .js-custom-fields': Popup.open('cardCustomFields'),
   'click .js-start-date': Popup.open('editCardStartDate'),
   'click .js-due-date': Popup.open('editCardDueDate'),
   'click .js-end-date': Popup.open('editCardEndDate'),

+ 6 - 2
client/components/cards/cardDetails.styl

@@ -69,10 +69,11 @@
 
   .card-details-items
     display: flex
-    margin: 15px 0
+    flex-wrap: wrap
+    margin: 0 0 15px
 
     .card-details-item
-      margin-right: 0.5em
+      margin: 15px 0.5em 0 0
       &:last-child
         margin-right: 0
       &.card-details-item-labels,
@@ -83,6 +84,9 @@
       &.card-details-item-end
         width: 50%
         flex-shrink: 1
+      &.card-details-item-customfield
+        max-width: 50%
+        flex-grow: 1
 
   .card-details-item-title
     font-size: 16px

+ 15 - 0
client/components/forms/datepicker.jade

@@ -0,0 +1,15 @@
+template(name="datepicker")
+    .datepicker-container
+        form.edit-date
+            .fields
+                .left
+                    label(for="date") {{_ 'date'}}
+                    input.js-date-field#date(type="text" name="date" value=showDate placeholder=dateFormat autofocus)
+                .right
+                    label(for="time") {{_ 'time'}}
+                    input.js-time-field#time(type="text" name="time" value=showTime placeholder=timeFormat)
+            .js-datepicker
+            if error.get
+                .warning {{_ error.get}}
+            button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
+            button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}

+ 17 - 0
client/components/forms/datepicker.styl

@@ -0,0 +1,17 @@
+.datepicker-container
+  .fields
+    .left
+      width: 56%
+    .right
+      width: 38%
+  .datepicker
+    width: 100%
+    table
+      width: 100%
+      border: none
+      border-spacing: 0
+      border-collapse: collapse
+      thead
+        background: none
+      td, th
+        box-sizing: border-box

+ 3 - 0
client/components/forms/forms.styl

@@ -85,6 +85,9 @@ select
   width: 256px
   margin-bottom: 8px
 
+  &.inline
+  	width: 100%
+
 option[disabled]
   color: #8c8c8c
 

+ 7 - 0
client/components/lists/listBody.js

@@ -35,6 +35,10 @@ BlazeComponent.extendComponent({
 
     const members = formComponent.members.get();
     const labelIds = formComponent.labels.get();
+    const customFields = formComponent.customFields.get();
+    console.log("members", members);
+    console.log("labelIds", labelIds);
+    console.log("customFields", customFields);
 
     const boardId = this.data().board()._id;
     let swimlaneId = '';
@@ -49,6 +53,7 @@ BlazeComponent.extendComponent({
         title,
         members,
         labelIds,
+        customFields,
         listId: this.data()._id,
         boardId: this.data().board()._id,
         sort: sortIndex,
@@ -146,11 +151,13 @@ BlazeComponent.extendComponent({
   onCreated() {
     this.labels = new ReactiveVar([]);
     this.members = new ReactiveVar([]);
+    this.customFields = new ReactiveVar([]);
   },
 
   reset() {
     this.labels.set([]);
     this.members.set([]);
+    this.customFields.set([]);
   },
 
   getLabels() {

+ 3 - 0
client/components/main/popup.styl

@@ -33,6 +33,9 @@ $popupWidth = 300px
   textarea
     height: 72px
 
+  form a span
+    padding: 0 0.5rem
+
   .header
     height: 36px
     position: relative

+ 1 - 0
client/components/sidebar/sidebar.js

@@ -6,6 +6,7 @@ const viewTitles = {
   filter: 'filter-cards',
   search: 'search-cards',
   multiselection: 'multi-selection',
+  customFields: 'custom-fields',
   archives: 'archives',
 };
 

+ 39 - 22
client/components/sidebar/sidebar.styl

@@ -45,28 +45,45 @@
       display: flex
       flex-direction: column
 
-      li > a
-        display: flex
-        height: 30px
-        margin: 0
-        padding: 4px
-        border-radius: 3px
-        align-items: center
-
-        &:hover
-          &, i, .quiet
-            color white
-
-        .member, .card-label
-          margin-right: 7px
-          margin-top: 5px
-
-        .sidebar-list-item-description
-          flex: 1
-          overflow: ellipsis
-
-        .fa.fa-check
-          margin: 0 4px
+      li
+        & > a
+          display: flex
+          height: 30px
+          margin: 0
+          padding: 4px
+          border-radius: 3px
+          align-items: center
+
+          &:hover
+            &, i, .quiet
+              color white
+
+          .member, .card-label
+            margin-right: 7px
+            margin-top: 5px
+
+          .minicard-edit-button
+            float: right
+            padding: 8px
+            border-radius: 3px
+
+          .sidebar-list-item-description
+            flex: 1
+            overflow: ellipsis
+
+          .fa.fa-check
+            margin: 0 4px
+
+        .minicard
+          padding: 6px 8px 4px
+
+          .minicard-edit-button
+            float: right
+            padding: 4px
+            border-radius: 3px
+
+            &:hover
+              background: #dbdbdb
 
     .sidebar-btn
       display: block

+ 52 - 0
client/components/sidebar/sidebarCustomFields.jade

@@ -0,0 +1,52 @@
+template(name="customFieldsSidebar")
+    ul.sidebar-list
+        each customFields
+            li
+                div.minicard-wrapper.js-minicard
+                    div.minicard
+                        a.fa.fa-pencil.js-edit-custom-field.minicard-edit-button
+                        div.minicard-title
+                            | {{ name }} ({{ type }})
+
+    if currentUser.isBoardMember
+        hr
+        a.sidebar-btn.js-open-create-custom-field
+            i.fa.fa-plus
+            span {{_ 'createCustomField'}}
+
+template(name="createCustomFieldPopup")
+    form
+        label
+            | {{_ 'name'}}
+            unless _id
+                input.js-field-name(type="text" autofocus)
+            else
+                input.js-field-name(type="text" value=name)
+
+        label
+            | {{_ 'type'}}
+            select.js-field-type(disabled="{{#if _id}}disabled{{/if}}")
+                each types
+                    if selected
+                        option(value=value selected="selected") {{name}}
+                    else
+                        option(value=value) {{name}}
+        div.js-field-settings.js-field-settings-dropdown(class="{{#if isTypeNotSelected 'dropdown'}}hide{{/if}}")
+            label
+                | {{_ 'custom-field-dropdown-options'}}
+            each dropdownItems.get
+                input.js-dropdown-item(type="text" value=name placeholder="")
+            input.js-dropdown-item.last(type="text" value="" placeholder="{{_ 'custom-field-dropdown-options-placeholder'}}")
+        a.flex.js-field-show-on-card
+            .materialCheckBox(class="{{#if showOnCard}}is-checked{{/if}}")
+
+            span {{_ 'show-field-on-card'}}
+        button.primary.wide.left(type="button")
+            | {{_ 'save'}}
+        if _id
+            button.negate.wide.right.js-delete-custom-field(type="button")
+                | {{_ 'delete'}}
+
+template(name="deleteCustomFieldPopup")
+    p {{_ "custom-field-delete-pop"}}
+    button.js-confirm.negate.full(type="submit") {{_ 'delete'}}

+ 130 - 0
client/components/sidebar/sidebarCustomFields.js

@@ -0,0 +1,130 @@
+BlazeComponent.extendComponent({
+
+  customFields() {
+    return CustomFields.find({
+      boardId: Session.get('currentBoard'),
+    });
+  },
+
+  events() {
+    return [{
+      'click .js-open-create-custom-field': Popup.open('createCustomField'),
+      'click .js-edit-custom-field': Popup.open('editCustomField'),
+    }];
+  },
+
+}).register('customFieldsSidebar');
+
+const CreateCustomFieldPopup = BlazeComponent.extendComponent({
+
+  _types: ['text', 'number', 'checkbox', 'date', 'dropdown'],
+
+  onCreated() {
+    this.type = new ReactiveVar((this.data().type) ? this.data().type : this._types[0]);
+    this.dropdownItems = new ReactiveVar((this.data().settings && this.data().settings.dropdownItems) ? this.data().settings.dropdownItems : []);
+  },
+
+  types() {
+    const currentType = this.data().type;
+    return this._types.
+        map(type => {return {
+          value: type,
+          name: TAPi18n.__('custom-field-' + type),
+          selected: type == currentType,
+        }});
+  },
+
+  isTypeNotSelected(type) {
+    return this.type.get() !== type;
+  },
+
+  getDropdownItems() {
+    var items = this.dropdownItems.get();
+    Array.from(this.findAll('.js-field-settings-dropdown input')).forEach((el, index) => {
+      //console.log('each item!', index, el.value);
+      if (!items[index]) items[index] = {
+        _id: Random.id(6),
+      };
+      items[index].name = el.value.trim();
+    });
+    return items;
+  },
+
+  getSettings() {
+    let settings = {};
+    switch (this.type.get()) {
+      case 'dropdown':
+        let dropdownItems = this.getDropdownItems().filter(item => !!item.name.trim());
+        settings.dropdownItems = dropdownItems;
+        break;
+    }
+    return settings;
+  },
+
+  events() {
+    return [{
+      'change .js-field-type'(evt) {
+        const value = evt.target.value;
+        this.type.set(value);
+      },
+      'keydown .js-dropdown-item.last'(evt) {
+        if (evt.target.value.trim() && evt.keyCode === 13) {
+          let items = this.getDropdownItems();
+          this.dropdownItems.set(items);
+          evt.target.value = '';
+        }
+      },
+      'click .js-field-show-on-card'(evt) {
+        let $target = $(evt.target);
+        if(!$target.hasClass('js-field-show-on-card')){
+          $target = $target.parent();
+        }
+        $target.find('.materialCheckBox').toggleClass('is-checked');
+        $target.toggleClass('is-checked');
+      },
+      'click .primary'(evt) {
+        evt.preventDefault();
+
+        const data = {
+          boardId: Session.get('currentBoard'),
+          name: this.find('.js-field-name').value.trim(),
+          type: this.type.get(),
+          settings: this.getSettings(),
+          showOnCard: this.find('.js-field-show-on-card.is-checked') != null
+        }
+
+        // insert or update
+        if (!this.data()._id) {
+          CustomFields.insert(data);
+        } else {
+          CustomFields.update(this.data()._id, {$set: data});
+        }
+
+        Popup.back();
+      },
+      'click .js-delete-custom-field': Popup.afterConfirm('deleteCustomField', function() {
+        const customFieldId = this._id;
+        CustomFields.remove(customFieldId);
+        Popup.close();
+      }),
+    }];
+  },
+
+});
+CreateCustomFieldPopup.register('createCustomFieldPopup');
+
+(class extends CreateCustomFieldPopup {
+
+  template() {
+    return 'createCustomFieldPopup';
+  }
+
+}).register('editCustomFieldPopup');
+
+/*Template.deleteCustomFieldPopup.events({
+  'submit'(evt) {
+    const customFieldId = this._id;
+    CustomFields.remove(customFieldId);
+    Popup.close();
+  }
+});*/

+ 86 - 0
client/lib/datepicker.js

@@ -0,0 +1,86 @@
+DatePicker = BlazeComponent.extendComponent({
+    template() {
+        return 'datepicker';
+    },
+
+    onCreated() {
+        this.error = new ReactiveVar('');
+        this.card = this.data();
+        this.date = new ReactiveVar(moment.invalid());
+    },
+
+    onRendered() {
+        const $picker = this.$('.js-datepicker').datepicker({
+            todayHighlight: true,
+            todayBtn: 'linked',
+            language: TAPi18n.getLanguage(),
+        }).on('changeDate', function(evt) {
+            this.find('#date').value = moment(evt.date).format('L');
+            this.error.set('');
+            this.find('#time').focus();
+        }.bind(this));
+
+        if (this.date.get().isValid()) {
+            $picker.datepicker('update', this.date.get().toDate());
+        }
+    },
+
+    showDate() {
+        if (this.date.get().isValid())
+            return this.date.get().format('L');
+        return '';
+    },
+    showTime() {
+        if (this.date.get().isValid())
+            return this.date.get().format('LT');
+        return '';
+    },
+    dateFormat() {
+        return moment.localeData().longDateFormat('L');
+    },
+    timeFormat() {
+        return moment.localeData().longDateFormat('LT');
+    },
+
+    events() {
+        return [{
+            'keyup .js-date-field'() {
+                // parse for localized date format in strict mode
+                const dateMoment = moment(this.find('#date').value, 'L', true);
+                if (dateMoment.isValid()) {
+                    this.error.set('');
+                    this.$('.js-datepicker').datepicker('update', dateMoment.toDate());
+                }
+            },
+            'keyup .js-time-field'() {
+                // parse for localized time format in strict mode
+                const dateMoment = moment(this.find('#time').value, 'LT', true);
+                if (dateMoment.isValid()) {
+                    this.error.set('');
+                }
+            },
+            'submit .edit-date'(evt) {
+                evt.preventDefault();
+
+                // if no time was given, init with 12:00
+                const time = evt.target.time.value || moment(new Date().setHours(12, 0, 0)).format('LT');
+
+                const dateString = `${evt.target.date.value} ${time}`;
+                const newDate = moment(dateString, 'L LT', true);
+                if (newDate.isValid()) {
+                    this._storeDate(newDate.toDate());
+                    Popup.close();
+                }
+                else {
+                    this.error.set('invalid-date');
+                    evt.target.date.focus();
+                }
+            },
+            'click .js-delete-date'(evt) {
+                evt.preventDefault();
+                this._deleteDate();
+                Popup.close();
+            },
+        }];
+    },
+});

+ 22 - 1
i18n/en.i18n.json

@@ -7,6 +7,7 @@
     "act-addComment": "commented on __card__: __comment__",
     "act-createBoard": "created __board__",
     "act-createCard": "added __card__ to __list__",
+    "act-createCustomField": "created custom field __customField__",
     "act-createList": "added __list__ to __board__",
     "act-addBoardMember": "added __member__ to __board__",
     "act-archivedBoard": "__board__ moved to Recycle Bin",
@@ -30,6 +31,7 @@
     "activity-archived": "%s moved to Recycle Bin",
     "activity-attached": "attached %s to %s",
     "activity-created": "created %s",
+    "activity-customfield-created": "created custom field %s",
     "activity-excluded": "excluded %s from %s",
     "activity-imported": "imported %s into %s from %s",
     "activity-imported-board": "imported %s from %s",
@@ -111,6 +113,7 @@
     "card-due-on": "Due on",
     "card-spent": "Spent Time",
     "card-edit-attachments": "Edit attachments",
+    "card-edit-custom-fields": "Edit custom fields",
     "card-edit-labels": "Edit labels",
     "card-edit-members": "Edit members",
     "card-labels-title": "Change the labels for the card.",
@@ -118,6 +121,8 @@
     "card-start": "Start",
     "card-start-on": "Starts on",
     "cardAttachmentsPopup-title": "Attach From",
+    "cardCustomField-datePopup-title": "Change date",
+    "cardCustomFieldsPopup-title": "Edit custom fields",
     "cardDeletePopup-title": "Delete Card?",
     "cardDetailsActionsPopup-title": "Card Actions",
     "cardLabelsPopup-title": "Labels",
@@ -167,11 +172,25 @@
     "createBoardPopup-title": "Create Board",
     "chooseBoardSourcePopup-title": "Import board",
     "createLabelPopup-title": "Create Label",
+    "createCustomField": "Create Field",
+    "createCustomFieldPopup-title": "Create Field",
     "current": "current",
+    "custom-field-delete-pop": "There is no undo. This will remove this custom field from all cards and destroy its history.",
+    "custom-field-checkbox": "Checkbox",
+    "custom-field-date": "Date",
+    "custom-field-dropdown": "Dropdown List",
+    "custom-field-dropdown-none": "(none)",
+    "custom-field-dropdown-options": "List Options",
+    "custom-field-dropdown-options-placeholder": "Press enter to add more options",
+    "custom-field-dropdown-unknown": "(unknown)",
+    "custom-field-number": "Number",
+    "custom-field-text": "Text",
+    "custom-fields": "Custom Fields",
     "date": "Date",
     "decline": "Decline",
     "default-avatar": "Default avatar",
     "delete": "Delete",
+    "deleteCustomFieldPopup-title": "Delete Custom Field?",
     "deleteLabelPopup-title": "Delete Label?",
     "description": "Description",
     "disambiguateMultiLabelPopup-title": "Disambiguate Label Action",
@@ -186,7 +205,7 @@
     "soft-wip-limit": "Soft WIP Limit",
     "editCardStartDatePopup-title": "Change start date",
     "editCardDueDatePopup-title": "Change due date",
-    "editCardSpentTimePopup-title": "Change spent time",
+    "editCustomFieldPopup-title": "Edit Field",
     "editLabelPopup-title": "Change Label",
     "editNotificationPopup-title": "Edit Notification",
     "editProfilePopup-title": "Edit Profile",
@@ -366,6 +385,7 @@
     "title": "Title",
     "tracking": "Tracking",
     "tracking-info": "You will be notified of any changes to those cards you are involved as creator or member.",
+    "type": "Type",
     "unassign-member": "Unassign member",
     "unsaved-description": "You have an unsaved description.",
     "unwatch": "Unwatch",
@@ -430,6 +450,7 @@
     "hours": "hours",
     "minutes": "minutes",
     "seconds": "seconds",
+    "show-field-on-card": "Show this field on card",
     "yes": "Yes",
     "no": "No",
     "accounts": "Accounts",

+ 8 - 0
models/activities.js

@@ -44,6 +44,9 @@ Activities.helpers({
   checklistItem() {
     return ChecklistItems.findOne(this.checklistItemId);
   },
+  customField() {
+    return CustomFields.findOne(this.customFieldId);
+  },
 });
 
 Activities.before.insert((userId, doc) => {
@@ -60,6 +63,7 @@ if (Meteor.isServer) {
     Activities._collection._ensureIndex({ boardId: 1, createdAt: -1 });
     Activities._collection._ensureIndex({ commentId: 1 }, { partialFilterExpression: { commentId: { $exists: true } } });
     Activities._collection._ensureIndex({ attachmentId: 1 }, { partialFilterExpression: { attachmentId: { $exists: true } } });
+    Activities._collection._ensureIndex({ customFieldId: 1 }, { partialFilterExpression: { customFieldId: { $exists: true } } });
   });
 
   Activities.after.insert((userId, doc) => {
@@ -127,6 +131,10 @@ if (Meteor.isServer) {
       const checklistItem = activity.checklistItem();
       params.checklistItem = checklistItem.title;
     }
+    if (activity.customFieldId) {
+      const customField = activity.customField();
+      params.customField = customField.name;
+    }
     if (board) {
       const watchingUsers = _.pluck(_.where(board.watchers, {level: 'watching'}), 'userId');
       const trackingUsers = _.pluck(_.where(board.watchers, {level: 'tracking'}), 'userId');

+ 4 - 0
models/boards.js

@@ -249,6 +249,10 @@ Boards.helpers({
     return `board-color-${this.color}`;
   },
 
+  customFields() {
+    return CustomFields.find({ boardId: this._id }, { sort: { name: 1 } });
+  },
+
   // XXX currently mutations return no value so we have an issue when using addLabel in import
   // XXX waiting on https://github.com/mquandalle/meteor-collection-mutations/issues/1 to remove...
   pushLabel(name, color) {

+ 66 - 0
models/cards.js

@@ -41,6 +41,21 @@ Cards.attachSchema(new SimpleSchema({
       }
     },
   },
+  customFields: {
+    type: [Object],
+    optional: true,
+  },
+  'customFields.$': {
+    type: new SimpleSchema({
+      _id: {
+        type: String,
+      },
+      value: {
+        type: Match.OneOf(String,Number,Boolean,Date),
+        optional: true,
+      },
+    })
+  },
   dateLastActivity: {
     type: Date,
     autoValue() {
@@ -192,6 +207,31 @@ Cards.helpers({
     return this.checklistItemCount() !== 0;
   },
 
+  customFieldIndex(customFieldId) {
+    return _.pluck(this.customFields, '_id').indexOf(customFieldId);
+  },
+
+  // customFields with definitions
+  customFieldsWD() {
+
+    // get all definitions
+    const definitions = CustomFields.find({
+      boardId: this.boardId,
+    }).fetch();
+
+    // match right definition to each field
+    return this.customFields.map((customField) => {
+      return {
+        _id: customField._id,
+        value: customField.value,
+        definition: definitions.find((definition) => {
+          return definition._id == customField._id;
+        })
+      }
+    });
+
+  },
+
   absoluteUrl() {
     const board = this.board();
     return FlowRouter.url('card', {
@@ -271,6 +311,32 @@ Cards.mutations({
     }
   },
 
+  assignCustomField(customFieldId) {
+    return {$addToSet: {customFields: {_id: customFieldId, value: null}}};
+  },
+
+  unassignCustomField(customFieldId) {
+    return {$pull: {customFields: {_id: customFieldId}}};
+  },
+
+  toggleCustomField(customFieldId) {
+    if (this.customFields && this.customFieldIndex(customFieldId) > -1) {
+      return this.unassignCustomField(customFieldId);
+    } else {
+      return this.assignCustomField(customFieldId);
+    }
+  },
+
+  setCustomField(customFieldId, value) {
+    // todo
+    const index = this.customFieldIndex(customFieldId);
+    if (index > -1) {
+      var update = {$set: {}};
+      update.$set["customFields." + index + ".value"] = value;
+      return update;
+    }
+  },
+
   setCover(coverId) {
     return {$set: {coverId}};
   },

+ 132 - 0
models/customFields.js

@@ -0,0 +1,132 @@
+CustomFields = new Mongo.Collection('customFields');
+
+CustomFields.attachSchema(new SimpleSchema({
+  boardId: {
+    type: String,
+  },
+  name: {
+    type: String,
+  },
+  type: {
+    type: String,
+    allowedValues: ['text', 'number', 'checkbox', 'date', 'dropdown']
+  },
+  settings: {
+    type: Object,
+  },
+  'settings.dropdownItems': {
+    type: [Object],
+    optional: true
+  },
+  'settings.dropdownItems.$': {
+    type: new SimpleSchema({
+      _id: {
+        type: String,
+      },
+      name: {
+        type: String,
+      },
+    })
+  },
+  showOnCard: {
+    type: Boolean,
+  }
+}));
+
+CustomFields.allow({
+  insert(userId, doc) {
+    return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+  },
+  update(userId, doc) {
+    return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+  },
+  remove(userId, doc) {
+    return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+  },
+  fetch: ['userId', 'boardId'],
+});
+
+// not sure if we need this?
+//CustomFields.hookOptions.after.update = { fetchPrevious: false };
+
+function customFieldCreation(userId, doc){
+  Activities.insert({
+    userId,
+    activityType: 'createCustomField',
+    boardId: doc.boardId,
+    customFieldId: doc._id,
+  });
+}
+
+if (Meteor.isServer) {
+  /*Meteor.startup(() => {
+    CustomFields._collection._ensureIndex({ boardId: 1});
+  });*/
+
+  CustomFields.after.insert((userId, doc) => {
+    customFieldCreation(userId, doc);
+  });
+
+  CustomFields.after.remove((userId, doc) => {
+    Activities.remove({
+      customFieldId: doc._id,
+    });
+  });
+}
+
+//CUSTOM FIELD REST API
+if (Meteor.isServer) {
+  JsonRoutes.add('GET', '/api/boards/:boardId/custom-fields', function (req, res, next) {
+    Authentication.checkUserId( req.userId);
+    const paramBoardId = req.params.boardId;
+    JsonRoutes.sendResult(res, {
+      code: 200,
+      data: CustomFields.find({ boardId: paramBoardId })
+    });
+  });
+
+  JsonRoutes.add('GET', '/api/boards/:boardId/custom-fields/:customFieldId', function (req, res, next) {
+    Authentication.checkUserId( req.userId);
+    const paramBoardId = req.params.boardId;
+    const paramCustomFieldId = req.params.customFieldId;
+    JsonRoutes.sendResult(res, {
+      code: 200,
+      data: CustomFields.findOne({ _id: paramCustomFieldId, boardId: paramBoardId }),
+    });
+  });
+
+  JsonRoutes.add('POST', '/api/boards/:boardId/custom-fields', function (req, res, next) {
+    Authentication.checkUserId( req.userId);
+    const paramBoardId = req.params.boardId;
+    const id = CustomFields.direct.insert({
+      name: req.body.name,
+      type: req.body.type,
+      settings: req.body.settings,
+      showOnCard: req.body.showOnCard,
+      boardId: paramBoardId,
+    });
+
+    const customField = CustomFields.findOne({_id: id, boardId: paramBoardId });
+    customFieldCreation(req.body.authorId, customField);
+
+    JsonRoutes.sendResult(res, {
+      code: 200,
+      data: {
+        _id: id,
+      },
+    });
+  });
+
+  JsonRoutes.add('DELETE', '/api/boards/:boardId/custom-fields/:customFieldId', function (req, res, next) {
+    Authentication.checkUserId( req.userId);
+    const paramBoardId = req.params.boardId;
+    const id = req.params.customFieldId;
+    CustomFields.remove({ _id: id, boardId: paramBoardId });
+    JsonRoutes.sendResult(res, {
+      code: 200,
+      data: {
+        _id: id,
+      },
+    });
+  });
+}

+ 1 - 0
server/publications/boards.js

@@ -75,6 +75,7 @@ Meteor.publishRelations('board', function(boardId) {
     this.cursor(Lists.find({ boardId }));
     this.cursor(Swimlanes.find({ boardId }));
     this.cursor(Integrations.find({ boardId }));
+    this.cursor(CustomFields.find({ boardId }, { sort: { name: 1 } }));
 
     // Cards and cards comments
     // XXX Originally we were publishing the card documents as a child of the

+ 3 - 0
server/publications/customFields.js

@@ -0,0 +1,3 @@
+Meteor.publish('customFields', function() {
+  return CustomFields.find();
+});