diff options
Diffstat (limited to 'client/lib')
-rw-r--r-- | client/lib/datepicker.js | 86 | ||||
-rw-r--r-- | client/lib/escapeActions.js | 2 | ||||
-rw-r--r-- | client/lib/filter.js | 425 | ||||
-rw-r--r-- | client/lib/inlinedform.js | 12 | ||||
-rw-r--r-- | client/lib/modal.js | 14 | ||||
-rw-r--r-- | client/lib/popup.js | 12 | ||||
-rw-r--r-- | client/lib/utils.js | 153 |
7 files changed, 677 insertions, 27 deletions
diff --git a/client/lib/datepicker.js b/client/lib/datepicker.js new file mode 100644 index 00000000..ab2da0bd --- /dev/null +++ b/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(); + }, + }]; + }, +}); diff --git a/client/lib/escapeActions.js b/client/lib/escapeActions.js index 666e33e0..0757ae46 100644 --- a/client/lib/escapeActions.js +++ b/client/lib/escapeActions.js @@ -135,6 +135,6 @@ $(document).on('click', (evt) => { } }); -$(document).on('click', 'a[href=#]', (evt) => { +$(document).on('click', 'a[href=\\#]', (evt) => { evt.preventDefault(); }); diff --git a/client/lib/filter.js b/client/lib/filter.js index 8129776b..c3c1b070 100644 --- a/client/lib/filter.js +++ b/client/lib/filter.js @@ -3,17 +3,19 @@ // RangeFilter, dateFilter, etc.). We then define a global `Filter` object whose // goal is to filter complete documents by using the local filters for each // fields. - function showFilterSidebar() { Sidebar.setView('filter'); } // Use a "set" filter for a field that is a set of documents uniquely // identified. For instance `{ labels: ['labelA', 'labelC', 'labelD'] }`. +// use "subField" for searching inside object Fields. +// For instance '{ 'customFields._id': ['field1','field2']} (subField would be: _id) class SetFilter { - constructor() { + constructor(subField = '') { this._dep = new Tracker.Dependency(); this._selectedElements = []; + this.subField = subField; } isSelected(val) { @@ -61,7 +63,9 @@ class SetFilter { _getMongoSelector() { this._dep.depend(); - return { $in: this._selectedElements }; + return { + $in: this._selectedElements, + }; } _getEmptySelector() { @@ -72,10 +76,385 @@ class SetFilter { includeEmpty = true; } }); - return includeEmpty ? { $eq: [] } : null; + return includeEmpty ? { + $eq: [], + } : null; } } + +// Advanced filter forms a MongoSelector from a users String. +// Build by: Ignatz 19.05.2018 (github feuerball11) +class AdvancedFilter { + constructor() { + this._dep = new Tracker.Dependency(); + this._filter = ''; + this._lastValide = {}; + } + + set(str) { + this._filter = str; + this._dep.changed(); + } + + reset() { + this._filter = ''; + this._lastValide = {}; + this._dep.changed(); + } + + _isActive() { + this._dep.depend(); + return this._filter !== ''; + } + + _filterToCommands() { + const commands = []; + let current = ''; + let string = false; + let regex = false; + let wasString = false; + let ignore = false; + for (let i = 0; i < this._filter.length; i++) { + const char = this._filter.charAt(i); + if (ignore) { + ignore = false; + current += char; + continue; + } + if (char === '/') { + string = !string; + if (string) regex = true; + current += char; + continue; + } + if (char === '\'') { + string = !string; + if (string) wasString = true; + continue; + } + if (char === '\\' && !string) { + ignore = true; + continue; + } + if (char === ' ' && !string) { + commands.push({ + 'cmd': current, + 'string': wasString, + regex, + }); + wasString = false; + current = ''; + continue; + } + current += char; + } + if (current !== '') { + commands.push({ + 'cmd': current, + 'string': wasString, + regex, + }); + } + return commands; + } + + _fieldNameToId(field) { + const found = CustomFields.findOne({ + 'name': field, + }); + return found._id; + } + + _fieldValueToId(field, value) { + const found = CustomFields.findOne({ + 'name': field, + }); + if (found.settings.dropdownItems && found.settings.dropdownItems.length > 0) { + for (let i = 0; i < found.settings.dropdownItems.length; i++) { + if (found.settings.dropdownItems[i].name === value) { + return found.settings.dropdownItems[i]._id; + } + } + } + return value; + } + + _arrayToSelector(commands) { + try { + //let changed = false; + this._processSubCommands(commands); + } catch (e) { + return this._lastValide; + } + this._lastValide = { + $or: commands, + }; + return { + $or: commands, + }; + } + + _processSubCommands(commands) { + const subcommands = []; + let level = 0; + let start = -1; + for (let i = 0; i < commands.length; i++) { + if (commands[i].cmd) { + switch (commands[i].cmd) { + case '(': + { + level++; + if (start === -1) start = i; + continue; + } + case ')': + { + level--; + commands.splice(i, 1); + i--; + continue; + } + default: + { + if (level > 0) { + subcommands.push(commands[i]); + commands.splice(i, 1); + i--; + continue; + } + } + } + } + } + if (start !== -1) { + this._processSubCommands(subcommands); + if (subcommands.length === 1) + commands.splice(start, 0, subcommands[0]); + else + commands.splice(start, 0, subcommands); + } + this._processConditions(commands); + this._processLogicalOperators(commands); + } + + _processConditions(commands) { + for (let i = 0; i < commands.length; i++) { + if (!commands[i].string && commands[i].cmd) { + switch (commands[i].cmd) { + case '=': + case '==': + case '===': + { + const field = commands[i - 1].cmd; + const str = commands[i + 1].cmd; + if (commands[i + 1].regex) { + const match = str.match(new RegExp('^/(.*?)/([gimy]*)$')); + let regex = null; + if (match.length > 2) + regex = new RegExp(match[1], match[2]); + else + regex = new RegExp(match[1]); + commands[i] = { + 'customFields._id': this._fieldNameToId(field), + 'customFields.value': regex, + }; + } else { + commands[i] = { + 'customFields._id': this._fieldNameToId(field), + 'customFields.value': { + $in: [this._fieldValueToId(field, str), parseInt(str, 10)], + }, + }; + } + commands.splice(i - 1, 1); + commands.splice(i, 1); + //changed = true; + i--; + break; + } + case '!=': + case '!==': + { + const field = commands[i - 1].cmd; + const str = commands[i + 1].cmd; + if (commands[i + 1].regex) { + const match = str.match(new RegExp('^/(.*?)/([gimy]*)$')); + let regex = null; + if (match.length > 2) + regex = new RegExp(match[1], match[2]); + else + regex = new RegExp(match[1]); + commands[i] = { + 'customFields._id': this._fieldNameToId(field), + 'customFields.value': { + $not: regex, + }, + }; + } else { + commands[i] = { + 'customFields._id': this._fieldNameToId(field), + 'customFields.value': { + $not: { + $in: [this._fieldValueToId(field, str), parseInt(str, 10)], + }, + }, + }; + } + commands.splice(i - 1, 1); + commands.splice(i, 1); + //changed = true; + i--; + break; + } + case '>': + case 'gt': + case 'Gt': + case 'GT': + { + const field = commands[i - 1].cmd; + const str = commands[i + 1].cmd; + commands[i] = { + 'customFields._id': this._fieldNameToId(field), + 'customFields.value': { + $gt: parseInt(str, 10), + }, + }; + commands.splice(i - 1, 1); + commands.splice(i, 1); + //changed = true; + i--; + break; + } + case '>=': + case '>==': + case 'gte': + case 'Gte': + case 'GTE': + { + const field = commands[i - 1].cmd; + const str = commands[i + 1].cmd; + commands[i] = { + 'customFields._id': this._fieldNameToId(field), + 'customFields.value': { + $gte: parseInt(str, 10), + }, + }; + commands.splice(i - 1, 1); + commands.splice(i, 1); + //changed = true; + i--; + break; + } + case '<': + case 'lt': + case 'Lt': + case 'LT': + { + const field = commands[i - 1].cmd; + const str = commands[i + 1].cmd; + commands[i] = { + 'customFields._id': this._fieldNameToId(field), + 'customFields.value': { + $lt: parseInt(str, 10), + }, + }; + commands.splice(i - 1, 1); + commands.splice(i, 1); + //changed = true; + i--; + break; + } + case '<=': + case '<==': + case 'lte': + case 'Lte': + case 'LTE': + { + const field = commands[i - 1].cmd; + const str = commands[i + 1].cmd; + commands[i] = { + 'customFields._id': this._fieldNameToId(field), + 'customFields.value': { + $lte: parseInt(str, 10), + }, + }; + commands.splice(i - 1, 1); + commands.splice(i, 1); + //changed = true; + i--; + break; + } + } + } + } + } + + _processLogicalOperators(commands) { + for (let i = 0; i < commands.length; i++) { + if (!commands[i].string && commands[i].cmd) { + switch (commands[i].cmd) { + case 'or': + case 'Or': + case 'OR': + case '|': + case '||': + { + const op1 = commands[i - 1]; + const op2 = commands[i + 1]; + commands[i] = { + $or: [op1, op2], + }; + commands.splice(i - 1, 1); + commands.splice(i, 1); + //changed = true; + i--; + break; + } + case 'and': + case 'And': + case 'AND': + case '&': + case '&&': + { + const op1 = commands[i - 1]; + const op2 = commands[i + 1]; + commands[i] = { + $and: [op1, op2], + }; + commands.splice(i - 1, 1); + commands.splice(i, 1); + //changed = true; + i--; + break; + } + case 'not': + case 'Not': + case 'NOT': + case '!': + { + const op1 = commands[i + 1]; + commands[i] = { + $not: op1, + }; + commands.splice(i + 1, 1); + //changed = true; + i--; + break; + } + } + } + } + } + + _getMongoSelector() { + this._dep.depend(); + const commands = this._filterToCommands(); + return this._arrayToSelector(commands); + } + +} + // The global Filter object. // XXX It would be possible to re-write this object more elegantly, and removing // the need to provide a list of `_fields`. We also should move methods into the @@ -86,8 +465,10 @@ Filter = { // before changing the schema. labelIds: new SetFilter(), members: new SetFilter(), + customFields: new SetFilter('_id'), + advanced: new AdvancedFilter(), - _fields: ['labelIds', 'members'], + _fields: ['labelIds', 'members', 'customFields'], // We don't filter cards that have been added after the last filter change. To // implement this we keep the id of these cards in this `_exceptions` fields @@ -98,7 +479,7 @@ Filter = { isActive() { return _.any(this._fields, (fieldName) => { return this[fieldName]._isActive(); - }); + }) || this.advanced._isActive(); }, _getMongoSelector() { @@ -111,7 +492,11 @@ Filter = { this._fields.forEach((fieldName) => { const filter = this[fieldName]; if (filter._isActive()) { - filterSelector[fieldName] = filter._getMongoSelector(); + if (filter.subField !== '') { + filterSelector[`${fieldName}.${filter.subField}`] = filter._getMongoSelector(); + } else { + filterSelector[fieldName] = filter._getMongoSelector(); + } emptySelector[fieldName] = filter._getEmptySelector(); if (emptySelector[fieldName] !== null) { includeEmptySelectors = true; @@ -119,13 +504,24 @@ Filter = { } }); - const exceptionsSelector = {_id: {$in: this._exceptions}}; + const exceptionsSelector = { + _id: { + $in: this._exceptions, + }, + }; this._exceptionsDep.depend(); - if (includeEmptySelectors) - return {$or: [filterSelector, exceptionsSelector, emptySelector]}; - else - return {$or: [filterSelector, exceptionsSelector]}; + const selectors = [exceptionsSelector]; + + if (_.any(this._fields, (fieldName) => { + return this[fieldName]._isActive(); + })) selectors.push(filterSelector); + if (includeEmptySelectors) selectors.push(emptySelector); + if (this.advanced._isActive()) selectors.push(this.advanced._getMongoSelector()); + + return { + $or: selectors, + }; }, mongoSelector(additionalSelector) { @@ -133,7 +529,9 @@ Filter = { if (_.isUndefined(additionalSelector)) return filterSelector; else - return {$and: [filterSelector, additionalSelector]}; + return { + $and: [filterSelector, additionalSelector], + }; }, reset() { @@ -141,6 +539,7 @@ Filter = { const filter = this[fieldName]; filter.reset(); }); + this.advanced.reset(); this.resetExceptions(); }, diff --git a/client/lib/inlinedform.js b/client/lib/inlinedform.js index 56768a13..e5e4d4ed 100644 --- a/client/lib/inlinedform.js +++ b/client/lib/inlinedform.js @@ -75,6 +75,16 @@ InlinedForm = BlazeComponent.extendComponent({ EscapeActions.register('inlinedForm', () => { currentlyOpenedForm.get().close(); }, () => { return currentlyOpenedForm.get() !== null; }, { - noClickEscapeOn: '.js-inlined-form', + enabledOnClick: false, } ); + +// submit on click outside +document.addEventListener('click', function(evt) { + const openedForm = currentlyOpenedForm.get(); + const isClickOutside = $(evt.target).closest('.js-inlined-form').length === 0; + if (openedForm && isClickOutside) { + $('.js-inlined-form button[type=submit]').click(); + openedForm.close(); + } +}, true); diff --git a/client/lib/modal.js b/client/lib/modal.js index d5350264..3c27a179 100644 --- a/client/lib/modal.js +++ b/client/lib/modal.js @@ -4,6 +4,7 @@ window.Modal = new class { constructor() { this._currentModal = new ReactiveVar(closedValue); this._onCloseGoTo = ''; + this._isWideModal = false; } getHeaderName() { @@ -20,6 +21,10 @@ window.Modal = new class { return this.getTemplateName() !== closedValue; } + isWide(){ + return this._isWideModal; + } + close() { this._currentModal.set(closedValue); if (this._onCloseGoTo) { @@ -27,9 +32,16 @@ window.Modal = new class { } } + openWide(modalName, { header = '', onCloseGoTo = ''} = {}) { + this._currentModal.set({ header, modalName }); + this._onCloseGoTo = onCloseGoTo; + this._isWideModal = true; + } + open(modalName, { header = '', onCloseGoTo = ''} = {}) { this._currentModal.set({ header, modalName }); this._onCloseGoTo = onCloseGoTo; + } }(); @@ -38,5 +50,5 @@ Blaze.registerHelper('Modal', Modal); EscapeActions.register('modalWindow', () => Modal.close(), () => Modal.isOpen(), - { noClickEscapeOn: '.modal-content' } + { noClickEscapeOn: '.modal-container' } ); diff --git a/client/lib/popup.js b/client/lib/popup.js index 0a700f82..9abe48aa 100644 --- a/client/lib/popup.js +++ b/client/lib/popup.js @@ -4,9 +4,9 @@ window.Popup = new class { this.template = Template.popup; // We only want to display one popup at a time and we keep the view object - // in this `Popup._current` variable. If there is no popup currently opened + // in this `Popup.current` variable. If there is no popup currently opened // the value is `null`. - this._current = null; + this.current = null; // It's possible to open a sub-popup B from a popup A. In that case we keep // the data of popup A so we can return back to it. Every time we open a new @@ -27,11 +27,9 @@ window.Popup = new class { open(name) { const self = this; const popupName = `${name}Popup`; - function clickFromPopup(evt) { return $(evt.target).closest('.js-pop-over').length !== 0; } - return function(evt) { // If a popup is already opened, clicking again on the opener element // should close it -- and interrupt the current `open` function. @@ -57,7 +55,6 @@ window.Popup = new class { self._stack = []; openerElement = evt.currentTarget; } - $(openerElement).addClass('is-active'); evt.preventDefault(); @@ -139,6 +136,7 @@ window.Popup = new class { const openerElement = this._getTopStack().openerElement; $(openerElement).removeClass('is-active'); + this._stack = []; } } @@ -186,7 +184,7 @@ window.Popup = new class { // positives. const title = TAPi18n.__(translationKey); // when popup showed as full of small screen, we need a default header to clearly see [X] button - const defaultTitle = Utils.isMiniScreen() ? 'Wekan' : false; + const defaultTitle = Utils.isMiniScreen() ? '' : false; return title !== translationKey ? title : defaultTitle; }; } @@ -200,7 +198,7 @@ escapeActions.forEach((actionName) => { () => Popup[actionName](), () => Popup.isOpen(), { - noClickEscapeOn: '.js-pop-over', + noClickEscapeOn: '.js-pop-over,.js-open-card-title-popup', enabledOnClick: actionName === 'close', } ); diff --git a/client/lib/utils.js b/client/lib/utils.js index 1f44c60d..e2339763 100644 --- a/client/lib/utils.js +++ b/client/lib/utils.js @@ -39,11 +39,11 @@ Utils = { if (!prevData && !nextData) { base = 0; increment = 1; - // If we drop the card in the first position + // If we drop the card in the first position } else if (!prevData) { base = nextData.sort - 1; increment = -1; - // If we drop the card in the last position + // If we drop the card in the last position } else if (!nextData) { base = prevData.sort + 1; increment = 1; @@ -71,11 +71,11 @@ Utils = { if (!prevCardDomElement && !nextCardDomElement) { base = 0; increment = 1; - // If we drop the card in the first position + // If we drop the card in the first position } else if (!prevCardDomElement) { base = Blaze.getData(nextCardDomElement).sort - 1; increment = -1; - // If we drop the card in the last position + // If we drop the card in the last position } else if (!nextCardDomElement) { base = Blaze.getData(prevCardDomElement).sort + 1; increment = 1; @@ -95,6 +95,151 @@ Utils = { increment, }; }, + + // Detect touch device + isTouchDevice() { + const isTouchable = (() => { + const prefixes = ' -webkit- -moz- -o- -ms- '.split(' '); + const mq = function(query) { + return window.matchMedia(query).matches; + }; + + if (('ontouchstart' in window) || window.DocumentTouch && document instanceof window.DocumentTouch) { + return true; + } + + // include the 'heartz' as a way to have a non matching MQ to help terminate the join + // https://git.io/vznFH + const query = ['(', prefixes.join('touch-enabled),('), 'heartz', ')'].join(''); + return mq(query); + })(); + Utils.isTouchDevice = () => isTouchable; + return isTouchable; + }, + + calculateTouchDistance(touchA, touchB) { + return Math.sqrt( + Math.pow(touchA.screenX - touchB.screenX, 2) + + Math.pow(touchA.screenY - touchB.screenY, 2) + ); + }, + + enableClickOnTouch(selector) { + let touchStart = null; + let lastTouch = null; + + $(document).on('touchstart', selector, function(e) { + touchStart = e.originalEvent.touches[0]; + }); + $(document).on('touchmove', selector, function(e) { + const touches = e.originalEvent.touches; + lastTouch = touches[touches.length - 1]; + }); + $(document).on('touchend', selector, function(e) { + if (touchStart && lastTouch && Utils.calculateTouchDistance(touchStart, lastTouch) <= 20) { + e.preventDefault(); + const clickEvent = document.createEvent('MouseEvents'); + clickEvent.initEvent('click', true, true); + e.target.dispatchEvent(clickEvent); + } + }); + }, + + manageCustomUI(){ + Meteor.call('getCustomUI', (err, data) => { + if (err && err.error[0] === 'var-not-exist'){ + Session.set('customUI', false); // siteId || address server not defined + } + if (!err){ + Utils.setCustomUI(data); + } + }); + }, + + setCustomUI(data){ + const currentBoard = Boards.findOne(Session.get('currentBoard')); + if (currentBoard) { + DocHead.setTitle(`${currentBoard.title } - ${ data.productName}`); + } else { + DocHead.setTitle(`${data.productName}`); + } + }, + + setMatomo(data){ + window._paq = window._paq || []; + window._paq.push(['setDoNotTrack', data.doNotTrack]); + if (data.withUserName){ + window._paq.push(['setUserId', Meteor.user().username]); + } + window._paq.push(['trackPageView']); + window._paq.push(['enableLinkTracking']); + + (function() { + window._paq.push(['setTrackerUrl', `${data.address}piwik.php`]); + window._paq.push(['setSiteId', data.siteId]); + + const script = document.createElement('script'); + Object.assign(script, { + id: 'scriptMatomo', + type: 'text/javascript', + async: 'true', + defer: 'true', + src: `${data.address}piwik.js`, + }); + + const s = document.getElementsByTagName('script')[0]; + s.parentNode.insertBefore(script, s); + })(); + + Session.set('matomo', true); + }, + + manageMatomo() { + const matomo = Session.get('matomo'); + if (matomo === undefined){ + Meteor.call('getMatomoConf', (err, data) => { + if (err && err.error[0] === 'var-not-exist'){ + Session.set('matomo', false); // siteId || address server not defined + } + if (!err){ + Utils.setMatomo(data); + } + }); + } else if (matomo) { + window._paq.push(['trackPageView']); + } + }, + + getTriggerActionDesc(event, tempInstance) { + const jqueryEl = tempInstance.$(event.currentTarget.parentNode); + const triggerEls = jqueryEl.find('.trigger-content').children(); + let finalString = ''; + for (let i = 0; i < triggerEls.length; i++) { + const element = tempInstance.$(triggerEls[i]); + if (element.hasClass('trigger-text')) { + finalString += element.text().toLowerCase(); + } else if (element.hasClass('user-details')) { + let username = element.find('input').val(); + if(username === undefined || username === ''){ + username = '*'; + } + finalString += `${element.find('.trigger-text').text().toLowerCase() } ${ username}`; + } else if (element.find('select').length > 0) { + finalString += element.find('select option:selected').text().toLowerCase(); + } else if (element.find('input').length > 0) { + let inputvalue = element.find('input').val(); + if(inputvalue === undefined || inputvalue === ''){ + inputvalue = '*'; + } + finalString += inputvalue; + } + // Add space + if (i !== length - 1) { + finalString += ' '; + } + } + return finalString; + }, }; // A simple tracker dependency that we invalidate every time the window is |