diff options
| author | Jan-Hendrik Willms <tleilax+github@gmail.com> | 2021-07-22 16:07:19 +0200 |
|---|---|---|
| committer | Jan-Hendrik Willms <tleilax+github@gmail.com> | 2021-07-22 16:19:12 +0200 |
| commit | a3da1483a9e689846179159355badfec8073dbec (patch) | |
| tree | 770dcca6bdf5f6f2a11b0e7fcbbeda6919a3fc52 /resources/assets/javascripts/lib | |
current code from svn, revision 62608
Diffstat (limited to 'resources/assets/javascripts/lib')
85 files changed, 13150 insertions, 0 deletions
diff --git a/resources/assets/javascripts/lib/abstract-api.js b/resources/assets/javascripts/lib/abstract-api.js new file mode 100644 index 0000000..4239118 --- /dev/null +++ b/resources/assets/javascripts/lib/abstract-api.js @@ -0,0 +1,108 @@ +import Overlay from './overlay.js'; + +class AbstractAPI +{ + static get supportedMethods() { + return ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE']; + } + + // Helper function that normalizes options + static adjustOptions (options = {}) { + return Object.assign({}, { + method: 'get', + parameters: {}, + headers: {}, + data: {}, + overlay: false, + async: false, + before: false + }, options || {}); + } + + constructor (base_url) { + if (this.constructor === AbstractAPI) { + throw new TypeError('You should not instantiate the abstract api'); + } + + this.total_requests = 0; + this.request_count = 0; + this.queue = []; + this.base_url = base_url; + } + + encodeData (data) { + if (data instanceof Function) { + data = data(); + } + return data; + } + + request (url, options = {}) { + // Normalize parameters + if (Array.isArray(url)) { + // Remove empty trailing chunks + while (url[url.length - 1] === '') { + delete url[url.length - 1]; + } + // Convert array to string + url = url.join('/'); + } + + options = this.constructor.adjustOptions(options); + + var deferred; + + if (options.async && this.request_count > 0) { + // Request should be sent asynchronous after every other request + // is finished. The configuration for this particular request is + // stored in a deferred which is then queued for execution. + deferred = $.Deferred(); + deferred.then(() => this.request(url, options)); + + this.queue.push(deferred); + } else if (options.before instanceof Function && !options.before()) { + // A before function was defined and returned false, so the request + // is canceled + deferred = $.Deferred((dfd) => dfd.reject()); + } else { + // Increase request counters, show overlay if neccessary + if (this.request_count === 0 && options.overlay) { + Overlay.show(true, null, true); + } + this.request_count += 1; + this.total_requests += 1; + + // Actual request + deferred = $.ajax(STUDIP.URLHelper.getURL(`${this.base_url}/${url}`, {}, true), { + contentType: options.contentType || 'application/x-www-form-urlencoded; charset=UTF-8', + method: options.method.toUpperCase(), + data: this.encodeData(options.data), + headers: options.headers + }).always(() => { + // Decrease request counter, remove overlay if neccessary + this.request_count -= 1; + if (this.request_count === 0 && options.overlay) { + Overlay.hide(); + } + }); + } + return deferred.always(() => { + // Check if any request was queued + if (this.request_count === 0 && this.queue.length > 0) { + this.queue.shift().resolve(); + } + }).promise(); + }; +} + +// Create shortcut methods for easier access by method +AbstractAPI.supportedMethods.forEach((method) => { + AbstractAPI.prototype[method] = function (url, options = {}) { + options = this.constructor.adjustOptions(options); + options.method = method; + + return this.request.call(this, url, options); + }; +}); + +export default AbstractAPI; diff --git a/resources/assets/javascripts/lib/actionmenu.js b/resources/assets/javascripts/lib/actionmenu.js new file mode 100644 index 0000000..7422ac8 --- /dev/null +++ b/resources/assets/javascripts/lib/actionmenu.js @@ -0,0 +1,231 @@ +/*jslint esversion: 6*/ + +/** + * Determine whether the menu should be opened in dialog or regular layout. + * @type {[type]} + */ +function determineBreakpoint(element) { + return $(element).closest('.ui-dialog-content').length > 0 ? '.ui-dialog-content' : '#layout_content'; +} + +/** + * Obtain all parents of the given element that have scrollable content. + */ +function getScrollableParents(element, menu_width, menu_height) { + const offset = $(element).offset(); + const breakpoint = determineBreakpoint(element); + + var elements = []; + $(element).parents().each(function () { + // Stop at breakpoint + if ($(this).is(breakpoint)) { + return false; + } + + // Exit early if overflow is visible + const overflow = $(this).css('overflow'); + if (overflow === 'visible' || overflow === 'inherit') { + return; + } + + // Check whether element is overflown + const overflown = this.scrollHeight > this.clientHeight || this.scrollWidth > this.clientWidth; + if (overflow === 'hidden' && overflown) { + elements.push(this); + return; + } + + // Check if menu fits inside element + const offs = $(this).offset(); + const w = $(this).width(); + const h = $(this).height(); + + if (offset.left + menu_width > offs.left + w) { + elements.push(this); + } else if (offset.top + menu_height > offs.top + h) { + elements.push(this); + } + }); + + return elements; +} + +/** + * Scroll handler for all scroll related events. + * This will reposition the menu(s) according to the scrolled distance. + */ +function scrollHandler(event) { + const data = $(event.target).data('action-menu-scroll-data'); + + const diff_x = event.target.scrollLeft - data.left; + const diff_y = event.target.scrollTop - data.top; + + data.menus.forEach((menu) => { + const offset = menu.offset(); + menu.offset({ + left: offset.left - diff_x, + top: offset.top - diff_y + }); + }); + + data.left = event.target.scrollLeft; + data.top = event.target.scrollTop; + + $(event.target).data('action-menu-scroll-data', data); +} + +const stash = new Map(); +const secret = typeof Symbol === 'undefined' ? Math.random().toString(36).substring(2, 15) : Symbol(); + +class ActionMenu { + /** + * Create menu using a singleton pattern for each element. + */ + static create(element, position = true) { + const id = $(element).uniqueId().attr('id'); + const breakpoint = determineBreakpoint(element); + if (!stash.has(id)) { + const menu_offset = $(element).offset().top + $('.action-menu-content', element).height(); + const max_offset = $(breakpoint).offset().top + $(breakpoint).height(); + const reversed = menu_offset > max_offset; + + stash.set(id, new ActionMenu(secret, element, reversed, position)); + } + + return stash.get(id); + } + + /** + * Closes all menus. + * @return {[type]} [description] + */ + static closeAll() { + stash.forEach((menu) => menu.close()); + } + + /** + * Private constructor by implementing the secret/passed_secret mechanism. + */ + constructor(passed_secret, element, reversed, position) { + // Enforce use of create (would use a private constructor if I could) + if (secret !== passed_secret) { + throw new Error('Cannot create ActionMenu. Use ActionMenu.create()!'); + } + + const offset = $(element).offset(); + const height = $('.action-menu-content').height(); + const width = $('.action-menu-content').width(); + const breakpoint = determineBreakpoint(element); + + this.element = $(element); + this.menu = this.element; + this.content = $('.action-menu-content', element); + this.is_reversed = reversed; + this.is_open = false; + + const menu_width = this.content.width(); + const menu_height = this.content.height(); + + // Reposition the menu? + if (position) { + var parents = getScrollableParents(this.element, menu_width, menu_height); + if (parents.length > 0) { + this.menu = $('<div class="action-menu-wrapper">').append(this.content.remove()); + $('.action-menu-icon', element).clone().data('action-menu-element', element).prependTo(this.menu); + + this.menu + .offset(this.element.offset()) + .appendTo(breakpoint); + + // Always add breakpoint + parents.push(breakpoint); + parents.forEach((parent, index) => { + var data = $(parent).data('action-menu-scroll-data') || { + menus: [], + left: parent.scrollLeft, + top: parent.scrollTop + }; + data.menus.push(this.menu); + + $(parent).data('action-menu-scroll-data', data); + + if (data.menus.length < 2) { + $(parent).scroll(scrollHandler); + } + }); + } + } + + this.update(); + } + + /** + * Adds a class to the menu's element. + */ + addClass(name) { + this.menu.addClass(name); + } + + /** + * Open the menu. + */ + open() { + this.toggle(true); + } + + /** + * Close the menu. + */ + close() { + this.toggle(false); + } + + /** + * Toggle the menus display state. Pass a state to enforce it. + */ + toggle(state = null) { + this.is_open = state === null ? !this.is_open : state; + + this.update(); + } + + /** + * Update the menu element's attributes. + */ + update() { + this.element.toggleClass('is-open', this.is_open); + + this.menu.toggleClass('is-open', this.is_open); + this.menu.toggleClass('is-reversed', this.is_reversed); + this.menu.attr('aria-expanded', this.is_open ? 'true' : 'false'); + } + + /** + * Confirms an action in the action menu that calls a JavaScript function + * instead of linking to another URL. + */ + confirmJSAction(element = null) { + //Show visual hint using a deferred. This way we don't need to + //duplicate the functionality in the done() handler. + //(code copied from copyable_link.js and modified) + (new Promise((resolve, reject) => { + var confirmation = $('<div class="js-action-confirmation">'); + confirmation.text = jQuery(element).data('confirmation_text'); + confirmation.insertBefore(element); + jQuery(element).parent().addClass('js-action-confirm-animation'); + var timeout = setTimeout(() => { + jQuery(element).parent().off('animationend'); + resolve(confirmation); + }, 1500); + jQuery(element).parent().one('animationend', () => { + clearTimeout(timeout); + resolve(confirmation); + }); + })).then((confirmation, parent) => { + confirmation.remove(); + jQuery(element).parent().removeClass('js-action-confirm-animation'); + }); + } +} + +export default ActionMenu; diff --git a/resources/assets/javascripts/lib/admin_sem_class.js b/resources/assets/javascripts/lib/admin_sem_class.js new file mode 100644 index 0000000..e4fc20d --- /dev/null +++ b/resources/assets/javascripts/lib/admin_sem_class.js @@ -0,0 +1,217 @@ +/* ------------------------------------------------------------------------ + * SemClass administration - only for root-user + * ------------------------------------------------------------------------ */ + +const admin_sem_class = { + make_sortable: function() { + var after_update = function(event, ui) { + if ( + jQuery(ui.item).is('.core') && + jQuery(this).is('#activated_plugins .droparea, #nonactivated_plugins .droparea') + ) { + jQuery('#deactivated_modules .droparea').append( + jQuery(ui.item) + .clone() + .fadeIn(1500) + ); + jQuery(ui.item).remove(); + } + if (jQuery(ui.item).is('.plugin:not(.core)') && jQuery(this).is('#deactivated_modules .droparea')) { + jQuery('#nonactivated_plugins .droparea').append( + jQuery(ui.item) + .clone() + .fadeIn(1500) + ); + jQuery(ui.item).remove(); + } + + jQuery('.droparea.limited').each(function(index, droparea) { + if (jQuery(this).children().length === 0) { + jQuery(this).removeClass('full'); + } else { + jQuery(this).addClass('full'); + } + }); + admin_sem_class.make_sortable(); + }; + jQuery('.droparea').sortable({ + connectWith: '.droparea:not(.full)', + revert: 200, + update: after_update + }); + jQuery('#plugins .droparea').sortable({ + connectWith: '.droparea:not(.full, #deactivated_modules .droparea)', + revert: 200, + update: after_update + }); + jQuery('#deactivated_modules .droparea').sortable({ + connectWith: '.droparea:not(.full, #plugins .droparea)', + revert: 200, + update: after_update + }); + }, + saveData: function() { + var core_module_slots = {}; + jQuery.each( + [ + 'overview', + 'forum', + 'admin', + 'documents', + 'participants', + 'schedule', + 'literature', + 'scm', + 'wiki', + 'calendar', + 'elearning_interface', + 'resources' + ], + function(index, element) { + var module = jQuery('div[container=' + element + '] .droparea > div.plugin').attr('id'); + if (module) { + module = module.substr(module.indexOf('_') + 1); + } + core_module_slots[element] = module ? module : '0'; + } + ); + var modules = {}; + jQuery('div.plugin').each(function() { + var activated = jQuery(this) + .find('input[name=active]') + .is(':checked'); + var sticky = + !jQuery(this) + .find('input[name=nonsticky]') + .is(':checked') || jQuery(this).is('#deactivated_modules div.plugin'); + var module_name = jQuery(this).attr('id'); + if (module_name) { + module_name = module_name.substr(module_name.indexOf('_') + 1); + } + modules[module_name] = { + activated: +activated, + sticky: +sticky + }; + }); + jQuery('#message_below').html(''); + jQuery.ajax({ + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/admin/sem_classes/save', + data: { + sem_class_id: jQuery('#sem_class_id').val(), + sem_class_name: jQuery('#sem_class_name').val(), + sem_class_description: jQuery('#sem_class_description').val(), + title_dozent: !jQuery('#title_dozent_isnull').is(':checked') ? jQuery('#title_dozent').val() : '', + title_dozent_plural: !jQuery('#title_dozent_isnull').is(':checked') + ? jQuery('#title_dozent_plural').val() + : '', + title_tutor: !jQuery('#title_tutor_isnull').is(':checked') ? jQuery('#title_tutor').val() : '', + title_tutor_plural: !jQuery('#title_tutor_isnull').is(':checked') + ? jQuery('#title_tutor_plural').val() + : '', + title_autor: !jQuery('#title_autor_isnull').is(':checked') ? jQuery('#title_autor').val() : '', + title_autor_plural: !jQuery('#title_autor_isnull').is(':checked') + ? jQuery('#title_autor_plural').val() + : '', + core_module_slots: core_module_slots, + modules: modules, + workgroup_mode: jQuery('#workgroup_mode').is(':checked') ? 1 : 0, + studygroup_mode: jQuery('#studygroup_mode').is(':checked') ? 1 : 0, + only_inst_user: jQuery('#only_inst_user').is(':checked') ? 1 : 0, + default_read_level: jQuery('#default_read_level').val(), + default_write_level: jQuery('#default_write_level').val(), + bereiche: jQuery('#bereiche').is(':checked') ? 1 : 0, + module: jQuery('#module').is(':checked') ? 1 : 0, + show_browse: jQuery('#show_browse').is(':checked') ? 1 : 0, + write_access_nobody: jQuery('#write_access_nobody').is(':checked') ? 1 : 0, + topic_create_autor: jQuery('#topic_create_autor').is(':checked') ? 1 : 0, + visible: jQuery('#visible').is(':checked') ? 1 : 0, + course_creation_forbidden: jQuery('#course_creation_forbidden').is(':checked') ? 1 : 0, + create_description: jQuery('#create_description').val(), + admission_prelim_default: jQuery('#admission_prelim_default').val(), + admission_type_default: jQuery('#admission_type_default').val(), + show_raumzeit: jQuery('#show_raumzeit').is(':checked') ? 1 : 0, + is_group: jQuery('#is_group').is(':checked') ? 1 : 0 + }, + type: 'POST', + dataType: 'json', + success: function(data) { + jQuery('#message_below').html(data.html); + } + }); + }, + delete_sem_type_question: function() { + var sem_type = jQuery(this) + .closest('li') + .attr('id'); + sem_type = sem_type.substr(sem_type.lastIndexOf('_') + 1); + jQuery('#sem_type_for_deletion').val(sem_type); + jQuery('#sem_type_delete_question').dialog({ + title: jQuery('#sem_type_delete_question_title').text() + }); + }, + add_sem_type: function() { + jQuery.ajax({ + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/admin/sem_classes/add_sem_type', + type: 'post', + data: { + sem_class: jQuery('#sem_class_id').val(), + name: jQuery('#new_sem_type').val() + }, + success: function(ret) { + jQuery('#sem_type_list').append(jQuery(ret)); + jQuery('#new_sem_type') + .val('') + .closest('li') + .children() + .toggle(); + }, + error: function() { + jQuery('#new_sem_type') + .val('') + .closest('li') + .children() + .toggle(); + } + }); + }, + delete_sem_type: function() { + jQuery.ajax({ + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/admin/sem_classes/delete_sem_type', + data: { + sem_type: jQuery('#sem_type_for_deletion').val() + }, + type: 'post', + success: function() { + jQuery('#sem_type_' + jQuery('#sem_type_for_deletion').val()).remove(); + jQuery('#sem_type_delete_question').dialog('close'); + } + }); + }, + rename_sem_type: function() { + jQuery(this) + .closest('span.name_container') + .children() + .toggle(); + var name = this.value; + var old_name = jQuery(this) + .closest('.name_container') + .find('.name_html'); + var sem_type = jQuery(this) + .closest('li') + .attr('id'); + sem_type = sem_type.substr(sem_type.lastIndexOf('_') + 1); + jQuery.ajax({ + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/admin/sem_classes/rename_sem_type', + data: { + sem_type: sem_type, + name: name + }, + type: 'post', + success: function() { + old_name.text(name); + } + }); + } +}; + +export default admin_sem_class; diff --git a/resources/assets/javascripts/lib/admission.js b/resources/assets/javascripts/lib/admission.js new file mode 100644 index 0000000..33e96aa --- /dev/null +++ b/resources/assets/javascripts/lib/admission.js @@ -0,0 +1,294 @@ +/* ------------------------------------------------------------------------ + * Anmeldeverfahren und -sets + * ------------------------------------------------------------------------ */ +import { $gettext } from './gettext.js'; +import Dialog from './dialog.js'; +import Dialogs from './dialogs.js'; + +const Admission = { + getCourses: function(targetUrl) { + var courseFilter = $('input[name="course_filter"]').val(); + if (courseFilter == '') { + courseFilter = '%%%'; + } + var data = { + 'courses[]': _.map($('#courselist input:checked'), 'id'), + course_filter: courseFilter, + semester: $('select[name="semester"]').val(), + 'institutes[]': $.merge( + _.map($('input[name="institutes[]"]:hidden'), 'value'), + _.map($('input[name="institutes[]"]:checked'), 'value') + ) + }; + var loading = $gettext('Wird geladen'); + $('#instcourses').empty(); + $('<img/>', { + src: STUDIP.ASSETS_URL + 'images/ajax_indicator_small.gif' + }).appendTo('#instcourses'); + $('#instcourses').append(loading); + $('#instcourses').load(targetUrl, data); + return false; + }, + + configureRule: function(ruleType, targetUrl, ruleId) { + var urlparts = targetUrl.split('?'); + targetUrl = urlparts[0] + '/' + ruleType; + if (urlparts[1]) { + targetUrl += '?' + urlparts[1]; + } + + Dialog.fromURL(targetUrl, { + method: 'post', + size: 'auto', + title: $gettext('Anmelderegel konfigurieren'), + id: 'configurerule', + data: { ruleId: ruleId, rules: _.map($('#rules input[name="rules[]"]'), 'value') } + }); + + return false; + }, + + selectRuleType: function(source) { + Dialog.fromURL(source, { + title: $gettext('Anmelderegel konfigurieren'), + size: 'auto', + data: { rules: _.map($('#rules input[name="rules[]"]'), 'value') }, + method: 'post', + id: 'configurerule' + }); + return false; + }, + + saveRule: function(ruleId, targetId, targetUrl) { + if ($('#action').val() !== 'cancel') { + $.ajax({ + type: 'post', + url: targetUrl, + data: $('#ruleform').serialize(), + dataType: 'html', + success: function(data, textStatus, jqXHR) { + if (data !== '') { + var result = ''; + if ($('#norules').length > 0) { + $('#norules').remove(); + $('#' + targetId).prepend('<div id="rulelist"></div>'); + } + result += data; + if ($('#rule_' + ruleId).length !== 0) { + $('#rule_' + ruleId).replaceWith(result); + } else { + $('#rulelist').append(result); + } + } + }, + error: function(jqXHR, textStatus, errorThrown) { + alert('Status: ' + textStatus + '\nError: ' + errorThrown); + } + }); + } + Admission.closeDialog('configurerule'); + Admission.toggleNotSavedAlert(); + return false; + }, + + removeRule: function(targetId, containerId) { + var parent = $('#' + targetId).parent(); + $('#' + targetId).remove(); + if (parent.children('div').length === 0) { + parent.remove(); + var norules = $gettext('Sie haben noch keine Anmelderegeln festgelegt.'); + $('#' + containerId).prepend('<span id="norules">' + '<i>' + norules + '</i></span>'); + } + Dialogs.closeConfirmDialog(); + Admission.toggleNotSavedAlert(); + }, + + toggleRuleDescription: function(targetId) { + $('#' + targetId).toggle(); + return false; + }, + + toggleDetails: function(arrowId, detailId) { + var oldSrc = $('#' + arrowId).attr('src'); + var newSrc = $('#' + arrowId).attr('rel'); + $('#' + arrowId).attr('src', newSrc); + $('#' + arrowId).attr('rel', oldSrc); + $('#' + detailId).slideToggle(); + return false; + }, + + /** + * + * @param String ruleId The rule to save. + * @param String errorTarget Target element ID where error messages will be + * shown. + * @param String validateUrl URL to call for validation. + * @param String savedTarget Target element ID where the saved rule will be + * displayed. + * @param String saveUrl URL to save the rule. + */ + checkAndSaveRule: function(ruleId, errorTarget, validateUrl, savedTarget, saveUrl) { + if (Admission.validateRuleConfig(errorTarget, validateUrl)) { + Admission.saveRule(ruleId, savedTarget, saveUrl); + Dialog.close({ id: 'configurerule' }); + } + return false; + }, + + validateRuleConfig: function(containerId, targetUrl) { + var valid = true; + var error = $.ajax({ + type: 'post', + async: false, + url: targetUrl, + data: $('#ruleform').serialize(), + dataType: 'html', + + error: function(jqXHR, textStatus, errorThrown) { + alert('Status: ' + textStatus + '\nError: ' + errorThrown); + } + }).responseText; + error = error.replace(/(\r\n|\n|\r)/gm, ''); + if ($.trim(error) != '') { + $('#' + containerId).html(error); + valid = false; + } + return valid; + }, + + removeUserFromUserlist: function(userId) { + var parent = $('#user_' + userId).parent(); + $('#user_' + userId).remove(); + if (parent.children('li').length === 0) { + var nousers = $gettext('Sie haben noch niemanden hinzugefügt.'); + $(parent) + .parent() + .append('<span id="nousers">' + '<i>' + nousers + '</i></span>'); + } + return false; + }, + + /** + * Creates a tree view from the HTML list in <elementId> using the + * given data for special node types. + * + * @param String elementId + * @param typesData JS object with tree nodes types + * (@see http://www.jstree.com/documentation/types) + */ + makeTree: function(elementId, typesData) { + var config = { + core: { + animation: 100, + open_parents: true, + initially_open: ['root'] + }, + checkbox: { + real_checkboxes: true, + selected_parent_open: true, + override_ui: false, + two_state: true + }, + plugins: ['html_data', 'themes', 'types', 'checkbox', 'ui'] + }; + config.types = { types: typesData }; + $('#' + elementId) + .on('loaded.jstree', function(event, data) { + // Show checked checkboxes. + var checkedItems = $('#' + elementId).find('.jstree-checked'); + checkedItems.removeClass('jstree-unchecked'); + // Open parent nodes of checked nodes. + checkedItems.parents().each(function() { + data.inst.open_node(this, false, true); + }); + }) + .jstree(config); + }, + + updateInstitutes: function(elementId, instURL, courseURL, mode) { + if (elementId !== '') { + var query = ''; + $('.institute').each(function() { + query += '&institutes[]=' + this.value; + }); + switch (mode) { + case 'delete': + $('#' + elementId).remove(); + break; + case 'add': + query += '&institutes[]=' + elementId; + $.post(instURL, query, function(data) { + $('#institutes').html(data); + }); + break; + } + $('#instcourses :checked').each(function() { + query += '&courses[]=' + this.value; + }); + this.getCourses(courseURL); + Admission.toggleNotSavedAlert(); + } + }, + + checkRuleActivation: function(target) { + var form = $('#' + target); + var globalActivation = form.find('input[name=enabled]'); + if (globalActivation.prop('checked')) { + $('#activation').show(); + if (form.find('input[name=activated]:checked').val() === 'studip') { + $('#institutes_activation').hide(); + } else { + $('#institutes_activation').show(); + } + } else { + $('#activation').hide(); + $('#institutes_activation').hide(); + } + }, + + closeDialog: function(elementId) { + $('#' + elementId).remove(); + }, + + checkUncheckAll: function(inputName, mode) { + switch (mode) { + case 'check': + $('input[name*="' + inputName + '"]').each(function() { + $(this).prop('checked', true); + }); + break; + case 'uncheck': + $('input[name*="' + inputName + '"]').each(function() { + $(this).prop('checked', false); + }); + break; + case 'invert': + $('input[name*="' + inputName + '"]').each(function() { + $(this).prop('checked', !$(this).prop('checked')); + }); + break; + } + return false; + }, + + toggleNotSavedAlert: function() { + $('.hidden-alert').show(); + }, + + autosaveCourseset: function(event) { + $.post({ + url: $('#courseset-form').attr('action'), + data: $('#courseset-form').serialize() + '&submit=1', + dataType: 'html', + success: function(data, textStatus, jqXHR) { + $('.hidden-alert').hide(); + }, + error: function(jqXHR, textStatus, errorThrown) { + alert('Status: ' + textStatus + '\nError: ' + errorThrown); + } + }); + } + +}; + +export default Admission; diff --git a/resources/assets/javascripts/lib/arbeitsgruppen.js b/resources/assets/javascripts/lib/arbeitsgruppen.js new file mode 100644 index 0000000..fa0f97e --- /dev/null +++ b/resources/assets/javascripts/lib/arbeitsgruppen.js @@ -0,0 +1,17 @@ +/* ------------------------------------------------------------------------ + * Studentische Arbeitsgruppen + * ------------------------------------------------------------------------ */ + +const Arbeitsgruppen = { + toggleOption: function(user_id) { + if (jQuery('#user_opt_' + user_id).is(':hidden')) { + jQuery('#user_opt_' + user_id).show('slide', { direction: 'left' }, 400, function() { + jQuery('#user_opt_' + user_id).css('display', 'inline-block'); + }); + } else { + jQuery('#user_opt_' + user_id).hide('slide', { direction: 'left' }, 400); + } + } +}; + +export default Arbeitsgruppen; diff --git a/resources/assets/javascripts/lib/archive.js b/resources/assets/javascripts/lib/archive.js new file mode 100644 index 0000000..64c689b --- /dev/null +++ b/resources/assets/javascripts/lib/archive.js @@ -0,0 +1,15 @@ +const Archive = { + removeArchivedCourses: function(courseIds) { + /* + * Removes courses that are archived from the course list + * seen in the admin/courses controller. + */ + + for (var i = 0; i < courseIds.length; i++) { + courseIds[i] = '#course-' + courseIds[i]; + jQuery(courseIds[i]).remove(); + } + } +}; + +export default Archive; diff --git a/resources/assets/javascripts/lib/audio.js b/resources/assets/javascripts/lib/audio.js new file mode 100644 index 0000000..7b23055 --- /dev/null +++ b/resources/assets/javascripts/lib/audio.js @@ -0,0 +1,44 @@ +var initialised = false, + loaded = false, + queue = [], + load_audioplayer = function() { + AudioPlayer.setup(STUDIP.ASSETS_URL + 'flash/player.swf', { + animation: 'no', + transparentpagebg: 'yes', + width: 300 + }); + loaded = true; + + // Process queue + var item = queue.shift(); + while (item) { + Audio.handle(item); + item = queue.shift(); + } + }, + initialise = function() { + if (!initialised) { + var script = document.createElement('script'); + script.src = STUDIP.ASSETS_URL + 'javascripts/audio-player.js'; + script.onload = load_audioplayer; + document.getElementsByTagName('head')[0].appendChild(script); + initialised = true; + } + return loaded; + }; + +const Audio = { + handle: function(element) { + if (!initialise()) { + queue.push(element); + } else { + AudioPlayer.embed(element.id, { + soundFile: encodeURIComponent(element.src), + titles: element.title, + width: element.clientWidth || 300 + }); + } + } +}; + +export default Audio; diff --git a/resources/assets/javascripts/lib/avatar.js b/resources/assets/javascripts/lib/avatar.js new file mode 100644 index 0000000..a034910 --- /dev/null +++ b/resources/assets/javascripts/lib/avatar.js @@ -0,0 +1,111 @@ +const Avatar = { + cropper: null, + + init: function(inputSelector) { + jQuery(document).on('change', inputSelector, function() { + Avatar.readFile(this); + + jQuery(document) + .off('submit.avatar', 'form.settings-avatar') + .on('submit.avatar', 'form.settings-avatar', function() { + var data = Avatar.cropper.getData(); + return Avatar.checkImageSize(data); + }); + }); + }, + + readFile: function(input) { + if (window.FileReader && input.files && input.files[0]) { + var reader = new window.FileReader(); + + if (input.files[0].size <= jQuery(input).data('max-size')) { + var container = jQuery('div#avatar-preview'), + dialog = container.closest('div[role="dialog"]'); + // We are in a modal dialog + if (dialog.length > 0) { + // Adjust maximal cropper container height to dialog dimensions. + container.css('height', dialog.height() - 200); + container.css('width', dialog.width() - 220); + container.css('max-height', dialog.height() - 200); + container.css('max-width', dialog.width() - 220); + // No dialog, full page. + } else { + dialog = jQuery('#layout_content'); + // Responsive view. + if (jQuery('html').hasClass('responsified')) { + // Adjust maximal cropper container height to page dimensions. + container.css('height', dialog.height() - 220); + container.css('width', 0.95 * dialog.width()); + container.css('max-height', dialog.height() * 220); + container.css('max-width', 0.95 * dialog.width()); + // Non-dialog, non-responsive view. + } else { + // Adjust maximal cropper container height to page dimensions. + container.css('height', dialog.height() - 100); + container.css('width', dialog.width() - 200); + container.css('max-height', dialog.height() * 220); + container.css('max-width', dialog.width() - 100); + } + } + + reader.onload = function(event) { + var image = document.getElementById('new-avatar'); + if (image) { + image.src = event.target.result; + + import(/* webpackChunkName: "avatarcropper" */ 'cropperjs/dist/cropper.js') + .then(function(cropperjs) { + var Cropper = cropperjs['default']; + Avatar.cropper = new Cropper(image, { + aspectRatio: 1, + viewMode: 2 + }); + }) + .catch(function(error) { + console.log('An error occurred while loading the croppers lib', error); + }); + } + }; + + reader.readAsDataURL(input.files[0]); + + jQuery('#avatar-buttons').removeClass('hidden-js'); + jQuery('label.file-upload').hide(); + jQuery('#avatar-zoom-in').on('click', function() { + Avatar.cropper.zoom(0.1); + return false; + }); + jQuery('#avatar-zoom-out').on('click', function() { + Avatar.cropper.zoom(-0.1); + return false; + }); + jQuery('#avatar-rotate-clockwise').on('click', function() { + Avatar.cropper.rotate(90); + return false; + }); + jQuery('#avatar-rotate-counter-clockwise').on('click', function() { + Avatar.cropper.rotate(-90); + return false; + }); + + jQuery('#submit-avatar').on('click', function() { + jQuery('#cropped-image').attr('value', Avatar.cropper.getCroppedCanvas().toDataURL('image/jpeg', 0.7)); + }); + } else { + alert(jQuery(input).data('message-too-large')); + } + } else { + alert("Sorry - your browser doesn't support the FileReader API"); + } + }, + + checkImageSize: function(data) { + // Show a warning if cropped area is smaller than 250x250px. + if (data.width < 250 || data.height < 250) { + return confirm(jQuery('#new-avatar').data('message-too-small')); + } + return true; + } +}; + +export default Avatar; diff --git a/resources/assets/javascripts/lib/big_image_handler.js b/resources/assets/javascripts/lib/big_image_handler.js new file mode 100644 index 0000000..5130997 --- /dev/null +++ b/resources/assets/javascripts/lib/big_image_handler.js @@ -0,0 +1,139 @@ +/** + * Handle oversized a.k.a. "big" images that are originally greater in + * width or height than they are displayed. + * + * Any oversized image will be clickable and is displayed in an overlay + * as long as it does not meet certain criteria that will exclude it from + * this mechanism (see method shouldSkip for more info). + * + * The big image handler my be enabled and disabled by an api bound to + * the global STUDIP object (see methods STUDIP.BigImageHandler.enable and + * STUDIP.BigImageHandler.disable). + * + * Images are only handled if they exceed a certain threshold in any + * direction. This threshold can be adjusted in the variable + * STUDIP.BigImageHandler.threshold. + * + * @author Jan-Hendrik Willms <tleilax+studip@gmail.com> + * @license GPL2 or any later version + * @since Stud.IP 3.4 + */ +import { $gettext } from './gettext.js'; + +var pixelRatio = window.devicePixelRatio || 1, + dataAttribute = 'big-image-handled'; + +// Determines whether the image should not be handled due to one of the +// following reasons: +// +// - image is inside an editable element (wysiwyg) +// - image is an avatar +// - image is an icon +// - image is a svg +// - image is linked to something else than itself +// - image has the class "ignore-size" +function shouldSkip(img) { + var $img = $(img), + $link = $img.closest('a'), + src = $img.attr('src'); + return ( + $img.data(dataAttribute) || + $img.closest('[contenteditable]').length > 0 || + ($link.length > 0 && $link.attr('href') !== src) || + src.match(/\.svg$/) + ); +} + +// The actual handler for images. This determines whether the image +// is considered big and should be treated that way. +// If the image is oversized, store the actual width and height of the +// image in it's data storage and add the "oversized-image" class to it. +// +// This function will return a function to be used as an onload handler. +function oversizedHandler(img) { + var display_width = Math.max(BigImageHandler.threshold, parseInt($(img).width(), 10)), + display_height = Math.max(BigImageHandler.threshold, parseInt($(img).height(), 10)); + return function() { + var width = this.width, + height = this.height, + title = + $(this).prop('title') || + $gettext('Dieses Bild wird verkleinert dargestellt. Klicken Sie für eine größere Darstellung.'), + highdpi_check = width / display_width === pixelRatio && height / display_height === pixelRatio; + if (!highdpi_check && (width > display_width || height > display_height)) { + $(img) + .data('oversized', { + width: width, + height: height + }) + .prop('title', title) + .addClass('oversized-image'); + } + }; +} + +// Set up global js api +const BigImageHandler = { + // Threshold for activating the handler, images must be greater + // than this value in any direction to trigger the handler + threshold: 64 +}; + +// Enables the mechanism +BigImageHandler.enable = function() { + // Global handlers: + // - check if an image is oversized on mouseenter + // - create overlay/zoom on click on the image + // - remove overlay/zoom on click on itself or escape key + $(document) + .on('mouseenter.big-image-handler', '.formatted-content img', function() { + if (!shouldSkip(this)) { + var img = new Image(); + img.onload = oversizedHandler(this); + img.src = this.src; + $(this).data(dataAttribute, true); + } + }) + .on('click.big-image-handler', 'img.oversized-image', function(event) { + var src = $(this).attr('src'), + data = $(this).data('oversized'), + zoomed = $('<span>').css('background-image', 'url(' + src + ')'), + overlay = $('<div class="oversized-image-zoom">'); + + // Set dimensions + zoomed.width(data.width); + zoomed.height(data.height); + + // Add invisible image (see css) to allow right click "view image" + $('<img>') + .attr('src', src) + .appendTo(zoomed); + + // Append overlay + overlay.append(zoomed).appendTo('body'); + + // Stop event + event.stopPropagation(); + event.preventDefault(); + }) + .on('click.big-image-handler', '.oversized-image-zoom', function() { + // remove overlay + $(this).remove(); + }) + .on('keypress.big-image-handler', 'body:has(.oversized-image-zoom)', function(event) { + if (event.key === 'Escape') { + $('.oversized-image-zoom').remove(); + + event.preventDefault(); + event.stopPropagation(); + } + }); +}; + +// Disable the mechanism +BigImageHandler.disable = function() { + $('img.oversized-image').removeClass('oversized-image'); + $(document).off('.big-image-handler'); +}; + +export default BigImageHandler; diff --git a/resources/assets/javascripts/lib/blubber.js b/resources/assets/javascripts/lib/blubber.js new file mode 100644 index 0000000..c2d85b2 --- /dev/null +++ b/resources/assets/javascripts/lib/blubber.js @@ -0,0 +1,221 @@ +/*jslint esversion: 6*/ +import { $gettext } from '../lib/gettext.js'; +import BlubberGlobalstream from '../../../vue/components/BlubberGlobalstream.vue'; +import BlubberPublicComposer from '../../../vue/components/BlubberPublicComposer.vue'; +import BlubberThread from '../../../vue/components/BlubberThread.vue'; +import BlubberThreadWidget from '../../../vue/components/BlubberThreadWidget.vue'; + +const components = { + BlubberGlobalstream, + BlubberPublicComposer, + BlubberThread, + BlubberThreadWidget, +}; + +const Blubber = { + App: null, //This app is not always available. The app is blubber with a widget and the threads next to it. + threads: [], + init () { + if ($('#blubber-index, #messenger-course, .blubber_panel.vueinstance').length) { + STUDIP.JSUpdater.register('blubber', Blubber.updateState, Blubber.getParamsForPolling); + + let panel_data = $('.blubber_panel').data(); + STUDIP.Vue.load().then(({createApp}) => { + STUDIP.Blubber.App = createApp({ + el: '#layout_container', + data: { + threads: $('.blubber_threads_widget').data('threads_data'), + thread_data: panel_data.thread_data, + active_thread: panel_data.active_thread, + threads_more_down: panel_data.threads_more_down, + waiting: false, + display_context_posting: 0 + }, + methods: { + changeActiveThread: function (thread_id) { + this.waiting = true; + let search = jQuery("form.sidebar-search input[name=search]").val(); + let parameters = search ? {data: {"search": search}} : {}; + STUDIP.api.GET(`blubber/threads/${thread_id}`, parameters).done((data) => { + this.active_thread = thread_id; + this.thread_data = data; + }).always(() => { + this.waiting = false; + }).fail(() => { + window.alert($gettext("Konnte die Konversation nicht laden. Probieren Sie es nachher erneut.")); + }); + for (let i in this.threads) { + if (this.threads[i].thread_id === thread_id) { + this.threads[i].unseen_comments = 0; + } + } + } + }, + components, + }); + }); + + jQuery("form.sidebar-search").on("submit", function (event) { + this.waiting = true; + let search = jQuery("form.sidebar-search input[name=search]").val(); + if ($('#messenger-course').length === 0) { + STUDIP.api.GET(`blubber/threads`, {data: {"search": search}}).done((data) => { + STUDIP.Blubber.App.threads = data.threads; + STUDIP.Blubber.App.threads_more_down = data.more_down; + $('.blubber_thread_widget')[0].__vue__.display_more_down = data.more_down; + }).always(() => { + this.waiting = false; + }).fail(() => { + window.alert($gettext("Konnte die Suche nicht ausführen. Probieren Sie es nachher erneut.")); + }); + } + let parameters = search ? {"search": search} : {"modifier": "olderthan"}; + STUDIP.api.GET(`blubber/threads/` + STUDIP.Blubber.App.active_thread + `/comments`, {data: parameters}).done((data) => { + STUDIP.Blubber.App.thread_data.comments = data.comments; + STUDIP.Blubber.App.thread_data.more_up = data.more_up; + STUDIP.Blubber.App.thread_data.more_down = data.more_down; + $('.blubber_thread')[0].__vue__.scrollDown(); + }).always(() => { + this.waiting = false; + }).fail(() => { + window.alert($gettext("Konnte die Suche nicht ausführen. Probieren Sie es nachher erneut.")); + }); + event.preventDefault(); + return false; + }); + jQuery('#blubber-index, #messenger-course').on("click", 'a.blubber_hashtag', function (event) { + let tag = jQuery(this).closest("a").data("tag"); + jQuery("form.sidebar-search input[name=search]").val("#" + tag); + jQuery("form.sidebar-search").trigger("submit"); + event.preventDefault(); + return false; + }); + } + + $(document).on('dialog-open', function() { + $('.studip-dialog .blubber_panel').each(function () { + STUDIP.JSUpdater.register('blubber', Blubber.updateState, Blubber.getParamsForPolling); + + let panel_data = $(this).data(); + STUDIP.Vue.load().then(({createApp}) => { + createApp({ + el: this, + data: { + threads: panel_data.threads_data, + thread_data: panel_data.thread_data, + active_thread: panel_data.active_thread, + threads_more_down: panel_data.threads_more_down, + waiting: false, + display_context_posting: 0 + }, + components, + }); + }); + }); + }); + }, + updateState(datagram) { + for (const [method, data] of Object.entries(datagram)) { + if (method in Blubber) { + Blubber[method](data); + } + } + }, + getParamsForPolling () { + const data = { + threads: [], + }; + $('.blubber_thread').each(function () { + data.threads.push(this.__vue__._props.thread_data.thread_posting.thread_id); + }); + + return data; + }, + addNewComments (blubberdata) { + $('.blubber_thread').each(function () { + for (let thread_id in blubberdata) { + if (this.__vue__._props.thread_data.thread_posting.thread_id === thread_id) { + this.__vue__.addComments(blubberdata[thread_id], true); + this.__vue__.scrollDown(); + } + } + }); + }, + removeDeletedComments: function (comment_ids) { + $('.blubber_thread').each(function () { + this.__vue__.removeDeletedComments(comment_ids); + }); + }, + updateThreadWidget (threaddata) { + for (let i in threaddata) { + let exists = false; + for (let k in STUDIP.Blubber.App.threads) { + if (STUDIP.Blubber.App.threads[k].thread_id == threaddata[i].thread_id) { + exists = true; + STUDIP.Blubber.App.threads[k].name = threaddata[i].name; + STUDIP.Blubber.App.threads[k].timestamp = threaddata[i].timestamp; + STUDIP.Blubber.App.threads[k].avatar = threaddata[i].avatar; + } + } + if (!exists) { + STUDIP.Blubber.App.threads.push(threaddata[i]); + } + } + }, + refreshThread (data) { + STUDIP.Blubber.App.changeActiveThread(data.thread_id); + }, + followunfollow (thread_id, follow) { + const elements = $(`.blubber_panel .followunfollow[data-thread_id="${thread_id}"]`); + if (follow === undefined) { + follow = elements.hasClass('unfollowed'); + } + elements.addClass('loading'); + + const promise = follow + ? STUDIP.api.POST(`blubber/threads/${thread_id}/follow`) + : STUDIP.api.DELETE(`blubber/threads/${thread_id}/follow`); + + return promise.then(() => { + elements.toggleClass('unfollowed', !follow); + return follow; + }).always(() => { + elements.removeClass('loading'); + }).promise(); + }, + Composer: { + vue: null, + async init () { + STUDIP.Blubber.Composer.vue = await STUDIP.Vue.load().then(({createApp}) => { + return createApp({ + el: '#blubber_contact_ids', + data: { + users: [] + }, + methods: { + addUser: function (user_id, name) { + this.users.push({ + user_id: user_id, + name: name + }); + }, + removeUser: function (event) { + let user_id = $(event.target).closest('li').find('input').val(); + for (let i in this.users) { + if (this.users[i].user_id === user_id) { + this.$delete(this.users, i); + } + } + }, + clearUsers: function () { + this.users = []; + } + }, + components, + }); + }); + } + } +}; + +export default Blubber; diff --git a/resources/assets/javascripts/lib/browse.js b/resources/assets/javascripts/lib/browse.js new file mode 100644 index 0000000..74e6d31 --- /dev/null +++ b/resources/assets/javascripts/lib/browse.js @@ -0,0 +1,7 @@ +const Browse = { + selectUser: function(username) { + window.location.href = STUDIP.URLHelper.getURL('dispatch.php/profile', { username: username }); + } +}; + +export default Browse; diff --git a/resources/assets/javascripts/lib/cache.js b/resources/assets/javascripts/lib/cache.js new file mode 100644 index 0000000..0423fb1 --- /dev/null +++ b/resources/assets/javascripts/lib/cache.js @@ -0,0 +1,229 @@ +/*jslint esversion: 6*/ +import Cookie from './cookie.js'; + +/** + * Stud.IP: Caching in JavaScript + * + * Uses local storage for persistent storage across browser sessions + * for items with a given expiry or as a tab spanning session storage + * when no expiry is given. + * + * Example: + * + * var cache = STUDIP.Cache.getInstance(), + * foo = cache.get('foo'); + * if (typeof foo === undefined) { + * foo = 'bar'; + * cache.set('foo', foo); + * } + * + * Pass set() an expiry duration in seconds to allow persistent storage + * across browser sessions. + * + * Example: + * + * var cache = STUDIP.Cache.getInstance(), + * tmp; + * cache.set('foo', 'bar', 5); + * tmp = cache.get('foo'); + * setTimeout(function () { + * console.log([tmp, cache.get('foo')]); + * }, 6000); + * // Will result in ['bar', undefined] after 6 seconds have passed + * + * You may pass get() a creator function as an optional second parameter + * so the value will be generated on the fly if not found in cache. + * + * Example: + * + * var cache = STUDIP.Cache.getInstance(), + * creator = function (index) { return 'Hello ' + index; }; + * cache.remove('World'); + * console.log(cache.get('World', creator)); + * // Will result in 'Hello World' both on the console and in cache + * + * Cache instances may use prefixes to avoid conflicts with other js + * functions (this is the single reason why the lib was designed to use a + * getInstance() method). + * + * Example: + * + * var cache0 = STUDIP.Cache.getInstance(''), + * cache1 = STUDIP.Cache.getInstance('foo'); + * cache0.set('foobar', 'baz'); + * console.log([cache0.get('bar'), cache1.get('bar')]); + * // Will result in [undefined, 'baz'] + * + * If the browser does not support any of the storage types, a dummy polyfill + * will be used that doesn't actually store data. + * + * Internally, all items are prefixed with a 'studip.' in order to avoid + * clashes. + * + * This implementation does not use sessionStorage due to the fact that the + * cache should work across tabs and windows. A session is indicated by a + * session cookie that this implementation will use. + * + * @author Jan-Hendrik Willms <tleilax+studip@gmail.com> + * @license GPL2 or any later version + * @copyright Stud.IP core group + * @since Stud.IP 3.2 + */ + +// Use localstorage or dummy +var cache; +try { + let test_key = '__storageTest123'; + window.localStorage.setItem(test_key, 'foo'); + window.localStorage.removeItem(test_key); + cache = window.localStorage; +} catch (e) { + cache = new class { + constructor() { this.length = 0; } + clear() {} + getItem() { return undefined; } + key() { return undefined; } + removeItem() {} + setItem() {} + }(); +} + +class Cache { + /** + * @param string prefix Optional prefix for the cache + */ + constructor(prefix, session_id) { + this.prefix = 'studip.' + (prefix || ''); + this.session_id = session_id; + } + + /** + * Locates an item in the caches. + * + * @param String index Key of the item to look up + * @return mixed false if item is not found, item's value otherwise + */ + locate(index) { + index = this.prefix + index; + + if (cache.hasOwnProperty(index)) { + const now = new Date().getTime(); + + let item = JSON.parse(cache.getItem(index)); + + if (!item.expires || item.expires > now) { + return item.value; + } + + cache.removeItem(index); + } + + return undefined; + } + + /** + * Returns whether the cache has an item stored for the given key. + * + * @param String index Key used to store the item + * @return bool + */ + has(index) { + return this.locate(index) !== undefined; + } + + /** + * Retrieves an object from the cache for the given key. + * You may provide an additional creator function if the + * value was not found to immediately create and set it. + * The function will be passed the index as it's only argument. + * + * @param String index Key used to store the item + * @param mixed creator Optional creator function for the value + * @param mixed expires Optional storage duration in seconds + * @return mixed Value of the item or undefined if not found. + */ + get(index, setter, expires) { + var result = this.locate(index); + if (result === undefined && setter && typeof setter === 'function') { + result = setter(index); + this.set(index, result, expires); + } + return result; + } + + /** + * Store an item in the cache. + * + * @param String index Key used to store the item + * @param mixed value Value of the item + * @param mixed expires Optional storage duration in seconds + */ + set(index, value, expires) { + index = this.prefix + index; + + cache.setItem(index, JSON.stringify({ + value: value, + expires: expires ? new Date().getTime() + expires * 1000 : false, + session: this.session_id + })); + } + + /** + * Removes an item from the cache. + * + * @param String index Key used to store the item + */ + remove(index) { + if (this.has(index)) { + index = this.prefix + index; + cache.removeItem(index); + } + } + + /** + * Clears the cache completely. Respects the prefix, so only + * the prefixed items will be removed. + */ + prune() { + if (this.prefix) { + for (let key in cache) { + if (cache.hasOwnProperty(key) && key.indexOf(this.prefix) === 0) { + cache.removeItem(key); + } + } + } else { + cache.clear(); + } + } +} + +/** + * Expose the Cache object with it's getInstance method to the global + * STUDIP object. + */ +const CacheFacade = { + getInstance: function (prefix) { + // Initialized browser session? + const now = new Date().getTime(); + var session_id = Cookie.get('cache_session'); + if (session_id === undefined) { + session_id = new Date().getTime().toString(); + Cookie.set('cache_session', session_id); + + for (let key in cache) { + if (!cache.hasOwnProperty(key) || key.indexOf('studip.') !== 0) { + continue; + } + + var item = JSON.parse(cache.getItem(key)); + if (item.expires < now || (item.expires === false && item.session !== session_id)) { + cache.removeItem(key); + } + } + } + + return new Cache(prefix, session_id); + } +}; + +export default CacheFacade; diff --git a/resources/assets/javascripts/lib/calendar.js b/resources/assets/javascripts/lib/calendar.js new file mode 100644 index 0000000..a693f6c --- /dev/null +++ b/resources/assets/javascripts/lib/calendar.js @@ -0,0 +1,117 @@ +import { $gettext } from '../lib/gettext.js'; + +/* ------------------------------------------------------------------------ + * calendar gui + * ------------------------------------------------------------------------ */ +const Calendar = { + cell_height: 20, + the_entry_content: null, + entry: null, + click_start_hour: -1, + click_entry: null, + click_in_progress: false, + + day_names: [ + $gettext('Montag'), + $gettext('Dienstag'), + $gettext('Mittwoch'), + $gettext('Donnerstag'), + $gettext('Freitag'), + $gettext('Samstag'), + $gettext('Sonntag') + ], + + /** + * this function is called, whenever an existing entry in the + * calendar is clicked. It calls the passed function with the + * calculcate id of the clicked element + * + * @param object a function or a reference to a function + * @param object the element in the dom, that has been clicked + * @param object the click-event itself + */ + clickEngine: function(func, target, event) { + event.cancelBubble = true; + var id = jQuery(target).parent()[0].id; + id = id.substr(id.lastIndexOf('_') + 1); + func(id); + }, + + /** + * check, that the submited input-field cotains of a valid hour + * + * @param object the input-element to check + */ + validateHour: function(element) { + var hour = parseInt(jQuery(element).val(), 10); + + if (hour > 23) { + hour = 23; + } + if (hour < 0 || isNaN(hour)) { + hour = 0; + } + + jQuery(element).val(hour); + }, + + /** + * check, that the submited input-field cotains of a valid minute + * + * @param object the input-element to check + */ + validateMinute: function(element) { + var minute = parseInt(jQuery(element).val(), 10); + + if (minute > 59) { + minute = 59; + } + if (minute < 0 || isNaN(minute)) { + minute = 0; + } + + jQuery(element).val(minute); + }, + + /** + * checks if at least one day is selected + * + * @return: bool true if selected days > 0 + */ + validateNumberOfDays: function() { + var days = $("input[name='days[]']:checked") + .map(function() { + return $(this).val(); + }) + .get(); + if (days.length === 0) { + jQuery('.settings > span[class=invalid_message]').show(); + return false; + } else { + return true; + } + }, + + /** + * check, that the submitted input-fields contain a valid time-range + * + * @param object the input-element to check (start-hour) + * @param object the input-element to check (start-minute) + * @param object the input-element to check (end-hour) + * @param object the input-element to check (end-minute) + * + * @return: bool true if valid time-range, false otherwise + */ + checkTimeslot: function(start_hour, start_minute, end_hour, end_minute) { + if ( + parseInt(start_hour.val(), 10) * 100 + parseInt(start_minute.val(), 10) >= + parseInt(end_hour.val(), 10) * 100 + parseInt(end_minute.val(), 10) + ) { + return false; + } + + return true; + } +}; + +export default Calendar; diff --git a/resources/assets/javascripts/lib/calendar_dialog.js b/resources/assets/javascripts/lib/calendar_dialog.js new file mode 100644 index 0000000..e42a149 --- /dev/null +++ b/resources/assets/javascripts/lib/calendar_dialog.js @@ -0,0 +1,64 @@ +import Dialog from './dialog.js'; + +const CalendarDialog = { + closeMps: function(form) { + var added_users = []; + jQuery('#calendar-manage_access_selectbox option:selected').each(function() { + added_users[added_users.length] = jQuery(this).attr('value'); + }); + jQuery.ajax({ + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/calendar/single/add_users/', + data: { + added_users: added_users + }, + type: 'post' + }); + jQuery(form) + .closest('.ui-dialog-content') + .dialog('close'); + Dialog.fromURL(jQuery('#calendar-open-manageaccess').attr('href')); + return false; + }, + + removeUser: function(element) { + var url = jQuery(element).attr('href'); + jQuery(element).removeAttr('href'); + jQuery.ajax({ + url: url, + type: 'get', + success: function() { + var head_tr = jQuery(element) + .closest('tr') + .prev('.calendar-user-head'); + jQuery(element) + .closest('tr') + .remove(); + if (head_tr.nextUntil('.calendar-user-head').length === 0) { + head_tr.remove(); + } + } + }); + return false; + }, + + addException: function() { + var exc_date = jQuery('#exc-date').val(); + var exists = jQuery('#exc-dates input').is("input[value='" + exc_date + "']"); + if (!exists) { + var compiled = _.template( + '<li><label>' + + '<input type="checkbox" name="del_exc_dates[]" value="<%- excdate %>" style="display: none">' + + '<span><%- excdate %><img src="' + + STUDIP.ASSETS_URL + + 'images/icons/blue/trash.svg' + + '"></span></label>' + + '<input type="hidden" name="exc_dates[]" value="<%- excdate %>">' + + '</li>' + ); + jQuery('#exc-dates').append(compiled({ excdate: exc_date, link: '' })); + } + return false; + } +}; + +export default CalendarDialog; diff --git a/resources/assets/javascripts/lib/clipboard.js b/resources/assets/javascripts/lib/clipboard.js new file mode 100644 index 0000000..86a2c62 --- /dev/null +++ b/resources/assets/javascripts/lib/clipboard.js @@ -0,0 +1,539 @@ +const Clipboard = { + + current_delete_icon: null, + + switchClipboard: function(event) { + var select = jQuery(event.target); + + if (!select) { + return; + } + + var selected_clipboard_id = jQuery(select).val(); + + //Make all clipboard areas of that clipboard invisible, except the one + //that has been selected: + var clipboard_areas = jQuery(select).parent().parent().find('.clipboard-area'); + + for (var clipboard of clipboard_areas) { + var current_clipboard_id = jQuery(clipboard).attr('data-id'); + + if (current_clipboard_id) { + if (current_clipboard_id == selected_clipboard_id) { + jQuery(clipboard).removeClass('invisible'); + if (jQuery(clipboard).find(".empty-clipboard-message").hasClass("invisible")) { + jQuery("#clipboard-group-container").find('.widget-links').removeClass('invisible'); + } else { + jQuery("#clipboard-group-container").find('.widget-links').addClass('invisible'); + } + } else { + jQuery(clipboard).addClass('invisible'); + } + } + } + }, + + handleAddForm: function(event) { + if (!event) { + return false; + } + + event.preventDefault(); + + //Check if a name is entered in the form: + var name_input = jQuery(event.target).find('input[type="text"][name="name"]'); + if (!name_input) { + //Something is wrong with the HTML: + return false; + } + var name = jQuery(name_input).val().trim(); + if (!name) { + //The name field is empty. Why send an empty field? + return false; + } + + //Submit the form via AJAX: + STUDIP.api.POST( + 'clipboard/add', + { + data: jQuery(event.target).serialize() + } + ).done(STUDIP.Clipboard.add); + }, + + add: function(data) { + if (!data['id'] || !data['name'] || !data['widget_id']) { + //Required data are missing! + return; + } + + //Get the clipboard template: + var widget_node = jQuery('#ClipboardWidget_' + data['widget_id'])[0]; + if (!widget_node) { + //No widget? No clipboard. + return; + } + + var clipboard_template = jQuery(widget_node).find( + '.clipboard-area.clipboard-template' + )[0]; + + if (!clipboard_template) { + //Something is wrong with the HTML + return; + } + + var clipboard_node = jQuery(clipboard_template).clone(); + + //Remove classes: + jQuery(clipboard_node).removeClass('clipboard-template'); + jQuery(clipboard_node).removeClass('invisible'); + + var clipboard_html = jQuery('<div></div>').append(clipboard_node).html(); + + //Replace placeholders for CLIPBOARD_ID: + clipboard_html = clipboard_html.replace(/CLIPBOARD_ID/g, data['id']); + + //Get the widget content element to append the clipboard: + var content_node = jQuery(widget_node).find('.sidebar-widget-content'); + + //Append the new clipboard's HTML code to the last clipboard: + var clipboards = jQuery(content_node).find('.clipboard-area'); + var last_clipboard = undefined; + if (clipboards.length > 0) { + last_clipboard = clipboards[clipboards.length -1]; + } else { + //No clipboards: Something is wrong with the HTML. + return; + } + + //Add the select option: + var clipboard_selector = jQuery(widget_node).find('.clipboard-selector')[0]; + if (!clipboard_selector) { + //Something is wrong with the HTML. + return; + } + var old_options = jQuery(clipboard_selector).find('option'); + jQuery(old_options).removeAttr('selected'); + + var new_option = jQuery('<option></option>'); + jQuery(new_option).val(data['id']); + jQuery(new_option).text(data['name']); + jQuery(new_option).attr('selected', 'selected'); + jQuery(clipboard_selector).append(new_option); + //Remove the "disabled" attribute, if it exists + //for the clipboard selector: + jQuery(clipboard_selector).removeAttr('disabled'); + //Change the icon next to the clipboard selector: + jQuery('.clipboard-edit-button').removeClass('invisible'); + jQuery('.clipboard-remove-button').removeClass('invisible'); + + //Make all the other clipboards invisible and add the new one: + clipboard_node = jQuery(clipboard_html); + jQuery(clipboards).addClass('invisible'); + jQuery(last_clipboard).after(clipboard_node); + jQuery(widget_node).find('#clipboard-group-container').removeClass('invisible'); + + //Call the droppable jQuery method on the new clipboard area: + jQuery(clipboard_node).droppable( + { + drop: STUDIP.Clipboard.handleItemDrop + } + ); + + //Clear the text input in the "add clipboard" form: + jQuery(widget_node).find( + 'form.new-clipboard-form input[type="text"][name="name"]' + ).val(''); + }, + + handleItemDrop: function(event, ui_element) { + + event.preventDefault(); + + var range_id = jQuery(ui_element.helper).data('id'); + var range_type = jQuery(ui_element.helper).data('range_type'); + + var clipboard = event.target; + if (!clipboard) { + //An event without a target. Nothing to do here. + return; + } + + STUDIP.Clipboard.prepareAddingItem(clipboard, range_id, range_type); + }, + + handleAddItemButtonClick: function (event) { + if (!event) { + return; + } + event.preventDefault(); + + var button = event.target; + if (!button) { + return; + } + + var clipboard_id = jQuery(button).data('clipboard_id'); + var clipboard_widget = jQuery('#ClipboardWidget_' + clipboard_id)[0]; + if (!clipboard_widget) { + return; + } + var clipboard = jQuery(clipboard_widget).find('.clipboard-area:not(.invisible)')[0]; + if (!clipboard) { + return; + } + + var range_id = jQuery(button).data('range_id'); + var range_type = jQuery(button).data('range_type'); + + STUDIP.Clipboard.prepareAddingItem(clipboard, range_id, range_type); + STUDIP.ActionMenu.confirmJSAction(event.target); + }, + + prepareAddingItem: function(clipboard = null, range_id = null, range_type = null) { + if (!clipboard || !range_id || !range_type) { + return false; + } + + var clipboard_id = clipboard.getAttribute('data-id'); + var widget_id = jQuery(clipboard).parents('.clipboard-widget').data('widget_id'); + + var allowed_classes = clipboard.getAttribute('data-allowed_classes'); + if (allowed_classes) { + //A list of allowed classes is set. Check if the specified + //range_type is in the list of allowed classes. + //Although this check can easily be overridden by users + //it doesn't matter in this case since in the database + //the classes whose objects can be linked in a specific clipboard + //are not stored so that every clipboard can contain IDs + //of any SORM object that implements the StudipItem interface. + //If a user overrides the check for allowed classes then + //the clipboard widget may display objects of classes who + //don't belong on the displayed page. That's all. + + allowed_classes = allowed_classes.replace(' ', '').split(','); + if (allowed_classes.indexOf(range_type) == -1) { + //The dropped item does not belong to the right class. + //Set the "not allowed" CSS class + //for the "not allowed" animation. + + jQuery(clipboard).removeClass('invalid-drop'); + jQuery(clipboard).addClass('invalid-drop'); + return false; + } + } + + if (!clipboard_id || !widget_id) { + //We can't do anything without the clipboard's ID + //or the ID of the widget it is inside! + return false; + } + + //Check for duplicates: + var already_existing_entry = jQuery(clipboard).find( + ".clipboard-item[data-range_id='" + range_id + "']" + ).length > 0; + if (already_existing_entry) { + //Nothing to do here. + return false; + } + + //Add the item to the clipboard via AJAX: + STUDIP.api.POST( + 'clipboard/' + clipboard_id + '/item', + { + data: { + 'range_id': range_id, + 'range_type': range_type, + 'widget_id': widget_id + } + } + ).done(STUDIP.Clipboard.addDroppedItem); + }, + + addDroppedItem: function(response_data) { + if (!response_data['id'] || !response_data['range_id'] + || !response_data['name'] || !response_data['widget_id']) { + //We cannot create a new entry if at least one of those fields + //is missing. + return; + } + + var widget = jQuery('#ClipboardWidget_' + response_data['widget_id']); + var clipboard_id = jQuery(widget).find(".clipboard-selector").val(); + + if (!widget) { + //The widget with the speicified widget-ID + //is not present on the current page. + return; + } + + var clipboard = jQuery(widget).find( + '.clipboard-area[data-id="' + clipboard_id + '"]' + )[0]; + if (!clipboard) { + //We need the clipboard node! + return; + } + + //Check for duplicates: + var already_existing_entry = jQuery(clipboard).find( + ".clipboard-item[data-range_id='" + response_data['range_id'] + "']" + ).length > 0; + if (already_existing_entry) { + //Nothing to do here. + return; + } + + var template = jQuery(clipboard).find('.clipboard-item-template')[0]; + if (!template) { + //What is the use of continuing when there is no template? + return; + } + + var new_item_node = jQuery(template).clone(); + var checkbox_id = "item_" + clipboard_id + "_" + response_data['range_type'] + "_" + response_data['range_id']; + + //Set some HTML attributes of the template: + jQuery(new_item_node).attr('data-range_id', response_data['range_id']); + jQuery(new_item_node).attr('id', checkbox_id); + jQuery(new_item_node).removeClass('clipboard-item-template'); + jQuery(new_item_node).removeClass('invisible'); + + var name_label = jQuery(new_item_node).find('label'); + jQuery(name_label).text(response_data['name']); + var id_field = jQuery(new_item_node).find("input[name='selected_clipboard_items[]']"); + jQuery(id_field).val(checkbox_id); + + var new_item_html = jQuery('<div></div>').append(new_item_node).html(); + //Replace RANGE_ID with an escaped real range-ID: + new_item_html = new_item_html.replace(/RANGE_ID/g, _.escape(response_data['range_id'])); + + //Append the template to the clipboard: + jQuery(clipboard).append(jQuery(new_item_html)); + + jQuery(clipboard).find('.empty-clipboard-message').addClass('invisible'); + jQuery("#clipboard-group-container").find('.widget-links').removeClass('invisible'); + + //Run the item drop animation: + jQuery(clipboard).addClass('animated-drop'); + //Remove the animation class after the end of the animation: + window.setTimeout( + function() {jQuery(clipboard).removeClass('animated-drop');}, + 500 + ); + }, + + rename: function(data) { + if (!data['widget_id']) { + //Required data are missing! + return; + } + + var widget = jQuery('#ClipboardWidget_' + data['widget_id']); + var clipboard_id = jQuery(widget).find(".clipboard-selector").val(); + var namer = jQuery(widget).find("input.clipboard-name"); + + var widget_id = data['widget_id']; + STUDIP.api.PUT( + 'clipboard/' + clipboard_id, + { + data: { + name: namer.val() + } + } + ).done(function(data) { + STUDIP.Clipboard.update(data, widget_id) + }); + }, + + update: function(data, widget_id) { + if (!widget_id || !data['id'] || !data['name']) { + //Required data are missing! + return; + } + + var widget = jQuery('#ClipboardWidget_' + widget_id); + var selector = jQuery(widget).find("select.clipboard-selector"); + selector.find("option[value=" + data['id'] + "]").text(data['name']); + STUDIP.Clipboard.toggleEditButtons(widget_id); + }, + + remove: function(clipboard_id, widget_id) { + if (!clipboard_id || !widget_id) { + //Required data are missing! + return; + } + + var widget = jQuery('#ClipboardWidget_' + widget_id); + + var clipboard_selector = jQuery(widget).find('.clipboard-selector')[0]; + if (!clipboard_selector) { + //Something is wrong with the HTML. + return; + } + + //Get the option and the corresponding clipboard area + //for the deleted clipboard: + var clipboard_select_option = jQuery(clipboard_selector).find( + 'option[value="' + clipboard_id + '"]' + )[0]; + var clipboard_area = jQuery(widget).find( + '.clipboard-area[data-id="' + clipboard_id + '"]' + )[0]; + + jQuery(clipboard_select_option).addClass('invisible'); + jQuery(clipboard_area).addClass('invisible'); + + //Display the previous or the next select option + //and the previous or next clipboard area: + var new_selected_clipboard_id = null; + var predecessor = jQuery(clipboard_select_option).prev(); + if (predecessor.length > 0) { + jQuery(predecessor).attr('selected', 'selected'); + new_selected_clipboard_id = jQuery(predecessor).val(); + } else { + var successor = jQuery(clipboard_select_option).next(); + if (successor.length > 0) { + jQuery(successor).attr('selected', 'selected'); + new_selected_clipboard_id = jQuery(successor).val(); + } + //No else here: If no select options are left + //we have an empty select element. + } + + //Now make the clipboard area visible which corresponds to the + //selected option: + if (new_selected_clipboard_id) { + //Another clipboard has been selected: Make it visible. + jQuery(widget).find( + '.clipboard-area[data-id="' + new_selected_clipboard_id + '"]' + ).removeClass('invisible'); + } else { + //No other clipboard selected: Display the "no clipboards" message + //and disable the clipboard select field: + jQuery(widget).find('#clipboard-group-container').addClass('invisible'); + jQuery(clipboard_selector).attr('disabled', 'disabled'); + //Change the icon next to the clipboard selector: + var active_icon = jQuery(clipboard_selector).next(); + var inactive_icon = jQuery(active_icon).next(); + jQuery(active_icon).addClass('invisible'); + jQuery(inactive_icon).removeClass('invisible'); + } + + //We have no need for the elements of the removed clipboard anymore. + //Now we can remove them: + jQuery(clipboard_select_option).remove(); + jQuery(clipboard_area).remove(); + }, + + + confirmRemoveClick: function(event) { + STUDIP.Clipboard.current_delete_icon = event.target; + STUDIP.Dialog.confirm( + 'Sind Sie sicher?', + STUDIP.Clipboard.handleRemoveClick + ); + }, + + + handleRemoveClick: function() { + var delete_icon = STUDIP.Clipboard.current_delete_icon; + if (!delete_icon) { + return; + } + + //Get the data of the clipboard: + var clipboard_select = jQuery(delete_icon).siblings('.clipboard-selector')[0]; + if (!clipboard_select) { + //Something is wrong with the HTML. + return; + } + + var clipboard_id = jQuery(clipboard_select).val(); + var widget = jQuery(delete_icon).parents('.clipboard-widget')[0]; + if (!widget) { + //Another case where something is wrong with the HTML. + return; + } + var widget_id = jQuery(widget).data('widget_id'); + + STUDIP.api.DELETE( + 'clipboard/' + clipboard_id, + { + data: { + widget_id: widget_id + } + } + ).done(function() { + STUDIP.Clipboard.remove(clipboard_id, widget_id); + }); + }, + + + confirmRemoveItemClick: function(event) { + STUDIP.Clipboard.current_delete_icon = event.target; + STUDIP.Dialog.confirm( + 'Sind Sie sicher?', + STUDIP.Clipboard.removeItem + ); + }, + + removeItem: function() { + var delete_icon = STUDIP.Clipboard.current_delete_icon; + if (!delete_icon) { + return; + } + + //Get the item-ID: + var item_html = jQuery(delete_icon).parents('tr'); + var range_id = jQuery(item_html).data('range_id'); + var clipboard_element = jQuery(item_html).parents('table'); + var clipboard_id = jQuery(clipboard_element).data('id'); + + if (!range_id || !clipboard_id) { + //We cannot proceed without the item-ID and the clipboard-ID! + return; + } + + STUDIP.api.DELETE( + 'clipboard/' + clipboard_id + '/item/' + range_id + ).done(function() { + //Check if the item has siblings: + var siblings = jQuery(item_html).siblings(); + if (siblings.length < 3) { + //Only the "no items" element and the template + //are siblings of the item. + //We must display the "no items" element: + jQuery(item_html).siblings( + '.empty-clipboard-message' + ).removeClass('invisible'); + jQuery("#clipboard-group-container").find('.widget-links').addClass('invisible'); + } + //Finally remove the item: + jQuery(item_html).remove(); + }); + }, + + toggleEditButtons: function(widget_id) { + if (!widget_id) { + //Required data are missing! + return; + } + var widget = jQuery('#ClipboardWidget_' + widget_id); + jQuery(widget).find("img.clipboard-edit-accept").toggle(); + jQuery(widget).find("img.clipboard-edit-cancel").toggle(); + jQuery(widget).find("img.clipboard-edit-button").toggle(); + jQuery(widget).find("img.clipboard-remove-button").toggle(); + + var selector = jQuery(widget).find("select.clipboard-selector"); + var namer = jQuery(widget).find("input.clipboard-name"); + selector.toggle(); + namer.val(selector.find("option:selected").text().trim()); + namer.toggle(); + namer.focus(); + }, +}; + +export default Clipboard; diff --git a/resources/assets/javascripts/lib/cookie.js b/resources/assets/javascripts/lib/cookie.js new file mode 100644 index 0000000..7823312 --- /dev/null +++ b/resources/assets/javascripts/lib/cookie.js @@ -0,0 +1,35 @@ +/*jslint esversion: 6*/ +class Cookie { + static set(name, value, expiry_days) { + var chunks = [name + '=' + value, 'SameSite=strict']; + if (expiry_days !== undefined) { + let date = new Date(); + date.setTime(date.getTime() + expiry_days * 24 * 60 * 60 * 1000); + + chunks.push(`expires=${date.toUTCString()}`); + } + chunks.push( + 'path=/' + STUDIP.URLHelper.getURL('a', {}, true) + .slice(0, -1) + .split('/') + .slice(3) + .map(encodeURIComponent) + .join('/') + ); + + document.cookie = chunks.join(';'); + } + + static get(name) { + let chunks = document.cookie.split(';'); + var data = {}; + chunks.forEach(chunk => { + let chunks = chunk.split('='); + data[chunks[0].trim()] = chunks.slice(1).join('='); + }); + + return data.hasOwnProperty(name) ? data[name] : undefined; + } +} + +export default Cookie; diff --git a/resources/assets/javascripts/lib/course_wizard.js b/resources/assets/javascripts/lib/course_wizard.js new file mode 100644 index 0000000..898fe1f --- /dev/null +++ b/resources/assets/javascripts/lib/course_wizard.js @@ -0,0 +1,559 @@ +const CourseWizard = { + /** + * Adds a new participating institute to the course. + * @param id Stud.IP institute ID + * @param name Full name + * @param inputName name of the for input to generate + * @param elClass desired CSS class name + * @param elId ID of the target container to append to + * @param otherInput name of other inputs to check + * + * (e.g. deputies if adding a lecturer) + */ + addParticipatingInst: function(id, name) { + // Check if already set. + if ($('input[name="participating[' + id + ']"]').length == 0) { + var wrapper = $('<div>').addClass('institute'); + $('#wizard-participating') + .children('div.description') + .removeClass('hidden-js'); + var input = $('<input>') + .attr('type', 'hidden') + .attr('name', 'participating[' + id + ']') + .attr('id', id) + .attr('value', '1'); + var trash = $('<input>') + .attr('type', 'image') + .attr('src', STUDIP.ASSETS_URL + 'images/icons/blue/trash.svg') + .attr('name', 'remove_participating[' + id + ']') + .attr('value', '1') + .attr('onclick', "return STUDIP.CourseWizard.removeParticipatingInst('" + id + "')") + .addClass('text-bottom') + .css({ + width: 16, + height: 16 + }); + wrapper.append(input); + var nametext = $('<span>') + .html(name) + .text(); + wrapper.append(nametext); + wrapper.append(trash); + $('#wizard-participating').append(wrapper); + } + }, + + /** + * Remove a participating institute from the list. + * @param id ID of the institute to remove + * @returns {boolean} + */ + removeParticipatingInst: function(id) { + var parent = $('input#' + id).parent(); + var grandparent = parent.parent(); + parent.remove(); + if (grandparent.children('div').length == 0) { + grandparent.children('div.description').addClass('hidden-js'); + } + return false; + }, + + /** + * Adds a new person to the course. + * @param id Stud.IP user ID + * @param name Full name + * @param inputName name of the for input to generate + * @param elClass desired CSS class name + * @param elId ID of the target container to append to + * @param otherInput name of other inputs to check + * + * (e.g. deputies if adding a lecturer) + */ + addPerson: function(id, name, inputName, elClass, elId, otherInput) { + // Check if already set. + if ($('input[name="' + inputName + '[' + id + ']"]').length == 0) { + var wrapper = $('<div>').addClass(elClass); + $('#' + elId) + .children('div.description') + .removeClass('hidden-js'); + var input = $('<input>') + .attr('type', 'hidden') + .attr('name', inputName + '[' + id + ']') + .attr('id', id) + .attr('value', '1'); + var trash = $('<input>') + .attr('type', 'image') + .attr('src', STUDIP.ASSETS_URL + 'images/icons/blue/trash.svg') + .attr('name', 'remove_' + elClass + '[' + id + ']') + .attr('value', '1') + .attr('onclick', "return STUDIP.CourseWizard.removePerson('" + id + "')"); + wrapper.append(input); + var nametext = $('<span>') + .html(name) + .text(); + wrapper.append(nametext); + wrapper.append(trash); + $('#' + elId).append(wrapper); + // Remove as deputy if set. + $('input[name="' + otherInput + '[' + id + ']"]') + .parent() + .remove(); + } + }, + + /** + * Adds a new lecturer to the course. + * @param id Stud.IP user ID + * @param name Full name + */ + addLecturer: function(id, name) { + CourseWizard.addPerson(id, name, 'lecturers', 'lecturer', 'wizard-lecturers', 'deputies'); + // Add deputies if applicable. + CourseWizard.addDefaultDeputies(id); + }, + + /** + * Adds a new deputy to the course. + * @param id Stud.IP user ID + * @param name Full name + */ + addDeputy: function(id, name) { + CourseWizard.addPerson(id, name, 'deputies', 'deputy', 'wizard-deputies', 'lecturers'); + }, + + addTutor: function(id, name) { + CourseWizard.addPerson(id, name, 'tutors', 'tutor', 'wizard-tutors', 'lecturers'); + }, + + /** + * Adds the default deputies of given user to the course. + * @param id Stud.IP user ID + */ + addDefaultDeputies: function(id) { + var lecturerDiv = $('#wizard-lecturers'); + if ($('input[name="deputy_id_parameter"]').length > 0 && lecturerDiv.data('default-enabled') == '1') { + var params = 'step=' + $('input[name="step"]').val() + '&method=getDefaultDeputies' + '¶meter[]=' + id; + $.ajax(lecturerDiv.data('ajax-url'), { + data: params, + success: function(data, status, xhr) { + if (data.length > 0) { + for (var i = 0; i < data.length; i++) { + CourseWizard.addDeputy(data[i].id, data[i].name); + } + } + } + }); + } + }, + + /** + * Remove a person (lecturer or deputy) from the list. + * @param id ID of the person to remove + * @returns {boolean} + */ + removePerson: function(id) { + var parent = $('input#' + id).parent(); + var grandparent = parent.parent(); + parent.remove(); + if (grandparent.children('div[class!="description"]').length == 0) { + grandparent.children('div.description').addClass('hidden-js'); + } + return false; + }, + + /** + * Fetches the children of a given sem tree node. + * @param node the ID of the parent. + * @param assignable is the given node part of the + * full sem tree or the tree of already + * assigned nodes? + * @returns {boolean} + */ + getTreeChildren: function(node, assignable) { + var target = $('.' + (assignable ? 'sem-tree-' : 'sem-tree-assign-') + node); + if (!target.hasClass('tree-loaded') && target.find('.tree-loading').length == 0) { + var params = + 'step=' + + $('input[name="step"]').val() + + '&method=getSemTreeLevel' + + '¶meter[]=' + + $('#' + node).attr('id'); + $.ajax($('#studyareas').data('ajax-url'), { + data: params, + beforeSend: function(xhr, settings) { + target.children('ul').append( + $('<li class="tree-loading">').html( + $('<img>') + .attr('src', STUDIP.ASSETS_URL + 'images/ajax-indicator-black.svg') + .css('width', '16') + .css('height', '16') + ) + ); + }, + success: function(data, status, xhr) { + target.find('li.sem-tree-result').remove(); + var items = $.parseJSON(data); + target.find('.tree-loading').remove(); + if (items.length > 0) { + var list = target.children('ul'); + for (var i = 0; i < items.length; i++) { + list.append(CourseWizard.createTreeNode(items[i], assignable)); + } + } + target.addClass('tree-loaded'); + }, + error: function(xhr, status, error) { + alert(error); + } + }); + } + if (!target.hasClass('tree-open')) { + target.removeClass('tree-closed').addClass('tree-open'); + } else { + target.removeClass('tree-open').addClass('tree-closed'); + } + var checkbox = target.children('input[id="' + node + '"]'); + checkbox.prop('checked', !checkbox.prop('checked')); + return false; + }, + + /** + * Search the sem tree for a given term and show all matching nodes. + * @returns {boolean} + */ + searchTree: function() { + var searchterm = $('#sem-tree-search').val(); + if (searchterm != '') { + var params = + 'step=' + $('input[name="step"]').val() + '&method=searchSemTree' + '¶meter[]=' + searchterm; + $.ajax($('#studyareas').data('ajax-url'), { + data: params, + beforeSend: function(xhr, settings) { + $('#sem-tree-search-start') + .parent() + .append( + $('<img>') + .attr('src', STUDIP.ASSETS_URL + 'images/ajax-indicator-black.svg') + .attr('id', 'sem-tree-search-loading') + .css('width', '16') + .css('height', '16') + ); + CourseWizard.loadingOverlay($('div#studyareas ul.css-tree')); + }, + success: function(data, status, xhr) { + $('#loading-overlay').remove(); + $('#sem-tree-search-loading').remove(); + var items = $.parseJSON(data); + if (items.length > 0) { + $('#sem-tree-search-reset') + .removeClass('hidden-js') + .css('display', ''); + $('#studyareas li input[type="checkbox"]').prop('checked', false); + $('#studyareas li') + .not('.keep-node') + .addClass('css-tree-hidden'); + CourseWizard.buildPartialTree(items, true, ''); + $('#sem-tree-assign-all').removeClass('hidden-js'); + $('li.sem-tree-root input#root').prop('checked', true); + } else { + alert($('#studyareas').data('no-search-result')); + } + }, + error: function(xhr, status, error) { + alert(error); + } + }); + } + return false; + }, + + /** + * Reset a search and restore the "normal" sem tree view. + * @returns {boolean} + */ + resetSearch: function() { + $('li.css-tree-hidden').removeClass('css-tree-hidden'); + $('#sem-tree-search-reset').addClass('hidden-js'); + $('#sem-tree-search').val(''); + $('.css-tree-hidden').removeClass('css-tree-hidden'); + var notloaded = $('#studyareas li').not('.tree-loaded'); + notloaded.children('input[type="checkbox"]').prop('checked', false); + notloaded.children('ul').empty(); + $('#sem-tree-assign-all').addClass('hidden-js'); + $('input[name="searchterm"]').remove(); + return false; + }, + + /** + * Build a partial sem tree, containing (or showing) only selected nodes. + * @param items items to show in the resulting tree + * @param assignable are the nodes part of the full + * sem tree whose entries can be assigned? + * @param source_node the single node that initiated the tree building, + * useful for marking elements. + * @returns {boolean} + */ + buildPartialTree: function(items, assignable, source_node) { + if (assignable) { + var classPrefix = 'sem-tree-'; + } else { + var classPrefix = 'sem-tree-assigned-'; + } + for (var i = 0; i < items.length; i++) { + var parent = $('.' + classPrefix + items[i].parent); + var node = $('.' + classPrefix + items[i].id); + if (node.length == 0) { + if (!assignable && source_node == items[i].id) { + var selected = true; + } else { + var selected = false; + } + var node = CourseWizard.createTreeNode(items[i], assignable, selected); + parent.children('ul').append(node); + } else { + node.removeClass('css-tree-hidden'); + if (!assignable && items[i].id == source_node) { + var input = $('<input>') + .attr('type', 'hidden') + .attr('name', 'studyareas[]') + .attr('value', items[i].id); + node.children('ul').before(input); + var unassign = $('<input>') + .attr('type', 'image') + .attr('name', 'unassign[' + items[i].id + ']') + .attr('src', STUDIP.ASSETS_URL + 'images/icons/blue/trash.svg') + .attr('width', '16') + .height('height', '16') + .attr('onclick', "return STUDIP.CourseWizard.unassignNode('" + items[i].id + "')"); + node.children('input[name="studyareas[]"]').before(unassign); + } + } + node.children('input#' + items[i].id).prop('checked', true); + if (items[i].assignable) { + node.addClass('sem-tree-result'); + } + parent.children('input[id="' + items[i].parent + '"]').attr('checked', true); + if (items[i].has_children) { + CourseWizard.buildPartialTree(items[i].children, assignable, source_node); + } + } + return false; + }, + + /** + * Creates a tree node element from given data. + * @param values values for the node + * @param assignable is the node part of the full + * sem tree whose entries can be assigned? + * @returns {*|jQuery} + */ + createTreeNode: function(values, assignable, selected) { + // Node in "All study areas" tree. + if (assignable) { + var item = $('<li>').addClass('sem-tree-' + values.id); + var assign = $('<input>') + .attr('type', 'image') + .attr('name', 'assign[' + values.id + ']') + .attr('src', STUDIP.ASSETS_URL + 'images/icons/yellow/arr_2left.svg') + .attr('width', '16') + .height('height', '16') + .attr('onclick', "return STUDIP.CourseWizard.assignNode('" + values.id + "')"); + if (values.assignable) { + item.append(assign); + item.append(document.createTextNode(' ')); + } + if (values.has_children) { + var input = $('<input>') + .attr('type', 'checkbox') + .attr('id', values.id); + var label = $('<label>') + .addClass('undecorated') + .attr('for', values.id) + .attr('onclick', "return STUDIP.CourseWizard.getTreeChildren('" + values.id + "', true)"); + // Build link for opening the current node. + var link = $('div#studyareas').data('forward-url'); + if (link.indexOf('?') > -1) { + link += '&open_node=' + values.id; + } else { + link += '?open_node=' + values.id; + } + var openLink = $('<a>').attr('href', link); + openLink.html( + $('<div/>') + .text(values.name) + .html() + ); + label.append(openLink); + item.append(input); + item.append(label); + if (values.has_children) { + item.append('<ul>'); + } + if (values.assignable) { + if ($('#assigned li.sem-tree-assigned-' + values.id).length > 0) { + assign.css('display', 'none'); + } + } + } else { + if ($('#assigned li.sem-tree-assigned-' + values.id).length > 0) { + assign.css('display', 'none'); + } + item.html( + item.html() + + $('<div/>') + .text(values.name) + .html() + ); + item.addClass('tree-node'); + } + // Node in "assigned study areas" tree. + } else { + var item = $('<li>').addClass('sem-tree-assigned-' + values.id); + item.html( + $('<div/>') + .text(values.name) + .html() + ); + if ((!values.has_children || values.assignable) && selected) { + var unassign = $('<input>') + .attr('type', 'image') + .attr('name', 'unassign[' + values.id + ']') + .attr('src', STUDIP.ASSETS_URL + 'images/icons/blue/trash.svg') + .attr('width', '16') + .height('height', '16') + .attr('onclick', "return STUDIP.CourseWizard.unassignNode('" + values.id + "')"); + item.append(unassign); + } + if (values.assignable && selected) { + var input = $('<input>') + .attr('type', 'hidden') + .attr('name', 'studyareas[]') + .attr('value', values.id); + item.append(input); + } + item.append('<ul>'); + } + $(item).data('id', values.id); + return item; + }, + + /** + * Assign a given node to the course. + * @param id sem tree ID to assign + * @returns {boolean} + */ + assignNode: function(id) { + var root = $('#sem-tree-assigned-nodes'); + var params = 'step=' + $('input[name="step"]').val() + '&method=getAncestorTree' + '¶meter[]=' + id; + $.ajax($('#studyareas').data('ajax-url'), { + data: params, + beforeSend: function(xhr, settings) { + CourseWizard.loadingOverlay($('div#assigned ul.css-tree')); + }, + success: function(data, status, xhr) { + $('#loading-overlay').remove(); + var items = $.parseJSON(data); + CourseWizard.buildPartialTree(items, false, id); + $('.sem-tree-assigned-root').removeClass('hidden-js'); + $('input[name="assign[' + id + ']"]').hide(); + $('svg[name="assign[' + id + ']"]').hide(); + }, + error: function(xhr, status, error) { + alert(error); + } + }); + return false; + }, + + /** + * Remove a node from the assigned ones. + * @param id sem tree ID to unassign + * @returns {boolean} + */ + unassignNode: function(id) { + var target = $('li.sem-tree-assigned-' + id); + if (target.children('ul').children('li').length > 0) { + target.children('input[name="studyareas[]"]').remove(); + target.children('input[name="unassign[' + id + ']"]').remove(); + target.children('a').remove(); + } else { + CourseWizard.cleanupAssignTree(target); + } + $('input[name="assign[' + id + ']"]').show(); + $('svg[name="assign[' + id + ']"]').show(); + return false; + }, + + /** + * Assign all visible nodes, e.g. search results. + * The nodes to assign are marked by the class + * "sem-tree-result". + * @returns {boolean} + */ + assignAllNodes: function() { + $('.sem-tree-result').each(function(index, element) { + var id = $(element).data('id'); + if ($('li.sem-tree-assigned-' + id).length == 0) { + CourseWizard.assignNode(id); + } + }); + CourseWizard.resetSearch(); + return false; + }, + + /** + * On unassigning a node, we need to check if the + * parent node has other children which are still + * assigned. If not, we can remove the parent node + * as well. + * @param element + */ + cleanupAssignTree: function(element) { + var parent = element.parent(); + var grandparent = parent.parent(); + if ( + parent.children('li').length == 1 && + !grandparent.hasClass('keep-node') && + grandparent.children('input[type="hidden"][name="studyareas[]"]').length == 0 + ) { + CourseWizard.cleanupAssignTree(element.parent().parent()); + } else { + element.remove(); + } + var root = $('li.sem-tree-assigned-root'); + if (root.children('ul').children('li').length < 1) { + root.addClass('hidden-js'); + } + }, + + /** + * Show some visible indicator that there is + * AJAX work in progress. + * @param parent + */ + loadingOverlay: function(parent) { + var pos = parent.offset(); + var div = $('<div>') + .attr('id', 'loading-overlay') + .addClass('ui-widget-overlay') + .width($(parent).width()) + .height($(parent).height()) + .css({ + position: 'absolute', + top: pos.top, + left: pos.left + }); + var loading = $('<img>') + .attr('src', STUDIP.ASSETS_URL + 'images/ajax-indicator-black.svg') + .css({ + width: 32, + height: 32, + 'margin-left': div.width() / 2 - 32, + 'margin-top': div.height() / 2 - 32 + }); + div.append(loading); + parent.append(div); + } +}; + +export default CourseWizard; diff --git a/resources/assets/javascripts/lib/css.js b/resources/assets/javascripts/lib/css.js new file mode 100644 index 0000000..79d40da --- /dev/null +++ b/resources/assets/javascripts/lib/css.js @@ -0,0 +1,66 @@ +/** + * Add methods to dynamically insert and remove css styles. + * + * @author Jan-Hendrik Willms <tleilax+studip@gmail.com> + * @copyright Stud.IP Core Group 2014 + * @license GPL2 or any later version + * @since Stud.IP 3.1 + */ +// "Private" stylesheet rules are applied to, generated from a dynamically +// inserted style tag in the document's header +var sheet = null; + +/** + * Dynamically add a ruleset for a given selector to the current site + * + * @param {string} selector - CSS selector to add rules for + * @param {object} css - Actual css rules as hash object + * @param {array} vendors - Optional array of vendor prefixes to apply + */ +function addRule(selector, css, vendors) { + vendors = vendors || []; + vendors.push(''); + + var style, propText; + if (sheet === null) { + style = document.createElement('style'); + sheet = document.head.appendChild(style).sheet; + } + + propText = Object.keys(css) + .map(function(p) { + var result = [], + i; + for (i = 0; i < vendors.length; i += 1) { + result.push(vendors[i] + p + ':' + css[p]); + } + return result.join(';'); + }) + .join(';'); + + sheet.insertRule(selector + '{' + propText + '}', sheet.cssRules.length); +} + +/** + * Removes a currently added, dynamic ruleset. + * + * @param {string} selector - CSS selector to remove rules for + */ +function removeRule(selector) { + var i; + if (sheet !== null) { + for (i = sheet.cssRules.length - 1; i >= 0; i -= 1) { + if (sheet.cssRules[i].selectorText === selector) { + sheet.deleteRule(i); + } + } + } +} + +// Expose functions to global STUDIP object, namespaced under CSS +const CSS = { + addRule, + removeRule +}; + +export default CSS; diff --git a/resources/assets/javascripts/lib/dates.js b/resources/assets/javascripts/lib/dates.js new file mode 100644 index 0000000..1fc9830 --- /dev/null +++ b/resources/assets/javascripts/lib/dates.js @@ -0,0 +1,56 @@ +const Dates = { + addTopic: function() { + var topic_name = $('#new_topic').val(), + termin_id = $('#new_topic') + .closest('[data-termin-id]') + .data().terminId; + + if (!topic_name) { + $('#new_topic').focus(); + return; + } + + $.post(STUDIP.URLHelper.getURL('dispatch.php/course/dates/add_topic'), { + title: topic_name, + termin_id: termin_id + }).done(function(response) { + if (response.hasOwnProperty('li')) { + $('#new_topic') + .closest('[data-termin-id]') + .find('.themen-list') + .append(response.li); + $('#date_' + termin_id) + .find('.themen-list') + .append(response.li); + } + + $('#new_topic') + .val('') + .focus(); + }); + }, + removeTopicFromIcon: function() { + var topic_id = $(this) + .closest('li') + .data('issue_id'), + termin_id = $(this) + .closest('[data-termin-id]') + .data().terminId; + Dates.removeTopic(termin_id, topic_id); + }, + removeTopic: function(termin_id, topic_id) { + $.ajax({ + url: STUDIP.URLHelper.getURL('dispatch.php/course/dates/remove_topic'), + data: { + issue_id: topic_id, + termin_id: termin_id + }, + dataType: 'json', + type: 'post' + }).done(function() { + $('.topic_' + termin_id + '_' + topic_id).remove(); + }); + } +}; + +export default Dates; diff --git a/resources/assets/javascripts/lib/dialog.js b/resources/assets/javascripts/lib/dialog.js new file mode 100644 index 0000000..de016ed --- /dev/null +++ b/resources/assets/javascripts/lib/dialog.js @@ -0,0 +1,751 @@ +import { $gettext } from '../lib/gettext.js'; + +/*jslint esversion: 6*/ + +/** + * Specialized dialog handler + * + * @author Jan-Hendrik Willms <tleilax+studip@gmail.com> + * @version 1.0 + * @since Stud.IP 3.1 + * @license GLP2 or any later version + * @copyright 2014 Stud.IP Core Group + * @todo Handle file uploads <http://goo.gl/PnSra8> + */ + +import parseOptions from './parse_options.js'; +import extractCallback from './extract_callback.js'; +import Overlay from './overlay.js'; +import PageLayout from './page_layout.js'; + +var dialog_margin = 0; + +/** + * Extract buttons from given element. + */ +function extractButtons(element) { + var buttons = {}; + $('[data-dialog-button]', element) + .hide() + .find('a,button') + .addBack() + .filter('a,button') + .each(function() { + var label = $(this).text(); + var cancel = $(this).is('.cancel'); + var index = cancel ? 'cancel' : label; + var classes = $(this).attr('class') || ''; + + classes = classes.replace(/\bbutton\b/, '').trim(); + + if ($(this).is('.accept,.cancel')) { + buttons[index] = { + text: label, + click: () => this.click() + }; + } else { + buttons[index] = () => this.click(); + } + + if ($(this).is(':disabled')) { + classes = classes + ' disabled'; + } + + buttons[index]['class'] = classes; + }); + + return buttons; +} + +const Dialog = { + instances: {}, + stack: [], + hasInstance: function(id) { + id = id || 'default'; + return this.instances.hasOwnProperty(id); + }, + getInstance: function(id) { + id = id || 'default'; + if (!this.hasInstance(id)) { + this.instances[id] = { + open: false, + fixedDimensions: false, + element: $('<div>'), + options: {}, + previous: this.stack[0] || false + }; + + this.stack.unshift(id); + } + return this.instances[id]; + }, + removeInstance: function(id) { + id = id || 'default'; + if (this.hasInstance(id)) { + delete this.instances[id]; + + var index = this.stack.indexOf(id); + this.stack.splice(index, 1); + } + }, + /** + * legacy method, remove in future + * @return bool + */ + shouldOpen: function() { + return true; +// return !$('html').is('.responsive-display') && $(window).innerHeight() >= 400; + }, + handlers: { + header: {} + } +}; + +// Handler for HTTP header X-Location: Relocate to another location +Dialog.handlers.header['X-Location'] = function(location, options) { + location = decodeURIComponent(location); + + if (document.location.href === location) { + document.location.reload(true); + } else { + $(window) + .on('hashchange', function() { + document.location.reload(true); + }) + .on('unload', function() { + $(window).off('hashchange'); + }); + } + + Dialog.close(options); + document.location = location; + + return false; +}; +// Handler for HTTP header X-Dialog-Execute: Execute arbitrary function +Dialog.handlers.header['X-Dialog-Execute'] = function(value, options, xhr) { + var callback = window, + payload = xhr.getResponseHeader('Content-Type').match(/json/) + ? $.parseJSON(xhr.responseText) + : xhr.responseText; + + // Try to parse value as JSON (value might be {func: 'foo', payload: {}}) + try { + value = $.parseJSON(value); + } catch (e) { + value = { func: value }; + } + + // Check for invalid call + if (!value.hasOwnProperty('func')) { + throw 'Dialog: Invalid value for X-Dialog-Execute'; + } + + // Populate payload if not set + if (!value.hasOwnProperty('payload')) { + value.payload = xhr.getResponseHeader('Content-Type').match(/json/) + ? $.parseJSON(xhr.responseText) + : xhr.responseText; + } + + // Find callback + callback = extractCallback(value.func, payload); + + // Check callback + if (typeof callback !== 'function') { + throw 'Dialog: Given callback is not a valid function'; + } + + // Execute callback + return callback(value.payload, xhr); +}; +// Handler for HTTP header X-Dialog-Close: Close the dialog +Dialog.handlers.header['X-Dialog-Close'] = function(value, options) { + Dialog.close(options); + return false; +}; +// Handler for HTTP header X-Wikilink: Set the options' wiki link +Dialog.handlers.header['X-Wikilink'] = function(link, options) { + options.wiki_link = link; +}; +// Handler for HTTP header X-Title: Set the dialog title +Dialog.handlers.header['X-Title'] = function(title, options) { + title = decodeURIComponent(title); + if (title !== $('title').data().original) { + options.title = title || options.title; + } +}; +// Handler for HTTP header X-No-Buttons: Decide whether to show dialog buttons +Dialog.handlers.header['X-No-Buttons'] = function(value, options) { + options.buttons = false; +}; + +// Creates a dialog from an anchor, a button or a form element. +// Will update the dialog if it is already open +Dialog.fromElement = function(element, options) { + options = options || {}; + + if ($(element).is(':disabled') || !Dialog.shouldOpen()) { + return; + } + + if (options.close) { + Dialog.close(options); + return; + } + + if (!$(element).is('a,button,form,input[type=image],input[type=submit]')) { + throw 'Dialog.fromElement called on an unsupported element.'; + } + + options.origin = element; + options.title = + options.title || + Dialog.getInstance(options.id).options.title || + $(element).attr('title') || + $(element).find('[title]').first().attr('title') || + $(element).filter('a,button').text(); + options.method = 'get'; + options.data = {}; + + var url, fd; + + // Predefine options + if ($(element).is('form,button,input')) { + url = $(element).attr('formaction') || + $(element).closest('form').data('formaction') || + $(element).closest('form').attr('action'); + options.method = $(element).closest('form').attr('method'); + options.data = $(element).closest('form').serializeArray(); + + if ($(element).is('button,input')) { + options.data.push({ + name: $(element).attr('name'), + value: $(element).val() + }); + } else if ($(element).data().triggeredBy) { + options.data.push($(element).data().triggeredBy); + } + $(element).closest('form').removeData('formaction'); + + if ($(element).closest('form').attr('enctype') === 'multipart/form-data') { + options.processData = false; + + fd = new FormData(); + options.data.forEach(function(item) { + fd.append(item.name, item.value); + }); + + $(element).closest('form').find('input[type=file]').each(function() { + var name = $(this).attr('name'), + i; + for (i = 0; i < this.files.length; i += 1) { + fd.append(name, this.files[i]); + } + }); + + options.data = fd; + } + } else { + url = $(element).attr('href'); + } + + return Dialog.fromURL(url, options); +}; + +// Creates a dialog from a passed url +Dialog.fromURL = function(url, options) { + options = options || {}; + + // Check if dialog should actually open + if (!Dialog.shouldOpen()) { + location.href = url; + } + + // Append overlay + if (Dialog.getInstance(options.id).open) { + Overlay.show(true, Dialog.getInstance(options.id).element.parent()); + } else { + Overlay.show(true); + } + + // Send ajax request + $.ajax({ + url: url, + type: (options.method || 'get').toUpperCase(), + data: options.data || {}, + headers: { 'X-Dialog': true }, + cache: false, + contentType: + options.hasOwnProperty('processData') && !options.processData + ? false + : 'application/x-www-form-urlencoded; charset=UTF-8', + processData: options.hasOwnProperty('processData') ? options.processData : true + }) + .done(function(response, status, xhr) { + var advance = true; + + // Trigger event + $(options.origin || document).trigger('dialog-load', { xhr: xhr, options: options }); + + // Execute all defined header handlers + var handlers = Object.assign( + Dialog.handlers.header, + STUDIP.Dialog.handlers.header + ); + $.each(handlers, (header, handler) => { + var value = xhr.getResponseHeader(header), + result = true; + if (value !== null) { + result = handler(value, options, xhr); + } + advance = advance && result !== false; + return result; + }); + + Overlay.hide(0); + + if (advance) { + Dialog.show(response, options); + } + }) + .fail(() => { + Overlay.hide(); + }); + + return true; +}; + +// Opens or updates the dialog +Dialog.show = function(content, options = {}) { + options = Object.assign({}, Dialog.options, options); + + options.wikilink = options.wikilink === undefined ? true : options.wikilink; + + var scripts = $('<div>' + content + '</div>').filter('script'); // Extract scripts + var dialog_options = {}; + var instance = Dialog.getInstance(options.id); + + if (instance.open) { + options.title = options.title || instance.element.dialog('option', 'title'); + } + + if (options['center-content']) { + content = '<div class="studip-dialog-centered-helper">' + content + '</div>'; + } + + // Hide and update container + instance.element.hide().html(content); + + // Store options and dimensions + instance.options = options; + instance.dimensions = Dialog.calculateDimensions(instance, content, options); + instance.previous_title = instance.previous_title || PageLayout.title; + + // Set dialog options + dialog_options = $.extend(dialog_options, { + width: instance.dimensions.width, + height: instance.dimensions.height, + dialogClass: Dialog.getClasses(options), + buttons: options.buttons || {}, + title: options.title, + modal: true, + resizable: options.resize ?? true, + create: function(event) { + $(event.target) + .parent() + .css('position', 'fixed'); + }, + resizeStop: function(event, ui) { + var position = [ + Math.floor(ui.position.left) - $(window).scrollLeft(), + Math.floor(ui.position.top) - $(window).scrollTop() + ]; + $(event.target) + .parent() + .css('position', 'fixed'); + $(event.target).dialog('option', 'position', position); + + instance.fixedDimensions = true; + instance.dimensions = ui.size; + }, + open: function() { + PageLayout.title = dialog_options.title; + + var helpbar_element = $('.helpbar a[href*="hilfe.studip.de"]'); + var tooltip = helpbar_element.text(); + var link = options.wiki_link || helpbar_element.attr('href'); + var element = $('<a class="ui-dialog-titlebar-wiki"' + ' target="_blank" rel="noopener noreferrer">') + .attr('href', link) + .attr('title', tooltip); + var buttons = $(this) + .parent() + .find('.ui-dialog-buttonset .ui-button'); + + if (options.wikilink) { + $(this) + .siblings('.ui-dialog-titlebar') + .addClass('with-wiki-link') + .find('.ui-dialog-titlebar-close') + .before(element); + } + + $(this).parent().find('.ui-dialog-title').attr('title', options.title); + + instance.open = true; + // Execute scripts + $('head').append(scripts); + + $(options.origin || document).trigger('dialog-open', { dialog: this, options: options }); + + // Transfer defined classes from options to actual displayed buttons + // This should work natively, but it kinda does not + Object.keys(dialog_options.buttons).forEach(function(label, index) { + var classes = dialog_options.buttons[label]['class']; + $(buttons.get(index)).addClass(classes); + }); + }, + close: function(event) { + $(options.origin || document).trigger('dialog-close', { dialog: this, options: options }); + + PageLayout.title = instance.previous_title; + + Dialog.close(options); + } + }); + + // Create buttons + if (!options.hasOwnProperty('buttons') || (options.buttons && !$.isPlainObject(options.buttons))) { + dialog_options.buttons = extractButtons.call(this, instance.element); + // Create 'close' button + if (!dialog_options.buttons.hasOwnProperty('cancel')) { + dialog_options.buttons.cancel = { + text: $gettext('Schließen'), + 'class': 'cancel' + }; + } + dialog_options.buttons.cancel.click = function() { + Dialog.close(options); + }; + } + + // Create/update dialog + instance.element.dialog(dialog_options); + instance.element.scrollTo(0, 0); + + // Trigger update event on document since options.origin might have been removed + $(document).trigger('dialog-update', { dialog: instance.element, options: options }); +}; + +// Closes the dialog for good +Dialog.close = function(options) { + options = options || {}; + + if (Dialog.hasInstance(options.id)) { + var instance = Dialog.getInstance(options.id); + + if (instance.open) { + instance.open = false; + try { + instance.element.dialog('close'); + instance.open = instance.element.dialog('isOpen'); + } catch (ignore) {} + + // Apparently the close event has been canceled, so don't force + // a close + if (instance.open) { + return false; + } + + try { + instance.element.dialog('destroy'); + instance.element.remove(); + } catch (ignore) {} + } + + Dialog.removeInstance(options.id); + } + + if (options['reload-on-close'] && !options.hasOwnProperty('is-reloading')) { + window.location.reload(); + options['is-reloading'] = true; + } +}; + +Dialog.getClasses = function (options) { + var classes = ['studip-dialog']; + + if (options.dialogClass) { + classes.push(options.dialogClass); + } else if (options['center-content']) { + classes.push('studip-dialog-centered'); + } + + return classes.join(' '); +}; + +Dialog.calculateDimensions = function (instance, content, options) { + var previous = instance.previous !== false ? Dialog.getInstance(instance.previous) : false; + var width = options.width || ($(window).width() * 2) / 3; + var height = options.height || ($(window).height() * 2) / 3; + var max_width = $(window).width() * 0.95; + var max_height = $(window).height() * 0.9; + var helper; + var temp; + + if (instance.fixedDimensions) { + return instance.dimensions; + } + + if ($('html').is('.responsive-display')) { + max_width = $(window).width() - 6; // Subtract border + max_height = $(window).height(); + + if (!options.hasOwnProperty('width')) { + width = $(window).width() * 0.95; + height = $(window).height() * 0.98; + } + } + + // Adjust size if neccessary + if (!options.size) { + width = instance.dimensions?.width ?? width; + height = instance.dimensions?.height ?? height; + } else if (options.size === 'auto' || options.size === 'fit') { + // Render off screen + helper = $('<div class="ui-dialog ui-widget ui-widget-content">'); + helper.addClass(Dialog.getClasses(options)); + + var helper_title = $('<span class="ui-dialog-title">') + .text(options.title) + .appendTo(helper) + .wrap('<div class="ui-dialog-titlebar ui-helper-clearfix">') + .after('<button class="ui-button ui-button-icon-only ui-dialog-titlebar-close">close</button>'); + if (options.wikilink) { + helper_title.parent().append('<a class="ui-dialog-titlebar-wiki"></a>').addClass('with-wiki-link'); + } + + + $('<div class="ui-dialog-content">').html($.parseHTML(content)).appendTo(helper); + // Prevent buttons from wrapping + $('[data-dialog-button]', helper).css('white-space', 'nowrap'); + // Add cancel button if missing + if ((!options.hasOwnProperty('buttons') || options.buttons !== false)) { + $('<div class="ui-dialog-buttonpane ui-widget-content ui-helper-clearfix"></div>') + .append('<div class="ui-dialog-buttonset"><button class="ui-button ui-widget ui-corner-all cancel">Foo</button></div>') + .appendTo(helper) + } + + helper.css({ + position: 'absolute', + left: '-10000px', + top: '-10000px', + width: 'auto' + }).appendTo('body'); + + // Calculate width and height + width = Math.min(helper.outerWidth(true) + dialog_margin, max_width); + height = Math.min(helper.outerHeight(true), max_height); + + if (options.size === 'auto') { + width = Math.max(300, width); + height = Math.max(200, height); + } + // Remove helper element + helper.remove(); + } else if (options.size === 'big') { + width = $('body').width() * 0.9; + height = $('body').height() * 0.8; + } else if (options.size === 'medium') { + width = $('body').width() * 0.6; + height = $('body').height() * 0.5; + } else if (options.size === 'medium-43') { + //Medium size in 4:3 aspect ratio + height = $('body').height() * 0.8; + width = parseInt(height) * 1.33333333; + if (width > $('body').width()) { + width = $('body').width() * 0.9; + } + } else if (options.size === 'small') { + width = 300; + height = 200; + } else if (options.size.match(/^\d+x\d+$/)) { + temp = options.size.split('x'); + width = temp[0]; + height = temp[1]; + } else if (!options.size.match(/\D/)) { + width = height = options.size; + } + + // Ensure dimensions fit in viewport + width = Math.min(width, max_width); + height = Math.min(height, max_height); + if ( + previous && + previous.hasOwnProperty('dimensions') && + width > previous.dimensions.width && + height > previous.dimensions.height + ) { + width = width > previous.dimensions.width ? previous.dimensions.width * 0.95 : width; + height = height > previous.dimensions.height ? previous.dimensions.height * 0.95 : height; + } + + return { + width: width, + height: height + }; +}; + +// Specialized confirmation dialog +Dialog.confirm = function(question, yes_callback, no_callback) { + return $.Deferred(function(defer) { + if (question === true) { + defer.resolve(); + } else if (question === false) { + defer.reject(); + } else { + Dialog.show(_.escape(question).replace("\n", '<br>'), { + id: 'confirmation-dialog', + title: $gettext('Bitte bestätigen Sie die Aktion'), + size: 'fit', + wikilink: false, + dialogClass: 'studip-confirmation', + buttons: { + accept: { + text: $gettext('Ja'), + click: defer.resolve, + class: 'accept' + }, + cancel: { + text: $gettext('Nein'), + click: defer.reject, + class: 'cancel' + } + } + }); + } + $(document).one('dialog-close', function() { + if (defer.state() === 'pending') { + defer.reject(); + } + }); + }) + .then(yes_callback, no_callback) + .always(function() { + Dialog.close({ id: 'confirmation-dialog' }); + }) + .promise(); +}; + +Dialog.confirmAsPost = function(question, action) { + var form = $('<form/>', { + action: action, + method: 'post' + }); + $('<input/>', { + type: 'hidden', + name: STUDIP.CSRF_TOKEN.name, + value: STUDIP.CSRF_TOKEN.value + }).appendTo(form); + + $('body').append(form); + + Dialog.confirm(question).done(function() { + form.submit(); + }); + + return false; +}; + +Dialog.registerHeaderHandler = function (header, handler) { + Dialog.handlers.header[header] = handler; +}; +Dialog.removeHeaderHandler = function (header) { + if (Dialog.handlers.header.hasOwnProperty(header)) { + delete Dialog.handlers.header[header]; + } +}; + +Dialog.initialize = function() { + // Actual dialog handler + function dialogHandler(event) { + if (!event.isDefaultPrevented()) { + var target = $(event.target).closest('[data-dialog]'); + var options = target.data().dialog; + if (Dialog.fromElement(target, parseOptions(options))) { + event.preventDefault(); + } + } + } + + function clickHandler(event) { + if (!event.isDefaultPrevented()) { + var element = $(event.target).closest(':submit,input[type="image"]'); + var form = element.closest('form'); + var action = element.attr('formaction'); + form.data('triggeredBy', { + name: $(event.target).attr('name'), + value: $(event.target).val() + }); + if (action) { + form.data('formaction', action); + } + } + } + + // Calculate dialogs margins (outer width - inner width of the dialog) in + // order to properly calculated needed dialog widths. Otherwise horizontal + // scrollbars will occur. This is located here because it is only + // used in Dialog.show(). + var temp = $('<div class="ui-dialog" style="position: absolute;left:-1000px;top:-1000px;"></div>'); + temp.html('<div class="ui-dialog-content ui-widget-content"><div style="width: 100%">foo</div></div>'); + temp.appendTo('body'); + dialog_margin = temp.outerWidth(true) - $('.ui-dialog-content', temp).width(); + temp.remove(); + + // Handle links, buttons and forms + $(document) + .on( + 'click', + 'a[data-dialog],button[data-dialog],input[type=image][data-dialog],input[type=submit][data-dialog]', + dialogHandler + ) + .on('click', 'form[data-dialog] :submit', clickHandler) + .on('click', 'form[data-dialog] input[type=image]', clickHandler) + .on('submit', 'form[data-dialog]', dialogHandler); + + // Close dialog on click outside of it + $(document).on('click', '.ui-widget-overlay', function() { + if ($('.ui-dialog').length > 0 && Dialog.stack.length) { + Dialog.close({ + id: Dialog.stack[0] + }); + } + }); + + // Recalculate dialog dimensions upon window resize. This is throttled + // since the resize event keeps on firing during the resizing. + var timeout = null; + $(window).on('resize', (event) => { + if (event.target !== window) { + return; + } + + clearTimeout(timeout); + setTimeout(() => { + Dialog.stack.forEach((id) => { + var instance = Dialog.getInstance(id); + instance.dimensions = Dialog.calculateDimensions( + instance, + $(instance.element).html(), + instance.options + ); + + $(instance.element).dialog('option', 'width', instance.dimensions.width); + $(instance.element).dialog('option', 'height', instance.dimensions.height); + }); + }, 10); + }); +}; + +export default Dialog; diff --git a/resources/assets/javascripts/lib/dialogs.js b/resources/assets/javascripts/lib/dialogs.js new file mode 100644 index 0000000..371c987 --- /dev/null +++ b/resources/assets/javascripts/lib/dialogs.js @@ -0,0 +1,28 @@ +/* ------------------------------------------------------------------------ + * Standard dialogs for confirmation or messages + * ------------------------------------------------------------------------ */ + +const Dialogs = { + showConfirmDialog: function(question, confirm) { + // compile template + var getTemplate = _.memoize(function(name) { + return _.template(jQuery('#' + name).html()); + }); + + var confirmDialog = getTemplate('confirm_dialog'); + $('body').append( + confirmDialog({ + question: question, + confirm: confirm + }) + ); + + return false; + }, + + closeConfirmDialog: function() { + $('div.modaloverlay').remove(); + } +}; + +export default Dialogs; diff --git a/resources/assets/javascripts/lib/drag_and_drop_upload.js b/resources/assets/javascripts/lib/drag_and_drop_upload.js new file mode 100644 index 0000000..ae37350 --- /dev/null +++ b/resources/assets/javascripts/lib/drag_and_drop_upload.js @@ -0,0 +1,21 @@ +/* Drag and drop file upload */ +const DragAndDropUpload = { + bind: function(form) { + form = form || document; + + jQuery('input[type=file]', form).change(function() { + jQuery(this) + .closest('form') + .submit(); + }); + + // The drag event handling is seriously messed up + // see http://www.quirksmode.org/blog/archives/2009/09/the_html5_drag.html + jQuery(form).on('dragover dragleave', function(event) { + jQuery(this).toggleClass('hovered', event.type === 'dragover'); + return false; + }); + } +}; + +export default DragAndDropUpload; diff --git a/resources/assets/javascripts/lib/enrollment.js b/resources/assets/javascripts/lib/enrollment.js new file mode 100644 index 0000000..5f0c129 --- /dev/null +++ b/resources/assets/javascripts/lib/enrollment.js @@ -0,0 +1,111 @@ +export default function enrollment() { + /** + * Filter logic for courses on both sides + */ + $('#enrollment').on('keyup', 'input[name="filter"]', function () { + var text = $(this).val().trim(), + list = $(this).next('ul'); + + if (text.length > 0) { + var exp = new RegExp(text, 'gi'); + + list.children('li').each(function() { + var name = $(this).text(); + $(this).toggle(name.search(exp) !== -1); + }); + } else { + list.children('li:not(.empty)').show(); + } + }).on('click', '.actions input, .button input', function () { + var action = $(this).closest('form').attr('action'), + data = $(this).closest('form').serializeArray(); + + data.push({name: this.name, value: this.value}); + + STUDIP.Overlay.show(true, null, null, null, 300); + + $.post(action, data).done(function (response) { + var enrollment = $('#enrollment', response); + $('#enrollment').html(enrollment); + }).always(function () { + STUDIP.Overlay.hide(); + }); + + return false; + }); + + // Disable drag and drop features for small displays + if (!$('html').hasClass('size-medium')) { + return; + } + + /** + * Allow courses to be sorted via drag and drop according to their + * priorities. + */ + $('#enrollment #selected-courses').sortable({ + appendTo: '#selected-courses', + axis: 'y', + cancel: 'li.empty,li:nth-child(2):last-child', + cursor: 'move', + items: 'li:not(.empty)', + placeholder: 'ui-state-highlight', + tolerance: 'pointer', + + helper: function (event, element) { + return $(element).clone().width($(element).width()).css({ + overflow: 'hidden' + }); + }, + receive: function (event, ui) { + ui.helper.width('auto'); + ui.item.removeClass('visible'); + }, + update: function(event, ui) { + // Adjust priority and add neccessary elements if missing + $(this).find('li:not(.empty)').each(function (index) { + var id = $(this).data().id, + hiddenElement = $(this).find('input[type="hidden"]'); + + index += 1; + + if ($(this).find('.delete').length === 0) { + var delete_icon = $('script#delete-icon-template').html(); + $(this).append(delete_icon); + } + + if (hiddenElement.length === 0) { + $(this).append('<input type="hidden" name="admission_prio[' + id + ']" value="' + index + '">'); + hiddenElement = $(this).find('input'); + } + + hiddenElement.val(index); + }); + } + }).on('click', '.delete', function() { + var id = $(this).closest('li').remove().data().id; + + $('#available-courses [data-id="' + id + '"]').addClass('visible'); + + $('#enrollment #selected-courses li:not(.empty)').each(function (index) { + $(this).find('input[type="hidden"]').val(index + 1); + }); + + return false; + }).disableSelection(); + + /** + * Allow courses to be dragged to the above defined sortable. + */ + $('#enrollment #available-courses li').draggable({ + activeClass: 'ui-state-highlight', + appendTo: '#available-courses', + connectToSortable: '#selected-courses', + containment: '#enrollment', + cursor: 'move', + + helper: function () { + return $(this).clone().width($(this).width()); + } + }).disableSelection(); +} diff --git a/resources/assets/javascripts/lib/event-bus.js b/resources/assets/javascripts/lib/event-bus.js new file mode 100644 index 0000000..3f6a58d --- /dev/null +++ b/resources/assets/javascripts/lib/event-bus.js @@ -0,0 +1,5 @@ +import mitt from 'mitt'; + +const eventBus = mitt(); + +export default eventBus; diff --git a/resources/assets/javascripts/lib/extract_callback.js b/resources/assets/javascripts/lib/extract_callback.js new file mode 100644 index 0000000..5b88607 --- /dev/null +++ b/resources/assets/javascripts/lib/extract_callback.js @@ -0,0 +1,80 @@ +export default function extractCallback(cmd, payload) { + var command = cmd, + chunks, + last_chunk = null, + callback = window, + previous = null; + + // Try to decode URI component in case it is encoded + try { + command = window.decodeURIComponent(command); + } catch (ignore) {} + + // Try to parse value as JSON (value might be {func: 'foo', payload: {}}) + try { + command = $.parseJSON(command); + } catch (e) { + command = { func: command }; + } + + // Check for invalid call + if (!command.hasOwnProperty('func')) { + throw 'Dialog: Invalid value for X-Dialog-Execute'; + } + + // Populate payload if not set + if (!command.hasOwnProperty('payload')) { + command.payload = payload; + } + + // Find callback + chunks = command.func.trim().split(/\./); + $.each(chunks, function(index, chunk) { + // Check if last chunk was unfinished + if (last_chunk !== null) { + chunk = last_chunk + '.' + chunk; + last_chunk = null; + } + + // Check for not finished/closed chunk + if (chunk.match(/\([^\)]*$/)) { + last_chunk = chunk; + return; + } + + previous = callback; + + var match = chunk.match(/\((.*)\);?$/), + parameters = null; + + if (match !== null) { + chunk = chunk.replace(match[0], ''); + try { + parameters = $.parseJSON('[' + match[1].replace(/'/g, '"') + ']'); + } catch (e) { + console.log('error parsing json', match); + } + } + + if (callback[chunk] === undefined) { + throw 'Error: Undefined callback ' + cmd; + } + + if ($.isFunction(callback[chunk]) && parameters !== null) { + callback = callback[chunk].apply(callback, parameters); + } else { + callback = callback[chunk]; + } + }); + + // Check callback + if (!$.isFunction(callback)) { + return function() { + return callback; + }; + } + + return function(p) { + return callback.apply(previous, [p || payload]); + }; +} diff --git a/resources/assets/javascripts/lib/files.js b/resources/assets/javascripts/lib/files.js new file mode 100644 index 0000000..59ae95f --- /dev/null +++ b/resources/assets/javascripts/lib/files.js @@ -0,0 +1,345 @@ +/*jslint esversion: 6*/ +import { $gettext } from './gettext.js'; +import Dialog from './dialog.js'; +import FilesTable from '../../../vue/components/FilesTable.vue'; + +const Files = { + init () { + if ($('#files-index, #files-system, #course-files-index, #institute-files-index, #files-flat, #course-files-flat, #institute-files-flat, #files-overview').length + && jQuery("#files_table_form").length) { + + STUDIP.Vue.load().then(({createApp}) => { + this.filesapp = createApp({ + el: "#layout_content", + data: { + "files": jQuery("#files_table_form").data("files") || [], + "folders": jQuery("#files_table_form").data("folders") || [], + "topfolder": jQuery("#files_table_form").data("topfolder"), + "breadcrumbs": jQuery("#files_table_form").data("breadcrumbs") || [] + }, + methods: { + hasFilesOfType (type) { + for (let i in this.files) { + if (this.files[i].mime_type.indexOf(type) === 0) { + return true; + } + } + return false; + }, + removeFile(id) { + this.files = this.files.filter(file => file.id != id) + } + }, + components: { FilesTable, }, + }); + }); + } + + //The following is only for (read only) vue file tables where multiple + //tables are displayed in one page. + var tables = jQuery('.vue-file-table'); + if (tables.length) { + for (var table of tables) { + STUDIP.Vue.load().then(({createApp}) => { + createApp({ + el: table, + data: { + "files": jQuery(table).data("files") || [], + "folders": jQuery(table).data("folders") || [], + "topfolder": jQuery(table).data("topfolder"), + "breadcrumbs": jQuery(table).data("breadcrumbs") || [] + }, + components: { FilesTable, }, + }); + }); + } + } + }, + + openAddFilesWindow: function(folder_id) { + var responsive_mode = jQuery('html').first().hasClass('responsive-display'); + if ($('.files_source_selector').length > 0) { + Dialog.show($('.files_source_selector').html(), { + title: $gettext('Dokument hinzufügen'), + size: (responsive_mode ? undefined : 'auto') + }); + } else { + Dialog.fromURL(STUDIP.URLHelper.getURL('dispatch.php/file/add_files_window/' + folder_id), { + title: $gettext('Dokument hinzufügen'), + size: (responsive_mode ? undefined : 'auto') + }); + } + }, + + validateUpload: function(file) { + if (!Files.uploadConstraints) { + return true; + } + if (file.size > Files.uploadConstraints.filesize) { + return false; + } + var ending = file.name.lastIndexOf('.') !== -1 ? file.name.substr(file.name.lastIndexOf('.') + 1) : ''; + + if (Files.uploadConstraints.type === 'allow') { + return $.inArray(ending, Files.uploadConstraints.file_types) === -1; + } + + return $.inArray(ending, Files.uploadConstraints.file_types) !== -1; + }, + + upload: function(filelist) { + var files = 0; + var folder_id = $('.files_source_selector').data('folder_id'); + var thresholds = [] + var data = new FormData(); + var updater_enabled = STUDIP.jsupdate_enable; + + //Open upload-dialog + const nameslist = $('.file_upload_window .filenames').show().empty(); + $('.file_upload_window .errorbox').hide().find('.errormessage').empty(); + + var total_size = 0; + $.each(filelist, function(index, file) { + if (Files.validateUpload(file)) { + data.append('file[]', file, file.name); + + var id = `upload-element-${index}`; + var li = $('<li/>').attr('id', id).appendTo(nameslist); + $('<span/>').text(file.name).appendTo(li); + $('<span class="upload-progress"/>').appendTo(li); + + thresholds.push({ + position: total_size, + threshold: total_size + file.size, + name: file.name, + size: file.size, + element: id + }); + + total_size += file.size; + files += 1; + } else { + const errorMessage = file.name + ': ' + $gettext('Datei ist zu groß oder hat eine nicht erlaubte Endung.') + "<br>"; + $('.file_upload_window .errorbox').show().find('.errormessage').append(errorMessage); + } + }); + if ($('.file_uploader').length > 0) { + Dialog.show($('.file_uploader').html(), { + title: $gettext('Datei hochladen') + }); + } else { + Dialog.fromURL(STUDIP.URLHelper.getURL('dispatch.php/file/upload_window'), { + title: $gettext('Datei hochladen') + }); + } + + //start upload + $('form.drag-and-drop.files').removeClass('hovered'); + if (files > 0) { + STUDIP.JSUpdater.stop(); + + $('.file_upload_window .uploadbar').show().filter('.uploadbar-inner').css({ + right: '100%' + }); + $.ajax({ + url: STUDIP.URLHelper.getURL(`dispatch.php/file/upload/${folder_id}`), + data: data, + cache: false, + contentType: false, + processData: false, + type: 'POST', + xhr: () => { + var xhr = $.ajaxSettings.xhr(); + if (xhr.upload) { + const uploadbar = $('.file_upload_window .uploadbar-inner'); + const uploadprogress = $('.file_upload_window .uploadbar .upload-progress'); + var last = null; + xhr.upload.addEventListener('progress', event => { + if (event.lengthComputable) { + //Set progress + const position = event.loaded || event.position; + const total = event.total; + const percent = Math.round(position / total * 100 * 100) / 100; + + uploadbar.css('right', `${100 - percent}%`); + uploadprogress.text(`${percent}%`); + + const current = thresholds.find(element => element.threshold >= position); + if (current) { + const current_percent = Math.round((position - current.position) / current.size * 100); + $(`#${current.element} .upload-progress`).text(`${current_percent}%`); + + if (current.element !== last && last !== null) { + $(`#${last} .upload-progress`).text(`100%`).closest('li').prevAll('li').find('.upload-progress').text('100%'); + } + last = current.element; + } + } + }, false); + } + + $(document).on('dialog-close.xhr-upload', () => xhr.abort()); + + return xhr; + } + }).done(json => { + $('.file_upload_window .uploadbar-inner').css('right', '0'); + $('.file_upload_window .upload-progress').text(`100%`); + + $(document).off('.xhr-upload'); + }).fail((jqxhr, textStatus, error) => { + const errorMessage = $gettext('Es gab einen Fehler beim Hochladen der Datei(en):') + ' ' + error; + $('.file_upload_window .errorbox').show().find('.errormessage').text(errorMessage); + $('.file_upload_window').children('.filenames,.uploadbar').hide(); + }).always(() => { + if (updater_enabled) { + STUDIP.JSUpdater.start(); + } + }); + } else { + $('.file_upload_window .uploadbar').hide(); + } + }, + + addFile: (payload, delay = 0, hide_dialog = true) => { + var redirect = false; + var html = []; + + if (payload.hasOwnProperty('html') && payload.html !== undefined) { + redirect = payload.redirect; + html = payload.html; + } + + if (redirect) { + Dialog.fromURL(redirect); + } else if (hide_dialog) { + window.setTimeout(Dialog.close, 20); + } + + if ($('table.documents').length > 0) { + // on files page + Files.addFileDisplay(html, delay); + } else if (payload.url) { + //not on files page + + Dialog.handlers.header['X-Location'](payload.url); + } + }, + + addFileDisplay: (html, delay = 0) => { + if (!Array.isArray(html)) { + html = html === null ? [] : [html]; + } + html.forEach((value, i) => { + let insert = true; + for (let i in STUDIP.Files.filesapp.files) { + if (value.id == STUDIP.Files.filesapp.files[i].id) { + STUDIP.Files.filesapp.files[i] = value; + insert = false; + } + } + if (insert) { + STUDIP.Files.filesapp.files.push(value); + } + }); + $(document).trigger('refresh-handlers'); + }, + + removeFileDisplay: function (ids) { + if (!Array.isArray(ids)) { + ids = [ids]; + } + + var count = ids.length; + ids.forEach((id) => { + STUDIP.Files.filesapp.removeFile(id); + }); + $(document).trigger('refresh-handlers'); + }, + + addFolderDisplay: function (html, delay = 0) { + if (!Array.isArray(html)) { + html = html === null ? [] : [html]; + } + html.forEach((value, i) => { + STUDIP.Files.filesapp.folders.push(value); + }); + $(document).trigger('refresh-handlers'); + }, + + getFolders: function(name) { + var element_name = 'folder_select_' + name, + context = $('#' + element_name + '-destination').val(), + range = null; + + if ($.inArray(context, ['courses']) > -1) { + range = $('#' + element_name + '-range-course > div > input') + .first() + .val(); + } else if ($.inArray(context, ['institutes']) > -1) { + range = $('#' + element_name + '-range-inst > div > input') + .first() + .val(); + } else if ($.inArray(context, ['myfiles']) > -1) { + range = $('#' + element_name + '-range-user_id').val(); + } + + if (range !== null) { + $.post( + STUDIP.URLHelper.getURL('dispatch.php/file/getFolders'), + { range: range }, + function(data) { + if (data) { + $('#' + element_name + '-subfolder select').empty(); + $.each(data, function(index, value) { + $.each(value, function(label, folder_id) { + $('#' + element_name + '-subfolder select').append( + '<option value="' + folder_id + '">' + label + '</option>' + ); + }); + }); + } + }, + 'json' + ).done(() => { + $(`#${element_name}-subfolder`).show(); + }); + } + }, + + changeFolderSource: function(name) { + var element_name = `folder_select_${name}`; + var elem = $(`#${element_name}-destination`); + + $(`#${element_name}-range-course`).toggle(elem.val() === 'courses'); + $(`#${element_name}-range-inst`).toggle(elem.val() === 'institutes'); + $(`#${element_name}-subfolder`).toggle(elem.val() === 'myfiles'); + + if (elem.val() === 'myfiles') { + $(`#${element_name}-subfolder select`).empty(); + Files.getFolders(name); + } + }, + + updateTermsOfUseDescription: function(e) { + //make all descriptions invisible: + $('div.terms_of_use_description_container > section').addClass('invisible'); + + var selected_id = $(this).val(); + + $(`#terms_of_use_description-${selected_id}`).removeClass('invisible'); + }, + + openGallery: function () { + $(".lightbox-image").first().click(); + }, + + // Upload constraints + uploadConstraints: false, + + setUploadConstraints (constraints) { + Files.uploadConstraints = constraints; + } +}; + +export default Files;
\ No newline at end of file diff --git a/resources/assets/javascripts/lib/files_dashboard.js b/resources/assets/javascripts/lib/files_dashboard.js new file mode 100644 index 0000000..5fc41c5 --- /dev/null +++ b/resources/assets/javascripts/lib/files_dashboard.js @@ -0,0 +1,19 @@ +import Table from './table.js'; + +const FilesDashboard = { + /** + * Diese Methode wird aufgerufen, sobald ein Dashboard-Widget + * maximiert wurde. Die dort enthaltene Tabelle wird dann + * sortierbar gemacht. + * Die `elementId` bezieht sich auf die widget_element_id des Widgets. + */ + enhanceList: function(elementId) { + $(document).on('dialog-open', function() { + $('.ui-dialog table[data-element-id="' + elementId + '"]').each(function(index, element) { + Table.enhanceSortableTable(element); + }); + }); + } +}; + +export default FilesDashboard; diff --git a/resources/assets/javascripts/lib/folders.js b/resources/assets/javascripts/lib/folders.js new file mode 100644 index 0000000..76dfadf --- /dev/null +++ b/resources/assets/javascripts/lib/folders.js @@ -0,0 +1,87 @@ +import { $gettext } from './gettext.js'; +import Dialog from './dialog.js'; + +const Folders = { + openAddFoldersWindow: function(folder_id, range_id) { + Dialog.fromURL( + STUDIP.ABSOLUTE_URI_STUDIP + + 'dispatch.php/folder/new?rangeId=' + + range_id + + '&parent_folder_id=' + + folder_id + + '&js=1', + { + title: $gettext('Dokument hinzufügen') + } + ); + }, + + sendNewFolderForm: function() { + var new_folder_form = jQuery('#new_folder_form'); + + //get form fields to check if the required fields are set: + var folder_name = jQuery(new_folder_form) + .find('input[name="name"]') + .val(); + var folder_type = jQuery(new_folder_form) + .find('input[name="folder_type"]') + .val(); + var parent_folder_id = jQuery(new_folder_form) + .find('input[name="parent_folder_id"]') + .val(); + + if (folder_name && folder_type && parent_folder_id) { + jQuery.ajax({ + method: 'POST', + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/folder/new', + data: new_folder_form.serialize(), + cache: false, + success: function(data) { + Folders.updateFolderListEntry(data.folder_id, data.tr); + Dialog.close(); + } + }); + } + }, + + updateFolderListEntry: function(folder_id, html, delay) { + //updates the folder entry in the folder list + var documents_table = jQuery('.documents[data-folder_id]'); + + if (jQuery('#row_folder_' + folder_id).length > 0) { + //row with folder-ID was found: + jQuery('#row_folder_' + folder_id).replaceWith(html); + } else { + jQuery(documents_table).append(html); + } + }, + + removeFolderListEntry: function(folder_id) { + //removes a row from the folder list: + if (jQuery('#row_folder_' + folder_id).length > 0) { + //row with folder-ID was found: + jQuery('#row_folder_' + folder_id).remove(); + } + }, + + delete: function(folder_id) { + if (!folder_id) { + return false; + } + + jQuery.ajax({ + method: 'GET', + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/folder/delete/' + folder_id, + data: null, + cache: false, + success: function(data) { + if ($(data).hasClass('messagebox_success')) { + Folders.removeFolderListEntry(folder_id); + } + $('#layout_content').prepend(data); + } + }); + } +}; + +export default Folders; diff --git a/resources/assets/javascripts/lib/forms.js b/resources/assets/javascripts/lib/forms.js new file mode 100644 index 0000000..8e6a3eb --- /dev/null +++ b/resources/assets/javascripts/lib/forms.js @@ -0,0 +1,59 @@ +/* ------------------------------------------------------------------------ + * Forms + * ------------------------------------------------------------------------ */ + +const Forms = { + initialized: false, + initialize: function(scope) { + if (scope === undefined) { + scope = document; + } + + $('input[required],textarea[required]', scope).attr('aria-required', true); + $('input[pattern][title],textarea[pattern][title]', scope).each(function() { + $(this).data('message', $(this).attr('title')); + }); + + if (!Forms.initialized) { + // add invalid-handler to every input and textarea on the page + $(document).on('invalid', 'input, textarea', function() { + $(this) + .attr('aria-invalid', 'true') + .change(function() { + $(this).removeAttr('aria-invalid'); + }); + + // get the fieldset that contains the invalid input + var fieldset = $(this).closest('fieldset'); + // toggle the collapsed class if the fieldset is currently collapsed + if (fieldset.hasClass('collapsed')) { + fieldset.toggleClass('collapsed'); + } + }); + + $(document).on('change', 'form.default label.file-upload input[type=file]', function(ev) { + var selected_file = ev.target.files[0], + filename; + if ( + $(this) + .closest('label') + .find('.filename').length + ) { + filename = $(this) + .closest('label') + .find('.filename'); + } else { + filename = $('<span class="filename"/>'); + $(this) + .closest('label') + .append(filename); + } + filename.text(selected_file.name + ' ' + Math.ceil(selected_file.size / 1024) + 'KB'); + }); + } + + Forms.initialized = true; + } +}; + +export default Forms; diff --git a/resources/assets/javascripts/lib/fullcalendar.js b/resources/assets/javascripts/lib/fullcalendar.js new file mode 100644 index 0000000..8c3c123 --- /dev/null +++ b/resources/assets/javascripts/lib/fullcalendar.js @@ -0,0 +1,601 @@ +/*jslint esversion: 6*/ + +/** + * This class contains Stud.IP specific code for the fullcalendar package. + */ + +import { Calendar } from '@fullcalendar/core'; +import deLocale from '@fullcalendar/core/locales/de'; +import enLocale from '@fullcalendar/core/locales/en-gb'; +import interactionPlugin from '@fullcalendar/interaction'; +import { Draggable } from '@fullcalendar/interaction'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import timeGridPlugin from '@fullcalendar/timegrid'; +import resourceCommonPlugin from '@fullcalendar/resource-common'; +import resourceTimeGridPlugin from '@fullcalendar/resource-timegrid'; +import resourceTimelinePlugin from '@fullcalendar/resource-timeline'; + +import jsPDF from 'jspdf-yworks'; +import html2canvas from 'html2canvas'; + +Date.prototype.getWeekNumber = function () { + var d = new Date(Date.UTC(this.getFullYear(), this.getMonth(), this.getDate())); + var dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + var yearStart = new Date(Date.UTC(d.getUTCFullYear(),0,1)); + return Math.ceil((((d - yearStart) / 86400000) + 1)/7); +}; + +function pad(what, length = 2, char = '0') { + let padding = new Array(length + 1).join(char); + return `${padding}${what}`.substr(-length); +} + +class Fullcalendar +{ + /** + * The initialisation method. It loads the JS files for fullcalendar + * in case they are not loaded and sets up a fullcalendar instance + * for the nodes specified in the parameter node. + * + * @param DOMElement|string node The node which shall have a full calendar. + * This must either be a DOMElement or a string + * containing a CSS selector. + */ + static init(node, fullcalendar_options = null) + { + // Convert css selector to actual dom element + node = $(node)[0]; + + if (!node) { + //We need a CSS selector or a node! + return; + } + + if (document.getElementById('external-events')) { + new Draggable(document.getElementById('external-events'), { + itemSelector: '.fc-event', + eventData (eventEl) { + return { + title: eventEl.dataset.eventTitle, + duration: eventEl.dataset.eventDuration, + course_id: eventEl.dataset.eventCourse, + tooltip: eventEl.dataset.eventTooltip, + studip_api_urls: {drop: eventEl.dataset.eventDropUrl}, + studip_view_urls: {edit: eventEl.dataset.eventDetailsUrl} + }; + } + }); + } + + var calendar = new Calendar(node, fullcalendar_options); + node.calendar = calendar; + calendar.render(); + + return calendar; + } + + /** + * Converts semester events to the default fullcalendar event format. + * The begin and end date are converted to fit into the current week. + */ + static convertSemesterEvents(event_data, fake_week_start = Date()) + { + if (!event_data) { + return {}; + } + + var start = String(event_data.start).split('T'); + var end = String(event_data.end).split('T'); + + //start and end must be transformed to the current week. + //Therefore, we need the ISO weekdays for begin and end. + var fake_start = new Date(fake_week_start); + fake_start.setHours(12); + fake_start.setMinutes(0); + fake_start.setSeconds(0); + var fake_end = new Date(fake_week_start); + fake_end.setHours(12); + fake_end.setMinutes(0); + fake_end.setSeconds(0); + + //Calculcate the week day of the current week for the event + //from the current day and convert sunday to ISO format + var start_day_diff = fake_start.getDay() || 7; + var end_day_diff = fake_end.getDay() || 7; + + start_day_diff = start_day_diff - event_data.studip_weekday_begin; + end_day_diff = end_day_diff - event_data.studip_weekday_end; + + fake_start = new Date( + fake_start.getTime() - start_day_diff * 24 * 60 * 60 * 1000 + ); + fake_end = new Date( + fake_end.getTime() - end_day_diff * 24 * 60 * 60 * 1000 + ); + + //Output the modified begin and end date in the correct ISO format: + event_data.start =`${fake_start.getFullYear()}-${pad(fake_start.getMonth() + 1)}-${pad(fake_start.getDate())}T${start[1]}`; + event_data.end = `${fake_end.getFullYear()}-${pad(fake_end.getMonth() + 1)}-${pad(fake_end.getDate())}T${end[1]}`; + + return event_data; + } + + + static createSemesterCalendarFromNode(node, additional_config = {}) + { + if (!node) { + //Ain't no fullcalendar when the node's gone! + return; + } + + var config = $.extend( + {}, + $(node).data('config') || {}, + additional_config + ); + + if (Array.isArray(config.eventSources)) { + config.eventSources = config.eventSources.map((s) => { + if (s.hasOwnProperty('url')) { + return s; + } + }); + } + + return this.createFromNode(node, config); + } + + + static defaultResizeEventHandler(info) + { + if (!info.event.durationEditable || !info.view.viewSpec.options.editable) { + //Read-only events cannot be resized! + info.revert(); + return; + } + + if (info.event.extendedProps.studip_api_urls.resize) { + $.post({ + url: info.event.extendedProps.studip_api_urls.resize, + async: false, + data: { + begin: this.toRFC3339String(info.event.start), + end: this.toRFC3339String(info.event.end) + } + }).fail(info.revert); + } + } + + static downloadPDF(format = 'landscape', withWeekend = false) + { + $('*[data-fullcalendar="1"]').each(function () { + if (this.calendar != undefined) { + $(this).addClass('print-view').toggleClass('without-weekend', !withWeekend); + + var title = $(this).data('title'); + let print_title = $('<h1>').text(title).prependTo(this); + + window.scrollTo(0, 0); + + html2canvas(this).then(canvas => { + var imgData = canvas.toDataURL('image/jpeg'); + var pdf = new jsPDF({ + orientation: format === 'landscape' ? 'landscape' : 'portrait' + }); + if (format === 'landscape') { + pdf.addImage(imgData, 'JPEG', 20, 20, 250, 250, 'i1', 'NONE', 0); + } else { + pdf.addImage(imgData, 'JPEG', 25, 20, 160, 190, 'i1', 'NONE', 0); + } + pdf.save(title + '.pdf'); + }); + + print_title.remove(); + $(this).removeClass('print-view without-weekend'); + } + }); + } + + static toRFC3339String(date) + { + var timezone_offset_min = date.getTimezoneOffset(); + var offset_hrs = parseInt(Math.abs(timezone_offset_min / 60), 10); + var offset_min = Math.abs(timezone_offset_min%60); + var timezone_standard; + + offset_hrs = pad(offset_hrs); + offset_min = pad(offset_min); + + // Add an opposite sign to the offset + // If offset is 0, it means timezone is UTC + if (timezone_offset_min < 0) { + timezone_standard = `+${offset_hrs}:${offset_min}`; + } else if (timezone_offset_min > 0) { + timezone_standard = `-${offset_hrs}:${offset_min}`; + } else { + timezone_standard = '+00:00'; + } + + var current_date = pad(date.getDate()); + var current_month = pad(date.getMonth() + 1); + var current_year = date.getFullYear(); + var current_hrs = pad(date.getHours()); + var current_mins = pad(date.getMinutes()); + var current_secs = pad(date.getSeconds()); + var current_datetime; + + // Current datetime + // String such as 2016-07-16T19:20:30 + current_datetime = `${current_year}-${current_month}-${current_date}T${current_hrs}:${current_mins}:${current_secs}`; + + return current_datetime + timezone_standard; + } + + static defaultDropEventHandler(info) + { + // The logic from fullcalendar is inversed here: + // If the calendar isn't editable, the event isn't either. + if (!info.event.startEditable || !info.view.viewSpec.options.editable) { + //Read-only events cannot be dragged and dropped! + info.revert(); + return; + } + + var drop_resource_id = info.newResource ? info.newResource.id : info.event.extendedProps.studip_range_id; + + if (info.event.extendedProps.studip_api_urls.move) { + if (info.event.allDay) { + $.post({ + async: false, + url: info.event.extendedProps.studip_api_urls.move, + data: { + resource_id: drop_resource_id, + begin: this.toRFC3339String(info.event.start.setHours(0,0,0)), + end: this.toRFC3339String(info.event.start.setHours(23,59,59)) + } + }).fail(info.revert); + } else if (info.event.end === null) { + var real_end = new Date(); + real_end.setTime(info.event.start.getTime()); + real_end.setHours(info.event.start.getHours()+2); + $.post({ + async: false, + url: info.event.extendedProps.studip_api_urls.move, + data: { + resource_id: drop_resource_id, + begin: this.toRFC3339String(info.event.start), + end: this.toRFC3339String(real_end) + } + }).fail(info.revert); + } else { + $.post({ + async: false, + url: info.event.extendedProps.studip_api_urls.move, + data: { + resource_id: drop_resource_id, + begin: this.toRFC3339String(info.event.start), + end: this.toRFC3339String(info.event.end) + } + }).fail(info.revert); + } + } + } + + static institutePlanDropEventHandler(info) + { + //The logic from fullcalendar is inversed here: + if (info.newResource) { + $.post({ + async: false, + url: info.event.extendedProps.studip_api_urls.move, + data: { + cycle_id: info.event.id, + resource_id: info.newResource.id, + begin: this.toRFC3339String(info.event.start), + end: this.toRFC3339String(info.event.end) + } + }).fail(info.revert); + } else { + //If the calendar isn't editable, the event isn't either. + if (!info.event.startEditable || !info.view.viewSpec.options.editable) { + //Read-only events cannot be dragged and dropped! + info.revert(); + return; + } + + $.post({ + async: false, + url: info.event.extendedProps.studip_api_urls.move, + data: { + cycle_id: info.event.id, + begin: this.toRFC3339String(info.event.start), + end: this.toRFC3339String(info.event.end) + } + }).fail(info.revert); + } + } + + static institutePlanExternalDropEventHandler(info) + { + var resourceIds = info.event.getResources().map(resource => resource.id); + + $.post({ + async: false, + url: info.event.extendedProps.studip_api_urls.drop, + data: { + course_id: info.event.extendedProps.course_id, + begin: this.toRFC3339String(info.event.start), + end: this.toRFC3339String(info.event.end), + resource_id: resourceIds[0] + } + }).done(data => { + if (data) { + info.view.context.calendar.addEvent(JSON.parse(data)); + info.event.remove(); + } + }); + } + + static createFromNode(node, additional_config = {}) + { + if (!node) { + //No node? No fullcalendar! + return; + } + + var config = $(node).data('config'); + + //Make sure the default values are set, if they are not found + //in the additional_config object: + config = $.extend({ + plugins: [ interactionPlugin, dayGridPlugin, timeGridPlugin, resourceCommonPlugin, resourceTimeGridPlugin, resourceTimelinePlugin ], + schedulerLicenseKey: 'GPL-My-Project-Is-Open-Source', + defaultView: 'timeGridWeek', + header: { + left: 'dayGridMonth,timeGridWeek,timeGridDay' + }, + minTime: '08:00:00', + maxTime: '20:00:00', + height: 'auto', + contentHeight: 'auto', + firstDay: 1, + weekNumberCalculation: 'ISO', + locales: [enLocale, deLocale ], + locale: String.locale === 'de-DE' ? 'de' : 'en-gb', + timeFormat: 'H:mm', + nowIndicator: true, + timeZone: 'local', + studip_functions: [], + resourceAreaWidth: '20%', + select (selectionInfo) { + if (!selectionInfo.view.viewSpec.options.editable || !selectionInfo.view.viewSpec.options.studip_urls) { + //The calendar isn't editable. + return; + } + if (selectionInfo.view.viewSpec.options.studip_urls.add) { + if (selectionInfo.resource) { + STUDIP.Dialog.fromURL( selectionInfo.view.viewSpec.options.studip_urls.add, { + data: { + begin: selectionInfo.start.getTime()/1000, + end: selectionInfo.end.getTime()/1000, + ressource_id: selectionInfo.resource.id + } + }); + } else { + STUDIP.Dialog.fromURL(selectionInfo.view.viewSpec.options.studip_urls.add, { + data: { + begin: selectionInfo.start.getTime()/1000, + end: selectionInfo.end.getTime()/1000 + } + }); + } + } + }, + eventClick (eventClickInfo) { + var event = eventClickInfo.event; + var extended_props = event.extendedProps; + if ($(eventClickInfo.jsEvent.target).hasClass('event-colorpicker')) { + STUDIP.Dialog.fromURL( + STUDIP.URLHelper.getURL('dispatch.php/admin/courseplanning/pick_color/' + extended_props.metadate_id + '/' + config.actionCalled), + {'size': '400x400'} + ); + return false; + } + + + if ($(eventClickInfo.event._calendar.el).hasClass('request-plan')) { + if (extended_props.request_id && extended_props.studip_view_urls.edit) { + STUDIP.Dialog.fromURL( + STUDIP.URLHelper.getURL(extended_props.studip_view_urls.edit) + ); + } else if(extended_props.studip_parent_object_class == 'ResourceBooking' && $.inArray('for-course', event._def.ui.classNames) != -1) { + STUDIP.Dialog.fromURL( + STUDIP.URLHelper.getURL('dispatch.php/resources/room_request/request_booking/' + extended_props.studip_parent_object_id) + ); + } + return false; + } + + if (extended_props.studip_view_urls === undefined) { + return; + } + if (!event.startEditable && extended_props.studip_view_urls.show) { + STUDIP.Dialog.fromURL( + STUDIP.URLHelper.getURL(extended_props.studip_view_urls.show) + ); + } else if (event.startEditable && extended_props.studip_view_urls.edit) { + STUDIP.Dialog.fromURL( + STUDIP.URLHelper.getURL(extended_props.studip_view_urls.edit), + {'size': 'big'} + ); + } + return false; + }, + eventResize (info) { + // The logic from fullcalendar is inversed here: + // If the calendar isn't editable, the event isn't either. + if (info.view.viewSpec.options.studip_functions.resize_event) { + info.view.viewSpec.options.studip_functions.resize_event(info); + } else { + STUDIP.Fullcalendar.defaultResizeEventHandler(info); + } + info.event.source.refetch(); + }, + eventDrop (info) { + if ($(info.view.context.calendar.el).hasClass('institute-plan')) { + var start = info.event.start; + var cal_start = info.view.activeStart; + if ((start.getHours() - cal_start.getHours()) % 2 === 1) { + info.event.moveDates('-01:00'); + } + STUDIP.Fullcalendar.institutePlanDropEventHandler(info); + } else { + if (info.view.viewSpec.options.studip_functions.drop_event) { + info.view.viewSpec.options.studip_functions.drop_event(info); + } else { + STUDIP.Fullcalendar.defaultDropEventHandler(info); + } + info.event.source.refetch(); + } + }, + eventRender (info) { + var event = info.event; + var eventElement = info.el; + var iconColor = event.textColor == '#000000' ? 'black' : 'white'; + + if ($(info.view.context.calendar.el).hasClass('institute-plan')) { + $(eventElement).attr('title', event.extendedProps.tooltip); + $(eventElement).find('.fc-title').html( + $('<div>').css({ + width: 'calc(100% - 21px)', + height: '100%', + wordBreak: 'break-word' + }).text(eventElement.text) + ); + $(eventElement).find('.fc-title').append( + $('<button class="event-colorpicker">').addClass(iconColor) + ); + } else { + $(eventElement).attr('title', event.title); + } + + if (event.extendedProps.icon) { + $(eventElement).find('.fc-title').prepend( + $('<img>').attr('src', `${STUDIP.ASSETS_URL}images/icons/${iconColor}/${event.extendedProps.icon}.svg`) + .css({ + verticalAlign: 'text-bottom', + marginRight: '3px', + width: 14, + height: 14 + }) + ); + } + }, + eventSourceSuccess: function(content, xhr) { + if ($(node).hasClass('semester-plan')) { + $(content).each(function(i, event_data){ + STUDIP.Fullcalendar.convertSemesterEvents(event_data, config.defaultDate); + }); + } + return content; + }, + loading (isLoading) { + if (isLoading) { + if (!$('#loading-spinner').length) { + jQuery('#layout_content').append( + $('<div id="loading-spinner" style="position: absolute; top: calc(50% - 55px); left: calc(50% + 135px); z-index: 9001;">').html( + $('<img>').attr('src', STUDIP.ASSETS_URL + 'images/ajax-indicator-black.svg') + .css({ + width: 64, + height: 64 + }) + ) + ); + } + } else { + $('#loading-spinner').remove(); + this.updateSize(); + } + }, + datesRender (info) { + var activeRange = info.view.props.dateProfile.activeRange; + var start = activeRange.start; + var end = activeRange.end; + + if ($(info.el).hasClass('institute-plan')) { + $('.fc-slats tr:odd .fc-widget-content:not(.fc-axis)').remove(); + } + + if ($('.booking-plan-header').length) { + end.setDate(end.getDate()); + var sem_start = $('.booking-plan-header').data('semester-begin'); + var sem_end = $('.booking-plan-header').data('semester-end'); + + if (sem_start && (start.getTime() / 1000 < sem_start || start.getTime() / 1000 > sem_end)) { + sem_start = null; + sem_end = null; + } else if(sem_start) { + var sem_week = Math.floor((end.getTime() / 1000 - 10800 - sem_start) / (7 * 24 * 60 * 60)) + 1; + $("#booking-plan-header-semweek-part").text("Vorlesungswoche".toLocaleString()); + $('#booking-plan-header-semweek').text(sem_week); + } + $('#booking-plan-header-calweek').text(start.getWeekNumber()); + $('#booking-plan-header-calbegin').text(start.toLocaleDateString('de-DE', {weekday: 'short'}) + ' ' + start.toLocaleDateString('de-DE')); + $('#booking-plan-header-calend').text(end.toLocaleDateString('de-DE', {weekday: 'short'}) + ' ' + end.toLocaleDateString('de-DE')); + if (!sem_start || !sem_end) { + STUDIP.Resources.updateBookingPlanSemesterByView(activeRange); + } + } + }, + resourceRender (renderInfo) { + if ($(renderInfo.view.context.calendar.el).hasClass('room-group-booking-plan')) { + var action = $(renderInfo.view.context.calendar.el).hasClass('semester-plan') ? 'semester' : 'booking'; + var url = STUDIP.URLHelper.getURL(`dispatch.php/resources/room_planning/${action}_plan/${renderInfo.resource.id}`); + $(renderInfo.el).find('.fc-cell-text').html( + $('<a>').attr('href', url).text(renderInfo.el.innerText) + ); + } else if ($("*[data-fullcalendar='1']").hasClass('institute-plan') && renderInfo.resource.id > 0) { + var icon = '<img class="text-bottom icon-role-clickable icon-shape-edit" width="16" height="16" src="' + STUDIP.URLHelper.getURL('assets/images/icons/blue/edit.svg') + '" alt="edit">'; + $(renderInfo.el).append( + '<a href="' + + STUDIP.URLHelper.getURL('dispatch.php/admin/courseplanning/rename_column/' + + renderInfo.resource.id + +'/' + + renderInfo.view.activeStart.getDay()) + + '" data-dialog="size=auto"> ' + + icon + + '</a>' + ); + } + }, + drop (dropInfo) { + $(dropInfo.draggedEl).remove(); + }, + eventReceive (info) { + if ($(info.view.context.calendar.el).hasClass('institute-plan')) { + STUDIP.Fullcalendar.institutePlanExternalDropEventHandler(info); + } + } + }, config); + + //Special treatment: If a general column header format is set, + //in the configuration, it shall be used for all columns in all views + //by using a special columnHeaderHtml function. + if (config.columnHeaderFormat) { + config.columnHeaderHtml = function (date) { + if ($("*[data-fullcalendar='1']").hasClass('institute-plan')) { + return '<a href="' + STUDIP.URLHelper.getURL('dispatch.php/admin/courseplanning/weekday/' + date.getDay()) + '">' + date.toLocaleDateString('de-DE', config.columnHeaderFormat) + '</a>'; + } else { + return date.toLocaleDateString('de-DE', config.columnHeaderFormat); + } + }; + } + + config = $.extend({}, config, additional_config); + + return this.init(node, config); + } +} + +export default Fullcalendar; diff --git a/resources/assets/javascripts/lib/fullscreen.js b/resources/assets/javascripts/lib/fullscreen.js new file mode 100644 index 0000000..10ff69e --- /dev/null +++ b/resources/assets/javascripts/lib/fullscreen.js @@ -0,0 +1,60 @@ +/*jslint esversion: 6*/ +const Fullscreen = { + toggle () { + if (sessionStorage.getItem('studip-fullscreen') === 'on') { + STUDIP.Fullscreen.leave(); + } else { + STUDIP.Fullscreen.enter(); + } + }, + + enter (immediate = false) { + // Set appropriate class on html element to trigger fullscreen mode and + // transisitions + $('html').addClass('is-fullscreen').toggleClass('is-fullscreen-immediately', immediate); + + // Move toggle element into viewport + $('.fullscreen-toggle').prependTo('#layout_content'); + + // Attach key handler that allows keypress on escape to leave fullscreen + $(document).on('keydown.key27', (event) => { + if (event.key === 'Escape') { + STUDIP.Fullscreen.leave(); + } + }); + + // Store indicator in session + sessionStorage.setItem('studip-fullscreen', 'on'); + }, + + leave () { + // Remove indicator from session + sessionStorage.removeItem('studip-fullscreen'); + + // Deactivate key handler + $(document).off('keydown.key27'); + + // Move toggle element into secondary navigation + $('.fullscreen-toggle').insertBefore('.helpbar-container'); + + // + (new Promise((resolve, reject) => { + var timeout = setTimeout(() => { + $('#layout-sidebar').off('transitionend'); + resolve(); + }, 500); + $('#layout-sidebar').one('transitionend', () => { + clearTimeout(timeout); + resolve(); + }); + })).then(() => { + $(document.body).trigger('sticky_kit:recalc'); + }); + + + // Remove classes on html element + $('html').removeClass('is-fullscreen is-fullscreen-immediately'); + } +}; + +export default Fullscreen; diff --git a/resources/assets/javascripts/lib/gettext.js b/resources/assets/javascripts/lib/gettext.js new file mode 100644 index 0000000..dea7f01 --- /dev/null +++ b/resources/assets/javascripts/lib/gettext.js @@ -0,0 +1,92 @@ +import { translate } from 'vue-gettext'; +import defaultTranslations from '../../../locales/de.json'; +import eventBus from './event-bus.js'; + +const DEFAULT_LANG = 'de_DE'; +const DEFAULT_LANG_NAME = 'Deutsch'; + +const state = getInitialState(); + +const $gettext = translate.gettext.bind(translate); + +export { $gettext, translate, getLocale, setLocale, getVueConfig }; + +function getLocale() { + return state.locale; +} + +async function setLocale(locale = getInitialLocale()) { + if (!(locale in getInstalledLanguages())) { + throw new Error('Invalid locale: ' + locale); + } + + state.locale = locale; + if (state.translations[state.locale] === null) { + state.translations[state.locale] = await getTranslations(state.locale); + } + + translate.initTranslations(state.translations, { + getTextPluginMuteLanguages: [DEFAULT_LANG], + getTextPluginSilent: false, + language: state.locale, + silent: false, + }); + + eventBus.emit('studip:set-locale', state.locale); +} + +function getVueConfig() { + const availableLanguages = Object.entries(getInstalledLanguages()).reduce((memo, [lang, { name }]) => { + memo[lang] = name; + + return memo; + }, {}); + + return { + availableLanguages, + defaultLanguage: DEFAULT_LANG, + muteLanguages: [DEFAULT_LANG], + silent: false, + translations: state.translations, + }; +} + +function getInitialState() { + const translations = Object.entries(getInstalledLanguages()).reduce((memo, [lang]) => { + memo[lang] = lang === DEFAULT_LANG ? defaultTranslations : null; + + return memo; + }, {}); + + return { + locale: DEFAULT_LANG, + translations, + }; +} + +function getInitialLocale() { + for (const [lang, { selected }] of Object.entries(getInstalledLanguages())) { + if (selected) { + return lang; + } + } + + return DEFAULT_LANG; +} + +function getInstalledLanguages() { + return window?.STUDIP?.INSTALLED_LANGUAGES ?? { [DEFAULT_LANG]: { name: DEFAULT_LANG_NAME, selected: true } }; +} + +async function getTranslations(locale) { + try { + const language = locale.split(/[_-]/)[0]; + const translation = await import(`../../../locales/${language}.json`); + + return translation; + } catch (exception) { + console.error('Could not load locale: "' + locale + '"', exception); + + return {}; + } +} diff --git a/resources/assets/javascripts/lib/global_search.js b/resources/assets/javascripts/lib/global_search.js new file mode 100644 index 0000000..49b3e03 --- /dev/null +++ b/resources/assets/javascripts/lib/global_search.js @@ -0,0 +1,234 @@ +const GlobalSearch = { + lastSearch: null, + + /** + * Toggles visibility of search input field and hints. + * @param visible boolean indicating whether shown or not + * @param cleanup boolean whether to clear search term and results + * @returns {boolean} + */ + toggleSearchBar: function(visible, cleanup) { + $('#globalsearch-searchbar').toggleClass('is-visible', visible); + $('#globalsearch-input').toggleClass('hidden-small-down', !visible); + $('#globalsearch-icon').toggleClass('hidden-small-down', visible); + $('#globalsearch-clear').toggleClass('hidden-small-down', !visible); + + if (!visible && cleanup) { + GlobalSearch.lastSearch = null; + $('#globalsearch-searchbar').removeClass('has-value'); + $('#globalsearch-results').html(''); + $('#globalsearch-input').blur().val(''); + } + + $('html:not(.size-large)').toggleClass('globalsearch-visible', visible); + + return false; + }, + + /** + * Performs the actual search. + */ + doSearch: function() { + var searchterm = $('#globalsearch-input').val().trim(); + var hasValue = searchterm.length >= 3; + var results = $(); + var resultsDiv = $('#globalsearch-results'); + var resultsPerType = resultsDiv.data('results-per-type'); + var moreResultsText = resultsDiv.data('more-results'); + var limit = resultsPerType * 3; + var currentSemester = resultsDiv.data('current-semester'); + var wrapper = $('#globalsearch-searchbar'); + + if (searchterm === '') { + return; + } + + wrapper.toggleClass('has-value', hasValue); + + if (!hasValue || GlobalSearch.lastSearch === searchterm) { + return; + } + + GlobalSearch.lastSearch = searchterm; + + // Display spinner symbol, user should always see something is happening. + wrapper.addClass('is-searching'); + + // Call AJAX endpoint and get search results. + $.getJSON(STUDIP.URLHelper.getURL('dispatch.php/globalsearch/find/' + limit, {}, true), { + search: searchterm, + filters: '{"category":"show_all_categories","semester":"' + currentSemester + '"}' + }).done(function(json) { + resultsDiv.empty(); + + // No results found... + if (!$.isPlainObject(json) || $.isEmptyObject(json)) { + wrapper.removeClass('is-searching'); + resultsDiv.html(resultsDiv.data('no-result')); + return; + } + + // Iterate over each result category. + $.each(json, function(name, value) { + // Create an <article> for category. + var category = $(`<article id="globalsearch-${name}">`), + header = $('<header>').appendTo(category), + counter = 0; + + // Create header name + $('<a href="#">') + .text(value.name) + .wrap('<div class="globalsearch-category">') + .parent() // Element is now the wrapper + .data('category', name) + .appendTo(header); + + // We have more search results than shown, provide link to + // full search if available. + if (value.more && value.fullsearch !== '') { + $('<a>') + .attr('href', value.fullsearch) + .text(moreResultsText) + .wrap('<div class="globalsearch-more-results">') + .parent() // Element is now the wrapper + .appendTo(header); + } + + // Process results and create corresponding entries. + $.each(value.content, function(index, result) { + // Create single result entry. + var single = $('<section>'), + data = $('<div class="globalsearch-result-data">'), + details = $('<div class="globalsearch-result-details">'); + + if (counter >= resultsPerType) { + single.addClass('globalsearch-extended-result'); + } + + // Which result types should be opened via dialog? + const openInDialog = ['GlobalSearchFiles', 'GlobalSearchMessages']; + var dataDialog = (openInDialog.indexOf(name) >= 0 ? dataDialog = 'data-dialog' : dataDialog = ''); + var link = $(`<a href="${result.url}" ${dataDialog}>`).appendTo(single); + + // Optional image... + if (result.img !== null) { + $(`<img src="${result.img}">`) + .wrap('<div class="globalsearch-result-img">') + .parent() // Element is now the wrapper + .appendTo(link); + } + + link.append(data); + + // Name/title + $('<div class="globalsearch-result-title">') + .html(result.name) + .appendTo(data); + + // Details: Descriptional text + if (result.description !== null) { + $('<div class="globalsearch-result-description">') + .html(result.description) + .appendTo(details); + } + + // Details: Additional information + if (result.additional !== null) { + $('<div class="globalsearch-result-additional">') + .html(result.additional) + .appendTo(details); + } + + data.append(details); + + // Date/Time of entry + if (result.date !== null) { + $('<div class="globalsearch-result-time">') + .html(result.date) + .appendTo(link); + } + + // "Expand" attribute for further, result-related search + // (e.g. search in course of found forum entry) + if (result.expand !== null && result.expand !== value.fullsearch && value.more) { + $(`<a href="${result.expand}" title="${result.expandtext}">`) + .wrap('<div class="globalsearch-result-expand">') + .parent() // Element is now the wrapper + .appendTo(single); + } + category.append(single); + + counter += 1; + }); + results = results.add(category); + }); + + resultsDiv.html(results); + wrapper.removeClass('is-searching'); + }).fail(function(xhr, status, error) { + if (error) { + window.alert(error); + } + }); + }, + + /** + * Clear search term and remove results for previous search term. + */ + resetSearch: function() { + GlobalSearch.lastSearch = null; + + $('#globalsearch-searchbar').removeClass('is-visible has-value'); + $('#globalsearch-input').val(''); + $('#globalsearch-results').html(''); + $('#globalsearch-input').focus(); + }, + + /** + * Expand a single category, showing more results, and hide other + * categories. + * @param category + * @returns {boolean} + */ + expandCategory: function(category) { + // Hide other categories. + $(`#globalsearch-results article:not([id="globalsearch-${category}"])`).hide(); + // Show all results. + $(`#globalsearch-${category} section.globalsearch-extended-result`).removeClass( + 'globalsearch-extended-result' + ); + $(`article#globalsearch-${category}`).get(0).scrollIntoView(); + // Reassign category click to closing extended view. + $(`#globalsearch-results article#globalsearch-${category} header div.globalsearch-category a`) + .off('click') + .on('click', function() { + GlobalSearch.showAllCategories(category); + return false; + }); + return false; + }, + + /** + * Close expanded view of a single category, showing normal view with + * all categories again. + * @param currentCategory + */ + showAllCategories: function(currentCategory) { + $(`#globalsearch-results article#globalsearch-${currentCategory} header div.globalsearch-category a`) + .off('click') + .on('click', function() { + GlobalSearch.expandCategory(currentCategory); + return false; + }); + var resultCount = $('#globalsearch-results').data('results-per-type') - 1; + $(`#globalsearch-${currentCategory} section:gt(${resultCount})`).addClass( + 'globalsearch-extended-result' + ); + $('#globalsearch-results') + .children(`article:not([id="globalsearch-${currentCategory}"])`) + .show(); + return false; + } +}; + +export default GlobalSearch; diff --git a/resources/assets/javascripts/lib/header_magic.js b/resources/assets/javascripts/lib/header_magic.js new file mode 100644 index 0000000..f960d92 --- /dev/null +++ b/resources/assets/javascripts/lib/header_magic.js @@ -0,0 +1,49 @@ +import NavigationShrinker from './navigation_shrinker.js'; +import Scroll from './scroll.js'; + +let fold; +let was_below_the_fold = false; + +const scroll = function(scrolltop) { + var is_below_the_fold = scrolltop > fold, + menu; + if (is_below_the_fold !== was_below_the_fold) { + $('body').toggleClass('fixed', is_below_the_fold); + + menu = $('#barTopMenu').remove(); + if (is_below_the_fold) { + menu.append( + $('.action-menu-list li', menu) + .remove() + .addClass('from-action-menu') + ); + menu.appendTo('#barBottomLeft'); + } else { + $('.action-menu-list', menu).append( + $('.from-action-menu', menu) + .remove() + .removeClass('from-action-menu') + ); + menu.prependTo('#flex-header'); + + NavigationShrinker(); + + $('#barTopMenu-toggle').prop('checked', false); + } + + was_below_the_fold = is_below_the_fold; + } +}; + +const HeaderMagic = { + enable() { + fold = $('#flex-header').height(); + Scroll.addHandler('header', scroll); + }, + disable() { + Scroll.removeHandler('header'); + $('body').removeClass('fixed'); + } +}; + +export default HeaderMagic; diff --git a/resources/assets/javascripts/lib/i18n.js b/resources/assets/javascripts/lib/i18n.js new file mode 100644 index 0000000..694716f --- /dev/null +++ b/resources/assets/javascripts/lib/i18n.js @@ -0,0 +1,43 @@ +const i18n = { + init: function(root) { + $('.i18n_group', root).each(function() { + var languages = $(this).children('.i18n'), + select = $('<select tabindex="-1">') + .addClass('i18n') + .css( + 'background-image', + $(languages) + .first() + .data('icon') + ); + select.change(function() { + var opt = $(this).find('option:selected'), + index = opt.index(); + languages.not(':eq(' + index + ')').hide(); + languages + .eq(index) + .show() + .find(':input') + .trigger('focus'); + $(this).css('background-image', opt.css('background-image')); + }); + languages.each(function(id, lang) { + select.append( + $('<option>', { text: $(lang).data('lang') }).css('background-image', $(lang).data('icon')) + ); + }); + $(this).append(select); + languages.not(':eq(0)').hide(); + + $('div.i18n input[required], div.i18n textarea[required]', this).on('invalid', function() { + var element = $(this).closest('.i18n'); + element + .siblings('select') + .val($(element).data('lang')) + .change(); + }); + }); + } +}; + +export default i18n; diff --git a/resources/assets/javascripts/lib/inline-editing.js b/resources/assets/javascripts/lib/inline-editing.js new file mode 100644 index 0000000..a86442d --- /dev/null +++ b/resources/assets/javascripts/lib/inline-editing.js @@ -0,0 +1,138 @@ +class InlineEditing +{ + static init(element) { + if (!element) { + return; + } + + var text = jQuery(element).text().trim(); + + var icon_path = STUDIP.ASSETS_URL + '/images/icons/blue/NAME.svg'; + var input_type = jQuery(element).data('input-type').toLowerCase(); + var input_name = jQuery(element).data('input-name'); + var icon_size = jQuery(element).data('icon-size'); + if (!icon_size) { + icon_size = '20px'; + } + + //Build the display container: + var text_container = jQuery('<span class="text"></span>'); + jQuery(text_container).text(text); + var icon_container = jQuery('<div></div>'); + var icon_element = jQuery('<img class="edit-button"></img>'); + jQuery(icon_element).attr('width', icon_size); + jQuery(icon_element).attr('height', icon_size); + jQuery(icon_element).attr('src', icon_path.replace('NAME', 'edit')); + jQuery(icon_container).append(icon_element); + var display_container = jQuery( + '<div class="display-container"></div>' + ); + jQuery(display_container).append(text_container); + jQuery(display_container).append(icon_container); + + var input_field = undefined; + var edit_icons_container = undefined; + var accept_icon = jQuery('<img class="save-button"></img>'); + jQuery(accept_icon).attr('width', icon_size); + jQuery(accept_icon).attr('height', icon_size); + jQuery(accept_icon).attr('src', icon_path.replace('NAME', 'accept')); + var abort_icon = jQuery('<img class="abort-button"></img>'); + jQuery(abort_icon).attr('width', icon_size); + jQuery(abort_icon).attr('height', icon_size); + jQuery(abort_icon).attr('src', icon_path.replace('NAME', 'decline')); + + if (input_type == 'textarea') { + input_field = jQuery('<textarea class="input-field"></textarea>'); + jQuery(input_field).attr('name', input_name); + jQuery(input_field).text(text); + edit_icons_container = jQuery('<div></div>'); + } else { + input_field = jQuery('<input class="input-field">'); + jQuery(input_field).attr('type', input_type); + jQuery(input_field).attr('name', input_name); + jQuery(input_field).val(text); + edit_icons_container = jQuery('<span></span>'); + } + jQuery(edit_icons_container).append(accept_icon); + jQuery(edit_icons_container).append(abort_icon); + + var edit_container = jQuery('<div class="edit-container invisible"></div>'); + jQuery(edit_container).append(input_field); + jQuery(edit_container).append(edit_icons_container); + + jQuery(element).empty(); + jQuery(element).append(display_container); + jQuery(element).append(edit_container); + }; + + + static activate(element) { + var container = jQuery(element).parents('[data-inline-editing]'); + if (!container) { + return; + } + + jQuery(container).children('.display-container').addClass('invisible'); + jQuery(container).children('.edit-container').removeClass('invisible'); + }; + + + static save(element) { + var container = jQuery(element).parents('[data-inline-editing]'); + if (!container) { + return; + } + var ajax_url = jQuery(container).data('inline-editing'); + + var text_field = jQuery(container).find('.text')[0]; + if (!text_field) { + return; + } + var input_field = jQuery(container).find('.input-field')[0]; + if (!input_field) { + return; + } + var input_name = jQuery(container).data('input-name'); + var input_value = jQuery(input_field).val(); + var data = { + quiet: 1 + }; + data[input_name] = input_value; + + jQuery.ajax( + { + url: ajax_url, + method: 'POST', + data: data + } + ).done( + function() { + jQuery(text_field).text(input_value); + jQuery(container).find('.edit-container').addClass('invisible'); + jQuery(container).find('.display-container').removeClass('invisible'); + } + ).fail( + function(data) { + jQuery(input_field).css('border-color', 'red'); + if (data) { + jQuery(container).find('.error-message').val(data); + } + } + ); + }; + + + static abort(element) { + var container = jQuery(element).parents('[data-inline-editing]'); + if (!container) { + return; + } + + jQuery(container).children('.edit-container').addClass('invisible'); + jQuery(container).children('.display-container').removeClass('invisible'); + + }; +} + + +export default InlineEditing; diff --git a/resources/assets/javascripts/lib/instschedule.js b/resources/assets/javascripts/lib/instschedule.js new file mode 100644 index 0000000..af438c2 --- /dev/null +++ b/resources/assets/javascripts/lib/instschedule.js @@ -0,0 +1,19 @@ +import { $gettext } from './gettext.js'; +import Dialog from './dialog.js'; + +const Instschedule = { + /** + * show the details of a grouped-entry in the isntitute-calendar, containing several seminars + * + * @param string the id of the grouped-entry to be displayed + */ + showInstituteDetails: function(id) { + jQuery.get(STUDIP.URLHelper.getURL('dispatch.php/calendar/instschedule/groupedentry/' + id), function(data) { + Dialog.show(data, { + title: $gettext('Detaillierte Veranstaltungsliste') + }); + }); + } +}; + +export default Instschedule; diff --git a/resources/assets/javascripts/lib/jsonapi.js b/resources/assets/javascripts/lib/jsonapi.js new file mode 100644 index 0000000..bcd954f --- /dev/null +++ b/resources/assets/javascripts/lib/jsonapi.js @@ -0,0 +1,27 @@ +import AbstractAPI from './abstract-api.js'; + +// Actual JSONAPI object +class JSONAPI extends AbstractAPI +{ + constructor(version = 1) { + super(`jsonapi.php/v${version}`); + } + + encodeData (data) { + data = super.encodeData(data); + + if (Object.keys(data).length === 0) { + return null; + } + + return JSON.stringify(data); + } + + request (url, options = {}) { + options.contentType = 'application/vnd.api+json'; + return super.request(url, options); + } +} + +export default JSONAPI; +export const jsonapi = new JSONAPI(); diff --git a/resources/assets/javascripts/lib/jsupdater.js b/resources/assets/javascripts/lib/jsupdater.js new file mode 100644 index 0000000..cb75540 --- /dev/null +++ b/resources/assets/javascripts/lib/jsupdater.js @@ -0,0 +1,233 @@ +/* ------------------------------------------------------------------------ + * JSUpdater - periodically polls for new data from server + * ------------------------------------------------------------------------ + * Exposes the following method on the global STUDIP.JSUpdater object: + * + * - start() + * - stop() + * - register(index, callback, data) + * - unregister(index) + * + * Refer to the according function definitions for further info. + * ------------------------------------------------------------------------ */ +import { $gettext } from './gettext.js'; + +let active = false; +let lastAjaxDuration = 200; //ms of the duration of an ajax-call +let currentDelayFactor = 0; +let lastJsonResult = null; +let dateOfLastCall = +new Date(); // Get milliseconds of date object +let serverTimestamp = STUDIP.server_timestamp; +let ajaxRequest = null; +let timeout = null; +let registeredHandlers = {}; + +// Reset json memory, used to delay polling if consecutive requests always +// return the same result +function resetJSONMemory(json) { + if (json.hasOwnProperty('server_timestamp')) { + delete json.server_timestamp; + } + json = JSON.stringify(json); + if (json !== lastJsonResult) { + currentDelayFactor = 0; + } + lastJsonResult = json; +} + +// Process returned json object by calling registered handlers +function process(json) { + for (const [index, value] of Object.entries(json)) { + // Set timestamp + if (index === 'server_timestamp') { + serverTimestamp = value; + } else { + // Call registered handler callback by index + if (index in registeredHandlers) { + registeredHandlers[index].callback(value); + } + } + } + + // Reset json memory + resetJSONMemory(json); +} + +// Registers next poll +function registerNextPoll() { + // Calculate smallest registered polling interval (but no more than 60 seconds) + let interval = 60000; + for (const [index, handler] of Object.entries(registeredHandlers)) { + if (handler.interval < interval) { + interval = handler.interval; + } + } + + // Define delay by last poll request (respond to load on server) and + // current delay factor (respond to user activity) + var delay = (interval || lastAjaxDuration * 15) * Math.pow(1.33, currentDelayFactor); + + // Clear any previously scheduled polling + window.clearTimeout(timeout); + timeout = window.setTimeout(poll, delay); + + // Increase current delay factor + currentDelayFactor += 1; +} + +// Collect data for polling +function collectData() { + var data = {}; + // Pull data from all registered handlers, either by collecting the data + // itself or by calling the appropriate function + for (const [index, handler] of Object.entries(registeredHandlers)) { + if (handler.data) { + const thisData = $.isFunction(handler.data) ? handler.data() : handler.data; + if (thisData !== null && !$.isEmptyObject(thisData)) { + data[index] = thisData; + } + } + } + + return data; +} + +// User activity handler +function userActivityHandler() { + currentDelayFactor = 0; + if (+new Date() - dateOfLastCall > 5000) { + poll(true); + } +} + +// Window activity handler +function windowActivityHandler(event) { + if (event.type === 'blur') { + // Increase delay factor and reschedule next polling + currentDelayFactor += 10; + registerNextPoll(); + } else if (event.type === 'focus') { + // Reset delay factor and start polling if neccessary + userActivityHandler(); + } +} + +// Actually poll data +function poll(forced) { + // Skip polling if an ajax request is already running, unless forced + if (!forced && ajaxRequest) { + registerNextPoll(); + return false; + } + + // If forced, abort potential current ajax request + if (ajaxRequest) { + ajaxRequest.abort(); + ajaxRequest = null; + } + // Abort potentially scheduled polling + window.clearTimeout(timeout); + + // Store current timestamp + dateOfLastCall = +new Date(); + + // Prepare variables + var url = STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/jsupdater/get', + page = window.location.href.replace(STUDIP.ABSOLUTE_URI_STUDIP, ''); + + // Actual poll request, uses promises + ajaxRequest = $.ajax(url, { + data: { + page: page, + page_info: collectData(), + server_timestamp: serverTimestamp + }, + type: 'POST', + dataType: 'json', + timeout: 5000 + }) + .done(function(json) { + process(json); + }) + .fail(function(jqXHR, textStatus, errorThrown) { + resetJSONMemory({ + text: textStatus, + error: errorThrown + }); + }) + .always(function() { + ajaxRequest = null; + lastAjaxDuration = +new Date() - dateOfLastCall; + + // If logged out + if (arguments.length === 3 && arguments[1] === 'error' && arguments[0].status === 403) { + // Stop updater + JSUpdater.stop(); + + // Present appropriate message in dialog + var message = $gettext('Bitte laden Sie die Seite neu, um fortzufahren'), + buttons = {}; + buttons[$gettext('Neu laden')] = function() { + location.reload(); + }; + buttons[$gettext('Schließen')] = function() { + $(this).dialog('close'); + }; + + $('<div>') + .html(message) + .css({ + textAlign: 'center', + padding: '2em 0' + }) + .dialog({ + width: '50%', + modal: true, + buttons: buttons, + title: $gettext('Sie sind nicht mehr im System angemeldet.') + }); + } else { + registerNextPoll(); + } + }); +} + +// Register global object +const JSUpdater = { + // Starts the updater, also registers the activity handlers + start() { + if (!active) { + $(document).on('mousemove', userActivityHandler); + $(window).on('blur focus', windowActivityHandler); + registerNextPoll(); + } + active = true; + }, + + // Stops the updater, also unregisters the activity handlers + stop() { + if (active) { + $(document).off('mousemove', userActivityHandler); + $(window).off('blur focus', windowActivityHandler); + if (ajaxRequest) { + ajaxRequest.abort(); + ajaxRequest = null; + } + window.clearTimeout(timeout); + } + active = false; + }, + + // Registers a new handler by an index, a callback and an optional data + // object or function + register(index, callback, data = null, interval = 0) { + registeredHandlers[index] = { callback, data, interval }; + }, + + // Unregisters/removes a previously registered handler + unregister(index) { + delete registeredHandlers[index]; + } +} + +export default JSUpdater; diff --git a/resources/assets/javascripts/lib/lightbox.js b/resources/assets/javascripts/lib/lightbox.js new file mode 100644 index 0000000..134cfca --- /dev/null +++ b/resources/assets/javascripts/lib/lightbox.js @@ -0,0 +1,148 @@ +import { $gettext } from './gettext.js'; +import Dialog from './dialog.js'; + +function sprintf(string) { + var args = arguments, + index = 1; + return string.replace(/%(s|u)/g, function(match, modifier) { + if (index > args.length) { + throw 'Invalid sprintf usage - not enough arguments'; + } + var value = args[index]; + + if (modifier === 'u') { + value = parseInt(value, 10); + } + + index += 1; + + return String(value); + }); +} + +const Lightbox = { + max_width: false, + max_height: false, + extra_height: 55, // TODO: While this seems to work, hardcoded values suck + images: [], + current: false, + show: function(index) { + this.current = index || 0; + + var image = new Image(); + image.onload = $.proxy(this, 'onload', image); + image.src = this.getImage().src; + }, + onload: function(image) { + var wrapper = $('<div class="wrapper">'); + $('<a href="#" class="previous">').appendTo(wrapper); + $('<a href="#" class="next">').appendTo(wrapper); + + wrapper.addClass(this.getClasses()).css({ + backgroundImage: sprintf('url(%s)', this.getImage().src) + }); + + $(document).one('dialog-open.lightbox', $.proxy(this, 'registerEvents')); + + Dialog.show(wrapper, { + buttons: false, + dialogClass: 'studip-lightbox', + id: 'lightbox', + resize: false, + size: this.getSize(image), + title: this.getTitle(), + wikilink: false + }); + }, + getImage: function() { + return this.images[this.current]; + }, + getTitle: function() { + var img = this.images[this.current], + title = []; + if (img.title) { + title.push(img.title); + } + + if (this.images.length > 1) { + title.unshift(sprintf($gettext('Bild %u von %u'), this.current + 1, this.images.length)); + } + return title.join(': '); + }, + getClasses: function() { + var classes = []; + if (this.current === 0) { + classes.push('first'); + } + if (this.current === this.images.length - 1) { + classes.push('last'); + } + return classes.join(' '); + }, + getSize: function(image) { + var width = image.width, + height = image.height; + + if (width > this.max_width) { + height *= this.max_width / width; + width = this.max_width; + } + if (height > this.max_height) { + width *= this.max_height / height; + height = this.max_height; + } + + return Math.floor(width) + 'x' + Math.floor(height + this.extra_height); + }, + setImages: function(images) { + if (typeof images === 'string') { + images = $(images); + } + if (images instanceof jQuery) { + images = images.map(function() { + return { + src: $(this).attr('href'), + title: $(this).data().title || $(this).attr('title') + }; + }); + } + this.images = images; + }, + init: function() { + // Values should match the ones in studip-dialog.js (this should be more generic) + this.max_width = $(window).width() * 0.95; + this.max_height = $(window).height() * 0.9 - Lightbox.extra_height; + }, + registerEvents: function() { + $('.studip-lightbox') + .on('click', 'a.previous', function() { + Lightbox.show(Lightbox.current - 1); + return false; + }) + .on('click', 'a.next', function() { + Lightbox.show(Lightbox.current + 1); + return false; + }); + + $(document) + .on('keyup.lightbox', function(event) { + if (event.keyCode === 37) { + $('.studip-lightbox .previous:visible').click(); + } else if (event.keyCode === 39) { + $('.studip-lightbox .next:visible').click(); + } else if (event.keyCode === 27) { + Dialog.close({id: 'lightbox'}); + } else { + return; + } + + return false; + }) + .one('dialog-close', $.proxy(this, 'unregisterEvents')); + }, + unregisterEvents: function() { + $(document).off('.lightbox'); + } +}; + +export default Lightbox; diff --git a/resources/assets/javascripts/lib/markup.js b/resources/assets/javascripts/lib/markup.js new file mode 100644 index 0000000..76cff88 --- /dev/null +++ b/resources/assets/javascripts/lib/markup.js @@ -0,0 +1,41 @@ +/* ------------------------------------------------------------------------ + * Javascript-spezifisches Markup + * ------------------------------------------------------------------------ */ + +const Markup = { + element: function (selector) { + var elements; + if (typeof selector === 'string' && document.getElementById(selector)) { + elements = $('#' + selector); + } else { + elements = $(selector); + } + elements.each((index, element) => { + $.each(Markup.callbacks, (index, func) => { + if (index !== 'element' || typeof func === 'function') { + func(element); + } + }); + }); + }, + callbacks: { + math_jax: function (element) { + $('span.math-tex:not(:has(.MathJax)),.formatted-content:contains("[tex]")', element).each((index, block) => { + STUDIP.loadChunk('mathjax').then((MathJax) => { + if (typeof MathJax.typeset === "function") { + MathJax.typeset([block]); + } + }); + }); + }, + codehighlight: function (element) { + $('pre.usercode:not(.hljs)', element).each(function (index, block) { + STUDIP.loadChunk('code-highlight').then((hljs) => { + hljs.highlightBlock(block); + }); + }); + } + } +}; + +export default Markup; diff --git a/resources/assets/javascripts/lib/members.js b/resources/assets/javascripts/lib/members.js new file mode 100644 index 0000000..3fbdfb9 --- /dev/null +++ b/resources/assets/javascripts/lib/members.js @@ -0,0 +1,24 @@ +const Members = { + addPersonToSelection: function(userId, name) { + var target = $('#persons-to-add'), + newEl = $('<li>').html( + $('<span>') + .html(name) + .text() + ), + input = $('<input type="hidden" name="users[]">').val(userId), + remove = $('<img>').attr('src', STUDIP.ASSETS_URL + 'images/icons/blue/trash.svg'); + + remove.on('click', function() { + $(this) + .parent() + .remove(); + }); + + newEl.append(input, remove).appendTo(target); + + return false; + } +}; + +export default Members; diff --git a/resources/assets/javascripts/lib/messages.js b/resources/assets/javascripts/lib/messages.js new file mode 100644 index 0000000..3bd1656 --- /dev/null +++ b/resources/assets/javascripts/lib/messages.js @@ -0,0 +1,304 @@ +import { $gettext } from './gettext.js'; +import Markup from './markup.js'; + +const Messages = { + init() { + STUDIP.JSUpdater.register('messages', Messages.newMessages, Messages.getParamsForPolling, 60000); + }, + + /*********** AJAX-reload function for overview ***********/ + + getParamsForPolling() { + if (jQuery('#messages').length && jQuery('#since').val()) { + return { + since: jQuery('#since').val(), + received: jQuery('#received').val(), + tag: jQuery('#tag').val() + }; + } + }, + newMessages: function(response) { + jQuery.each(response.messages, function(message_id, message) { + if (jQuery('#message_' + message_id).length === 0) { + jQuery('#messages > tbody').prepend(message); + } + }); + jQuery('#since').val(Math.floor(new Date().getTime() / 1000)); + }, + + /*********** helper for the overview site ***********/ + + whenMessageIsShown: function(lightbox) { + jQuery(lightbox) + .closest('tr') + .removeClass('unread'); + }, + + /*********** helper for the composer-site ***********/ + + add_adressee: function(user_id, name) { + var new_adressee = jQuery('#template_adressee').clone(); + new_adressee.find('input').val(user_id); + new_adressee + .find('.visual') + .text(name) + .find('b') + .replaceWith(function() { + return jQuery(this).contents(); + }); + new_adressee.find('img.avatar-medium').remove(); + new_adressee.find('br').replaceWith(' '); + new_adressee + .removeAttr('id') + .appendTo('#adressees') + .fadeIn(); + return false; + }, + + add_adressees: function(form) { + jQuery(form) + .find('#add_adressees_selectbox option:selected') + .each(function() { + var user_id = jQuery(this).val(), + name = jQuery(this).text(); + + var new_adressee = jQuery('#template_adressee').clone(); + new_adressee.find('input').val(user_id); + new_adressee.find('.visual').text(name); + new_adressee + .removeAttr('id') + .appendTo('#adressees') + .fadeIn(); + }); + jQuery(form) + .closest('.ui-dialog-content') + .dialog('close'); + return false; + }, + + remove_adressee: function() { + jQuery(this) + .closest('li') + .fadeOut(300, function() { + jQuery(this).remove(); + }); + }, + + remove_attachment: function() { + jQuery.ajax({ + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/messages/delete_attachment', + data: { + document_id: jQuery(this) + .closest('li') + .data('document_id'), + message_id: jQuery(this) + .closest('form') + .find('input[name=message_id]') + .val() + }, + type: 'POST' + }); + jQuery(this) + .closest('li') + .fadeOut(300, function() { + jQuery(this).remove(); + }); + }, + + upload_from_input: function(input) { + Messages.upload_files(input.files); + jQuery(input).val(''); + }, + fileIDQueue: 1, + upload_files: function(files) { + for (var i = 0; i < files.length; i++) { + var fd = new FormData(); + fd.append('file', files[i], files[i].name); + var statusbar = jQuery('#statusbar_container .statusbar') + .first() + .clone() + .show(); + statusbar.appendTo('#statusbar_container'); + fd.append('message_id', jQuery('#message_id').val()); + Messages.upload_file(fd, statusbar); + } + }, + upload_file: function(formdata, statusbar) { + $(".ui-dialog-buttonset button:first-child, footer[data-dialog-button] button:first-child").attr("disabled", "disabled"); + $.ajax({ + xhr: function() { + var xhrobj = $.ajaxSettings.xhr(); + if (xhrobj.upload) { + xhrobj.upload.addEventListener( + 'progress', + function(event) { + var percent = 0; + var position = event.loaded || event.position; + var total = event.total; + if (event.lengthComputable) { + percent = Math.ceil((position / total) * 100); + } + //Set progress + statusbar.find('.progress').css({ 'min-width': percent + '%', 'max-width': percent + '%' }); + statusbar + .find('.progresstext') + .text(percent === 100 ? jQuery('#upload_finished').text() : percent + '%'); + }, + false + ); + } + return xhrobj; + }, + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/messages/upload_attachment', + type: 'POST', + contentType: false, + processData: false, + cache: false, + data: formdata, + dataType: 'json' + }) + .done(function(data) { + $(".ui-dialog-buttonset button:first-child, footer[data-dialog-button] button:first-child").removeAttr("disabled"); + statusbar.find('.progress').css({ 'min-width': '100%', 'max-width': '100%' }); + var file = jQuery('#attachments .files > .file') + .first() + .clone(); + file.find('.name').text(data.name); + if (data.size < 1024) { + file.find('.size').text(data.size + 'B'); + } + if (data.size > 1024 && data.size < 1024 * 1024) { + file.find('.size').text(Math.floor(data.size / 1024) + 'KB'); + } + if (data.size > 1024 * 1024 && data.size < 1024 * 1024 * 1024) { + file.find('.size').text(Math.floor(data.size / 1024 / 1024) + 'MB'); + } + if (data.size > 1024 * 1024 * 1024) { + file.find('.size').text(Math.floor(data.size / 1024 / 1024 / 1024) + 'GB'); + } + file.find('.icon').html(data.icon); + file.data('document_id', data.document_id); + file.appendTo('#attachments .files'); + file.fadeIn(300); + statusbar.find('.progresstext').text(jQuery('#upload_received_data').text()); + statusbar.delay(1000).fadeOut(300, function() { + jQuery(this).remove(); + }); + }) + .fail(function(jqxhr, status, errorThrown) { + var error = jqxhr.responseJSON.error; + + statusbar + .find('.progress') + .addClass('progress-error') + .attr('title', error); + statusbar.find('.progresstext').html(error); + statusbar.on('click', function() { + jQuery(this).fadeOut(300, function() { + jQuery(this).remove(); + }); + }); + }); + }, + checkAdressee: function() { + // Check if recipients added (one element is always there -> template) + var quicksearch = jQuery('form[name="write_message"] input[name="user_id_parameter"]'); + if (jQuery('li.adressee').children('input[name^="message_to"]').length <= 1) { + quicksearch.attr('required', 'required').attr('value', ''); + quicksearch[0].setCustomValidity( + $gettext('Sie haben nicht angegeben, wer die Nachricht empfangen soll!') + ); + return true; + } else { + quicksearch.removeAttr('required'); + quicksearch[0].setCustomValidity(''); + return true; + } + }, + setTags: function(message_id, tags) { + var container = jQuery('#message_' + message_id) + .find('.tag-container') + .empty(), + template = _.template('<a href="<%- url %>" class="message-tag"><%- tag %></a>'); + + jQuery.each(tags, function(index, tag) { + var html = template({ + url: STUDIP.URLHelper.getURL('dispatch.php/messages/overview', { tag: tag }), + tag: tag.charAt(0).toUpperCase() + tag.slice(1) // ucfirst + }); + jQuery(container) + .append(html) + .append(' '); + }); + }, + setAllTags: function(tags) { + var container = $('#messages-tags ul'); + var template = _.template('<li><a href="<%- url %>" class="tag"><%- tag %></a></li>'); + + container.children('li:not(:has(.all-tags))').remove(); + + jQuery.each(tags, (index, tag) => { + let html = template({ + url: STUDIP.URLHelper.getURL('dispatch.php/messages/overview', { tag: tag }), + tag: tag.charAt(0).toUpperCase() + tag.slice(1) // ucfirst + }); + $(container).append(html); + }); + $('#messages-tags') + .toggle(tags.length !== 0) + .find('li:has(.tag):not(.ui-droppable)') + .each(Messages.createDroppable); + }, + createDroppable: function(element) { + jQuery(arguments.length === 1 ? element : this).droppable({ + hoverClass: 'dropping', + drop: function(event, ui) { + var message_id = ui.draggable.attr('id').substr(ui.draggable.attr('id').lastIndexOf('_') + 1), + tag = jQuery(this) + .text() + .trim(); + jQuery + .post(STUDIP.URLHelper.getURL('dispatch.php/messages/tag/' + message_id), { + add_tag: tag + }) + .then(function(response, status, xhr) { + var tags = jQuery.parseJSON(xhr.getResponseHeader('X-Tags')); + Messages.setTags(message_id, tags); + }); + } + }); + }, + toggleSetting: function(name) { + jQuery('#' + name).toggle('fade'); + if (jQuery('#' + name).is(':visible')) { + jQuery('#' + name)[0].scrollIntoView(false); + } + }, + previewComposedMessage: function() { + var old_written_text = '', + written_text = jQuery('textarea[name=message_body]').val(); + var updatePreview = function() { + written_text = jQuery('textarea[name=message_body]').val(); + if (old_written_text !== written_text) { + jQuery.ajax({ + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/messages/preview', + data: { + text: STUDIP.editor_enabled ? STUDIP.wysiwyg.markAsHtml(written_text) : written_text + }, + type: 'POST', + success: function(html) { + jQuery('#preview .message_body').html(html); + Markup.element('#preview .message_body'); + } + }); + old_written_text = written_text; + } + if (jQuery('#preview .message_body').is(':visible')) { + window.setTimeout(updatePreview, 1000); + } + }; + updatePreview(); + } +}; + +export default Messages; diff --git a/resources/assets/javascripts/lib/multi_person_search.js b/resources/assets/javascripts/lib/multi_person_search.js new file mode 100644 index 0000000..c3fd617 --- /dev/null +++ b/resources/assets/javascripts/lib/multi_person_search.js @@ -0,0 +1,160 @@ +import { $gettext } from './gettext.js'; + +const MultiPersonSearch = { + init: function() { + $('.multi_person_search_link').each(function() { + // init js form + $(this).attr('href', $(this).data('js-form')); + // init form if it is loaded via ajax + $(this).on('dialog-open', function(event, parameters) { + MultiPersonSearch.dialog( + $(parameters.dialog) + .find('.mpscontainer') + .data('dialogname') + ); + }); + }); + }, + + dialog: function(name) { + var count_template = _.template($gettext('Sie haben <%= count %> Personen ausgewählt')); + + this.name = name; + + $('#' + name + '_selectbox').multiSelect({ + selectableHeader: '<div>' + $gettext('Suchergebnisse') + '</div>', + selectionHeader: + '<div>' + count_template({ count: "<span id='" + this.name + "_count'>0</span>" }) + '.</div>', + selectableFooter: + '<a href="javascript:STUDIP.MultiPersonSearch.selectAll();">' + + $gettext('Alle hinzufügen') + + '</a>', + selectionFooter: + '<a href="javascript:STUDIP.MultiPersonSearch.unselectAll();">' + + $gettext('Alle entfernen') + + '</a>' + }); + + $('#' + this.name).on('keyup keypress', function(e) { + var code = e.keyCode || e.which; + if (code == 13) { + e.preventDefault(); + MultiPersonSearch.search(); + return false; + } + }); + + $('#' + this.name + '_selectbox').change(function() { + MultiPersonSearch.count(); + }); + + $('#' + this.name + ' .quickfilter').click(function() { + MultiPersonSearch.loadQuickfilter($(this).data('quickfilter')); + return false; + }); + }, + + loadQuickfilter: function(title) { + MultiPersonSearch.removeAllNotSelected(); + + var count = 0; + $('#' + this.name + '_quickfilter_' + title + ' option').each(function() { + count += MultiPersonSearch.append( + $(this).val(), + $(this).text(), + MultiPersonSearch.isAlreadyMember($(this).val()) + ); + }); + + if (count == 0) { + MultiPersonSearch.append('--', $gettext(' Dieser Filter enthält keine (neuen) Personen.'), true); + } + + MultiPersonSearch.refresh(); + }, + + isAlreadyMember: function(user_id) { + if ($('#' + this.name + '_selectbox_default option[value="' + user_id + '"]').length > 0) { + return true; + } else { + return false; + } + }, + + search: function() { + var searchterm = $('#' + this.name + '_searchinput').val(), + name = this.name, + not_found_template = _.template( + $gettext('Es wurden keine neuen Ergebnisse für "<%= needle %>" gefunden.') + ); + $.getJSON( + STUDIP.URLHelper.getURL('dispatch.php/multipersonsearch/ajax_search/' + this.name, { s: searchterm }), + function(data) { + MultiPersonSearch.removeAllNotSelected(); + var searchcount = 0; + $.each(data, function(i, item) { + searchcount += MultiPersonSearch.append( + item.user_id, + item.avatar + ' -- ' + item.text, + item.member + ); + }); + MultiPersonSearch.refresh(); + + if (searchcount == 0) { + MultiPersonSearch.append('--', not_found_template({ needle: searchterm }), true); + MultiPersonSearch.refresh(); + } + } + ); + return false; + }, + + selectAll: function() { + $('#' + this.name + '_selectbox').multiSelect('select_all'); + this.count(); + }, + + unselectAll: function() { + $('#' + this.name + '_selectbox').multiSelect('deselect_all'); + this.count(); + }, + + removeAll: function() { + $('#' + this.name + '_selectbox option').remove(); + this.refresh(); + }, + + removeAllNotSelected: function() { + $('#' + this.name + '_selectbox option:not(:selected)').remove(); + this.refresh(); + }, + + resetSearch: function() { + $('#' + this.name + '_searchinput').val(''); + MultiPersonSearch.removeAllNotSelected(); + }, + + append: function(value, text, selected) { + if ($('#' + this.name + '_selectbox option[value=' + value + ']').length == 0) { + $('#' + this.name + '_selectbox').multiSelect('addOption', { + value: value, + text: text, + disabled: selected + }); + return 1; + } + return 0; + }, + + refresh: function() { + $('#' + this.name + '_selectbox').multiSelect('refresh'); + MultiPersonSearch.count(); + }, + + count: function() { + $('#' + this.name + '_count').text($('#' + this.name + '_selectbox option:enabled:selected').length); + } +}; + +export default MultiPersonSearch; diff --git a/resources/assets/javascripts/lib/multi_select.js b/resources/assets/javascripts/lib/multi_select.js new file mode 100644 index 0000000..3a447fb --- /dev/null +++ b/resources/assets/javascripts/lib/multi_select.js @@ -0,0 +1,48 @@ +/*jslint esversion:6*/ +import { $gettext } from './gettext.js'; + +/** + * Turns a select-box into an easy to use multiple select-box + */ + +const MultiSelect = { + create: function (id, itemName, options = {}) { + const count = $(id).find('option:selected').length; + const count_template = _.template(_('<%= count %> ausgewählt')); + const update_counter = function () { + const count = $(id).find('option:selected').length; + $(id).next().find('.counter').text(count_template({count: count})); + }; + + if (!$(id).attr('multiple')) { + $(id).attr('multiple', 'multiple').css('height', '6em'); + } + $(id).multiSelect({ + selectableHeader: + `<div class="header"> + <a href="#" class="button select-all">${$gettext('Alle hinzufügen')}</a> + </div>`, + selectionHeader: + `<div class="header"> + <div class="counter">${count_template({count: count})}.</div> + <a href="#" class="button deselect-all">${$gettext('Alle entfernen')}</a> + </div>`, + keepOrder: true, + cssClass: ['studip-multi-select', options.cssClass || ''].join(' ').trim(), + afterInit: function () { + $(id).next().find('.ms-elem-selectable,.ms-elem-selection').find('br').remove(); + }, + afterSelect: update_counter, + afterDeselect: update_counter + }); + + $(id).next().find('.select-all').click(function () { + $(id).multiSelect('select_all'); + }); + $(id).next().find('.deselect-all').click(function () { + $(id).multiSelect('deselect_all'); + }); + } +}; + +export default MultiSelect; diff --git a/resources/assets/javascripts/lib/navigation_shrinker.js b/resources/assets/javascripts/lib/navigation_shrinker.js new file mode 100644 index 0000000..dde8618 --- /dev/null +++ b/resources/assets/javascripts/lib/navigation_shrinker.js @@ -0,0 +1,51 @@ +import Cookie from './cookie.js'; + +// Enable shrinking of navigation +var shrinker = function() { + var main = $('#barTopMenu'), + sink = $('li.overflow', main), + x = 0, + index = false, + total = 0; + if (main.length === 0 || sink.length === 0) { + return; + } + + // Reset sink (hide and lose all content) + main.removeClass('overflown'); + $('> label > a', sink).removeAttr('data-badge'); + $('li', sink) + .remove() + .insertBefore(sink); + + if ($('html').is('.responsive-display')) { + return; + } + + $('li:not(.overflow)', main).each(function(idx) { + var this_x = $(this).position().left; + if (this_x > x) { + x = this_x; + } else { + index = idx; + return false; + } + }); + + if (index !== false) { + $('li:not(.overflow)', main) + .slice(index - 2) + .detach() + .prependTo($('ul', sink)) + .each(function() { + total += parseInt($('a', this).data().badge, 10) || 0; + }); + + main.addClass('overflown'); + $('> label > a', sink).attr('data-badge', total); + } + + Cookie.set('navigation-length', main.children(':not(.overflow)').length, 30); +}; + +export default shrinker; diff --git a/resources/assets/javascripts/lib/news.js b/resources/assets/javascripts/lib/news.js new file mode 100644 index 0000000..5319ed7 --- /dev/null +++ b/resources/assets/javascripts/lib/news.js @@ -0,0 +1,147 @@ +import { $gettext } from '../lib/gettext.js'; + +/*jslint browser: true, unparam: true */ +/*global jQuery, STUDIP */ +const News = { + /** + * (Re-)initialise news-page, f.e. to stay in dialog + */ + init (id) { + $('.add_toolbar').addToolbar(); + STUDIP.i18n.init(`#${id}`); + + // prevent forms within dialog from reloading whole page, and reload dialog instead + $(`#${id} form`).on('click', function (event) { + $(this).data('clicked', $(event.target)); + }).on('submit', function (event) { + event.preventDefault(); + + var textarea, button, form_route, form_data; + if (STUDIP.editor_enabled) { + textarea = $('textarea.news_body'); + // wysiwyg is active, ensure HTML markers are set + textarea.each(function () { + $(this).val(STUDIP.wysiwyg.markAsHtml($(this).val())); + }); + } + + button = $(this).data('clicked').attr('name'); + form_route = $(this).attr('action'); + form_data = $(this).serialize() + '&' + button + '=1'; + + $(this).find(`input[name=${button}]`).showAjaxNotification('left'); + News.update_dialog(id, form_route, form_data); + }); + + $(document).on('change', `#${id} form .news_date`, function () { + // This is neccessary since datepickers are initialiszed on focus + // which might not have occured yet + STUDIP.UI.Datepicker.init(); + + var start = $('#news_startdate').blur().datepicker('getDate'), + duration, + end, + result; + if ($(this).is('#news_duration')) { + // datepicker assumes beginning of day (00:00), but the duration includes the end date (until 23:59) + duration = window.parseInt(this.value, 10) - 1; + result = new Date(start); + result.setDate(result.getDate() + duration); + + $('#news_enddate').datepicker('setDate', result); + } else { + start = $('#news_startdate').datepicker('getDate'); + end = $('#news_enddate').datepicker('getDate'); + // datepicker assumes beginning of day (see above) and we need to add a day to the duration + duration = Math.round((end - start) / (24 * 60 * 60 * 1000)) + 1; + duration = Math.max(0, duration); + + $('#news_duration').val(duration); + } + }); + }, + + get_dialog (id, route) { + // initialize dialog + $('body').append(`<div id="${id}"></div>`); + $(`#${id}`).dialog({ + modal: true, + height: News.dialog_height, + title: $gettext('Dialog wird geladen...'), + width: News.dialog_width, + close () { + $(`#${id}`).remove(); + } + }); + + // load actual dialog content + $.get(route, 'html').done(function (html, status, xhr) { + $(`#${id}`).dialog('option', 'title', decodeURIComponent(xhr.getResponseHeader('X-Title'))); + $(`#${id}`).html(html); + $(`#${id}_content`).css({ + height : (News.dialog_height - 120) + 'px', + maxHeight: (News.dialog_height - 120) + 'px' + }); + + News.init(id); + }).fail(function () { + window.alert($gettext('Fehler beim Aufruf des News-Controllers')); + }); + }, + + update_dialog (id, route, form_data) { + if (!News.pending_ajax_request) { + News.pending_ajax_request = true; + + $.post(route, form_data, 'html').done(function (html) { + var obj; + + News.pending_ajax_request = false; + if (html.length > 0) { + $(`#${id}`).html(html); + $(`#${id}_content`).css({ + height : (News.dialog_height - 120) + 'px', + maxHeight: (News.dialog_height - 120) + 'px' + }); + // scroll to anker + obj = $('a[name=anker]'); + if (obj.length > 0) { + $(`#${id}_content`).scrollTop(obj.position().top); + } + } else { + $(`#${id}`).dialog('close'); + obj = $('#admin_news_form'); + if (obj.length > 0) { + $('#admin_news_form').submit(); + } else { + location.replace(STUDIP.URLHelper.getURL(location.href, {nsave: 1})); + } + } + + News.init(id); + }).fail(function () { + News.pending_ajax_request = false; + window.alert($gettext('Fehler beim Aufruf des News-Controllers')); + }); + } + }, + + toggle_category_view (id) { + if ($(`input[name=${id}_js]`).val() === 'toggle') { + $(`input[name=${id}_js]`).val(''); + } else { + $(`input[name=${id}_js]`).val('toggle'); + } + if ($(`#${id}_content`).is(':visible')) { + $(`#${id}_content`).slideUp(400); + $(`#${id} input[type=image]:first`) + .attr('src', STUDIP.ASSETS_URL + 'images/icons/blue/arr_1right.svg'); + } else { + $(`#${id}_content`).slideDown(400); + $(`#${id} input[type=image]:first`) + .attr('src', STUDIP.ASSETS_URL + 'images/icons/blue/arr_1down.svg'); + } + } +}; + +export default News; diff --git a/resources/assets/javascripts/lib/oer.js b/resources/assets/javascripts/lib/oer.js new file mode 100755 index 0000000..02f1d16 --- /dev/null +++ b/resources/assets/javascripts/lib/oer.js @@ -0,0 +1,230 @@ +import { $gettext } from '../lib/gettext.js'; + +const OER = { + periodicalPushData: function () { + if (jQuery(".comments").length) { + return { + 'review_id': jQuery("[name=comment]").data("review_id") + }; + } + }, + update: function (output) { + if (output.comments) { + for (var i = 0; i < output.comments.length; i++) { + if (jQuery("#comment_" + output.comments[i].comment_id).length === 0) { + jQuery(".comments").append(output.comments[i].html).find(":last-child").hide().fadeIn(300); + } + } + } + }, + requestFullscreen: function (selector) { + var player = jQuery(selector)[0]; + if (!player) { + window.alert($gettext('Kein passendes Element für Vollbildmodus.')); + return; + } + if (player.requestFullscreen) { + player.requestFullscreen(); + } else if (player.msRequestFullscreen) { + player.msRequestFullscreen(); + } else if (player.mozRequestFullScreen) { + player.mozRequestFullScreen(); + } else if (player.webkitRequestFullscreen) { + player.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT); + } + }, + initSearch: function () { + STUDIP.Vue.load().then(({createApp}) => { + STUDIP.OER.Search = createApp({ + el: ".oer_search", + data: { + browseMode: false, + tags: $(".oer_search").data("tags"), + tagHistory: [], + searchtext: "", + activeFilterPanel: false, + difficulty: [1, 12], + category: null, + results: false, + material_select_url_template: $(".oer_search").data("material_select_url_template") + }, + methods: { + sync_search_text: function () { + this.searchtext = $(".oer_search input[name=search]").val(); + }, + triggerFilterPanel: function () { + this.activeFilterPanel = !this.activeFilterPanel; + }, + showFilterPanel: function () { + this.activeFilterPanel = true; + }, + hideFilterPanel: function () { + this.activeFilterPanel = false; + }, + clearAllFilters: function (keep_results) { + this.clearCategory(); + this.clearDifficulty(); + if (this.searchtext != '') { + this.searchtext = ''; + } + $(".oer_search input[name=search]").val(''); + if (keep_results !== true) { + this.results = false; + } + }, + clearDifficulty: function () { + if ((this.difficulty[0] != 1) && (this.difficulty[1] != 12)) { + this.difficulty = [1, 12]; + } + jQuery("#difficulty_slider").slider("values", this.difficulty); + }, + clearCategory: function () { + if (this.category != null) { + this.category = null; + } + }, + getIconShape: function (result) { + if (result.category === "video") { + return "video"; + } + if (result.category === "audio") { + return "file-audio"; + } + if (result.category === "presentation") { + return "file-pdf"; + } + if (result.category === "elearning") { + return "learnmodule"; + } + if (result.content_type === "application/zip") { + return "archive3"; + } + return "file"; + }, + search: function () { + let v = this; + this.browseMode = false; + $.ajax({ + url: STUDIP.URLHelper.getURL("dispatch.php/oer/market/search"), + data: { + type: this.category, + difficulty: this.difficulty.join(","), + search: this.searchtext + }, + dataType: "json", + success: function (output) { + $("#new_ones").hide(); + v.results = output.materials; + v.activeFilterPanel = false; + $(".material_navigation").toggle(v.results.length == 0); + $(".mainlist").toggle(v.results.length == 0); + $(".new_ones").toggle(v.results.length == 0); + } + }); + return false; + }, + browseTag: function (tag_hash, name) { + let v = this; + this.clearAllFilters(true); + let tags = []; + for (let i in this.tagHistory) { + tags.push(this.tagHistory[i].tag_hash); + } + if (tag_hash && (tags.indexOf(tag_hash) === -1)) { + tags.push(tag_hash); + } + let p = new Promise(function (resolve, reject) { + $.ajax({ + url: STUDIP.URLHelper.getURL("dispatch.php/oer/market/get_tags"), + data: { + tags: tags + }, + dataType: "json", + success: function (output) { + v.results = output.results.materials; + v.tags = output.tags; + if (tag_hash) { + v.tagHistory.push({ + tag_hash: tag_hash, + name: name + }); + } + if (v.tagHistory.length > 0) { + $("#new_ones").hide(); + } + resolve(); + }, + error: function () { + reject(); + } + }); + }); + return p; + }, + backInCloud: function () { + this.tagHistory.pop(); + let tag_hash = null; + let tag_name = null; + if (this.tagHistory.length > 0) { + tag_hash = this.tagHistory[this.tagHistory.length - 1].tag_hash; + tag_name = this.tagHistory[this.tagHistory.length - 1].name; + } + let v = this; + this.tagHistory.pop(); + this.browseTag(tag_hash, tag_name).then(function () { + if (v.tagHistory.length === 0) { + $("#new_ones").show(); + } + }); + + }, + getTagStyle: function (tag_hash) { + return "position: relative; top: " + Math.floor(Math.random() * 15 - 15) + "px"; + }, + capitalizeFirstLetter: function (string) { + return string.charAt(0).toUpperCase() + string.slice(1); + }, + getMaterialURL: function (material_id) { + return this.material_select_url_template.replace("__material_id__", material_id); + } + }, + mounted: function () { + this.results = $(this.$el).data('searchresults'); + if (this.results !== null) { + $("#new_ones").hide(); + } + if ($(this.$el).data('filteredcategory')) { + this.category = $(this.$el).data('filteredcategory'); + } + }, + updated: function () { + this.$nextTick(function () { + if (!jQuery("#difficulty_slider.ui-slider").length) { //to prevent an endless loop + let v = this; + jQuery("#difficulty_slider").slider({ + range: true, + min: 1, + max: 12, + values: [v.difficulty[0], v.difficulty[1]], + change: function (event, ui) { + v.difficulty = ui.values; + } + }); + } + }); + } + }); + }); + + + jQuery(document).on("click", function (evnt) { + if (!jQuery(evnt.target).is(".searchform *") && STUDIP.OER.Search) { + STUDIP.OER.Search.hideFilterPanel(); + } + }); + + } +}; + + +export default OER; diff --git a/resources/assets/javascripts/lib/old_upload.js b/resources/assets/javascripts/lib/old_upload.js new file mode 100644 index 0000000..7c8a084 --- /dev/null +++ b/resources/assets/javascripts/lib/old_upload.js @@ -0,0 +1,64 @@ +const OldUpload = { + upload: false, + msg_window: null, + upload_end: function() { + if (OldUpload.upload) { + OldUpload.msg_window.close(); + } + return; + }, + upload_start: function(form_name) { + var file_name = jQuery(form_name) + .find('input[type=file]') + .val(); + var ende, file_only; + if (!file_name) { + alert(jQuery('#upload_select_file_message').text()); + jQuery(form_name) + .find('input[type=file]') + .focus(); + return false; + } + + if (file_name.charAt(file_name.length - 1) === '"') { + ende = file_name.length - 1; + } else { + ende = file_name.length; + } + var ext = file_name.substring(file_name.lastIndexOf('.') + 1, ende).toLowerCase(); + file_only = file_name; + if (file_name.lastIndexOf('/') > 0) { + file_only = file_name.substring(file_name.lastIndexOf('/') + 1, ende); + } + if (file_name.lastIndexOf('\\') > 0) { + file_only = file_name.substring(file_name.lastIndexOf('\\') + 1, ende); + } + + var permission = jQuery.parseJSON(jQuery('#upload_file_types').html()); + if ( + (permission.allow && jQuery.inArray(ext, permission.types) !== -1) || + (!permission.allow && jQuery.inArray(ext, permission.types) === -1) + ) { + alert(jQuery('#upload_error_message_wrong_type').text()); + jQuery(form_name) + .find('input[type=file]') + .focus(); + return false; + } + + OldUpload.msg_window = window.open( + '', + 'messagewindow', + 'height=250,width=200,left=20,top=20,scrollbars=no,resizable=no,toolbar=no' + ); + OldUpload.msg_window.document.write(jQuery('#upload_window_template').text()); + jQuery(OldUpload.msg_window.document) + .find('b') + .text(file_only); + + OldUpload.upload = true; + return true; + } +}; + +export default OldUpload; diff --git a/resources/assets/javascripts/lib/overlapping.js b/resources/assets/javascripts/lib/overlapping.js new file mode 100644 index 0000000..73ab32f --- /dev/null +++ b/resources/assets/javascripts/lib/overlapping.js @@ -0,0 +1,94 @@ +import { $gettext } from './gettext.js'; + +const Overlapping = { + + /** + * Initialize Select2 select boxes. + * @returns {undefined} + */ + init: function () { + $('#base-version-select').select2({ + placeholder: $gettext('Studiengangteil suchen'), + minimumInputLength: 3, + ajax: { + url: STUDIP.URLHelper.getURL('dispatch.php/admin/overlapping/base_version'), + dataType: 'json' + } + }); + + $('#comp-versions-select').select2({ + placeholder: $gettext('Optional weitere Studiengangteile (max. 5)'), + minimumInputLength: 3, + ajax: { + url: STUDIP.URLHelper.getURL('dispatch.php/admin/overlapping/comp_versions'), + dataType: 'json' + } + }); + + $('#fachsem-select').select2({ + placeholder: $gettext('Fachsemester auswählen (optional)') + }); + $('#semtype-select').select2({ + placeholder: $gettext('Veranstaltungstyp auswählen (optional)') + }); + $('#base-version-select').on('select2:select', function (e) { + $('#comp-versions-select').val(null).trigger('change'); + $.ajax({ + url: STUDIP.URLHelper.getURL('dispatch.php/admin/overlapping/comp_versions'), + dataType: 'json', + data: { + version_id: $('#base-version-select').select2('data')[0].id + }, + success: function(data) { + if (data.results.length) { + var inputlength = 3; + if (data.results.length < 4) { + inputlength = 0; + } + $('#comp-versions-select').select2({ + placeholder: $gettext('Optional weitere Studiengangteile (max. 5)'), + minimumInputLength: inputlength, + ajax: { + url: STUDIP.URLHelper.getURL('dispatch.php/admin/overlapping/comp_versions', + {'version_id': $('#base-version-select').select2('data')[0].id}), + dataType: 'json' + } + }); + } else { + $('#comp-versions-select').select2({ + placeholder: $gettext('Keine weitere Auswahl möglich') + }); + $('#comp-versions-select').prop('disabled', true).trigger('change'); + } + } + }); + }); + + $('span.mvv-overlapping-exclude').on('click', function () { + var course_id = $(this).data('mvv-ovl-course'); + var selection_id = $(this).data('mvv-ovl-selection'); + $.ajax({ + method: 'post', + url: STUDIP.URLHelper.getURL('dispatch.php/admin/overlapping/set_exclude'), + data: { + 'excluded': $(this).is('.mvv-overlapping-invisible') ? 1 : 0, + 'course_id': course_id, + 'selection_id': selection_id + }, + success: function(data, textStatus, jqXHR) { + $('.mvv-overlapping-exclude').each(function () { + if ($(this).data('mvv-ovl-course') == course_id) { + $(this).toggleClass('mvv-overlapping-invisible'); + } + }); + $('.mvv-overlapping-exclude').attr('title', $gettext('Veranstaltung berücksichtigen')); + $('.mvv-overlapping-invisible').attr('title', $gettext('Veranstaltung nicht berücksichtigen')); + + } + }) + return false; + }); + } +}; + +export default Overlapping;
\ No newline at end of file diff --git a/resources/assets/javascripts/lib/overlay.js b/resources/assets/javascripts/lib/overlay.js new file mode 100644 index 0000000..b4c6d4d --- /dev/null +++ b/resources/assets/javascripts/lib/overlay.js @@ -0,0 +1,108 @@ +import { $gettext } from './gettext.js'; + +const Overlay = { + delay: 300, + element: null, + selector: '.ui-front.modal-overlay', + timeout: null +}; + +Overlay.reset = function() { + if (this.timeout !== null) { + clearTimeout(this.timeout); + this.timeout = null; + } +}; + +Overlay.schedule = function(callback, delay) { + this.reset(); + if (delay !== undefined && !delay) { + callback.call(this); + } else { + this.timeout = setTimeout(callback.bind(this), this.delay); + } +}; + +Overlay.show = function(ajax, containment, secure, callback, delay) { + this.schedule(function() { + if (this.element === null) { + containment = containment || 'body'; + + this.element = $('<div class="ui-front modal-overlay">'); + if (ajax) { + this.element.addClass('modal-overlay-ajax'); + if (ajax === 'dark') { + this.element.addClass('modal-overlay-dark'); + } + } + if (containment !== 'body') { + this.element.addClass('modal-overlay-local'); + } else { + // Blur background + $('#layout_wrapper').addClass('has-overlay'); + } + this.element.appendTo(containment); + } + + if (secure) { + $(window).on('beforeunload.overlay', Overlay.securityHandler); + } + if ($.type(callback) === 'function') { + callback.call(this); + } + }, delay); +}; + +Overlay.hide = function(delay) { + this.schedule(function() { + if (this.element !== null) { + this.element.remove(); + this.element = null; + } + + $('#layout_wrapper').removeClass('has-overlay'); + $(window).off('beforeunload.overlay'); + }, delay); +}; + +// Secure the overlay +Overlay.securityHandler = function(event) { + event = event || window.event || {}; + event.returnValue = $gettext('Ihre Eingaben wurden bislang noch nicht gespeichert.'); + return event.returnValue; +}; + +// Allows progress information +Overlay.showProgress = function(title, ajax, secure, delay) { + this.show( + ajax, + null, + secure, + function() { + if ($('h1', this.selector).length === 0) { + $(this.selector) + .append($('<h1>').text(title)) + .append('<progress max="100" value="0">') + .append('<ul class="overlay-progress-log">'); + } + }, + delay + ); +}; + +Overlay.updateProgress = function(percent, message) { + $('progress', this.selector).val(percent); + if (message) { + this.progressInfo(message); + } +}; + +Overlay.progressInfo = function(message) { + var li = $('<li>').text(message); + $('.overlay-progress-log', this.selector).prepend(li); + li.delay(1000).hide('fade', 300, function() { + $(this).remove(); + }); +}; + +export default Overlay; diff --git a/resources/assets/javascripts/lib/page_layout.js b/resources/assets/javascripts/lib/page_layout.js new file mode 100644 index 0000000..9509518 --- /dev/null +++ b/resources/assets/javascripts/lib/page_layout.js @@ -0,0 +1,29 @@ +/*jslint esversion: 6*/ +let options = { + title: document.title, + prefix: '' +}; + +export default { + get title () { + return options.title; + }, + + set title (title) { + options.title = title; + this.displayTitle(); + }, + + get title_prefix () { + return options.prefix; + }, + + set title_prefix (prefix) { + options.prefix = prefix; + this.displayTitle(); + }, + + displayTitle () { + document.title = `${options.prefix}${options.title}`; + } +}; diff --git a/resources/assets/javascripts/lib/parse_options.js b/resources/assets/javascripts/lib/parse_options.js new file mode 100644 index 0000000..8160f37 --- /dev/null +++ b/resources/assets/javascripts/lib/parse_options.js @@ -0,0 +1,89 @@ +/** + * Parses a given string "foo needle[option1;option2=value;option3=42;option4=false] bar" + * into the following structure: + * + * {option1: true, option2: "value", option3: 42, option4: false} + */ +function parseOptions(string, needle) { + var temp = needle ? string.match(/\w+\[(.*?)\]/g) || [] : [string], + options = {}; + + temp.forEach(function(slice) { + if (needle && (slice.indexOf(needle) !== 0 || slice === needle)) { + return; + } + var split = needle ? slice.replace(/^\w+\[(.*)\]$/, '$1') : slice, + index = '', + value = '', + inval = false, + escaped = 0, + inquotes = false, + l = split.length, + token, + write, + skip, + i; + for (i = 0; i < l; i += 1) { + token = split[i]; + write = false; + skip = false; + if (inval && token === '\\' && escaped <= 0) { + escaped = 2; + } else if (!inval && token === '=') { + inval = true; + skip = true; + } else if (inval && value.length === 0 && (token === '"' || token === "'")) { + inquotes = token; + } else if (inval && inquotes && escaped <= 0 && token === inquotes) { + inquotes = false; + } else if (!inquotes && token === ';') { + write = true; + skip = true; + } + if (!skip && escaped <= 0) { + if (inval) { + value += token; + } else { + index += token; + } + } + escaped -= 1; + + if (write || i === split.length - 1) { + if (i === split.length - 1 && inquotes) { + throw 'Invalid data, missing closing quote'; + } + if (index.trim().length > 0) { + options[index.trim()] = inval ? parseValue(value) : true; + } + inval = false; + inquotes = false; + index = ''; + value = ''; + } + } + }); + return options; +} + +/** + * Tries to parse a given string into it's appropriate type. + * Supports boolean, int and float. + */ +function parseValue(value) { + if (value.toLowerCase() === 'true') { + return true; + } + if (value.toLowerCase() === 'false') { + return false; + } + if (/^[+\-]\d+$/.test(value)) { + return parseInt(value, 10); + } + if (/^[+\-]\d+\.\d+$/.test(value)) { + return parseFloat(value, 10); + } + return value.replace(/^(["'])(.*)\1$/, '$2'); +} + +export default parseOptions; diff --git a/resources/assets/javascripts/lib/personal_notifications.js b/resources/assets/javascripts/lib/personal_notifications.js new file mode 100644 index 0000000..4a0a6e3 --- /dev/null +++ b/resources/assets/javascripts/lib/personal_notifications.js @@ -0,0 +1,219 @@ +import Favico from 'favico.js'; +import Cache from './cache.js'; +import PageLayout from './page_layout.js'; + +var stack = {}; +var audio_notification = false; +var directlydeleted = []; +var favicon = null; + +function updateFavicon(text) { + if (favicon === null) { + var valid = $('head') + .find('link[rel=icon]') + .first(); + $('head') + .find('link[rel*=icon]') + .not(valid) + .remove(); + + favicon = new Favico({ + bgColor: '#d60000', + textColor: '#fff', + fontStyle: 'normal', + fontFamily: 'Lato', + position: 'right', + type: 'rectangle' + }); + } + favicon.badge(text); +} + +// Wrapper function that creates a desktop notification from given data +function create_desktop_notification(data) { + var notification = new Notification(STUDIP.STUDIP_SHORT_NAME, { + body: data.text, + icon: data.avatar, + tag: data.id + }); + notification.addEventListener('click', () => { + location.href = STUDIP.URLHelper.getURL(`dispatch.php/jsupdater/mark_notification_read/${notification.tag}`); + }); +} + +// Handler for all notifications received by an ajax request +function process_notifications({ notifications }) { + var cache = Cache.getInstance('desktop.notifications'); + var ul = $('<ul/>'); + var changed = false; + var new_stack = {}; + + notifications.forEach(notification => { + if (directlydeleted.indexOf(notification.personal_notification_id) !== -1) { + return; + } + + ul.append(notification.html); + + var id = $('.notification:last', ul).data().id; + new_stack[id] = notification; + if (notification.html_id) { + $(`#${notification.html_id}`).on('mouseenter', PersonalNotifications.isVisited); + } + + changed = changed || !stack.hasOwnProperty(id); + + // Check if notifications should be sent (depends on the + // Notification itself and session storage) + if ( + !window.hasOwnProperty('Notification') + || Notification.permission !== 'granted' + || cache.has(notification.id) + ) { + return; + } + + // If it's okay let's create a notification + create_desktop_notification(notification); + + cache.set(id, true); + }); + + // Anything changed? Replace stack and display + if (changed || Object.keys(stack).length !== Object.keys(new_stack).length) { + stack = new_stack; + $('#notification_list > ul').replaceWith(ul); + } + + PersonalNotifications.update(); + directlydeleted = []; +} + +const PersonalNotifications = { + initialize () { + if ($('#notification_marker').length > 0) { + $('#notification_list .notification').map(function() { + var data = $(this).data(); + stack[data.id] = data; + }); + + STUDIP.JSUpdater.register( + 'personalnotifications', + process_notifications, + null, + 60000 + ); + + if ($('#audio_notification').length > 0) { + audio_notification = $('#audio_notification').get(0); + audio_notification.load(); + } + + if ('Notification' in window) { + $('#notification_list .enable-desktop-notifications') + .toggle(Notification.permission === 'default') + .click(STUDIP.PersonalNotifications.activate); + } + } + }, + activate () { + Promise.resolve(Notification.requestPermission()).then(permission => { + $('#notification_list .enable-desktop-notifications') + .toggle(permission === 'default'); + }); + }, + markAsRead (event) { + var notification = $(this).closest('.notification'), + id = notification.data().id; + PersonalNotifications.sendReadInfo(id, notification); + return false; + }, + markAllAsRead (event) { + var notifications = $(this) + .parent() + .find('.notification'); + PersonalNotifications.sendReadInfo('all', notifications); + return false; + }, + sendReadInfo (id, notification) { + $.get(STUDIP.URLHelper.getURL(`dispatch.php/jsupdater/mark_notification_read/${id}`)).done(() => { + if (notification) { + var count = notification.length; + notification.toggle('blind', 'fast', function() { + var data = $(this).data(); + delete stack[data.id]; + $(this).remove(); + + count -= 1; + if (count === 0) { + PersonalNotifications.update(); + } + }); + } + }); + }, + update () { + var count = _.values(stack).length; + var old_count = parseInt($('#notification_marker').text(), 10); + var really_new = 0; + $('#notification_list > ul > li').each(function() { + if (parseInt($(this).data('timestamp'), 10) > parseInt($('#notification_marker').data('lastvisit'), 10)) { + really_new += 1; + } + }); + if (really_new > 0) { + $('#notification_marker') + .data('seen', false) + .addClass('alert'); + PageLayout.title_prefix = '(!) '; + } else { + $('#notification_marker').removeClass('alert'); + PageLayout.title_prefix = ''; + } + if (count) { + $('#notification_container').addClass('hoverable'); + if (count > old_count && audio_notification !== false) { + audio_notification.play(); + } + } else { + $('#notification_container').removeClass('hoverable'); + } + if (old_count !== count) { + $('#notification_marker').text(count); + updateFavicon(count); + $('#notification_container .mark-all-as-read').toggleClass('notification_hidden', count < 2); + } + }, + isVisited () { + const id = this.id; + $.each(stack, (index, notification) => { + if (notification.html_id === id) { + PersonalNotifications.sendReadInfo(notification.personal_notification_id); + + delete stack[index]; + + $(`.notification[data-id=${notification.personal_notification_id}]`).fadeOut(function () { + $(this).remove(); + }); + + directlydeleted.push(notification.personal_notification_id); + + PersonalNotifications.update(); + } + }); + }, + setSeen () { + if ($('#notification_marker').data('seen')) { + return; + } + $('#notification_marker').data('seen', true); + + $.get(STUDIP.URLHelper.getURL('dispatch.php/jsupdater/notifications_seen')).then(time => { + $('#notification_marker') + .removeClass('alert') + .data('lastvisit', time); + }); + } +}; + +export default PersonalNotifications; diff --git a/resources/assets/javascripts/lib/plus.js b/resources/assets/javascripts/lib/plus.js new file mode 100644 index 0000000..0d447fd --- /dev/null +++ b/resources/assets/javascripts/lib/plus.js @@ -0,0 +1,23 @@ +const Plus = { + setModule: function () { + $.ajax({ + "url": STUDIP.URLHelper.getURL("dispatch.php/course/plus/trigger"), + "data": { + "moduleclass": $(this).data("moduleclass"), + "key": $(this).data("key"), + "active": $(this).is(":checked") ? 1 : 0 + }, + "dataType": "json", + "type": "post", + "success": function (output) { + if (output.tabs) { + $(".tabs_wrapper").replaceWith(output.tabs); + } + } + }); + } +}; + + + +export default Plus; diff --git a/resources/assets/javascripts/lib/qr_code.js b/resources/assets/javascripts/lib/qr_code.js new file mode 100644 index 0000000..781d1aa --- /dev/null +++ b/resources/assets/javascripts/lib/qr_code.js @@ -0,0 +1,60 @@ +import QRCodeGenerator from "../vendor/qrcode-04f46c6.js" + +const QRCode = { + show: function() { + jQuery('#qr_code').remove(); + jQuery("<div id='qr_code'/>").appendTo('body'); + var title = jQuery(this).data('qr-title'); + if (title) { + jQuery('#qr_code').append('<h1 class="title">' + title + '</h1>'); + } else { + jQuery('#qr_code').append("<div class='header'/>"); + } + jQuery('#qr_code').append("<div class='code'/>"); + jQuery('#qr_code').append("<div class='url'/>"); + jQuery('#qr_code').append("<div class='description'/>"); + + var code = new QRCodeGenerator(jQuery('#qr_code .code')[0], { + text: jQuery(this).attr('href'), + width: 1280, + height: 1280, + correctLevel: 3 + }); + + jQuery('#qr_code .url').text(jQuery(this).attr('href')); + jQuery('#qr_code .description').text(jQuery(this).data('qr-code')); + var print_button_enabled = jQuery(this).data('qr-code-print'); + if (print_button_enabled) { + var icon_path = STUDIP.URLHelper.getURL( + 'assets/images/icons/blue/print.svg' + ); + var print_element = jQuery('<img></img>'); + jQuery(print_element).attr('src', icon_path); + jQuery(print_element).addClass('PrintAction'); + jQuery('#qr_code').append(print_element); + } + + //jQuery("#qr_code .code").html(jQuery(this).find(".qrcode_image").clone()); + var qr = jQuery('#qr_code')[0]; + if (qr.requestFullscreen) { + qr.requestFullscreen(); + } else if (qr.msRequestFullscreen) { + qr.msRequestFullscreen(); + } else if (qr.mozRequestFullScreen) { + qr.mozRequestFullScreen(); + } else if (qr.webkitRequestFullscreen) { + qr.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT); + } + return false; + }, + generate: function (element, text, options = {}) { + options.text = text; + if (!options.hasOwnProperty('correctLevel')) { + options.correctLevel = 3; + } + + var qrcode = new QRCodeGenerator(element, options); + } +}; + +export default QRCode; diff --git a/resources/assets/javascripts/lib/questionnaire.js b/resources/assets/javascripts/lib/questionnaire.js new file mode 100644 index 0000000..199a98a --- /dev/null +++ b/resources/assets/javascripts/lib/questionnaire.js @@ -0,0 +1,233 @@ +import { $gettext } from '../lib/gettext.js'; + +const Questionnaire = { + lastUpdate: null, + initialize() { + STUDIP.JSUpdater.register( + 'questionnaire', + Questionnaire.updateQuestionnaireResults, + Questionnaire.getParamsForPolling, + 15000 + ); + }, + getParamsForPolling: function() { + var questionnaires = { + questionnaire_ids: [], + last_update: Questionnaire.lastUpdate + }; + Questionnaire.lastUpdate = Math.floor(Date.now() / 1000); + jQuery('.questionnaire_results').each(function() { + questionnaires.questionnaire_ids.push(jQuery(this).data('questionnaire_id')); + }); + if (questionnaires.questionnaire_ids.length > 0) { + return questionnaires; + } + }, + updateQuestionnaireResults: function(data) { + for (var questionnaire_id in data) { + if (data[questionnaire_id].html) { + var new_view = jQuery(data[questionnaire_id].html); + jQuery('.questionnaire_results.questionnaire_' + questionnaire_id).replaceWith(new_view); + jQuery(document).trigger('dialog-open'); + } + } + }, + updateOverviewQuestionnaire: function(data) { + if (jQuery('#questionnaire_overview tr#questionnaire_' + data.questionnaire_id).length > 0) { + jQuery('#questionnaire_overview tr#questionnaire_' + data.questionnaire_id).replaceWith(data.overview_html); + } else { + if (jQuery('#questionnaire_overview').length > 0) { + jQuery(data.overview_html) + .hide() + .insertBefore('#questionnaire_overview > tbody > :first-child') + .delay(300) + .fadeIn(); + jQuery('#questionnaire_overview .noquestionnaires').remove(); + } + if (data.message) { + jQuery('.messagebox').hide(); + jQuery('#layout_content').prepend(data.message); + } + } + if (jQuery('.questionnaire_widget .widget_questionnaire_' + data.questionnaire_id).length > 0) { + if (data.widget_html) { + jQuery('.questionnaire_widget .widget_questionnaire_' + data.questionnaire_id).replaceWith( + data.widget_html + ); + } else { + jQuery('.questionnaire_widget .widget_questionnaire_' + data.questionnaire_id).remove(); + } + } else { + if (jQuery('.questionnaire_widget').length > 0 && data.widget_html) { + jQuery('.ui-dialog-content').dialog('close'); + if (jQuery('.questionnaire_widget > article').length > 0) { + jQuery(data.widget_html) + .hide() + .insertBefore( + '.questionnaire_widget > article:first-of-type, .questionnaire_widget > section:first-of-type' + ) + .delay(300) + .fadeIn(); + } else { + jQuery('.questionnaire_widget .noquestionnaires') + .replaceWith(data.widget_html) + .hide() + .delay(300) + .fadeIn(); + } + } else { + if (data.message) { + jQuery('.messagebox').hide(); + jQuery('#layout_content').prepend(data.message); + jQuery.scrollTo('#layout_content', 400); + } + } + } + jQuery(document).trigger('dialog-open'); + }, + updateWidgetQuestionnaire: function(html) { + //update the results of a questionnaire + var questionnaire_id = jQuery(html).data('questionnaire_id'); + jQuery('.questionnaire_widget .questionnaire_' + questionnaire_id).replaceWith(html); + jQuery(document).trigger('dialog-open'); + }, + beforeAnswer: function() { + var form = jQuery(this).closest('form')[0]; + var questionnaire_id = jQuery(form) + .closest('article') + .data('questionnaire_id'); + let validated = true; + + //validation + $(form).find("input, select, textarea").each(function () { + if ($(this).is(":invalid")) { + validated = false; + } + }); + + $(form).find(".questionnaire_answer > article").each(function () { + let question_type = $(this).data("question_type"); + if (typeof STUDIP.Questionnaire[question_type] !== "undefined" + && typeof STUDIP.Questionnaire[question_type].validator === "function") { + if (!STUDIP.Questionnaire[question_type].validator.call(this)) { + validated = false; + } + } + }); + + if (!validated) { + $(form).addClass("show_validation_hints"); + STUDIP.Report.warning($gettext("Noch nicht komplett ausgefüllt."), $gettext("Füllen Sie noch die rot markierten Stellen korrekt aus.")); + return false; + } + + if (jQuery(form).is('.questionnaire_widget form')) { + jQuery.ajax({ + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/questionnaire/submit/' + questionnaire_id, + data: new FormData(form), + cache: false, + processData: false, + contentType: false, + type: 'POST', + success: function(output) { + jQuery(form).replaceWith(output); + jQuery(document).trigger('dialog-open'); + } + }); + jQuery(form).css('opacity', '0.5'); + return false; + } else { + return true; + } + }, + Test: { + updateCheckboxValues: function() { + jQuery('.questionnaire_edit .question.test').each(function() { + jQuery(this) + .find('.options > li') + .each(function(index, li) { + jQuery(li) + .find('input[type=checkbox]') + .val(index + 1); + }); + }); + } + }, + Vote: { + validator: function () { + if ($(this).find(".mandatory").length > 0) { + if ($(this).find(":selected, :checked").length === 0) { + $(this).find(".invalidation_notice").addClass("invalid"); + return false; + } else { + $(this).find(".invalidation_notice").removeClass("invalid"); + } + } + return true; + } + }, + addQuestion: function(questiontype) { + jQuery.ajax({ + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/questionnaire/add_question', + data: { + questiontype: questiontype + }, + dataType: 'json', + success: function(output) { + var order = JSON.parse(jQuery("input[name=order]").val()); + order.push(output.question_id); + jQuery("input[name=order]").val(JSON.stringify(order)); + jQuery(output.html) + .hide() + .appendTo('.questionnaire_edit .all_questions') + .show('fade'); + } + }); + }, + moveQuestionUp: function () { + let thisquestion = jQuery(this).closest(".question"); + let upper = thisquestion.prev(); + thisquestion.insertBefore(upper); + upper.hide().fadeIn(); + thisquestion.hide().fadeIn(); + }, + moveQuestionDown: function () { + let thisquestion = jQuery(this).closest(".question"); + let downer = thisquestion.next(); + thisquestion.insertAfter(downer); + downer.hide().fadeIn(); + thisquestion.hide().fadeIn(); + }, + initVoteEvaluation: async function (el, data, isAjax, isMultiple) { + + const Chartist = await STUDIP.loadChunk('chartist'); + + if (isAjax) { + jQuery(document).add(".questionnaire_results").one("dialog-open", enhance); + } else { + jQuery(enhance); + } + + function enhance() { + if (isMultiple) { + new Chartist.Bar( + el, + data, + { onlyInteger: true, axisY: { onlyInteger: true } } + ); + } else { + data.series = data.series[0]; + new Chartist.Pie( + el, + data, + { labelPosition: 'outside' } + ); + } + }; + }, + initTestEvaluation: async function (el, data, isAjax, isMultiple) { + this.initVoteEvaluation(el, data, isAjax, isMultiple); + }, +}; + +export default Questionnaire; diff --git a/resources/assets/javascripts/lib/quick_search.js b/resources/assets/javascripts/lib/quick_search.js new file mode 100644 index 0000000..d8fda74 --- /dev/null +++ b/resources/assets/javascripts/lib/quick_search.js @@ -0,0 +1,172 @@ +/*jslint esversion: 6*/ +import { $gettext } from './gettext.js'; + +/* ------------------------------------------------------------------------ + * QuickSearch inputs + * ------------------------------------------------------------------------ */ + +const QuickSearch = { + /** + * the function to be called from the QuickSearch class template + * @param name string: ID of input + * @param url string: URL of AJAX-response + * @param func string: name of a possible function executed + * when user has selected something + * @return: void + */ + autocomplete: function(name, url, func, disabled) { + if (disabled === undefined || disabled !== true) { + var appendTo = 'body'; + if (jQuery(`#${name}_frame`).length > 0) { + appendTo = `#${name}_frame`; + } else if ($(`#${name}`).closest('.ui-dialog').length > 0) { + appendTo = $(`#${name}`).closest('.ui-dialog'); + } + jQuery('#' + name).quicksearch({ + delay: 500, + minLength: 3, + appendTo: appendTo, + create: function() { + if ($(this).is('[autofocus]')) { + $(this).focus(); + } + }, + position: $('#' + name).is('.expand-to-left') + ? { + my: 'right top', + at: 'right bottom', + collision: 'none' + } + : { + my: 'left top', + at: 'left bottom', + collision: 'none' + }, + source: function(input, add) { + //get the variables that should be sent: + var send_vars = jQuery('#' + name) + .closest('form') + .serializeArray(); + send_vars.push({ + name: 'request', + value: input.term + }); + + jQuery + .ajax({ + url: url, + type: 'post', + data: send_vars + }) + .done(function(data) { + var stripTags = /<\w+(\s+("[^"]*"|'[^']*'|[^>])+)?>|<\/\w+>/gi; + //an array of possible selections + + if (!data.length) { + add([{ + value: '', + label: $gettext('Kein Ergebnis gefunden.'), + disabled: true + }]); + return; + } + + var suggestions = _.map(data, function(val) { + //adding a label and a hidden item_id - don't use "value": + var label_text = val.item_name; + if (val.item_description !== undefined) { + label_text += '<br>' + val.item_description; + } + + return { + //what is displayed in the drop down box + label: label_text, + //the hidden ID of the item + item_id: val.item_id, + //what is inserted in the visible input box + value: + val.item_search_name !== null + ? val.item_search_name + : jQuery('<div/>') + .html((val.item_name || '').replace(stripTags, '')) + .text() + }; + }); + //pass it to the function of UI-widget: + add(suggestions); + }) + .fail(function(jqxhr, textStatus) { + add([ + { + value: '', + label: $gettext('Fehler') + ': ' + jqxhr.responseText, + disabled: true + } + ]); + }); + }, + select: function(event, ui) { + if (ui.item.disabled) { + return; + } + + //inserts the ID of the selected item in the hidden input: + jQuery('#' + name + '_realvalue').attr('value', ui.item.item_id); + //and execute a special function defined before by the programmer: + if (func) { + var proceed = func.bind(event.target)(ui.item.item_id, ui.item.value); + if (!proceed) { + jQuery(this).val(''); + return false; + } + } + } + }); + + if (jQuery('#' + name + '_frame').length) { + // trigger search on button click + jQuery('#' + name + '_frame input[type="submit"]').click(function(e) { + e.preventDefault(); + QuickSearch.triggerSearch(name); + }); + + // trigger search on enter key down + jQuery('#' + name).keydown(function(e) { + if (e.keyCode == 13) { + e.preventDefault(); + QuickSearch.triggerSearch(name); + } + }); + } + + var input = jQuery('#' + name); + var hidden = jQuery('#' + name + '_realvalue'); + if (input.is('[required]')) { + input.closest('form').submit(function (event) { + if (hidden.val() === '') { + input[0].setCustomValidity($gettext('Bitte wählen Sie einen gültigen Wert aus!')); + event.preventDefault(); + } + }); + } + } + }, + + // start searching now + triggerSearch: function(name) { + var term = jQuery('#' + name).val(); + jQuery('#' + name).quicksearch({ minLength: 1 }); + jQuery('#' + name).quicksearch('search', term); + jQuery('#' + name).quicksearch({ minLength: 3 }); + }, + + reset: function(form_name, quick_search_name) { + if (!form_name || !quick_search_name) { + return; + } + document.forms[form_name].elements[quick_search_name].value = ''; + document.forms[form_name].elements[quick_search_name + '_parameter'].value = ''; + } +}; + +export default QuickSearch; diff --git a/resources/assets/javascripts/lib/raumzeit.js b/resources/assets/javascripts/lib/raumzeit.js new file mode 100644 index 0000000..5cd5e55 --- /dev/null +++ b/resources/assets/javascripts/lib/raumzeit.js @@ -0,0 +1,19 @@ +import { $gettext } from './gettext.js'; + +const Raumzeit = { + disableBookableRooms: function(icon) { + var select = $(icon).prev('select')[0]; + var me = $(icon); + select.title = ''; + $(select) + .children('option') + .each(function() { + $(this).prop('disabled', false); + }); + + me.attr('data-state', false); + me.attr('title', $gettext('Nur buchbare Räume anzeigen')); + } +}; + +export default Raumzeit; diff --git a/resources/assets/javascripts/lib/ready.js b/resources/assets/javascripts/lib/ready.js new file mode 100644 index 0000000..3464ec6 --- /dev/null +++ b/resources/assets/javascripts/lib/ready.js @@ -0,0 +1,60 @@ +/*jslint esversion: 6*/ + +function ready(callback, top = false) { + if (top) { + ready.handlers.unshift({ + type: false, + callback: callback + }); + } else { + ready.handlers.push({ + type: false, + callback: callback + }); + } + return this; // = STUDIP +} +ready.handlers = []; +ready.trigger = function (type, context) { + ready.handlers.filter(handler => !handler.type || handler.type === type).forEach(handler => { + handler.callback({ + target: context || document + }); + }); + + let event = $.Event('studip-ready'); + event.target = context || document; + $(document).trigger(event); +}; + +function domReady(callback, top = false) { + if (top) { + ready.handlers.unshift({ + type: 'dom', + callback: callback + }); + } else { + ready.handlers.push({ + type: 'dom', + callback: callback + }); + } + return this; // = STUDIP +} + +function dialogReady(callback, top = false) { + if (top) { + ready.handlers.unshift({ + type: 'dialog', + callback: callback + }); + } else { + ready.handlers.push({ + type: 'dialog', + callback: callback + }); + } + return this; // = STUDIP +} + +export { ready, domReady, dialogReady }; diff --git a/resources/assets/javascripts/lib/register.js b/resources/assets/javascripts/lib/register.js new file mode 100644 index 0000000..da81132 --- /dev/null +++ b/resources/assets/javascripts/lib/register.js @@ -0,0 +1,134 @@ +import { $gettext } from './gettext.js'; + +const register = { + re_username: null, + re_name: null, + + clearErrors: function(field) { + jQuery('input[name=' + field + ']') + .parent() + .find('div.error') + .remove(); + }, + + addError: function(field, error) { + jQuery('input[name=' + field + ']') + .parent() + .append('<div class="error">' + error + '</div>'); + jQuery('div[class=error]').show(); + }, + + checkusername: function() { + register.clearErrors('username'); + + if (jQuery('input[name=username]').val().length < 4) { + register.addError( + 'username', + $gettext('Der Benutzername ist zu kurz, er sollte mindestens 4 Zeichen lang sein.') + ); + document.login.username.focus(); + return false; + } + + if (register.re_username.test(jQuery('input[name=username]').val()) === false) { + register.addError( + 'username', + $gettext('Der Benutzername enthält unzulässige Zeichen, er darf keine Sonderzeichen oder Leerzeichen enthalten.') + ); + document.login.username.focus(); + return false; + } + + return true; + }, + + checkpassword: function() { + register.clearErrors('password'); + + var checked = true; + if (jQuery('input[name=password]').val().length < 8) { + register.addError( + 'password', + $gettext('Das Passwort ist zu kurz. Es sollte mindestens 8 Zeichen lang sein.') + ); + document.login.password.focus(); + checked = false; + } + return checked; + }, + + checkpassword2: function() { + register.clearErrors('password2'); + + var checked = true; + if (jQuery('input[name=password]').val() !== jQuery('input[name=password2]').val()) { + register.addError( + 'password2', + $gettext('Das Passwort stimmt nicht mit dem Bestätigungspasswort überein!') + ); + document.login.password2.focus(); + checked = false; + } + return checked; + }, + + checkVorname: function() { + register.clearErrors('Vorname'); + + var checked = true; + if (register.re_name.test(jQuery('input[name=Vorname]').val()) === false) { + register.addError('Vorname', $gettext('Bitte geben Sie Ihren tatsächlichen Vornamen an.')); + document.login.Vorname.focus(); + checked = false; + } + return checked; + }, + + checkNachname: function() { + register.clearErrors('Nachname'); + + var checked = true; + if (register.re_name.test(jQuery('input[name=Nachname]').val()) === false) { + register.addError('Nachname', $gettext('Bitte geben Sie Ihren tatsächlichen Nachnamen an.')); + document.login.Nachname.focus(); + checked = false; + } + return checked; + }, + + checkEmail: function() { + register.clearErrors('Email'); + + var email = jQuery('input[name=Email]').val(); + var domain = jQuery('select[name=emaildomain]').val(); + var checked = false; + + if (domain) { + email += '@' + domain; + } + + checked = $('<input type="email">') + .val(email)[0] + .checkValidity(); + + if (!checked) { + register.addError('Email', $gettext('Die E-Mail-Adresse ist nicht korrekt!')); + $('#Email').focus(); + } + + return checked; + }, + + checkdata: function() { + return ( + this.checkusername() && + this.checkpassword() && + this.checkpassword2() && + this.checkVorname() && + this.checkNachname() && + this.checkEmail() + ); + } +}; + +export default register; diff --git a/resources/assets/javascripts/lib/report.js b/resources/assets/javascripts/lib/report.js new file mode 100644 index 0000000..b81e0c4 --- /dev/null +++ b/resources/assets/javascripts/lib/report.js @@ -0,0 +1,48 @@ +/** + * Message reporting + * + * @author Viktoria Wiebe + * @author Jan-Hendrik Willms <tleilax+studip@gmail.com> + * @version 1.0 + * @since Stud.IP 4.5 + * @license GLP2 or any later version + * @copyright 2019 Stud.IP Core Group + */ + +import Dialog from './dialog.js'; + +let counter = 0; + +function reportMessage(type, title, content, options) { + options.id = `report-${type}-${counter++}`; + options.title = title; + options.size = 'fit'; + options.wikilink = false; + options.dialogClass = `report-${type}`; + + Dialog.show(content, options); +} + +const Report = { + // Info message + info (title, content, options = {}) { + reportMessage('info', title, content, options); + }, + + // Success message + success (title, content, options = {}) { + reportMessage('success', title, content, options); + }, + + // Warning message + warning (title, content, options = {}) { + reportMessage('warning', title, content, options); + }, + + // Error message + error (title, content, options = {}) { + reportMessage('error', title, content, options); + } +}; + +export default Report; diff --git a/resources/assets/javascripts/lib/resources.js b/resources/assets/javascripts/lib/resources.js new file mode 100644 index 0000000..126a55e --- /dev/null +++ b/resources/assets/javascripts/lib/resources.js @@ -0,0 +1,907 @@ +import { $gettext } from '../lib/gettext.js'; + +class Resources +{ + + static addUserToPermissionList(user_id, table_element) + { + if (!user_id || !table_element) { + return; + } + + var is_temporary_table = false; + var table_id = jQuery(table_element).attr('id'); + if (table_id === 'TemporaryPermissionList') { + is_temporary_table = true; + } + + var template_row = jQuery(table_element).find('tr.resource-permission-list-template')[0]; + if (!template_row) { + //Something is wrong with the HTML + return; + } + var temp_perms_row = false; + + if (jQuery(template_row).attr('data-temp-perms') === '1') { + temp_perms_row = true; + } + + if (!is_temporary_table) { + //Check if the user is already in the list: + var trs = jQuery(table_element).find('tr'); + for (var tr of trs) { + if (jQuery(tr).data('user_id') === user_id) { + //We have found a table entry for the user specified by + //user_id. Nothing to do here. + return; + } + } + } + var insert_function = function(user_id = null, username = null) { + var new_row = jQuery(template_row).clone(true); + jQuery(new_row).removeClass('invisible'); + jQuery(new_row).removeClass('resource-permission-list-template'); + + jQuery(new_row).attr('data-user_id', user_id); + + var row_tds = jQuery(new_row).children('td'); + + //Set the name-TD's content: + var user_td_index = 1; + jQuery(row_tds[user_td_index]).children('input').removeAttr('disabled'); + + if (username) { + jQuery(row_tds[user_td_index]).append(username); + } else { + jQuery(row_tds[user_td_index]).append('ID ' + user_id); + } + var user_id_input = jQuery(row_tds[user_td_index]).children('input')[0]; + if (!user_id_input) { + return; + } + jQuery(user_id_input).val(user_id); + + var perm_select = jQuery(row_tds[user_td_index + 1]).children()[0]; + + if (temp_perms_row) { + //Set the time input fields to useful values: + + var begin = new Date(); + begin.setHours(begin.getHours() + 1); + + var begin_month = (begin.getMonth() + 1).toString(); + if (begin_month.length === 1) { + begin_month = '0' + begin_month; + } + var begin_date = begin.getDate() + + '.' + + begin_month + + '.' + + begin.getFullYear(); + + var begin_time = begin.getHours() + ':00'; + if (begin.getHours() < 10) { + begin_time = '0' + begin_time; + } + + var end = new Date(); + end.setDate(end.getDate() + 14); + var end_month = (end.getMonth() + 1).toString(); + if (end_month.length === 1) { + end_month = '0' + end_month; + } + + var end_date = end.getDate() + + '.' + + end_month + + '.' + + end.getFullYear(); + + var end_time = end.getHours() + ':00'; + if (end.getHours() < 10) { + end_time = '0' + end_time; + } + + var begin_td_inputs = jQuery(row_tds[user_td_index + 2]).children(); + + jQuery(begin_td_inputs[0]).addClass('has-date-picker'); + jQuery(begin_td_inputs[1]).addClass('has-time-picker'); + jQuery(begin_td_inputs[1]).timepicker({timeFormat: 'HH:mm'}); + jQuery(begin_td_inputs[0]).val(begin_date); + jQuery(begin_td_inputs[1]).val(begin_time); + + var end_td_inputs = jQuery(row_tds[user_td_index + 3]).children(); + jQuery(end_td_inputs[0]).addClass('has-date-picker'); + jQuery(end_td_inputs[1]).addClass('has-time-picker'); + jQuery(end_td_inputs[1]).timepicker({timeFormat: 'HH:mm'}); + jQuery(end_td_inputs[0]).val(end_date); + jQuery(end_td_inputs[1]).val(end_time); + + } + + var last_tr = jQuery(table_element).find('tr:last')[0]; + if (!last_tr) { + //Something is wrong with the HTML. + return; + } + jQuery(last_tr).parent().append(new_row); + + //Make the empty permission list message box + //invisible if it is still visible: + jQuery('#ResourceEmptyPermissionListMessage').addClass('invisible'); + + //Trigger a table update so that the tablesorter will re-sort + //the table: + jQuery(table_element).trigger('update'); + }; + + STUDIP.api.GET( + `user/${user_id}` + ).done(function(data) { + var username = data.name.family + + ', ' + + data.name.given; + if (data.name.prefix) { + username += ', ' + data.name.prefix; + } + if (data.name.suffix) { + username += ' ' + data.name.suffix; + } + username += ' (' + data.name.username +')' + + ' (' + data.perms + ')'; + insert_function(user_id, username); + }).fail(function() { + insert_function(user_id); + }); + } + + + static addCourseUsersToPermissionList(course_id, table_element) + { + if (!course_id || !table_element) { + return; + } + + STUDIP.api.GET( + `course/${course_id}/members`, + { + //The limit '0' results in a division by zero. + //Hopefully, the limit is set to a value high enough: + limit: 1000000 + } + ).done(function(data) { + for (var attribute in data.collection) { + var user_id = data.collection[attribute].member.id; + STUDIP.Resources.addUserToPermissionList( + user_id, + table_element + ); + } + }); + } + + + static removeUserFromPermissionList(html_node) + { + if (!html_node) { + return; + } + + var row = jQuery(html_node).parent().parent(); + var tbody = jQuery(row).parent(); + + STUDIP.Dialog.confirm( + $gettext('Soll die ausgewählte Berechtigung wirklich entfernt werden?') + ).done(function () { + jQuery(row).remove(); + if (jQuery(tbody).children().length < 3) { + //No special permissions available: show the empty permission list + //message box: + jQuery('#ResourceEmptyPermissionListMessage').removeClass('invisible'); + } + }); + } + + + //Room search related methods: + + + static addSearchCriteriaToRoomSearchWidget(select_node) + { + if (!select_node) { + return; + } + + var selected_option = jQuery(select_node).find(":selected")[0]; + if (!selected_option) { + return; + } + + var option_value = jQuery(selected_option).val(); + if (!option_value) { + //The first option which is left blank intentionally + //has been selected. + return; + } + var option_title = jQuery(selected_option).attr('data-title'); + var option_type = jQuery(selected_option).attr('data-type'); + var option_select_options = jQuery(selected_option).attr('data-select_options').split(';;'); + var option_range_search = jQuery(selected_option).attr('data-range-search'); + + var template = undefined; + if (option_type === 'bool') { + template = jQuery(select_node).parent().parent().find( + '.criteria-list .template[data-template-type="' + + option_type + + '"]' + )[0]; + } else if (option_type === 'select') { + template = jQuery(select_node).parent().parent().find( + '.criteria-list .template[data-template-type="select"]' + )[0]; + } else if (option_type === 'date') { + if (option_range_search) { + template = jQuery(select_node).parent().parent().find( + '.criteria-list .template[data-template-type="date_range"]' + )[0]; + } else { + template = jQuery(select_node).parent().parent().find( + '.criteria-list .template[data-template-type="date"]' + )[0]; + } + } else if (option_type === 'num') { + if (option_range_search) { + template = jQuery(select_node).parent().parent().find( + '.criteria-list .template[data-template-type="range"]' + )[0]; + } else { + template = jQuery(select_node).parent().parent().find( + '.criteria-list .template[data-template-type="num"]' + )[0]; + } + } else { + template = jQuery(select_node).parent().parent().find( + '.criteria-list .template[data-template-type="other"]' + )[0]; + } + + if (!template) { + return; + } + + var criteria_list = jQuery(template).parent(); + + var new_criteria = jQuery(template).clone(); + jQuery(new_criteria).attr('class', 'item'); + jQuery(new_criteria).attr('data-criteria', option_value); + + var new_criteria_text_field = jQuery(new_criteria).find('span')[0]; + jQuery(new_criteria_text_field).text(option_title); + + if (option_type === 'bool') { + var new_criteria_input = jQuery(new_criteria).find('input'); + jQuery(new_criteria_input).attr('name', option_value); + } else if (option_type === 'select') { + var new_criteria_select = jQuery(new_criteria).find('select')[0]; + jQuery(new_criteria_select).attr('name', option_value); + //Build the option elements from the data-options field: + if (!option_select_options) { + //Something is wrong. + return; + } + var options_html = ''; + for (option of option_select_options) { + var splitted_option = option.split('~~'); + options_html += '<option value="' + splitted_option[0] + '">' + + splitted_option[1] + + '</option>'; + } + jQuery(new_criteria_select).html(options_html); + } else if (option_type === 'date') { + var time_inputs = jQuery(new_criteria).find('input[data-time="yes"]'); + var date_inputs = jQuery(new_criteria).find('input[type="date"]'); + + if (time_inputs.length < 2) { + //Something is wrong with the HTML. + return; + } + var now = new Date(); + + jQuery(time_inputs[0]).attr('name', option_value + '_begin_time'); + jQuery(time_inputs[1]).attr('name', option_value + '_end_time'); + jQuery(time_inputs[0]).val( + now.getHours() + ':00' + ); + jQuery(time_inputs[1]).val( + (now.getHours() + 2) + ':00' + ); + + if (option_range_search) { + //We must fill two date fields. + if (date_inputs.length < 2) { + //Something is wrong with the HTML. + return; + } + + jQuery(date_inputs[0]).attr('name', option_value + '_begin_date'); + jQuery(date_inputs[1]).attr('name', option_value + '_end_date'); + jQuery(date_inputs[0]).val( + now.getFullYear() + '-' + + (now.getMonth() + 1) + '-' + + (now.getDate() + 1) + ); + jQuery(date_inputs[1]).val( + now.getFullYear() + '-' + + (now.getMonth() + 1) + '-' + + (now.getDate() + 2) + ); + } else { + //One date field, two time fields. + if (date_inputs.length < 1) { + //Something is wrong with the HTML. + return; + } + jQuery(date_inputs[0]).attr('name', option_value + '_date'); + jQuery(date_inputs[0]).val( + now.getFullYear() + '-' + + (now.getMonth() + 1) + '-' + + (now.getDate() + 1) + ); + } + + } else { + if (option_type === 'num' && option_range_search) { + var new_criteria_inputs = jQuery(new_criteria).find('input'); + jQuery(new_criteria_inputs[0]).attr('name', option_value); + var min_input = new_criteria_inputs[1]; + var max_input = new_criteria_inputs[2]; + jQuery(min_input).attr('name', option_value + '_min'); + jQuery(min_input).attr('type', 'number'); + jQuery(max_input).attr('name', option_value + '_max'); + jQuery(max_input).attr('type', 'number'); + jQuery(min_input).val(Math.round(parseInt(min_input) * 1.25)); + jQuery(max_input).val(Math.round(parseInt(max_input) * 0.75)); + } else { + var new_criteria_input = jQuery(new_criteria).find('input')[0]; + jQuery(new_criteria_input).attr('name', option_value); + if (option_type === 'num') { + jQuery(new_criteria_input).attr('type', 'number'); + } else { + jQuery(new_criteria_input).attr('type', 'text'); + } + } + } + + jQuery(criteria_list).append(new_criteria); + + //hide the criteria in the select list: + jQuery(selected_option).addClass('invisible'); + //set the select field to the first option: + jQuery(select_node).val(''); + } + + + static removeSearchCriteriaFromRoomSearchWidget(icon_node) + { + if (!icon_node) { + return; + } + + var input = jQuery(icon_node).parent().find('input'); + var criteria_name = jQuery(input).attr('name'); + + var form = jQuery(icon_node).parents('form')[0]; + + if (!form) { + return; + } + + var select_element = jQuery(form).find('select.criteria-selector'); + + jQuery(icon_node).parent().remove(); + + //enable the option in the select field: + var disabled_option = jQuery(select_element).find( + 'option[value="' + criteria_name + '"]' + )[0]; + + jQuery(disabled_option).removeClass('invisible'); + + //Trigger change event: + jQuery(form).find('.room-search-widget_criteria-list_input').trigger('change'); + } + + + static submitRoomSearchWidgetForm(input_node) + { + if (!input_node) { + return; + } + + //find the form element: + var form = jQuery(input_node).parents('form')[0]; + if (!form) { + return; + } + + jQuery(form).submit(); + } + + + //Resource request related methods: + + + static addPropertyToRequest(event) + { + var select = jQuery(event.target).siblings('select.requestable-properties-select')[0]; + if (!select) { + return; + } + + var table = jQuery(event.target).parents('.resource-request-properties-table')[0]; + if (!table) { + return; + } + var tbody = jQuery(table).find('tbody')[0]; + if (!tbody) { + } + + var selected_option = jQuery(select).find(':selected')[0]; + if (!selected_option) { + return; + } + + var property_id = jQuery(selected_option).val(); + var option_html = jQuery(selected_option).data('input-html'); + if (!property_id || !option_html) { + return; + } + + var template = jQuery(tbody).find('tr.template')[0]; + if (!template) { + return; + } + + var new_row = jQuery(template).clone(); + if (!new_row) { + return; + } + + jQuery(new_row).removeClass('template'); + jQuery(new_row).removeClass('invisible'); + jQuery(new_row).attr('data-property_id', property_id); + var row_cells = jQuery(new_row).find('td'); + jQuery(row_cells[0]).text(jQuery(selected_option).text()); + jQuery(row_cells[1]).html(option_html); + + jQuery(tbody).append(new_row); + jQuery(tbody).find('.empty-table-message').addClass('invisible'); + jQuery(selected_option).attr('disabled', 'disabled'); + jQuery(selected_option).removeAttr('selected'); + jQuery(select).val([]); + } + + + //ResourceBookingInterval methods: + + + static toggleBookingIntervalStatus(event) + { + var li = jQuery(event.target).parents('tr')[0]; + if (!li) { + //Something is wrong with the HTML. + return; + } + var interval_id = jQuery(li).data('interval_id'); + if (!interval_id) { + return; + } + + STUDIP.api.POST( + `resources/booking_interval/${interval_id}/toggle_takes_place` + ).done(function(data) { + if (data['takes_place'] === undefined) { + //Something went wrong: do nothing. + return; + } + + if (data['takes_place'] === '1') { + //Switch on the icons and text for the "takes place" + //status and switch off the other ones: + jQuery(li).find('.takes-place-revive').addClass('invisible'); + jQuery(li).find('.takes-place-delete').removeClass('invisible'); + jQuery(li).find('.booking-list-interval-date').removeClass('not-taking-place'); + } else { + //Do the opposite of the if-block above: + jQuery(li).find('.takes-place-delete').addClass('invisible'); + jQuery(li).find('.takes-place-revive').removeClass('invisible'); + jQuery(li).find('.booking-list-interval-date').addClass('not-taking-place'); + } + }); + } + + + //Methods for the resource category form: + + + static addResourcePropertyToTable(event) + { + var select = jQuery(event.target).siblings('select')[0]; + if (!select) { + //Something is wrong with the HTML + return; + } + + var selected_property_id = jQuery(select).val(); + var selected_property = jQuery(select).children( + 'option:selected' + )[0]; + if (!selected_property) { + return; + } + var selected_property_name = jQuery(selected_property).text(); + + if (!selected_property_id || !selected_property_name) { + //Invalid option + return; + } + + var table = jQuery(event.target).parents( + 'table' + )[0]; + if (!table) { + return; + } + + var template = jQuery(table).find('tr.template')[0]; + if (!template) { + return; + } + + var new_row = jQuery(template).clone(); + if (!new_row) { + return; + } + + var columns = jQuery(new_row).find('td'); + var text_field = jQuery(new_row).find('.name'); + jQuery(text_field).text(selected_property_name); + var set_input = jQuery(new_row).find('.property-input'); + jQuery(set_input).attr( + 'name', + 'prop[' + selected_property_id + ']' + ); + var value_input = jQuery(new_row).find('.value-input'); + jQuery(value_input).attr( + 'name', + 'prop_value[' + selected_property_id + ']' + ); + var requestable_input = jQuery(new_row).find('.requestable-input'); + jQuery(requestable_input).attr( + 'name', + 'prop_requestable[' + selected_property_id + ']' + ); + var protected_input = jQuery(new_row).find('.protected-input'); + jQuery(protected_input).attr( + 'name', + 'prop_protected[' + selected_property_id + ']' + ); + + var tbody = jQuery(table).find('tbody')[0]; + if (!tbody) { + return; + } + + jQuery(new_row).removeClass('invisible'); + jQuery(new_row).removeClass('template'); + jQuery(new_row).data('property_id', selected_property_id); + jQuery(tbody).append(new_row); + jQuery(selected_property).attr('disabled', 'disabled'); + jQuery(selected_property).removeAttr('selected'); + jQuery(select).val([]); + } + + + //Methods for opening or closing of ressource tree elements: + + + static toggleTreeNode(treenode) + { + var arr = treenode.children("img"); + if (arr.hasClass('rotated')) { + arr.attr('style', 'transform: rotate(0deg)'); + } else { + arr.attr('style', 'transform: rotate(90deg)'); + } + arr.toggleClass('rotated') ; + treenode.children(".resource-tree").children("li").toggle(); + } + + + static moveTimeOptions(bookingtype_val) + { + if(bookingtype_val === 'single') { + $(".time-option-container").hide(); + $(".block-booking-item").hide(); + $(".repetition-booking-item").hide(); + $("#BookingStartDateInput").show(); + $(".semester-selector").parent().hide(); + $(".manual-time-option").prop('checked', true).trigger('change'); + } else { + var time_options = $(".time-option-container"); + $(".time-option-container").detach(); + if(bookingtype_val === 'block') { + $("#BlockBookingFieldset").prepend(time_options); + + $("#BlockEndLabel").show(); + $("#RepetitionEndLabel").hide(); + + $(".block-booking-item").show(); + $(".repetition-booking-item").hide(); + } else { + $("#RepetitionBookingFieldset").prepend(time_options); + + $("#RepetitionEndLabel").show(); + $("#BlockEndLabel").hide(); + + $(".repetition-booking-item").show(); + $(".block-booking-item").hide(); + } + $(".time-option-container").show(); + } + }; + + + //Fullcalendar specialisations: + + + static updateEventUrlsInCalendar(calendar_event) + { + if (!calendar_event) { + return; + } + + STUDIP.api.GET( + `resources/booking/${calendar_event.extendedProps.studip_parent_object_id}/intervals`, + { + data: { + begin: STUDIP.Fullcalendar.toRFC3339String(calendar_event.start), + end: STUDIP.Fullcalendar.toRFC3339String(calendar_event.end) + } + } + ).done(function (data) { + if (!data || (data.length == 0)) { + return; + } + var new_interval_id = data[0].interval_id; + calendar_event.setExtendedProp('studip_object_id', new_interval_id); + if (new_interval_id) { + var move_url = calendar_event.extendedProps.studip_api_urls['move']; + var resize_url = calendar_event.extendedProps.studip_api_urls['resize']; + move_url = move_url.replace( + /\&interval_id=([0-9a-f]{32})/, + '&interval_id=' + new_interval_id + ); + resize_url = resize_url.replace( + /\&interval_id=([0-9a-f]{32})/, + '&interval_id=' + new_interval_id + ); + var studip_api_urls = calendar_event.extendedProps.studip_api_urls; + studip_api_urls['move'] = move_url; + studip_api_urls['resize'] = resize_url; + calendar_event.setExtendedProp('studip_api_urls', studip_api_urls); + } + }); + } + + + static resizeEventInRoomGroupBookingPlan(info) + { + STUDIP.Fullcalendar.defaultResizeEventHandler(info); + STUDIP.Resources.updateEventUrlsInCalendar(info.event); + } + + static dropEventInRoomGroupBookingPlan(info) + { + STUDIP.Fullcalendar.defaultDropEventHandler(info); + STUDIP.Resources.updateEventUrlsInCalendar(info.event); + } + + + static updateBookingPlanSemesterByView(activeRange, api_url = 'api.php/semesters') { + var semester = null; + jQuery.ajax( + STUDIP.URLHelper.getURL(api_url), + { + method: 'get', + dataType: 'json', + success: function(data) { + if (data) { + var start = activeRange.start; + var end = activeRange.end; + Object.values(data.collection).forEach(item => { + if (start.getTime()/1000 >= item.seminars_begin && start.getTime()/1000 < item.seminars_end) { + semester = item; + } + }); + if (semester) { + $(".booking-plan-header") + .data('semester-begin', semester.seminars_begin) + .data('semester-end', semester.seminars_end); + $("#booking-plan-header-semrow").show(); + $("#booking-plan-header-semname").text(semester.title); + var sem_week = Math.floor((end.getTime()/1000 - 10800 - semester.seminars_begin) / 604800)+1; + $("#booking-plan-header-semweek-part").text("Vorlesungswoche".toLocaleString()); + $("#booking-plan-header-semweek").text(sem_week); + } else { + if (data.pagination && data.pagination.links && data.pagination.links.next != api_url) { + semester = STUDIP.Resources.updateBookingPlanSemesterByView(activeRange, data.pagination.links.next); + } else { + $(".booking-plan-header") + .data('semester-begin', '') + .data('semester-end', ''); + } + } + + $('#booking-plan-header-calweek').text(start.getWeekNumber()); + $('#booking-plan-header-calbegin').text(start.toLocaleDateString('de-DE', {weekday: 'short'}) + ' ' + start.toLocaleDateString('de-DE')); + $('#booking-plan-header-calend').text(end.toLocaleDateString('de-DE', {weekday: 'short'}) + ' ' + end.toLocaleDateString('de-DE')); + } + } + } + ); + } + + + static toggleRequestMarked(source_node) + { + if (!source_node) { + return; + } + + var request_id = jQuery(source_node).data('request_id'); + if (!request_id) { + return; + } + + STUDIP.api.POST( + `resources/request/${request_id}/toggle_marked` + ).done(function(data) { + jQuery(source_node).attr('data-marked', data.marked); + jQuery(source_node).parent().attr('data-sort-value', data.marked); + jQuery(source_node).parents('table.request-list').trigger('update'); + }); + } + + static bookAllCalendarRequests() + { + var calendarSektion = $('*[data-resources-fullcalendar="1"]')[0]; + if (calendarSektion) { + var calendar = calendarSektion.calendar; + if (calendar) { + if (!$('#loading-spinner').length) { + jQuery('#layout_content').append( + $('<div id="loading-spinner" style="position: absolute; top: calc(50% - 55px); left: calc(50% + 135px); z-index: 9001;">').html( + $('<img>').attr('src', STUDIP.ASSETS_URL + 'images/ajax-indicator-black.svg') + .css({ + width: 64, + height: 64 + }) + ) + ); + } + $('.fc-request-event').each(function(){ + var objectData = $(this).data(); + var existingRequestEvent = calendar.getEventById(objectData.eventId); + if (existingRequestEvent) { + var bookingURL = 'dispatch.php/resources/room_request/quickbook/' + + objectData.eventRequest +'/' + + objectData.eventResource +'/' + + objectData.eventMetadate; + jQuery.ajax( + STUDIP.URLHelper.getURL(bookingURL), + { + method: 'get', + dataType: 'json', + async: false, + success: function(data) { + if (data) { + } + } + } + ); + } + }); + document.location.reload(true); + } + } + } + +}; + + +//Class properties: + + +Resources.definedResourceClasses = [ + 'Resource', 'Room', 'Building', 'Location' +]; + + +class Messages +{ + static selectRoom(room_id, room_name) + { + if (!room_id) { + return; + } + + var selection_area = jQuery('.resources_messages-form .selection-area')[0]; + if (!selection_area) { + return; + } + + var template = jQuery(selection_area).find('.template')[0]; + if (!template) { + return; + } + + var new_room = jQuery(template).clone(); + jQuery(new_room).removeClass('template'); + jQuery(new_room).removeClass('invisible'); + jQuery(new_room).find('span').text(room_name); + jQuery(new_room).find('input[type="hidden"]').val(room_id); + jQuery(selection_area).append(new_room); + } +} +Resources.Messages = Messages; + + +class BookingPlan +{ + static insertEntry(new_entry, date, begin_hour, end_hour) + { + //Get the resource-ID from the current URL: + var results = window.location.href.match( + /dispatch.php\/resources\/resource\/booking_plan\/([a-z0-9]{1,32})/ + ); + if (results.length === 0) { + //No resource-ID found. + jQuery(new_entry).remove(); + return; + } + var resource_id = results[1]; + + //Now we re-format the time from begin_hour and end_hour. + //In case the data-dragged attribute is set for the + //calendar entry we just add two hours to the start time + //to get the end time. + + var dragged = jQuery(new_entry).data('dragged'); + if (dragged) { + end_hour = begin_hour + 2; + } + begin_hour += ':00'; + if (end_hour > 23) { + end_hour = '23:59'; + } else { + end_hour += ':00'; + } + + var result = STUDIP.Dialog.fromURL( + STUDIP.URLHelper.getURL( + 'dispatch.php/resources/booking/add/' + resource_id, + { + 'begin_date': date, + 'begin_time': begin_hour, + 'end_date': date, + 'end_time': end_hour + } + ), {size: 'auto'} + ); + } +} +Resources.BookingPlan = BookingPlan; + + +export default Resources; diff --git a/resources/assets/javascripts/lib/responsive.js b/resources/assets/javascripts/lib/responsive.js new file mode 100644 index 0000000..09712dd --- /dev/null +++ b/resources/assets/javascripts/lib/responsive.js @@ -0,0 +1,156 @@ +/*jslint esversion: 6*/ + +import HeaderMagic from './header_magic.js'; +import Sidebar from './sidebar.js'; + +const Responsive = { + media_query: window.matchMedia('(max-width: 767px)'), + + // Builds a dom element from a navigation object + buildMenu (navigation, id, activated) { + var list = $('<ul>'); + + if (id) { + list.attr('id', id); + } + + // TODO: Templating? + _.forEach(navigation, (nav, node) => { + nav.url = STUDIP.URLHelper.getURL(nav.url, {}, true); + let li = $('<li class="navigation-item">'); + let title = $('<div class="nav-title">').appendTo(li); + let link = $(`<a href="${nav.url}">`).text(nav.title).appendTo(title); + + if (nav.icon) { + if (!nav.icon.match(/^https?:\/\//)) { + nav.icon = STUDIP.ASSETS_URL + nav.icon; + } + $(link).prepend(`<img class="icon" src="${nav.icon}">`); + } + + if (nav.children) { + let active = activated.indexOf(node) !== -1; + $(`<input type="checkbox" id="resp/${node}">`) + .prop('checked', active) + .appendTo(li); + li.append( + `<label class="nav-label" for="resp/${node}"> </label>`, + Responsive.buildMenu(nav.children, false, activated) + ); + } + + list.append(li); + }); + + return list; + }, + + // Adds the responsive menu to the dom + addMenu () { + let wrapper = $('<div id="responsive-container">').append( + '<label for="responsive-toggle">', + '<input type="checkbox" id="responsive-toggle">', + Responsive.buildMenu( + STUDIP.Navigation.navigation, + 'responsive-navigation', + STUDIP.Navigation.activated + ), + '<label for="responsive-toggle">' + ); + + $('<li>', { html: wrapper }).prependTo('#barBottomright > ul'); + }, + + // Responsifies the layout. Builds the responsive menu from existing + // STUDIP.Navigation object + responsify () { + Responsive.media_query.removeListener(Responsive.responsify); + + $('html').addClass('responsified'); + + Responsive.addMenu(); + + if ($('#layout-sidebar > section').length > 0) { + $('<li id="sidebar-menu">') + .on('click', () => Sidebar.open()) + .appendTo('#barBottomright > ul'); + + $('<label id="sidebar-shadow-toggle">') + .on('click', () => Sidebar.close()) + .prependTo('#layout-sidebar'); + + $('#responsive-toggle').on('change', function() { + $('#layout-sidebar').removeClass('visible-sidebar'); + $('#responsive-navigation').toggleClass('visible', this.checked); + }); + } else { + $('#responsive-toggle').on('change', function() { + $('#responsive-navigation').toggleClass('visible', this.checked); + }); + } + + $('#responsive-navigation :checkbox').on('change', function () { + let li = $(this).closest('li'); + if ($(this).is(':checked')) { + li.siblings().find(':checkbox:checked').prop('checked', false); + } + + // Force redraw of submenu (at least ios safari/chrome would + // not show it without a forced redraw) + $(this).siblings('ul').hide(0, function () { + $(this).show(); + }); + }).reverse().trigger('change'); + + var sidebar_avatar_menu = $('<div class="sidebar-widget sidebar-avatar-menu">'); + var avatar_menu = $('#header_avatar_menu'); + var title = $('.action-menu-title', avatar_menu).text(); + var list = $('<ul class="widget-list widget-links">'); + $('<div class="sidebar-widget-header">').text(title).appendTo(sidebar_avatar_menu); + + $('.action-menu-item', avatar_menu).each(function() { + var src = $('img', this).attr('src'); + var link = $('a', this).clone(); + + link.find('img').remove(); + + $('<li>').append(link).css({ + backgroundSize: '16px', + backgroundImage: `url(${src})` + }).appendTo(list); + }); + + $('<div class="sidebar-widget-content">') + .append(list) + .appendTo(sidebar_avatar_menu); + + $('#layout-sidebar > .sidebar').prepend(sidebar_avatar_menu); + }, + + setResponsiveDisplay (state = true) { + $('html').toggleClass('responsive-display', state); + + if (state) { + Sidebar.disableSticky(); + HeaderMagic.disable(); + } else { + Sidebar.enableSticky(); + HeaderMagic.enable(); + } + }, + + engage () { + if (Responsive.media_query.matches) { + Responsive.responsify(); + Responsive.setResponsiveDisplay(); + } else { + Responsive.media_query.addListener(Responsive.responsify); + } + + Responsive.media_query.addListener(() => { + Responsive.setResponsiveDisplay(Responsive.media_query.matches); + }); + } +}; + +export default Responsive; diff --git a/resources/assets/javascripts/lib/restapi.js b/resources/assets/javascripts/lib/restapi.js new file mode 100644 index 0000000..b6e31df --- /dev/null +++ b/resources/assets/javascripts/lib/restapi.js @@ -0,0 +1,12 @@ +import AbstractAPI from './abstract-api.js'; + +// Actual RESTAPI object +class RESTAPI extends AbstractAPI +{ + constructor() { + super('api.php'); + } +} + +export default RESTAPI; +export const api = new RESTAPI(); diff --git a/resources/assets/javascripts/lib/schedule.js b/resources/assets/javascripts/lib/schedule.js new file mode 100644 index 0000000..b7c9d37 --- /dev/null +++ b/resources/assets/javascripts/lib/schedule.js @@ -0,0 +1,254 @@ +import { $gettext } from './gettext.js'; +import Calendar from './calendar.js'; +import Dialog from './dialog.js'; + +const Schedule = { + inst_changed: false, + + /** + * this function is called, when an entry shall be created in the calendar + * + * @param object the empty entry in the calendar + * @param int the day that has been clicked + * @param int the start-hour that has been clicked + * @param int the end-hour that has been chosen + */ + newEntry: function(entry, day, start_hour, end_hour) { + /* + // do not allow creation of new entry, if one of the following popups is visible! + if (jQuery('#edit_sem_entry').is(':visible') || + jQuery('#edit_entry').is(':visible') || + jQuery('#edit_inst_entry').is(':visible')) { + jQuery(entry).remove(); + return; + } + */ + + // if there is already an entry set, kick him first before showing a new one + if (this.entry) { + jQuery(this.entry).fadeOut('fast'); + jQuery(this.entry).remove(); + } + + this.entry = entry; + + if (!Schedule.new_entry_template) { + jQuery.get(STUDIP.URLHelper.getURL('dispatch.php/calendar/schedule/entry'), function(data) { + Schedule.new_entry_template = data; + Schedule.showEntryDialog(Schedule.new_entry_template, day, start_hour, end_hour); + }); + } else { + Schedule.showEntryDialog(Schedule.new_entry_template, day, start_hour, end_hour); + } + }, + + /** + * this function is called, when an entry shall be created in the calendar + * and the template-data has been loaded + * + * @param string the html for the new-entry dialog + * @param int the day that has been clicked + * @param int the start-hour that has been clicked + * @param int the end-hour that has been chosen + */ + showEntryDialog: function(template, day, start_hour, end_hour) { + // do not open dialog, if no new-entry-marker is present + if ($('#schedule_entry_new').length === 0) return; + + Dialog.show(template, { + title: $gettext('Neuen Termin eintragen'), + origin: this + }); + + $(this).on('dialog-close', function() { + $('#schedule_entry_new').remove(); + }); + + // fill values of overlay + jQuery('input[name=entry_start]').val(start_hour + ':00'); + jQuery('input[name=entry_end]').val(end_hour + ':00'); + jQuery('select[name=entry_day]').val(parseInt(day) + 1); + }, + + /** + * this function morphs from the quick-add box for adding a new entry to the schedule + * to the larger box with more details to edit + * + * @return: void + */ + showDetails: function() { + // set the values for detailed view + jQuery('select[name=entry_day]').val(Number(jQuery('#new_entry_day').val()) + 1); + jQuery('input[name=entry_start_hour]').val(parseInt(jQuery('#new_entry_start_hour').val(), 10)); + jQuery('input[name=entry_start_minute]').val('00'); + jQuery('input[name=entry_end_hour]').val(parseInt(jQuery('#new_entry_end_hour').val(), 10)); + jQuery('input[name=entry_end_minute]').val('00'); + + jQuery('input[name=entry_title]').val(jQuery('#entry_title').val()); + jQuery('textarea[name=entry_content]').val(jQuery('#entry_content').val()); + + jQuery('#edit_entry_drag').html(jQuery('#new_entry_drag').html()); + + // morph to the detailed view + jQuery('#schedule_new_entry').animate( + { + left: Math.floor(jQuery(window).width() / 4), // for safari + width: '50%', + top: '180px' + }, + 500, + function() { + jQuery('#edit_entry').fadeIn(400, function() { + // reset the box + jQuery('#schedule_new_entry').css({ + display: 'none', + left: 0, + width: '400px', + top: 0, + height: '230px', + 'margin-left': 0 + }); + }); + } + ); + }, + + /** + * show a popup conatining the details of the passed seminar + * at the passed cycle + * + * @param string the seminar to be shown + * @param string the cycle-id of the regular time-entry to be shown + * (a seminar can have multiple of these + */ + showSeminarDetails: function(seminar_id, cycle_id) { + jQuery.get( + STUDIP.URLHelper.getURL('dispatch.php/calendar/schedule/entryajax/' + seminar_id + '/' + cycle_id), + function(data) { + Dialog.show(data, { + title: $gettext('Veranstaltungsdetails') + }); + } + ); + + Calendar.click_in_progress = false; + }, + + /** + * show a popup with the details of a regular schedule entry with passed id + * + * @param string the id of the schedule-entry + */ + showScheduleDetails: function(id) { + jQuery.get(STUDIP.URLHelper.getURL('dispatch.php/calendar/schedule/entry/' + id), function(data) { + Dialog.show(data, { + title: $gettext('Termindetails bearbeiten') + }); + }); + + Calendar.click_in_progress = false; + }, + + /** + * show a popup with the details of a group entry, containing several seminars + * + * @param string the id of the grouped entry to be displayed + */ + showInstituteDetails: function(id) { + jQuery.get(STUDIP.URLHelper.getURL('dispatch.php/calendar/schedule/groupedentry/' + id + '/true'), function( + data + ) { + Dialog.show(data, { + title: $gettext('Veranstaltungsdetails') + }); + }); + + Calendar.click_in_progress = false; + }, + + /** + * hide a seminar-entry in the schedule (admin-version) + * + * @param string the seminar to be shown + * @param string the cycle-id of the regular time-entry to be shown + * (a seminar can have multiple of these + */ + instSemUnbind: function(seminar_id, cycle_id) { + Schedule.inst_changed = true; + jQuery.ajax({ + type: 'GET', + url: STUDIP.URLHelper.getURL( + 'dispatch.php/calendar/schedule/adminbind/' + seminar_id + '/' + cycle_id + '/0/true' + ) + }); + + jQuery('#' + seminar_id + '_' + cycle_id + '_hide').fadeOut('fast', function() { + jQuery('#' + seminar_id + '_' + cycle_id + '_show').fadeIn('fast'); + }); + }, + + /** + * make a hidden seminar-entry visible in the schedule again + * + * @param string the seminar to be shown + * @param string the cycle-id of the regular time-entry to be shown + * (a seminar can have multiple of these + */ + instSemBind: function(seminar_id, cycle_id) { + Schedule.inst_changed = true; + jQuery.ajax({ + type: 'GET', + url: STUDIP.URLHelper.getURL( + 'dispatch.php/calendar/schedule/adminbind/' + seminar_id + '/' + cycle_id + '/1/true' + ) + }); + + jQuery('#' + seminar_id + '_' + cycle_id + '_show').fadeOut('fast', function() { + jQuery('#' + seminar_id + '_' + cycle_id + '_hide').fadeIn('fast'); + }); + }, + + /** + * hide the popup of grouped-entry, containing a list of seminars. + * returns true if the visiblity of one of the entries has been changed, + * false otherwise + * + * @param object the element to be hidden + * + * @return bool true if the visibility of one seminar hase changed, false otherwise + */ + hideInstOverlay: function(element) { + if (Schedule.inst_changed) { + return true; + } + jQuery(element).fadeOut('fast'); + + Calendar.click_in_progress = false; + + return false; + }, + + /** + * calls Calendar.checkTimeslot to check that the time is valid + * + * @param bool returns true if the time is valid, false otherwise + */ + checkFormFields: function() { + if ( + !Calendar.checkTimeslot( + jQuery('#schedule_entry_hours > input[name=entry_start_hour]'), + jQuery('#schedule_entry_hours > input[name=entry_start_minute]'), + jQuery('#schedule_entry_hours > input[name=entry_end_hour]'), + jQuery('#schedule_entry_hours > input[name=entry_end_minute]') + ) + ) { + jQuery('#schedule_entry_hours').addClass('invalid'); + jQuery('#schedule_entry_hours > span[class=invalid_message]').show(); + return false; + } + + return true; + } +}; + +export default Schedule; diff --git a/resources/assets/javascripts/lib/scroll.js b/resources/assets/javascripts/lib/scroll.js new file mode 100644 index 0000000..a4d24d5 --- /dev/null +++ b/resources/assets/javascripts/lib/scroll.js @@ -0,0 +1,59 @@ +/** + * Provides means to hook into the scroll event. Registered callbacks are + * called with the current scroll top and scroll left position so both + * vertical and horizontal scroll events can be handled. + * + * Updates/calls to the callback are synchronized to screen refresh by using + * the animation frame method (which will fallback to a timer based solution). + */ +var handlers = {}; +var animId = false; + +var lastTop = null; +var lastLeft = null; + +function scrollHandler() { + var scrollTop = $(document).scrollTop(); + var scrollLeft = $(document).scrollLeft(); + + if (scrollTop !== lastTop || scrollLeft !== lastLeft) { + $.each(handlers, function(index, handler) { + handler(scrollTop, scrollLeft); + }); + + lastTop = scrollTop; + lastLeft = scrollLeft; + } + + animId = false; + + engageScrollTrigger(); +} + +function refresh() { + var hasHandlers = !$.isEmptyObject(handlers); + if (!hasHandlers && animId !== false) { + window.cancelAnimationFrame(animId); + animId = false; + } else if (hasHandlers && animId === false) { + animId = window.requestAnimationFrame(scrollHandler); + } +} + +function engageScrollTrigger() { + $(window).off('scroll.studip-handler'); + $(window).one('scroll.studip-handler', refresh); +} + +const Scroll = { + addHandler(index, handler) { + handlers[index] = handler; + engageScrollTrigger(); + }, + removeHandler(index) { + delete handlers[index]; + engageScrollTrigger(); + } +}; + +export default Scroll; diff --git a/resources/assets/javascripts/lib/scroll_to_top.js b/resources/assets/javascripts/lib/scroll_to_top.js new file mode 100644 index 0000000..2a75402 --- /dev/null +++ b/resources/assets/javascripts/lib/scroll_to_top.js @@ -0,0 +1,38 @@ +import Scroll from './scroll.js'; + +let fold; +let was_below_the_fold = false; + +const back_to_top = function(scrolltop) { + var is_below_the_fold = scrolltop > fold; + if (is_below_the_fold !== was_below_the_fold) { + $('#scroll-to-top').toggleClass('hide', !is_below_the_fold); + was_below_the_fold = is_below_the_fold; + } +}; + +const ScrollToTop = { + enable() { + var minScrollHeight = Math.min( + document.body.scrollHeight, document.documentElement.scrollHeight, + document.body.offsetHeight, document.documentElement.offsetHeight, + document.body.clientHeight, document.documentElement.clientHeight + ); + fold = minScrollHeight - (minScrollHeight / 5); // top of fifth portion! + Scroll.addHandler('back-to-top', back_to_top); + }, + disable() { + Scroll.removeHandler('header'); + $('#scroll-to-top').addClass('hide'); + }, + moveBack() { + $('#scroll-to-top').on('click', function(e) { + $('html, body').stop().animate({ + scrollTop: (0) + }, 1000, 'easeInOutExpo'); + e.preventDefault(); + }); + } +}; + +export default ScrollToTop; diff --git a/resources/assets/javascripts/lib/search.js b/resources/assets/javascripts/lib/search.js new file mode 100644 index 0000000..38894f7 --- /dev/null +++ b/resources/assets/javascripts/lib/search.js @@ -0,0 +1,566 @@ +var cache = null; + +const Search = { + lastSearch: null, + lastSearchFilter: null, + resultsInCategory: false, + searchTermLength: 3, + + getCache: function () { + if (cache === null) { + let prefix = ''; + if ($('meta[name="studip-cache-prefix"]').length > 0) { + prefix = $('meta[name="studip-cache-prefix"]').attr('content'); + } + cache = STUDIP.Cache.getInstance(prefix); + } + return cache; + }, + + /** + * This function starts the actual search via AJAX call. + * + * @param {Object} filter object with filter information (e.g. 'category', 'semester', etc.) + * that is set by the filter selects in the sidebar. + */ + doSearch: function (filter) { + + var cache = STUDIP.Search.getCache(); + var searchterm = $('#search-input').val().trim() || cache.get('searchterm'); + var hasValue = searchterm && searchterm.length >= STUDIP.Search.searchTermLength; + var resultsDiv = $('#search-results'); + var wrapper = $('#search'); + const data = resultsDiv.data(); + const limit = 100; + + if (searchterm === '') { + return; + } + + if (!hasValue) { + $('#search-term-invalid .searchtermlen').text(STUDIP.Search.searchTermLength); + $('#search-term-invalid').show(); + } else { + $('#search-term-invalid').hide(); + } + + if (!hasValue || STUDIP.Search.lastSearch === searchterm + && JSON.stringify(STUDIP.Search.lastSearchFilter) === JSON.stringify(filter)) { + return; + } + + STUDIP.Search.resultsInCategory = false; + + $('#search-no-result').hide(); + $('#reset-search').show(); + + STUDIP.Search.resetSearchCategories(); + STUDIP.Search.greyOutSearchCategories(); + + cache.set('searchterm', searchterm); + STUDIP.Search.lastSearch = searchterm; + STUDIP.Search.lastSearchFilter = filter; + + // Display spinner symbol, user should always see something is happening. + wrapper.addClass('is-searching'); + + // Call AJAX endpoint and get search results. + $.getJSON(STUDIP.URLHelper.getURL('dispatch.php/globalsearch/find/' + limit), { + search: searchterm, + filters: JSON.stringify(filter) + }).done(function (json) { + // Trigger searched event (regardless of successful or not) + $(document).trigger('searched.studip', { + needle: searchterm, + category: STUDIP.Search.getActiveCategory() + }); + + resultsDiv.empty(); + + // No results found... + if (!$.isPlainObject(json) || $.isEmptyObject(json)) { + wrapper.removeClass('is-searching'); + $('#search-no-result .searchterm').text(searchterm); + $('#search-no-result').show(); + STUDIP.Search.setActiveCategory('show_all_categories'); + return; + } + + // Iterate over each result category. + $.each(json, function (name, value) { + var category = STUDIP.Search.printCategory(name, value, data); + resultsDiv.append(category); + }); + + if (STUDIP.Search.getActiveCategory() + && STUDIP.Search.getActiveCategory() !== 'show_all_categories') + { + STUDIP.Search.expandCategory(STUDIP.Search.getActiveCategory()); + if (!STUDIP.Search.resultsInCategory) { + $('#search-no-result .searchterm').text(searchterm); + $('#search-no-result').show(); + } + } + + wrapper.removeClass('is-searching'); + }).fail(function (xhr, status, error) { + if (error) { + window.alert(error); + } + }); + }, + + printCategory: function (name, value, data) { + // Create an <article> for category. + var allResultsText = data.allResults; + var category = $(`<article id="search-${name}" class="studip padding-less">`); + var header = $('<header>').appendTo(category); + var categoryBodyDiv = $(`<div id="${name}-body">`).appendTo(category); + var counter = 0; + var isActive = STUDIP.Search.getActiveCategory() === name; + + if (isActive) { + STUDIP.Search.resultsInCategory = true; + } + + // Create header name + $(`<h1 class="search-category" data-category="${name}">`) + .append(`<a href="#">${value.name}</a>`) + .appendTo(header); + + if (value.more) { + $(`<div id="show-all-categories-${name}" class="search-more-results">`) + .append(`<a href="#">${allResultsText}</a>`) + .toggle(isActive) + .appendTo(header); + } + + // Process results and create corresponding entries. + $.each(value.content, function (index, result) { + STUDIP.Search.printSingleResult(name, data, result, counter, value.fullsearch, categoryBodyDiv); + counter += 1; + }); + + $(`a#search_category_${name}`) + .removeClass('no-result') + .text(`${value.name} (${counter}${value.plus ? '+' : ''})`); + + // We have more search results than shown, provide link to + // full search if available. + if (value.more) { + var footer = $('<footer class="search-more-results">'); + $(`<a id="link_all_results_${name}" href="#">`).text(`alle ${counter} ${value.name} anzeigen`) + .click(function() { + STUDIP.Search.toggleLinkText(name); + STUDIP.Search.expandCategory(name); + STUDIP.Search.setActiveCategory(name); + }) + .toggle(!isActive) + .appendTo(footer); + $(`<a id="link_results_${name}" href="#">`).text(allResultsText).hide() + .click(function() { + STUDIP.Search.toggleLinkText(name); + STUDIP.Search.showAllCategories(name); + STUDIP.Search.setActiveCategory(name); + }) + .toggle(isActive) + .appendTo(footer); + footer.appendTo(category); + } + + return category; + }, + + printSingleResult: function(categoryName, data, result, counter, fullsearch, categoryBodyDiv) { + var resultsPerType = data.resultsPerType; + var hasSubcourses = (categoryName === 'GlobalSearchMyCourses' || categoryName === 'GlobalSearchCourses') && result.has_children; + var addIcon = data.imgAdd; + var removeIcon = data.imgRemove; + // Create single result entry. + var single = $('<section>'); + var data = $('<div class="search-result-data">'); + var details = $('<div class="search-result-details">'); + var information = $('<div class="search-result-information">'); + + if (counter >= resultsPerType) { + single.addClass('search-extended-result'); + } + // Which result types should be opened via dialog? + const openInDialog = ['GlobalSearchFiles', 'GlobalSearchMessages']; + var dataDialog = (openInDialog.indexOf(categoryName) >= 0 ? dataDialog = 'data-dialog' : dataDialog = ''); + var link = $(`<a href="${result.url}" ${dataDialog}>`) + .appendTo(single); + + // Optional image... + if (result.img !== null) { + $('<div class="search-result-img hidden-tiny-down">') + .append(`<img src="${result.img}">`) + .appendTo(link); + } + + link.append(data); + + // add/remove icon for courses with sub courses + if (hasSubcourses) { + // initially show the 'add' icon + $(`<a href="#" id="show-subcourses-${result.id}" class="search-has-subcourses">`) + .click(function(e) { + STUDIP.Search.showSubcourses(result.id); + e.preventDefault(); + }) + .html(addIcon) + .appendTo(data); + // initially hide the 'remove' icon + $(`<a href="#" id="hide-subcourses-${result.id}" class="search-has-subcourses">`) + .click(function(e) { + STUDIP.Search.hideSubcourses(result.id); + e.preventDefault(); + }) + .html(removeIcon) + .appendTo(data) + .hide(); + } + + // Name/title + $('<div class="search-result-title">') + .html(result.name) + .appendTo(data); + + if (result.number !== null) { + $('<div class="search-result-number">') + .html(result.number) + .appendTo(details); + } + + // Details: Descriptional text + if (result.description !== null) { + $('<div class="search-result-description">') + .html(result.description) + .appendTo(details); + } + + if (result.dates !== null) { + $('<div class="search-result-dates">') + .html(result.dates) + .appendTo(details); + } + + data.append(details); + + // Date/Time of entry + if (result.date !== null) { + $('<div class="search-result-time">') + .html(result.date) + .appendTo(information); + } + + // Course Admission State as Img + if (result.admission_state !== null) { + $('<div class="search-result-admission-state">') + .html(result.admission_state) + .appendTo(information); + } + + + // Details: Additional information + var additional = $('<div class="search-result-additional">'); + if (result.additional !== null) { + additional.html(result.additional); + + // "Expand" attribute for further, result-related search + // (e.g. search in course of found forum entry) + if (result.expand !== null && result.expand !== fullsearch) { + additional.wrapInner(`<a href="${result.expand}" title="${result.expandtext}">`); + } + additional.appendTo(information); + } + + link.append(information); + + categoryBodyDiv.append(single); + + if (hasSubcourses) { + $.each(result.children, function(key, child) { + var subcourse = STUDIP.Search.printSingleResult(name, data, child, counter, fullsearch, categoryBodyDiv); + subcourse.addClass('search-is-subcourse'); + subcourse.addClass(`search-subcourse-${result.id}`); + subcourse.hide(); + }); + } + + return single; + }, + + /** + * Clear search term and category from the cache, + * reload the page and reset the active category. + */ + resetSearch: function () { + var cache = STUDIP.Search.getCache(); + STUDIP.Search.lastSearch = null; + cache.remove('searchterm'); + cache.remove('search_category'); + // reload without parameters + if (location.href.includes('?')) { + location = location.href.split('?')[0]; + } else { + location.reload(); + } + STUDIP.Search.setActiveCategory('show_all_categories'); + }, + + /** + * Show all possible categories in the sidebar without result numbers. + */ + resetSearchCategories: function () { + $('a[id^="search_category_"]').each(function () { + var category = $(this).text(); + if (category.includes('(')) { + category = category.substr(0, category.indexOf('(') - 1); + $(this).text(category); + } + }).show(); + }, + + /** + * Grey out all categories in the sidebar with no results. + */ + greyOutSearchCategories: function () { + $('a[id^="search_category_"]').addClass('no-result'); + }, + + /** + * Hide all select filters in the sidebar. + */ + hideAllFilters: function () { + $('div[id$="_filter"]').hide(); + }, + + /** + * Show the select filters for a given category in the sidebar. Default: semester filter. + * + * @param {string} category Given category for which specific select filters should be shown. + */ + showFilter: function (category) { + var filters = $('#search-results').data('filters'); + STUDIP.Search.hideAllFilters(); + if (filters && filters.hasOwnProperty(category) && category != 'show_all_categories') { + for (let i = 0; i < filters[category].length; i++) { + $(`#${filters[category][i]}_filter`).show(); + } + } else if (category === 'show_all_categories') { + $('#semester_filter').show(); + } + }, + + /** + * Set the specified category active (highlighted) in the sidebar. + * <li class="active"> + * + * @param {string} category Given category which should be highlighted in the sidebar. + */ + setActiveCategory: function (category) { + var cache = STUDIP.Search.getCache(); + cache.set('search_category', category); + // remove all active classes + $('#show_all_categories').closest('li').removeClass('active'); + $('a[id^="search_category_"]').closest('li').removeClass('active'); + + // set clicked class active + if (category === 'show_all_categories') { + $('#show_all_categories').closest('li').addClass('active'); + } else { + $(`#search_category_${category}`).closest('li').addClass('active'); + } + STUDIP.Search.showFilter(category); + + $(document).trigger('search-category-change.studip', {category: category}); + }, + + /** + * Get the current values from the filter selects in the sidebar that are relevant. + * + * @return {Object} filter object with the filter values set by the user. + */ + getFilter: function () { + var filters = $('#search-results').data('filters'); + var category = STUDIP.Search.getActiveCategory(); + var filter = {category: category}; + if (filters !== undefined) { + var active_filters = filters[category]; + $('select[id$="_select"]').each(function () { + var selected = this.id.substr(0, this.id.lastIndexOf('_')); + if ($.inArray(selected, active_filters) !== -1) { + filter[selected] = $('option:selected', this).val(); + } + }); + } + return filter; + }, + + /** + * Set a specific sidebar filter select to the given value. + * + * @param {string} filter filter that should be set. + * @param {string} value value that the filter should be set to. + */ + setFilter: function (filter, value) { + $(`#${filter}_select`).val(value); + }, + + /** + * Reset all sidebar filters except for the semester filter to their default value ('all'). + */ + resetFilters: function () { + $('select[id$="_select"]').not('#semester_select').val('').change(); + }, + + /** + * Getter for the selected (active) category. + * + * @return {string} The active (currently selected) category in the sidebar widget. + */ + getActiveCategory: function () { + var cache = STUDIP.Search.getCache(); + return cache.get('search_category'); + }, + + /** + * Toggle the link text for 'show all' results of one category and 'show all categories' + * with max. 3 results each. + * + * @param {string} category Category for which the link text should be toggled + */ + toggleLinkText: function (category) { + var visible = $(`a#link_all_results_${category}`).is(':visible'); + $(`a#link_all_results_${category}`).toggle(!visible); + $(`a#link_results_${category}`).toggle(visible); + $(`div#show-all-categories-${category}`).toggle(visible); + }, + + /** + * When clicked on toggle the icon ('+' -> '-' and vice versa) + * belonging to a parent course which has sub courses. + * + * @param {string} id parent course ID with add/remove Icon + */ + toggleParentCourseIcon: function (id) { + var visible = $(`a#show-subcourses-${id}`).is(':visible'); + $(`a#show-subcourses-${id}`).toggle(!visible); + $(`a#hide-subcourses-${id}`).toggle(visible); + }, + + /** + * Shows all sub courses for a specific parent course. + * + * @param {string} id parent course ID with sub courses + */ + showSubcourses: function (id) { + STUDIP.Search.toggleParentCourseIcon(id); + $(`section.search-subcourse-${id}`).show(); + }, + + /** + * Hides all sub courses for a specific parent course. + * + * @param {string} id parent course ID with sub courses + */ + hideSubcourses: function (id) { + STUDIP.Search.toggleParentCourseIcon(id); + $(`section.search-subcourse-${id}`).hide(); + }, + + /** + * Expand a single category, showing more results, and hide other categories. + * + * @param {string} category Category that should be expanded. + * @returns {boolean} false + */ + expandCategory: function (category) { + // Hide other categories. + $(`#search-results article:not([id="search-${category}"])`).hide(); + $('#search-no-result').hide(); + // Show all results. + $(`#search-${category} section.search-extended-result`) + .removeClass('search-extended-result'); + // Reassign category click to closing extended view. + var selector = [ + `#search-results article#search-${category} header a`, + `#link_all_results_${category}`, + `#link_results_${category}`, + `#show-all-categories-${category}` + ].join(','); + $(selector).off('click').on('click', function () { + STUDIP.Search.toggleLinkText(category); + STUDIP.Search.showAllCategories(category); + return false; + }); + return false; + }, + + /** + * Close expanded view of a single category, showing normal view with + * all categories again. + * + * @param {string} currentCategory Category that was previously selected. + * @return {boolean} false + */ + showAllCategories: function (currentCategory) { + var selector = [ + `#search-results article#search-${currentCategory} header a`, + `#link_all_results_${currentCategory}`, + `#link_results_${currentCategory}` + ].join(','); + $(selector).off('click').on('click', function () { + STUDIP.Search.toggleLinkText(currentCategory); + STUDIP.Search.expandCategory(currentCategory); + STUDIP.Search.setActiveCategory(currentCategory); + return false; + }); + var resultCount = $('#search-results').data('results-per-type') - 1; + $(`#search-${currentCategory} section:gt(${resultCount})`) + .addClass('search-extended-result'); + $('#search-results').children(`article:not([id="search-${currentCategory}"])`).show(); + STUDIP.Search.setActiveCategory('show_all_categories'); + $('#search-no-result').hide(); + return false; + }, + + /** + * Show active filters based on active category + * + * @param {Object} filter object with filter information (e.g. 'category', 'semester', etc.) + * @return {boolean} false + */ + showActiveFilters: function (filter) { + var container = $('#search-active-filters').find('.filter-items'); + container.empty(); + var emptyFilter = true; + for ( var item in filter) { + if (item != 'category') { + var value = filter[item]; + if (value.trim()) { + var name = $(`#${item}_filter .sidebar-widget-header`).text().trim(); + var value_text = $(`#${item}_select option:selected`).text().trim(); + var filterItem = $('<button></button>').addClass('button remove-filter').text(name + ': ' + value_text).attr('data-filter-name', item); + filterItem.on('click', function () { + var filter_name = $(this).data('filter-name'); + $(`#${filter_name}_select`).val(""); + $(`#${filter_name}_select`).trigger('change'); + return false; + }); + container.append(filterItem); + emptyFilter = false; + } + } + } + if (emptyFilter) { + $('#search-active-filters').hide(); + } else { + $('#search-active-filters').show(); + } + return false; + } +}; + +export default Search; diff --git a/resources/assets/javascripts/lib/sidebar.js b/resources/assets/javascripts/lib/sidebar.js new file mode 100644 index 0000000..6768a86 --- /dev/null +++ b/resources/assets/javascripts/lib/sidebar.js @@ -0,0 +1,75 @@ +import Scroll from './scroll.js'; + +const Sidebar = { + stickyEnabled: true, + enableSticky() { + this.stickyEnabled = true; + this.setSticky(); + }, + disableSticky() { + this.stickyEnabled = false; + this.setSticky(false); + }, + open () { + this.toggle(true); + }, + close () { + this.toggle(false); + }, + toggle (visible = null) { + visible = visible ?? !$('#layout-sidebar').hasClass('visible-sidebar'); + + // Hide navigation + $('#responsive-toggle').prop('checked', false); + $('#responsive-navigation').removeClass('visible'); + + $('#layout-sidebar').toggleClass('visible-sidebar', visible); + } +}; + +// This function inits the sticky sidebar by using the StickyKit lib +// <http://leafo.net/sticky-kit/> +Sidebar.setSticky = function(is_sticky) { + if (!this.stickyEnabled) { + return; + } + + if (is_sticky === undefined || is_sticky) { + $('#layout-sidebar .sidebar') + .stick_in_parent({ + offset_top: $('#barBottomContainer').outerHeight(true) + 15, + inner_scrolling: true + }) + .on('sticky_kit:stick sticky_kit:unbottom', function() { + var stuckHandler = function(top, left) { + $('#layout-sidebar .sidebar').css('margin-left', -left); + }; + Scroll.addHandler('sticky.horizontal', stuckHandler); + stuckHandler(0, $(window).scrollLeft()); + }) + .on('sticky_kit:unstick sticky_kit:bottom', function() { + Scroll.removeHandler('sticky.horizontal'); + $(this).css('margin-left', 0); + }); + } else { + Scroll.removeHandler('sticky.horizontal'); + $('#layout-sidebar .sidebar') + .trigger('sticky_kit:unstick') + .trigger('sticky_kit:detach'); + } +}; + +Sidebar.checkActiveLineHeight = () => { + $('#layout-sidebar .sidebar .sidebar-widget-content .widget-links li.active a.active').each(function() { + var link = $(this); + var actual_text = link.text(); + link.text('tmp'); + var default_height = link.outerHeight(); + link.text(actual_text); + var actual_height = link.outerHeight(); + if (actual_height > default_height) { //it is rendered in more lines + link.css('line-height', '20px'); + } + }); +} +export default Sidebar; diff --git a/resources/assets/javascripts/lib/skip_links.js b/resources/assets/javascripts/lib/skip_links.js new file mode 100644 index 0000000..2d17534 --- /dev/null +++ b/resources/assets/javascripts/lib/skip_links.js @@ -0,0 +1,143 @@ +const SkipLinks = { + activeElement: null, + navigationStatus: 0, + + /** + * Displays the skip link navigation after first hitting the tab-key + * @param event: event-object of type keyup + */ + showSkipLinkNavigation: function(event) { + if (event.keyCode === 9) { + //tab-key + SkipLinks.moveSkipLinkNavigationIn(); + jQuery('.focus_box').removeClass('focus_box'); + } + }, + + /** + * shows the skiplink-navigation window by moving it from the left + */ + moveSkipLinkNavigationIn: function() { + if (SkipLinks.navigationStatus === 0) { + var VpWidth = jQuery(window).width(); + jQuery('#skip_link_navigation li:first a').focus(); + jQuery('#skip_link_navigation').css({ left: VpWidth / 2, opacity: 0 }); + jQuery('#skip_link_navigation').animate({ opacity: 1.0 }, 500); + SkipLinks.navigationStatus = 1; + } + }, + + /** + * removes the skiplink-navigation window by moving it out of viewport + */ + moveSkipLinkNavigationOut: function() { + if (SkipLinks.navigationStatus === 1) { + jQuery(SkipLinks.box).hide(); + jQuery('#skip_link_navigation').animate({ opacity: 0 }, 500, function() { + jQuery(this).css('left', '-600px'); + }); + } + SkipLinks.navigationStatus = 2; + }, + + getFragment: function() { + var fragmentStart = document.location.hash.indexOf('#'); + if (fragmentStart < 0) { + return ''; + } + return document.location.hash.substring(fragmentStart); + }, + + /** + * Inserts the list with skip links + */ + insertSkipLinks: function() { + jQuery('#skip_link_navigation').prepend(jQuery('#skiplink_list')); + jQuery('#skiplink_list').show(); + jQuery('#skip_link_navigation').attr('aria-busy', 'false'); + jQuery('#skip_link_navigation').attr('tabindex', '-1'); + SkipLinks.insertHeadLines(); + return false; + }, + + /** + * sets the area (of the id) as the current area for tab-navigation + * and highlights it + */ + setActiveTarget: function(id) { + var fragment = null; + // set active area only if skip links are activated + if (!jQuery('*').is('#skip_link_navigation')) { + return false; + } + if (id) { + fragment = id; + } else { + fragment = SkipLinks.getFragment(); + } + if (jQuery('*').is(fragment) && fragment.length > 0 && fragment !== SkipLinks.activeElement) { + SkipLinks.moveSkipLinkNavigationOut(); + jQuery('.focus_box').removeClass('focus_box'); + jQuery(fragment).addClass('focus_box'); + jQuery(fragment) + .attr('tabindex', '-1') + .click() + .focus(); + SkipLinks.activeElement = fragment; + return true; + } else { + jQuery('#skip_link_navigation li a') + .first() + .focus(); + } + return false; + }, + + injectAriaRoles: function() { + jQuery('#main_content').attr({ + role: 'main', + 'aria-labelledby': 'main_content_landmark_label' + }); + jQuery('#layout_content').attr({ + role: 'main', + 'aria-labelledby': 'layout_content_landmark_label' + }); + jQuery('#layout_infobox').attr({ + role: 'complementary', + 'aria-labelledby': 'layout_infobox_landmark_label' + }); + }, + + insertHeadLines: function() { + var target = null; + jQuery('#skip_link_navigation a').each(function() { + target = jQuery(this).attr('href'); + if (jQuery(target).is('li,td')) { + jQuery(target).prepend( + '<h2 id="' + + jQuery(target).attr('id') + + '_landmark_label" class="skip_target">' + + jQuery(this).text() + + '</h2>' + ); + } else { + jQuery(target).before( + '<h2 id="' + + jQuery(target).attr('id') + + '_landmark_label" class="skip_target">' + + jQuery(this).text() + + '</h2>' + ); + } + jQuery(target).attr('aria-labelledby', jQuery(target).attr('id') + '_landmark_label'); + }); + }, + + initialize: function() { + SkipLinks.insertSkipLinks(); + SkipLinks.injectAriaRoles(); + SkipLinks.setActiveTarget(); + } +}; + +export default SkipLinks; diff --git a/resources/assets/javascripts/lib/smiley_picker.js b/resources/assets/javascripts/lib/smiley_picker.js new file mode 100644 index 0000000..8fd85ca --- /dev/null +++ b/resources/assets/javascripts/lib/smiley_picker.js @@ -0,0 +1,128 @@ +/** + * smiley-picker.js - Smiley Picker + * + * Creates a SmileyPicker object in the global STUDIP namespace with + * the methods show, hide and toggle. + * show and toggle accept two arguments "triggerElement, onSelect": + * - triggerElement is the element that triggered the event + * - onSelect is a function to be executed once a smiley is selected + * + * The picker requires a php based backend under the route + * "smileys/picker" which renders the html for the picker. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Jan-Hendrik Willms <tleilax+studip@gmail.com> + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ +import { $gettext } from './gettext.js'; +import Dialog from './dialog.js'; + +var initialized = false, + picker_element = $('<div/>'), + select_handler = function() {}; + +// Loads a url +function loadURL(url, callback) { + $.get(url, function(response) { + response = $(response); + + // Add a preload icon for each smiley to avoid a potential flash + // of the alternative text + $('.smileys img', response).each(function() { + var that = this, + src = this.src, + image = new Image(); + this.src = STUDIP.ASSETS_URL + 'images/ajax_indicator_small.gif'; + + image.onload = image.onerror = function() { + that.src = src; + }; + image.src = src; + }); + + picker_element.html(response); + + if ($.isFunction(callback)) { + callback(); + } + }); +} + +// Create smiley picker object and bind it to global STUDIP namespace +const SmileyPicker = { + // Show smiley picker, triggered by a specific element and handle + // a selected smiley by the passed function + show: function(triggerElement, onSelect) { + select_handler = onSelect; + + if (!initialized) { + // Setup picker dialog + picker_element.dialog({ + autoOpen: false, + width: 420, // needs to be hardcoded, unfortunately. + dialogClass: 'smiley-picker-dialog', + resizable: false, + title: $gettext('Smileys'), + show: 'fade', + hide: 'fade', + buttons: [ + { + text: $gettext('Zur Gesamtübersicht'), + click: function() { + var url = STUDIP.URLHelper.getURL('dispatch.php/smileys'); + picker_element.dialog('close'); + Dialog.fromURL(url); + } + }, + { + text: $gettext('Schliessen'), + click: function() { + picker_element.dialog('close'); + } + } + ] + }); + + // Initial load with spinner next to trigger element + $(triggerElement).showAjaxNotification(); + loadURL(STUDIP.URLHelper.getURL('dispatch.php/smileys/picker'), function() { + $(triggerElement).hideAjaxNotification(); + picker_element.dialog('open'); + }); + + initialized = true; + } else { + picker_element.dialog('open'); + } + }, + // Hide smiley picker + hide: function() { + picker_element.dialog('close'); + }, + // Toggle smiley picker display (pass the same arguments as for show) + toggle: function(triggerElement, onSelect) { + if (initialized && picker_element.dialog('isOpen')) { + SmileyPicker.hide(); + } else { + SmileyPicker.show(triggerElement, onSelect); + } + }, + + handleNavigationClick: function(event) { + loadURL(this.href); + return false; + }, + + handleSmileyClick: function(event) { + select_handler($(this).data().code); + picker_element.dialog('close'); + return false; + } +}; + +export default SmileyPicker; diff --git a/resources/assets/javascripts/lib/startpage.js b/resources/assets/javascripts/lib/startpage.js new file mode 100644 index 0000000..15b2c59 --- /dev/null +++ b/resources/assets/javascripts/lib/startpage.js @@ -0,0 +1,64 @@ +const startpage = { + init: function() { + $('.start-widgetcontainer .portal-widget-list').sortable({ + handle: '.widget-header', + connectWith: 'ul.portal-widget-list', + start: function() { + $(this) + .closest('.start-widgetcontainer') + .find('.portal-widget-list') + .addClass('ui-sortable move'); + }, + stop: function(event, ui) { + $.get(STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/start/storeNewOrder', { + widget: $(ui.item).attr('id'), + position: $(ui.item).index(), + column: $(ui.item) + .parent() + .index() + }); + $(this) + .closest('.start-widgetcontainer') + .find('.portal-widget-list') + .removeClass('move'); + } + }); + }, + + init_edit: function(perm) { + $('.edit-widgetcontainer .portal-widget-list').sortable({ + handle: '.widget-header', + connectWith: '.edit-widgetcontainer .portal-widget-list', + start: function() { + $(this) + .closest('.edit-widgetcontainer') + .find('.portal-widget-list') + .addClass('ui-sortable move'); + }, + stop: function() { + // store the whole widget constellation + var widgets = { + left: {}, + right: {} + }; + + $('.edit-widgetcontainer .start-widgetcontainer .portal-widget-list:first-child > li').each(function() { + widgets.left[$(this).attr('id')] = $(this).index(); + }); + + $('.edit-widgetcontainer .start-widgetcontainer .portal-widget-list:last-child > li').each(function() { + widgets.right[$(this).attr('id')] = $(this).index(); + }); + + $.post(STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/start/update_defaults/' + perm, widgets); + + $(this) + .closest('.edit-widgetcontainer') + .find('.portal-widget-list') + .removeClass('move'); + } + }); + } +}; + +export default startpage; diff --git a/resources/assets/javascripts/lib/statusgroups.js b/resources/assets/javascripts/lib/statusgroups.js new file mode 100644 index 0000000..ea6b6bd --- /dev/null +++ b/resources/assets/javascripts/lib/statusgroups.js @@ -0,0 +1,77 @@ +const Statusgroups = { + ajax_endpoint: false, + apply: function() { + $('.movable tbody').sortable({ + axis: 'y', + handle: '.dragHandle', + helper: function(event, ui) { + ui.children().each(function() { + $(this).width($(this).width()); + }); + return ui; + }, + start: function(event, ui) { + $(this) + .closest('table') + .addClass('nohover'); + }, + stop: function(event, ui) { + var table = $(this).closest('table'), + group = table.attr('id'), + user = ui.item.data('userid'), + position = $(ui.item).prevAll().length; + + table.removeClass('nohover'); + + $.ajax({ + type: 'POST', + url: Statusgroups.ajax_endpoint, + dataType: 'html', + data: { group: group, user: user, pos: position }, + async: false + }).done(function(data) { + $('tbody', table).html(data); + Statusgroups.apply(); + }); + } + }); + }, + + initInputs: function() { + //$('input[name="selfassign_start"]').datetimepicker(); + if (!$('input[name="selfassign"]').attr('checked')) { + $('input[name="exclusive"]') + .closest($('section')) + .hide(); + $('input[name="selfassign_start"]') + .closest($('section')) + .hide(); + $('input[name="selfassign_end"]') + .closest($('section')) + .hide(); + } + //$('input[name="selfassign_end"]').datetimepicker(); + $('input[name="selfassign"]').on('click', function() { + $('input[name="exclusive"]') + .closest($('section')) + .toggle(); + $('input[name="selfassign_start"]') + .closest($('section')) + .toggle(); + $('input[name="selfassign_end"]') + .closest($('section')) + .toggle(); + }); + + $('input[name="numbering_type"]').on('click', function() { + var type = $('input[name="numbering_type"]:checked').val(), + disabled = parseInt(type, 10) === 2; + + $('input[name="startnumber"]') + .prop('disabled', disabled) + .toggle(!disabled); + }); + } +}; + +export default Statusgroups; diff --git a/resources/assets/javascripts/lib/studip-vue.js b/resources/assets/javascripts/lib/studip-vue.js new file mode 100644 index 0000000..eac6679 --- /dev/null +++ b/resources/assets/javascripts/lib/studip-vue.js @@ -0,0 +1,15 @@ +const load = async function () { + return await STUDIP.loadChunk('vue'); +}; + +const on = async function (...args) { + const { eventBus } = await load(); + eventBus.on(...args); +}; + +const emit = async function (...args) { + const { eventBus } = await load(); + eventBus.emit(...args); +}; + +export default { load, on, emit }; diff --git a/resources/assets/javascripts/lib/study_area_selection.js b/resources/assets/javascripts/lib/study_area_selection.js new file mode 100644 index 0000000..b973424 --- /dev/null +++ b/resources/assets/javascripts/lib/study_area_selection.js @@ -0,0 +1,120 @@ +/* ------------------------------------------------------------------------ + * study area selection for courses + * ------------------------------------------------------------------------ */ +const study_area_selection = { + initialize: function() { + // Ein bisschen hässlich im Sinne von "DRY", aber wie sonst? + jQuery(document).on('click', 'input[name^="study_area_selection[add]"]', function() { + var parameters = jQuery(this).data(); + if (!(parameters && parameters.id)) { + return; + } + study_area_selection.add(parameters.id, parameters.course_id || '-'); + return false; + }); + jQuery(document).on('click', 'input[name^="study_area_selection[remove]"]', function() { + var parameters = jQuery(this).data(); + if (!(parameters && parameters.id)) { + return; + } + study_area_selection.remove(parameters.id, parameters.course_id || '-'); + return false; + }); + jQuery(document).on('click', 'a.study_area_selection_expand', function() { + var parameters = jQuery(this).data(); + if (!(parameters && parameters.id)) { + return; + } + study_area_selection.expandSelection(parameters.id, parameters.course_id || '-'); + return false; + }); + }, + + url: function(/* action, args...*/) { + return STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/course/study_areas/' + jQuery.makeArray(arguments).join('/'); + }, + + add: function(id, course_id) { + // may not be visible at the current + jQuery('.study_area_selection_add_' + id) + .prop('disabled', true) + .fadeTo('slow', 0); + + jQuery.ajax({ + type: 'POST', + url: study_area_selection.url('add', course_id || '-'), + data: { id: id }, + dataType: 'html', + async: false, // Critical request thus synchronous + success: function(data) { + jQuery('#study_area_selection_none').fadeOut(); + jQuery('#study_area_selection_selected').replaceWith(data); + study_area_selection.refreshSelection(); + } + }); + }, + + remove: function(id, course_id) { + var jQueryselection = jQuery('#study_area_selection_' + id); + + if (jQueryselection.siblings().length === 0) { + jQuery('#study_area_selection_at_least_one') + .fadeIn() + .delay(5000) + .fadeOut(); + jQueryselection.effect('bounce', 'fast'); + return; + } + + jQuery.ajax({ + type: 'POST', + url: study_area_selection.url('remove', course_id || '-'), + data: { id: id }, + dataType: 'html', + async: false, // Critical request thus synchronous + success: function() { + jQueryselection.fadeOut(function() { + jQuery(this).remove(); + }); + if (jQuery('#study_area_selection_selected li').length === 0) { + jQuery('#study_area_selection_none').fadeIn(); + } + jQuery('.study_area_selection_add_' + id) + .css({ + visibility: 'visible', + opacity: 0 + }) + .fadeTo('slow', 1, function() { + jQuery(this).prop('disabled', false); + }); + + study_area_selection.refreshSelection(); + }, + error: function() { + jQueryselection.fadeIn(); + } + }); + }, + + expandSelection: function(id, course_id) { + jQuery.post( + study_area_selection.url('expand', course_id || '-', id), + function(data) { + jQuery('#study_area_selection_selectables ul').replaceWith(data); + }, + 'html' + ); + }, + + refreshSelection: function() { + // "even=odd && odd=even ??" - this may seem strange but jQuery and Stud.IP differ in odd/even + jQuery('#study_area_selection_selected li:odd') + .removeClass('odd') + .addClass('even'); + jQuery('#study_area_selection_selected li:even') + .removeClass('even') + .addClass('odd'); + } +}; + +export default study_area_selection; diff --git a/resources/assets/javascripts/lib/table-of-contents.js b/resources/assets/javascripts/lib/table-of-contents.js new file mode 100644 index 0000000..09cbd74 --- /dev/null +++ b/resources/assets/javascripts/lib/table-of-contents.js @@ -0,0 +1,11 @@ +const TableOfContents = { + toggle_toc() { + $('.transform').toggleClass('transform-active'); + } +}; + +export default TableOfContents; + + + + diff --git a/resources/assets/javascripts/lib/table.js b/resources/assets/javascripts/lib/table.js new file mode 100644 index 0000000..0325fbe --- /dev/null +++ b/resources/assets/javascripts/lib/table.js @@ -0,0 +1,54 @@ +function enhanceSortableTable(table) { + var headers = {}; + $('thead tr:last th', table).each(function(index, element) { + headers[index] = { + sorter: $(element).data().sort || false + }; + }); + + if ($('tbody tr[data-sort-fixed]', table).length > 0) { + $('tbody tr[data-sort-fixed]', table).each(function() { + $(this).data('sort-fixed', { + index: $(this).index(), + tbody: $(this).closest('table').find('tbody').index($(this).parent()) + }); + }); + $(table) + .on('sortStart', function() { + $('tbody tr[data-sort-fixed]', table).each(function() { + var hidden = $(this).is(':hidden'); + $(this).data('sort-hidden', hidden); + }); + }) + .on('sortEnd', function() { + $('tbody tr[data-sort-fixed]', table) + .detach() + .each(function() { + var pos = $(this).data('sort-fixed'); + if ($(`tbody:eq(${pos.tbody}) tr:eq(${pos.index})`, table).length > 0) { + $(`tbody:eq(${pos.tbody}) tr:eq(${pos.index})`, table).before(this); + } else { + $(`tbody:eq(${pos.tbody})`, table).append(this); + } + + if ($(this).data('sort-hidden')) { + setTimeout(() => $(this).hide(), 100); + } + }); + }); + } + + $(table).tablesorter({ + headers: headers, + sortLocaleCompare : true, + sortRestart: true + }); +} + +const Table = { + enhanceSortableTable: function (table) { + STUDIP.loadChunk('tablesorter').then(() => enhanceSortableTable(table)); + } +}; + +export default Table; diff --git a/resources/assets/javascripts/lib/toolbar.js b/resources/assets/javascripts/lib/toolbar.js new file mode 100644 index 0000000..3251065 --- /dev/null +++ b/resources/assets/javascripts/lib/toolbar.js @@ -0,0 +1,135 @@ +import { $gettext } from './gettext.js'; +import ToolbarButtonset from './toolbar_buttonset.js'; +import Dialog from './dialog.js'; + +function getElementWidth(element) { + var proxy = null; + + // Special case: Handle i18n hidden textareas + // - Hidden textareas have no dimensions thus we need to proxy the + // width from the first visible element in the i18n group + if ($(element).is(':hidden') && $(element).closest('.i18n_group').length > 0) { + proxy = $(element) + .closest('.i18n_group') + .find('div.i18n:visible') + .children() + .first(); + if (proxy.length > 0) { + element = proxy; + } + } + + return $(element).css('width') || $(element).outerWidth(true); +} + +const Toolbar = { + buttonSet: ToolbarButtonset, + + // Initializes (adds) a toolbar the passed textarea element + initialize: function(element, button_set) { + var $element = $(element), + wrap, + toolbar; + + // don't initialize toolbar for wysiwyg textareas + if (STUDIP.editor_enabled && $element.hasClass('wysiwyg')) { + return; + } + + // Bail out if the element is not a tetarea or a toolbar has already + // been applied + if (!$element.is('textarea') || $element.data('toolbar-added')) { + return; + } + + button_set = button_set || Toolbar.buttonSet; + + // if WYSIWYG is globally enabled then add a button so + // the user can activate it + if (STUDIP.wysiwyg_enabled && $element.hasClass('wysiwyg')) { + button_set.right.wysiwyg = { + label: 'WYSIWYG', + evaluate: function() { + var question = [ + $gettext('Soll der WYSIWYG Editor aktiviert werden?'), + '', + $gettext('Die Seite muss danach neu geladen werden, um den WYSIWYG Editor zu laden.') + ].join('\n'); + Dialog.confirm(question, function() { + var url = STUDIP.URLHelper.resolveURL('dispatch.php/wysiwyg/settings/users/current'); + + $.ajax({ + url: url, + type: 'PUT', + contentType: 'application/json', + data: JSON.stringify({ disabled: false }) + }).fail(function(xhr) { + window.alert( + [ + $gettext('Das Aktivieren des WYSIWYG Editors ist fehlgeschlagen.'), + '', + $gettext('URL') + ': ' + url, + $gettext('Status') + ': ' + xhr.status + ' ' + xhr.statusText, + $gettext('Antwort') + ': ' + xhr.responseText + ].join('\n') + ); + }); + }); + } + }; + } + + // Add flag so one element will never have more than one toolbar + $element.data('toolbar-added', true); + + // Create toolbar element + toolbar = $('<div class="buttons">'); + + // Assemble toolbar + ['left', 'right'].forEach(function(position) { + var buttons = $('<span>').addClass(position); + $.each(button_set[position], function(name, format) { + var button = $('<span>').addClass(name), + label = format.label || name; + + if (format.icon) { + label = $('<img>', { + alt: format.label || name, + src: STUDIP.ASSETS_URL + 'images/icons/blue/' + format.icon + '.svg' + }); + } + + button + .html(label) + .button() + .click(function() { + var selection = $element.getSelection(), + result = format.evaluate(selection, $element, this) || selection, + replacement = $.isPlainObject(result) + ? result.replacement + : result === undefined + ? selection + : result, + offset = $.isPlainObject(result) ? result.offset : (result || '').length; + $element.replaceSelection(replacement, offset).change(); + return false; + }); + + buttons.append(button); + }); + toolbar.append(buttons); + }); + + // Attach toolbar to the specified element + wrap = $('<div class="editor_toolbar">').css({ + width: getElementWidth($element), + display: $element.css('display') + }); + $element + .css('width', '100%') + .wrap(wrap) + .before(toolbar); + } +}; + +export default Toolbar; diff --git a/resources/assets/javascripts/lib/toolbar_buttonset.js b/resources/assets/javascripts/lib/toolbar_buttonset.js new file mode 100644 index 0000000..5bb211f --- /dev/null +++ b/resources/assets/javascripts/lib/toolbar_buttonset.js @@ -0,0 +1,77 @@ +import SmileyPicker from './smiley_picker.js'; + +// Creates a wrapper function that wraps the passed string using the +// passed prefix and suffix. If the suffix is omitted, it will be replaced +// by the prefix. +// Be aware that the wrap function will not wrap a string twice. +function createWrap(prefix, suffix) { + if (suffix === undefined) { + suffix = prefix; + } + return function(string) { + if (string.substr(0, prefix.length) === prefix && string.substr(-suffix.length) === suffix) { + return string; + } + if (string) { + return prefix + string + suffix; + } + return { + replacement: prefix + suffix, + offset: prefix.length + }; + }; +} + +// Define default stud.ip button set +const buttonSet = { + left: { + bold: { label: '<strong>B</strong>', evaluate: createWrap('**') }, + italic: { label: '<em>i</em>', evaluate: createWrap('%%') }, + underline: { label: '<u>u</u>', evaluate: createWrap('__') }, + strikethrough: { label: '<del>u</del>', evaluate: createWrap('{-', '-}') }, + code: { label: '<code>code</code>', evaluate: createWrap('[code]', '[/code]') }, + larger: { label: 'A+', evaluate: createWrap('++') }, + smaller: { label: 'A-', evaluate: createWrap('--') }, + signature: { label: 'signature', evaluate: createWrap('', '\u2013~~~') }, + link: { + label: 'link', + evaluate: function(string) { + string = string || window.prompt('Text:') || ''; + if (string.length === 0) { + return string; + } + + var url = window.prompt('URL:') || ''; + return url.length === 0 ? string : '[' + string + ']' + url; + } + }, + image: { + label: 'img', + evaluate: function(string) { + var url = window.prompt('URL:') || ''; + return url.length === 0 ? string : '[img]' + url; + } + } + }, + right: { + smilies: { + label: ':)', + evaluate: function(string, textarea, button) { + SmileyPicker.toggle(button, function(code) { + textarea.replaceSelection(code + ' '); + }); + } + }, + help: { + label: '?', + evaluate: function() { + var url = $('link[rel=help].text-format').attr('href'), + win; + win = window.open(url, '_blank'); + win.opener = null; + } + } + } +}; + +export default buttonSet; diff --git a/resources/assets/javascripts/lib/tooltip.js b/resources/assets/javascripts/lib/tooltip.js new file mode 100644 index 0000000..ae812b5 --- /dev/null +++ b/resources/assets/javascripts/lib/tooltip.js @@ -0,0 +1,198 @@ +/*jslint esversion: 6*/ + +import CSS from './css.js'; + +/** + * Tooltip library for Stud.IP + * + * @author Jan-Hendrik Willms <tleilax+studip@gmail.com> + * @copyright Stud.IP Core Group 2014 + * @license GPL2 or any later version + * @since Stud.IP 3.1 + */ + +let count = 0; +let threshold = 0; + +class Tooltip { + static get count() { + return count; + } + + static set count(value) { + count = value; + } + + // Threshold used for "edge detection" (imagine a padding along the edges) + static get threshold() { + return threshold; + } + + static set threshold(value) { + threshold = value; + } + + /** + * Returns a new unique id of a tooltip. + * + * @return {string} Unique id + * @static + */ + static getId() { + const id = `studip-tooltip-${Tooltip.count}`; + Tooltip.count += 1; + return id; + } + + /** + * Constructs a new tooltip at given location with given content. + * The applied css class may be changed by the fourth parameter. + * + * @class + * @classdesc Stud.IP tooltips provide an improved layout and handling + * of contents (including html) than the browser's default + * tooltip through title attribute would + * + * @param {int} x - Horizontal position of the tooltip + * @param {int} y - Vertical position of the tooltip + * @param {string} content - Content of the tooltip (may be html) + * @param {string} css_class - Optional name of the applied css class / + * defaults to 'studip-tooltip' + */ + constructor(x, y, content, css_class) { + // Obtain unique id of the tooltip + this.id = Tooltip.getId(); + + // Create dom element of the tooltip, apply id and class and attach + // to dom + this.element = $('<div>'); + this.element.addClass(css_class || 'studip-tooltip'); + this.element.attr('id', this.id); + this.element.appendTo('body'); + + // Set position and content and paint the tooltip + this.position(x, y); + this.update(content); + this.paint(); + } + + /** + * Translates the arrow(s) under a tooltip using css3 translate + * transforms. This is needed at the edges of the screen. + * This implies that a current browser is used. The translation could + * also be achieved by adjusting margins but that way we would need + * to hardcode values into this function since it's a struggle to + * obtain the neccessary values from the CSS pseudo selectors in JS. + * + * Internal, css rules are dynamically created and applied to the current + * document by using the methods provided in the file studip-css.js. + * + * @param {int} x - Horizontal offset + * @param {int} y - Vertical offset + */ + translateArrows(x, y) { + CSS.removeRule(`#${this.id}::before`); + CSS.removeRule(`#${this.id}::after`); + + if (x !== 0 || y !== 0) { + const rule = `translate(${x}px, ${y}px);`; + CSS.addRule(`#${this.id}::before`, { transform: rule }, ['-ms-', '-webkit-']); + CSS.addRule(`#${this.id}::after`, { transform: rule }, ['-ms-', '-webkit-']); + } + } + + /** + * Updates the position of the tooltip. + * + * @param {int} x - Horizontal position of the tooltip + * @param {int} y - Vertical position of the tooltip + */ + position(x, y) { + this.x = x; + this.y = y; + } + + /** + * Updates the contents of the tooltip. + * + * @param {string} content - Content of the tooltip (may be html) + */ + update(content) { + this.element.html(content); + } + + /** + * "Paints" the tooltip. This method actually computes the dimensions of + * the tooltips, checks for screen edges and calculates the actual offset + * in the current document. + * This method is neccessary due to the fact that position and content + * can be changed apart from each other. + * Thus: Don't forget to repaint after adjusting any of the two. + */ + paint() { + const width = this.element.outerWidth(true); + const height = this.element.outerHeight(true); + const maxWidth = $(document).width(); + let x = this.x - width / 2; + let y = this.y - height; + let arrowOffset = 0; + + if (x < Tooltip.threshold) { + arrowOffset = x - Tooltip.threshold; + x = Tooltip.threshold; + } else if (x + width > maxWidth - Tooltip.threshold) { + arrowOffset = x + width - maxWidth + Tooltip.threshold; + x = maxWidth - width - Tooltip.threshold; + } + this.translateArrows(arrowOffset, 0); + + this.element.css({ + left: x, + top: y + }); + } + + /** + * Toggles the visibility of the tooltip. If no state is provided, + * the tooltip will be hidden if visible and vice versa. Pretty straight + * forward and no surprises here. + * This method implicitely calls paint before a tooltip is shown (in case + * it was forgotten). + * + * @param {bool} visible - Optional visibility parameter to set the + * tooltip to a certain state + */ + toggle(visible) { + if (visible) { + this.paint(); + } + this.element.toggle(visible); + } + + /** + * Reveals the tooltip. + * + * @see Tooltip.toggle + */ + show() { + this.toggle(true); + } + + /** + * Hides the tooltip. + * + * @see Tooltip.toggle + */ + hide() { + this.toggle(false); + } + + /** + * Removes the tooltip + */ + remove() { + this.element.remove(); + } +} + +export default Tooltip; diff --git a/resources/assets/javascripts/lib/tour.js b/resources/assets/javascripts/lib/tour.js new file mode 100644 index 0000000..44b170e --- /dev/null +++ b/resources/assets/javascripts/lib/tour.js @@ -0,0 +1,655 @@ +import { $gettext } from './gettext.js'; + +/* ------------------------------------------------------------------------ + * Stud.IP Tour + * ------------------------------------------------------------------------ + * + * @author Arne Schröder, schroeder@data-quest.de + * @description Studip Tour + * + * Parts of this script are a modified version of: + * + * jQuery aSimpleTour + * @author alvaro.veliz@gmail.com + * @servedby perkins (http://p.erkins.com) + * + * Dependencies : + * - jQuery scrollTo + * + */ + +const Tour = { + show_helpcenter: function() { + jQuery('#helpbar-sticky').prop('checked', true); + }, + hide_helpcenter: function() { + jQuery('#helpbar-sticky').prop('checked', false); + }, + init: function(tour_id, step_nr) { + Tour.direction = 'f'; + if (!Tour.started && !Tour.pending_ajax_request) { + Tour.pending_ajax_request = true; + Tour.started = true; + jQuery.ajax({ + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/tour/get_data/' + tour_id + '/' + step_nr, + type: 'POST', + data: { route: window.location.href }, + dataType: 'json', + success: function(json) { + jQuery(document).trigger('tourstart.studip'); + + Tour.pending_ajax_request = false; + Tour.options = json; + if (Tour.options.redirect) { + window.location.href = Tour.options.redirect; + } + Tour.id = tour_id; + Tour.step = 0; + Tour.steps = Tour.options.data.length; + jQuery('body').prepend(Tour.options.tour_html); + if (!Tour.steps) { + Tour.started = false; + Tour.show_helpcenter(); + } else if (Tour.options.last_run) { + Tour.hide_helpcenter(); + if (Tour.options.tour_type === 'tour' && !Tour.options.edit_mode) { + jQuery('body').prepend('<div id="tour_overlay"></div>'); + } + jQuery('#tour_title').html(Tour.options.last_run); + jQuery('#tour_end').show(); + jQuery('#tour_next').hide(); + jQuery('#tour_prev').hide(); + jQuery('#tour_controls').show(); + jQuery('#tour_reset').show(); + jQuery('#tour_reset').on('click', function() { + jQuery.ajax({ + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/tour/set_status/' + Tour.id + '/1/on' + }); + jQuery('#tour_reset').hide(); + jQuery('#tour_proceed').hide(); + Tour.step = -1; + Tour.next(); + }); + jQuery('#tour_proceed').show(); + jQuery('#tour_proceed').on('click', function() { + if (Tour.options.last_run_href) { + jQuery.ajax({ + url: + STUDIP.ABSOLUTE_URI_STUDIP + + 'dispatch.php/tour/set_status/' + + Tour.id + + '/' + + Tour.options.last_run_step + + '/on', + success: function() { + window.location.href = STUDIP.URLHelper.getURL(Tour.options.last_run_href); + } + }); + } + }); + } else { + Tour.hide_helpcenter(); + if (Tour.options.tour_type === 'tour' && !Tour.options.edit_mode) { + jQuery('body').prepend('<div id="tour_overlay"></div>'); + } + Tour.step = step_nr - Tour.options.route_step_nr - 1; + Tour.next(); + if (Tour.options.edit_mode) { + Tour.startEditor(); + } + } + }, + fail: function() { + Tour.pending_ajax_request = false; + alert($gettext('Fehler beim Aufruf des Tour-Controllers')); + } + }); + } + }, + + showControlButtons: function() { + jQuery('#tour_tip').hide(); + jQuery('#tour_tip_interactive').hide(); + jQuery('.tour_focus_box').removeClass('tour_focus_box'); + jQuery('#tour_reset').hide(); + jQuery('#tour_proceed').hide(); + jQuery('#tour_end').show(); + if (Tour.step > 0 || Tour.options.back_link) { + jQuery('#tour_prev').show(); + } else { + jQuery('#tour_prev').hide(); + } + if (Tour.step < Tour.steps - 1 || Tour.options.proceed_link) { + jQuery('#tour_next').show(); + } else { + jQuery('#tour_next').hide(); + } + jQuery('#tour_controls').show(); + }, + + next: function() { + Tour.direction = 'f'; + Tour.step++; + + if (Tour.step >= Tour.steps) { + if (Tour.options.proceed_link) { + window.location.href = STUDIP.URLHelper.getURL(Tour.options.proceed_link); + } else { + this.destroy(); + } + } else { + if (Tour.options.data[Tour.step].action_next) { + jQuery(Tour.options.data[Tour.step].action_next).click(); + } + Tour.showControlButtons(); + Tour.setTooltip(Tour.options.data[Tour.step]); + } + }, + + prev: function() { + Tour.direction = 'b'; + Tour.step--; + + if (Tour.step < 0 && Tour.options.back_link) { + jQuery.ajax({ + url: + STUDIP.ABSOLUTE_URI_STUDIP + + 'dispatch.php/tour/set_status/' + + Tour.id + + '/' + + (Tour.options.route_step_nr - 1) + + '/on', + success: function() { + window.location.href = STUDIP.URLHelper.getURL(Tour.options.back_link); + } + }); + } else { + if (Tour.options.data[Tour.step].action_prev) { + jQuery(Tour.options.data[Tour.step].action_prev).click(); + } + Tour.showControlButtons(); + Tour.setTooltip(Tour.options.data[Tour.step]); + } + }, + + setTooltip: function(stepData) { + jQuery('.tour_focus_box').removeClass('tour_focus_box'); + var tip_id = 'tour_tip'; + if (stepData.interactive) { + if ( + Tour.step === Tour.steps - 1 && + parseInt(Tour.options.route_step_nr, 10) + Tour.step !== Tour.options.step_count + ) { + jQuery('#tour_interactive_text').show(); + } + tip_id = 'tour_tip_interactive'; + } + jQuery('#tour_title').html( + Tour.options.tour_title + + ' (' + + (parseInt(Tour.options.route_step_nr, 10) + Tour.step) + + '/' + + Tour.options.step_count + + ')' + ); + if (stepData.controlsPosition) { + Tour.setControlsPosition(stepData.controlsPosition); + } + if (stepData.title || stepData.tip) { + jQuery('#' + tip_id + ' #tour_tip_title').html(stepData.title); + jQuery('#' + tip_id + ' #tour_tip_content').html(stepData.tip); + + var tooltipPos = typeof stepData.orientation === 'undefined' ? 'B' : stepData.orientation; + Tour.setTooltipPosition(tooltipPos, stepData.element, tip_id); + if (stepData.interactive && stepData.element) { + jQuery(stepData.element).addClass('tour_focus_box'); + } + } + }, + + setControlsPosition: function(pos) { + var position = Tour.getControlPosition(pos); + jQuery('#tour_controls').css(position); + }, + + setTooltipPosition: function(pos, element, tip_id) { + jQuery('.tourArrow').remove(); + if (element && !jQuery(element).length) { + //alert('Das Element wurde nicht gefunden, Tooltip konnte nicht positioniert werden.'); + element = ''; + } + var tw = + jQuery('#' + tip_id).width() + + parseInt(jQuery('#' + tip_id).css('padding-left'), 10) + + parseInt(jQuery('#' + tip_id).css('padding-right'), 10); + var th = + jQuery('#' + tip_id).height() + + parseInt(jQuery('#' + tip_id).css('padding-top'), 10) + + parseInt(jQuery('#' + tip_id).css('padding-bottom'), 10); + if (Tour.options.edit_mode) { + Tour.setSelectorOverlay(); + if (jQuery('#tour_edit').length) { + jQuery('#tour_edit').attr( + 'href', + STUDIP.ABSOLUTE_URI_STUDIP + + 'dispatch.php/tour/edit_step/' + + Tour.id + + '/' + + (parseInt(Tour.options.route_step_nr, 10) + Tour.step) + + '?hide_route=1' + ); + jQuery('#tour_new_step').attr( + 'href', + STUDIP.ABSOLUTE_URI_STUDIP + + 'dispatch.php/tour/edit_step/' + + Tour.id + + '/' + + (parseInt(Tour.options.route_step_nr, 10) + Tour.step + 1) + + '/new?hide_route=1' + ); + jQuery('#tour_new_page').attr( + 'href', + STUDIP.ABSOLUTE_URI_STUDIP + + 'dispatch.php/tour/edit_step/' + + Tour.id + + '/' + + (parseInt(Tour.options.route_step_nr, 10) + Tour.step + 1) + + '/new' + ); + } + } + if (!element || !pos) { + jQuery('#' + tip_id).css({ + top: window.innerHeight / 2 - th / 2 + 'px', + left: window.innerWidth / 2 - tw / 2 + 'px', + position: 'fixed' + }); + jQuery('#' + tip_id).show('fast'); + return; + } + var ew = jQuery(element).outerWidth(); + var eh = jQuery(element).outerHeight(); + var el = jQuery(element).offset().left; + var et = jQuery(element).offset().top; + + var tbg = jQuery('#' + tip_id).css('background-color'); + var $upArrow = $('<div class="tourArrow"></div>').css({ + 'border-left': '16px solid transparent', + 'border-right': '16px solid transparent', + 'border-bottom': '16px solid ' + tbg + }); + var $downArrow = $('<div class="tourArrow"></div>').css({ + 'border-left': '16px solid transparent', + 'border-right': '16px solid transparent', + 'border-top': '16px solid ' + tbg + }); + var $rightArrow = $('<div class="tourArrow"></div>').css({ + 'border-top': '16px solid transparent', + 'border-bottom': '16px solid transparent', + 'border-left': '16px solid ' + tbg + }); + var $leftArrow = $('<div class="tourArrow"></div>').css({ + 'border-top': '16px solid transparent', + 'border-bottom': '16px solid transparent', + 'border-right': '16px solid ' + tbg + }); + var position; + switch (pos) { + case 'BL': + position = { left: el - 10, top: et + eh + 20 }; + $upArrow.css({ top: '-16px', left: '10px' }); + jQuery('#' + tip_id).prepend($upArrow); + break; + + case 'BR': + position = { left: el + ew - tw + 10, top: et + eh + 20 }; + $upArrow.css({ top: '-16px', right: '10px' }); + jQuery('#' + tip_id).prepend($upArrow); + break; + + case 'TL': + position = { left: el - 10, top: et - th - 20 }; + $downArrow.css({ top: th, left: '10px' }); + jQuery('#' + tip_id).append($downArrow); + break; + + case 'TR': + position = { left: el + ew - tw + 10, top: et - th - 20 }; + $downArrow.css({ top: th, right: '10px' }); + jQuery('#' + tip_id).append($downArrow); + break; + + case 'RT': + position = { left: el + ew + 20, top: et - 10 }; + $leftArrow.css({ left: '-16px' }); + jQuery('#' + tip_id).prepend($leftArrow); + break; + + case 'RB': + position = { left: el + ew + 20, top: et + eh - th + 10 }; + $leftArrow.css({ left: '-16px' }); + jQuery('#' + tip_id).prepend($leftArrow); + break; + + case 'LT': + position = { left: el - tw - 20, top: et - 10 }; + $rightArrow.css({ right: '-16px' }); + jQuery('#' + tip_id).prepend($rightArrow); + break; + + case 'LB': + position = { left: el - tw - 20, top: et + eh - th + 10 }; + $rightArrow.css({ right: '-16px' }); + jQuery('#' + tip_id).prepend($rightArrow); + break; + + case 'B': + position = { left: el + ew / 2 - tw / 2, top: et + eh + 20 }; + $upArrow.css({ top: '-16px', left: tw / 2 - 16 + 'px' }); + jQuery('#' + tip_id).prepend($upArrow); + break; + + case 'T': + position = { left: el + ew / 2 - tw / 2, top: et - th - 20 }; + $downArrow.css({ top: th, left: tw / 2 - 16 + 'px' }); + jQuery('#' + tip_id).append($downArrow); + break; + + case 'L': + position = { left: el - tw - 20, top: et + eh / 2 - th / 2 }; + $rightArrow.css({ right: '-16px', top: th / 2 - 16 + 'px' }); + jQuery('#' + tip_id).prepend($rightArrow); + break; + + case 'R': + position = { left: el + ew + 20, top: et + eh / 2 - th / 2 }; + $leftArrow.css({ left: '-16px', top: th / 2 - 16 + 'px' }); + jQuery('#' + tip_id).prepend($leftArrow); + break; + } + + jQuery('#' + tip_id).css({ top: position.top + 'px', left: position.left + 'px', position: 'absolute' }); + jQuery('#' + tip_id).show('fast'); + jQuery.scrollTo(jQuery('#' + tip_id), 400, { offset: -100 }); + }, + + destroy: function() { + jQuery(document).trigger('tourend.studip'); + + jQuery('#tour_overlay').remove(); + if (jQuery('#tour_selector_overlay').length) { + jQuery('#tour_selector_overlay').hide(); + } + if (!jQuery('#tour_proceed').is(':visible')) { + jQuery.ajax({ + url: + STUDIP.ABSOLUTE_URI_STUDIP + + 'dispatch.php/tour/set_status/' + + Tour.id + + '/' + + (parseInt(Tour.options.route_step_nr, 10) + Tour.step) + + '/off' + }); + } + jQuery('#tour_controls').hide(); + jQuery('#tour_tip').hide(); + jQuery('#tour_tip_interactive').hide(); + jQuery('.tour_focus_box').removeClass('tour_focus_box'); + Tour.show_helpcenter(); + Tour.step = -1; + Tour.started = false; + }, + + setSelectorOverlay: function() { + if (jQuery(Tour.options.data[Tour.step].element).length) { + jQuery('#tour_selector_overlay').css({ + display: 'block', + width: jQuery(Tour.options.data[Tour.step].element).outerWidth() + 'px', + height: jQuery(Tour.options.data[Tour.step].element).outerHeight() + 'px', + top: jQuery(Tour.options.data[Tour.step].element).offset().top + 'px', + left: jQuery(Tour.options.data[Tour.step].element).offset().left + 'px' + }); + } else { + jQuery('#tour_selector_overlay').hide(); + } + }, + + getSelector: function(target) { + var element = jQuery(target).prop('tagName'); + if (jQuery(target).attr('id')) { + element = '#' + jQuery(target).attr('id'); + } else if (jQuery(target).attr('name')) { + element = element + '[name=' + jQuery(target).attr('name') + ']'; + } else { + if (jQuery(target).parent().length) { + element = Tour.getSelector(jQuery(target).parent()) + ' ' + element; + element = element + ':eq(' + jQuery(target).index(element) + ') '; + } + } + return element; + }, + + deleteStep: function(tour_id, step_nr, button) { + button = typeof button !== 'undefined' ? button : 'question'; + if (!Tour.pending_ajax_request) { + Tour.pending_ajax_request = true; + jQuery.ajax({ + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/tour/delete_step/' + tour_id + '/' + step_nr, + data: jQuery('.modaloverlay form').serialize() + '&' + button + '=1', + success: function(html, status, xhr) { + Tour.pending_ajax_request = false; + if (xhr.getResponseHeader('X-Action') === 'question') { + if (Tour.started) { + jQuery('#tour_controls').hide(); + jQuery('#tour_tip').hide(); + jQuery('#tour_tip_interactive').hide(); + jQuery('#tour_selector_overlay').hide(); + jQuery('.tour_focus_box').removeClass('tour_focus_box'); + } + jQuery('body').prepend(html); + jQuery('.modaloverlay form').on('click', function(event) { + jQuery(this).data('clicked', jQuery(event.target)); + }); + jQuery('.modaloverlay form').on('submit', function(event) { + event.preventDefault(); + Tour.deleteStep( + jQuery('.modaloverlay form input[name=tour_id]').val(), + jQuery('.modaloverlay form input[name=step_nr]').val(), + jQuery(this) + .data('clicked') + .attr('name') + ); + jQuery('.modaloverlay').remove(); + }); + } else if (xhr.getResponseHeader('X-Action') === 'complete') { + if (Tour.started) { + Tour.showControlButtons(); + Tour.setTooltip(Tour.options.data[Tour.step]); + Tour.started = false; + if (step_nr > 1 && step_nr - Tour.options.route_step_nr >= Tour.steps - 1) { + Tour.init(tour_id, step_nr - 1); + } else { + Tour.init(tour_id, step_nr); + } + } + } + }, + fail: function() { + Tour.pending_ajax_request = false; + alert('Fehler beim Aufruf des Tour-Controllers'); + } + }); + } + }, + + saveStep: function(tour_id, step_nr) { + if (!Tour.pending_ajax_request) { + Tour.pending_ajax_request = true; + jQuery.ajax({ + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/tour/edit_step/' + tour_id + '/' + step_nr + '/save', + type: 'POST', + data: jQuery('#edit_tour_form').serialize(), + dataType: 'html', + success: function(html, status, xhr) { + Tour.pending_ajax_request = false; + if (xhr.getResponseHeader('X-Action') === 'close') { + jQuery('#edit_tour_step') + .parent() + .dialog('close'); + if (Tour.started) { + Tour.started = false; + Tour.init(tour_id, step_nr); + } else { + window.location.replace(window.location.href); + } + } else { + jQuery('#edit_tour_step').replaceWith(html); + } + }, + fail: function() { + Tour.pending_ajax_request = false; + alert('Fehler beim Aufruf des Tour-Controllers'); + } + }); + } + }, + + saveStepPosition: function(tour_id, step_nr, element, mode) { + mode = typeof mode !== 'undefined' ? mode : 'save_position'; + Tour.options.data[Tour.step].element = element; + if (!Tour.pending_ajax_request) { + Tour.pending_ajax_request = true; + jQuery.ajax({ + url: STUDIP.ABSOLUTE_URI_STUDIP + 'dispatch.php/tour/edit_step/' + tour_id + '/' + step_nr + '/' + mode, + type: 'POST', + data: { position: element }, + success: function(html, status, xhr) { + Tour.pending_ajax_request = false; + }, + fail: function() { + Tour.pending_ajax_request = false; + alert('Fehler beim Aufruf des Tour-Controllers'); + } + }); + } + }, + + startEditor: function() { + jQuery('#tour_editor').show(); + if (Tour.options.step_count > 1) { + jQuery('#tour_delete_step').show(); + } else { + jQuery('#tour_delete_step').hide(); + } + + jQuery('#tour_delete_step').on('click', function(event) { + Tour.deleteStep(Tour.id, parseInt(Tour.options.route_step_nr, 10) + Tour.step); + event.preventDefault(); + }); + + jQuery('#tour_no_css').on('click', function() { + jQuery('#tour_selector_overlay').hide(); + if (jQuery('#tour_overlay').length) { + jQuery('#tour_overlay').hide(); + } + Tour.saveStepPosition(Tour.id, parseInt(Tour.options.route_step_nr, 10) + Tour.step, ''); + Tour.setTooltip(Tour.options.data[Tour.step]); + }); + + jQuery('#tour_select_css').on('click', function() { + jQuery('#tour_controls').hide(); + jQuery('#tour_tip').hide(); + jQuery('#tour_tip_interactive').hide(); + jQuery('#tour_selector_overlay').hide(); + if (jQuery('#tour_overlay').length) { + jQuery('#tour_overlay').hide(); + } + Tour.options.edit_mode = 'select_css'; + }); + + jQuery('#tour_select_action_next').on('click', function() { + jQuery('#tour_controls').hide(); + jQuery('#tour_tip').hide(); + jQuery('#tour_tip_interactive').hide(); + jQuery('#tour_selector_overlay').hide(); + if (jQuery('#tour_overlay').length) { + jQuery('#tour_overlay').hide(); + } + Tour.options.edit_mode = 'select_action_next'; + }); + + jQuery('#tour_select_action_prev').on('click', function() { + jQuery('#tour_controls').hide(); + jQuery('#tour_tip').hide(); + jQuery('#tour_tip_interactive').hide(); + jQuery('#tour_selector_overlay').hide(); + if (jQuery('#tour_overlay').length) { + jQuery('#tour_overlay').hide(); + } + Tour.options.edit_mode = 'select_action_prev'; + }); + + if (!jQuery('#tour_selector_overlay').length) { + jQuery('body').prepend('<div id="tour_selector_overlay" style="z-index:20000;"></div>'); + } + jQuery('body').on('click', function(event) { + var clicked_element; + if (Tour.options.edit_mode === 'select_css') { + clicked_element = Tour.getSelector(event.target); + event.preventDefault(); + if (clicked_element !== '#tour_select_css') { + Tour.options.edit_mode = 1; + Tour.saveStepPosition( + Tour.id, + parseInt(Tour.options.route_step_nr, 10) + Tour.step, + clicked_element, + 'save_position' + ); + Tour.setTooltip(Tour.options.data[Tour.step]); + if (jQuery('#tour_overlay').length) { + jQuery('#tour_overlay').show(); + } + Tour.showControlButtons(); + } + } + if (Tour.options.edit_mode === 'select_action_next') { + clicked_element = Tour.getSelector(event.target); + event.preventDefault(); + if (clicked_element !== '#tour_select_action_next') { + Tour.options.edit_mode = 1; + Tour.saveStepPosition( + Tour.id, + parseInt(Tour.options.route_step_nr, 10) + Tour.step, + clicked_element, + 'save_action_next' + ); + Tour.setTooltip(Tour.options.data[Tour.step]); + if (jQuery('#tour_overlay').length) { + jQuery('#tour_overlay').show(); + } + Tour.showControlButtons(); + } + } + if (Tour.options.edit_mode === 'select_action_prev') { + clicked_element = Tour.getSelector(event.target); + event.preventDefault(); + if (clicked_element !== '#tour_select_action_prev') { + Tour.options.edit_mode = 1; + Tour.saveStepPosition( + Tour.id, + parseInt(Tour.options.route_step_nr, 10) + Tour.step, + clicked_element, + 'save_action_prev' + ); + Tour.setTooltip(Tour.options.data[Tour.step]); + if (jQuery('#tour_overlay').length) { + jQuery('#tour_overlay').show(); + } + Tour.showControlButtons(); + } + } + }); + } +}; + +export default Tour; diff --git a/resources/assets/javascripts/lib/url_helper.js b/resources/assets/javascripts/lib/url_helper.js new file mode 100644 index 0000000..61a5a62 --- /dev/null +++ b/resources/assets/javascripts/lib/url_helper.js @@ -0,0 +1,89 @@ +/** + * This class helps to handle URLs of hyperlinks and change their parameters. + * For example a javascript-page may open an item and the user expects other links + * on the same page to "know" that this item is now open. But because we don't use + * PHP session-variables here, this is difficult to use. This class can help. You + * can overwrite the href-attribute of the link by: + * + * [code] + * link.href = STUDIP.URLHelper.getURL("adresse.php?hello=world#anchor"); + * [/code] + * Returns something like: + * "http://uni-adresse.de/studip/adresse.php?hello=world&mandatory=parameter#anchor" + */ + +class URLHelper { + constructor(base_url = null, parameters = {}) { + //the base url for all links + this.base_url = base_url; + + // bound link parameters + this.parameters = parameters; + } + + /** + * method to extend short URLs like "dispatch.php/profile" to "http://.../dispatch.php/profile" + */ + resolveURL(url) { + if (!_.isString(this.base_url) || url.match(/^[a-zA-Z][a-zA-Z0-9+-.]*:/) !== null || url.charAt(0) === '?') { + //this method cannot do any more: + return url; + } + var base_url = this.base_url; + if (url.charAt(0) === '/') { + var host = this.base_url.match(/^[a-zA-Z][a-zA-Z0-9+-.]*:\/\/[\w:.\-]+/); + base_url = host ? host[0] : ''; + } + return base_url + url; + } + + /** + * returns a readily encoded URL with the mandatory parameters and additionally passed + * parameters. + * + * @param url string: any url-string + * @param param_object map: associative object for extra values + * @param ignore_params boolean: ignore previously bound parameters + * @return: url with all necessary and additional parameters, encoded + */ + getURL(url, param_object, ignore_params) { + var params = _.defaults(param_object || {}, ignore_params ? {} : this.parameters), + tmp, + fragment, + query; + + tmp = url.split('#'); + url = tmp[0]; + fragment = tmp[1]; + + tmp = url.split('?'); + url = tmp[0]; + query = tmp[1] || ''; + + if (url !== '') { + url = this.resolveURL(url); + } + query = decodeURIComponent(query); + // split query string and merge with param_object + _.each((query && query.split('&')) || [], function(e) { + var pair = e.split('='); + if (!(pair[0] in params)) { + params[pair[0]] = pair[1]; + } + }); + + if (_.keys(params).length || url === '') { + url += '?' + jQuery.param(params); + } + + if (fragment) { + url += '#' + fragment; + } + + return url; + } +} + +export default function createURLHelper(config) { + return new URLHelper(config && config.base_url, config && config.parameters); +} diff --git a/resources/assets/javascripts/lib/user_filter.js b/resources/assets/javascripts/lib/user_filter.js new file mode 100644 index 0000000..ee2d622 --- /dev/null +++ b/resources/assets/javascripts/lib/user_filter.js @@ -0,0 +1,172 @@ +/* ------------------------------------------------------------------------ + * Bedingungen zur Auswahl von Stud.IP-Nutzern + * ------------------------------------------------------------------------ */ +import { $gettext } from './gettext.js'; +import Dialog from './dialog.js'; + +const UserFilter = { + new_group_nr: 1, + + configureCondition: function(targetId, targetUrl) { + Dialog.fromURL(targetUrl, { + title: $gettext('Bedingung konfigurieren'), + size: Math.min(Math.round(0.9 * $(window).width()), 850) + 'x400', + method: 'post', + id: 'configurecondition' + }); + return false; + }, + + /** + * Adds a new user filter to the list of set filters. + * @param String containerId + * @param String targetUrl + */ + addCondition: function(containerId, targetUrl) { + var children = $('.conditionfield'); + var query = ''; + $('.conditionfield').each(function() { + query += + '&field[]=' + + encodeURIComponent( + $(this) + .children('.conditionfield_class:first') + .val() + ) + + '&compare_operator[]=' + + encodeURIComponent( + $(this) + .children('.conditionfield_compare_op:first') + .val() + ) + + '&value[]=' + + encodeURIComponent( + $(this) + .children('.conditionfield_value:first') + .val() + ); + }); + $.ajax({ + type: 'post', + url: targetUrl, + data: query, + dataType: 'html', + success: function(data, textStatus, jqXHR) { + var result = ''; + if ($('#' + containerId).children('.nofilter:visible').length > 0) { + $('#' + containerId) + .children('.nofilter') + .hide(); + $('#' + containerId) + .children('.userfilter') + .show(); + } else if ($('#' + containerId).children('.ungrouped_conditions .condition_list').length > 0) { + result += '<b>' + $gettext('oder') + '</b>'; + } + result += data; + $('#' + containerId) + .find('.userfilter .ungrouped_conditions .condition_list') + .append(result); + if ($('#no_conditiongroups').length > 0) { + $('.userfilter .ungrouped_conditions .condition_list input[type=checkbox]').hide(); + } + $('.userfilter .group_conditions').show(); + } + }); + Dialog.close({ id: 'configurecondition' }); + }, + + /** + * groups selected conditions + */ + groupConditions: function() { + var selected = $('.userfilter input:checked').parent('div'); + var group_template = $('.grouped_conditions_template').clone(); + if (selected.length > 0) { + $('.userfilter input[type=checkbox]:checked') + .prop('checked', false) + .hide(); + $('.userfilter .group_conditions').after(group_template.show()); + selected.find('input[name^=conditiongroup_]').prop('value', UserFilter.new_group_nr); + $('.grouped_conditions_template:last .condition_list').append(selected); + $('.grouped_conditions_template:last .condition_list input[name=quota]').prop( + 'name', + 'quota_' + UserFilter.new_group_nr + ); + $('.grouped_conditions_template:last').prop('id', 'new_conditiongroup_' + UserFilter.new_group_nr); + $('.grouped_conditions_template:last').prop('class', 'grouped_conditions'); + UserFilter.new_group_nr++; + } + if ($('.userfilter .ungrouped_conditions .condition_list .condition').length == 0) { + $('.userfilter .group_conditions').hide(); + } + return false; + }, + + /** + * removes group for conditions + */ + ungroupConditions: function(element) { + var selected = $(element) + .parents('.grouped_conditions') + .find('.condition'); + var empty_group = $(element).parents('.grouped_conditions'); + if (selected.length > 0) { + selected.find('input[name^=conditiongroup_]').prop('value', ''); + $('.ungrouped_conditions .condition_list').append(selected); + $('.ungrouped_conditions input[type=checkbox]:not(:visible)').show(); + empty_group.remove(); + } + $('.userfilter .group_conditions').show(); + return false; + }, + + getConditionFieldConfiguration: function(element, targetUrl) { + var target = $(element).parent(); + $.ajax(targetUrl, { + url: targetUrl, + data: { fieldtype: $(element).val() }, + success: function(data, textStatus, jqXHR) { + target.children('.conditionfield_compare_op').remove(); + target.children('.conditionfield_value').remove(); + target + .children('.conditionfield_delete') + .first() + .before(data); + }, + error: function(jqXHR, textStatus, errorThrown) { + alert('Status: ' + textStatus + '\nError: ' + errorThrown); + } + }); + return false; + }, + + addConditionField: function(targetId, targetUrl) { + $.ajax({ + url: targetUrl, + success: function(data, textStatus, jqXHR) { + $('#' + targetId).append(data); + }, + error: function(jqXHR, textStatus, errorThrown) { + alert('Status: ' + textStatus + '\nError: ' + errorThrown); + } + }); + return false; + }, + + removeConditionField: function(element) { + element.remove(); + Dialogs.closeConfirmDialog(); + return false; + }, + + closeDialog: function(button) { + var dialog = $(button) + .parents('div[role=dialog]') + .first(); + dialog.remove(); + return false; + } +}; + +export default UserFilter; diff --git a/resources/assets/javascripts/lib/wysiwyg.js b/resources/assets/javascripts/lib/wysiwyg.js new file mode 100644 index 0000000..87929c9 --- /dev/null +++ b/resources/assets/javascripts/lib/wysiwyg.js @@ -0,0 +1,569 @@ +/** + * wysiwyg.js - Replace HTML textareas with WYSIWYG editor. + * + * Developer documentation can be found at + * http://docs.studip.de/develop/Entwickler/Wysiwyg. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Robert Costa <zabbarob@gmail.com> + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ +import parseOptions from './parse_options.js'; + +const wysiwyg = { + disabled: !STUDIP.editor_enabled, + // NOTE keep this function in sync with Markup class + htmlMarker: '<!--HTML-->', + htmlMarkerRegExp: /^\s*<!--\s*HTML.*?-->/i, + + isHtml: function isHtml(text) { + // NOTE keep this function in sync with + // Markup::isHtml in Markup.class.php + return this.hasHtmlMarker(text); + }, + hasHtmlMarker: function hasHtmlMarker(text) { + // NOTE keep this function in sync with + // Markup::hasHtmlMarker in Markup.class.php + return this.htmlMarkerRegExp.test(text); + }, + markAsHtml: function markAsHtml(text) { + // NOTE keep this function in sync with + // Markup::markAsHtml in Markup.class.php + if (this.hasHtmlMarker(text) || text.trim() == '') { + return text; // marker already set, don't set twice + } + return this.htmlMarker + '\n' + text; + }, + // Create Stud.IP default configuration for editor + getDefaultConfig: function(textarea) { + // create new toolbar container + var textareaHeight = Math.max(textarea.height(), 200), + textareaWidth = (textarea.outerWidth() / textarea.parent().width()) * 100 + '%'; + + // fetch ckeditor configuration + var options = textarea.attr('data-editor'), + extraPlugins, + removePlugins; + + if (options) { + options = parseOptions(options); + extraPlugins = options.extraPlugins; + removePlugins = options.removePlugins; + } + + return { + allowedContent: { + // NOTE update the dev docs when changing ACF settings!! + // at http://docs.studip.de/develop/Entwickler/Wysiwyg + // + // note that changes here should also be reflected in + // HTMLPurifier's settings!! + a: { + // note that external links should always have + // class="link-extern", target="_blank" and rel="nofollow" + // and internal links should not have any attributes except + // for href, but this cannot be enforced here + attributes: ['href', 'target', 'rel', 'name', 'id'], + classes: ['link-extern', 'link-intern'] + }, + audio: { + attributes: ['controls', '!src', 'height', 'width'], + // only float:left and float:right should be allowed + styles: ['float', 'height', 'width'] + }, + big: {}, + blockquote: {}, + br: {}, + caption: {}, + code: {}, + em: {}, + div: { + classes: 'author', // needed for quotes + // only allow left margin and horizontal text alignment to + // be set in divs + // - margin-left should only be settable in multiples of + // 40 pixels + // - text-align should only be either "center", "right" or + // "justify" + // - note that maybe these two features will be removed + // completely in future versions + styles: ['margin-left', 'text-align'] + }, + h1: {}, + h2: {}, + h3: {}, + h4: {}, + h5: {}, + h6: {}, + hr: {}, + img: { + attributes: ['alt', '!src', 'height', 'width'], + // only float:left and float:right should be allowed + styles: ['float'] + }, + li: {}, + ol: {}, + p: { + // - margin-left should only be settable in multiples of + // 40 pixels + // - text-align should only be either "center", "right" or + // "justify" + styles: ['margin-left', 'text-align'] + }, + pre: { + classes: ['usercode'] + }, + span: { + // note that 'wiki-links' are currently set as a span due + // to implementation difficulties, but probably this + // might be changed in future versions + classes: ['wiki-link', 'math-tex'], + + // note that allowed (background-)colors should be further + // restricted + styles: ['color', 'background-color'] + }, + strong: {}, + u: {}, + ul: {}, + s: {}, + small: {}, + sub: {}, + sup: {}, + table: { + // note that tables should always have the class "content" + // (it should not be optional) + classes: 'content' + }, + tbody: {}, + td: { + // attributes and styles should be the same + // as for <th>, except for 'scope' attribute + attributes: ['colspan', 'rowspan'], + styles: ['text-align', 'width', 'height', 'background-color'] + }, + thead: {}, + th: { + // attributes and styles should be the same + // as for <td>, except for 'scope' attribute + // + // note that allowed scope values should be restricted to + // "col", "row" or "col row", if scope is set + attributes: ['colspan', 'rowspan', 'scope'], + styles: ['text-align', 'width', 'height'] + }, + tr: {}, + tt: {}, + video: { + attributes: ['controls', '!src', 'height', 'width'], + // only float:left and float:right should be allowed + styles: ['float', 'height', 'width'] + } + }, + height: textareaHeight, + width: textareaWidth, + skin: 'studip,' + STUDIP.ASSETS_URL + 'stylesheets/ckeditor-skin/', + // NOTE codemirror crashes when not explicitely loaded in CKEditor 4.4.7 + extraPlugins: + 'emojione,studip-floatbar,studip-quote,studip-upload,studip-settings' + + (extraPlugins ? ',' + extraPlugins : ''), + removePlugins: removePlugins ? removePlugins : textarea.closest('.ui-dialog').length ? 'autogrow' : '', + enterMode: CKEDITOR.ENTER_BR, + mathJaxLib: STUDIP.URLHelper.getURL('assets/javascripts/mathjax/MathJax.js?config=TeX-AMS_HTML,default'), + studipUpload_url: STUDIP.URLHelper.getURL('dispatch.php/wysiwyg/upload'), + codemirror: { + autoCloseTags: false, + autoCloseBrackets: false, + showSearchButton: false, + showFormatButton: false, + showCommentButton: false, + showUncommentButton: false, + showAutoCompleteButton: false + }, + autoGrow_onStartup: true, + + // configure toolbar + toolbarGroups: [ + { name: 'basicstyles', groups: ['undo', 'basicstyles', 'cleanup'] }, + { name: 'paragraph', groups: ['list', 'indent', 'blocks', 'align', 'quote'] }, + '/', + { name: 'styles', groups: ['styles', 'colors', 'tools', 'links', 'insert'] }, + { name: 'others', groups: ['mode', 'settings'] } + ], + removeButtons: 'Font,FontSize', + toolbarCanCollapse: true, + toolbarStartupExpanded: textarea.width() > 420, + + // configure dialogs + dialog_buttonsOrder: 'ltr', + removeDialogTabs: 'image:Link;image:advanced;' + 'link:target;link:advanced;' + 'table:advanced', + + // convert special chars except latin ones to html entities + entities: false, + entities_latin: false, + entities_processNumerical: true, + + // set WYSIWYG's menu language to the language set in Stud.IP + defaultLanguage: 'de', // use German if user language not available + language: String.locale, // override browser-stored preferences + + // configure list of special characters + // NOTE 17 characters fit in one row of special characters dialog + specialChars: [].concat( + [ + 'À', + 'Á', + 'Â', + 'Ã', + 'Ä', + 'Å', + 'Æ', + 'È', + 'É', + 'Ê', + 'Ë', + 'Ì', + 'Í', + 'Ï', + 'Î', + '', + 'Ý', + + 'à', + 'á', + 'â', + 'ã', + 'ä', + 'å', + 'æ', + 'è', + 'é', + 'ê', + 'ë', + 'ì', + 'í', + 'ï', + 'î', + '', + 'ý', + + 'Ò', + 'Ó', + 'Ô', + 'Õ', + 'Ö', + 'Ø', + 'Œ', + 'Ù', + 'Ú', + 'Û', + 'Ü', + '', + 'Ç', + 'Ñ', + 'Ŵ', + '', + 'Ŷ', + + 'ò', + 'ó', + 'ô', + 'õ', + 'ö', + 'ø', + 'œ', + 'ù', + 'ú', + 'û', + 'ü', + '', + 'ç', + 'ñ', + 'ŵ', + '', + 'ŷ', + + 'ß', + 'Ð', + 'ð', + 'Þ', + 'þ', + '', + '', + '`', + '´', + '^', + '¨', + '', + '¸', + '~', + '≈', + '', + 'ÿ' + ], + (function() { + var greek = []; + for (var i = 913; i <= 929; i++) { + // 17 uppercase characters + greek.push('&#' + String(i)); + } + for (var i = 945; i <= 962; i++) { + // 17 lowercase characters + greek.push('&#' + String(i)); + } + // NOTE character #930 is not assigned!! + for (var i = 931; i <= 937; i++) { + // remaining upercase + greek.push('&#' + String(i)); + } + greek.push(''); + for (var i = 963; i <= 969; i++) { + // remaining lowercase + greek.push('&#' + String(i)); + } + greek.push(''); + return greek; + })(), + [ + 'ª', + 'º', + '°', + '¹', + '²', + '³', + '¼', + '½', + '¾', + '‘', + '’', + '“', + '”', + '«', + '»', + '¡', + '¿', + + '@', + '§', + '¶', + 'µ', + '[', + ']', + '{', + '}', + '|', + '¦', + '–', + '—', + '¯', + '‚', + '‛', + '„', + '…', + + '€', + '¢', + '£', + '¥', + '¤', + '©', + '®', + '™', + + '¬', + '·', + '×', + '÷', + + '►', + '•', + '→', + '⇒', + '⇔', + '♦', + + '±', // ± + '∩', // ∩ INTERSECTION + '∪', // ∪ UNION + '∞', // ∞ INFINITY + 'ℇ', // ℇ EULER CONSTANT + '∀', // ∀ FOR ALL + '∁', // ∁ COMPLEMENT + '∂', // ∂ PARTIAL DIFFERENTIAL + '∃', // ∃ THERE EXISTS + '∄', // ∄ THERE DOES NOT EXIST + '∅', // ∅ EMPTY SET + '∆', // ∆ INCREMENT + '∇', // ∇ NABLA + '⊂', // ⊂ SUBSET OF + '⊃', // ⊃ SUPERSET OF + '⊄', // ⊄ NOT A SUBSET OF + '⊆', // ⊆ SUBSET OF OR EQUAL TO + '⊇', // ⊇ SUPERSET OF OR EQUAL TO + '∈', // ∈ ELEMENT OF + '∉', // ∉ NOT AN ELEMENT OF + '∧', // ∧ LOGICAL AND + '∨', // ∨ LOGICAL OR + '≤', // ≤ LESS-THAN OR EQUAL TO + '≥', // ≥ GREATER-THAN OR EQUAL TO + '∎', // ∎ END OF PROOF + '∏', // ∏ N-ARY PRODUCT + '∑', // ∑ N-ARY SUMMATION + '√', // √ SQUARE ROOT + '∫', // ∫ INTEGRAL + '∴', // ∴ THEREFORE + '∵', // ∵ BECAUSE + '≠', // ≠ NOT EQUAL TO + '≢', // ≢ NOT IDENTICAL TO + '≣', // ≣ STRICTLY EQUIVALENT TO + '⊢', // ⊢ RIGHT TACK + '⊣', // ⊣ LEFT TACK + '⊤', // ⊤ DOWN TACK + '⊥', // ⊥ UP TACK + '⊧', // ⊧ MODELS + '⊨', // ⊨ TRUE + '⊬', // ⊬ DOES NOT PROVE + '⊭', // ⊭ NOT TRUE + '⋮', // ⋮ VERTICAL ELLIPSIS + '⋯', // ⋯ MIDLINE HORIZONTAL ELLIPSIS + '⧼', // ⧼ LEFT-POINTING CURVED ANGLE BRACKET + '⧽', // ⧽ RIGHT-POINTING CURVED ANGLE BRACKET + 'ⁿ', // ⁿ SUPERSCRIPT LATIN SMALL LETTER N + '⊕', // ⊕ CIRCLED PLUS + '⊗', // ⊗ CIRCLED TIMES + '⊙' // ⊙ CIRCLED DOT OPERATOR + ] + ), + on: { pluginsLoaded: onPluginsLoaded }, + title: false + }; + }, + + // for jquery dialogs, see toolbar.js + replace: replaceTextarea +}; + +export default wysiwyg; + +function replaceTextarea(textarea, config) { + // TODO support jQuery object with multiple textareas + if (!(textarea instanceof jQuery)) { + textarea = $(textarea); + } + + // In Firefox the browser's window is not set active after a Drag and Drop action. + // So placeholders do not work correctly in Firefox and will be removed. + if (CKEDITOR.env.gecko) { + textarea.removeAttr('placeholder'); + } + + // create ID for textarea if it doesn't have one + if (!textarea.attr('id')) { + textarea.attr('id', createNewId('wysiwyg')); + } + + // No custom config given, fetch default config + if (config == undefined) { + config = wysiwyg.getDefaultConfig(textarea); + } + + // replace textarea with editor + CKEDITOR.replace(textarea[0], config); + + CKEDITOR.on('instanceReady', function(event) { + var editor = event.editor, + $textarea = $(editor.element.$); + + // auto-resize editor area in source view mode, and keep focus! + editor.on('mode', function(event) { + var editor = event.editor; + if (editor.mode === 'source') { + $(editor.container.$) + .find('.cke_source') + .focus(); + } else { + editor.focus(); + } + }); + + // fix for not pasting text from clipboard twice on firefox in a dialog + if (CKEDITOR.env.gecko && $textarea.closest('.ui-dialog').length) { + $(editor.container.$).on('paste', function(event) { + event.preventDefault(); + }); + } + + // clean up HTML edited in source mode before submit + var form = $textarea.closest('form'); + form.submit(function(event) { + // make sure HTML marker is always set, in + // case contents are cut-off by the backend + editor.setData(wysiwyg.markAsHtml(editor.getData())); + editor.updateElement(); // update textarea, in case it's accessed by other JS code + }); + + // update textarea on editor blur + editor.on('blur', function(event) { + event.editor.updateElement(); + }); + $(editor.container.$).on('blur', '.CodeMirror', function(event) { + editor.updateElement(); // also update in source mode + }); + + // blurDelay = 0 is an ugly hack to be faster than Stud.IP + // forum's save function; might produce "strange" behaviour + CKEDITOR.focusManager._.blurDelay = 0; + + // display "focused"-effect when editor area is focused + editor.on('focus', function(event) { + event.editor.container.addClass('cke_chrome_focused'); + }); + editor.on('blur', function(event) { + event.editor.container.removeClass('cke_chrome_focused'); + }); + + // keep the editor focused when a toolbar item gets selected + editor.on('blur', function(event) { + var toolbarContainer = $('#' + event.editor.config.sharedSpaces.top); + if (toolbarContainer.has(':focus').length > 0) { + event.editor.focus(); + } + }); + + // Trigger load event for the editor event. Uses the underlying + // textarea element to ensure that the event will be catchable by + // jQuery. + $textarea.trigger('load.wysiwyg'); + + // focus the editor if requested + if ($textarea.is('[autofocus]')) { + editor.focus(); + } + }); +} + +// editor events +function onPluginsLoaded(event) { + // tell editor to always remove html comments + event.editor.dataProcessor.htmlFilter.addRules({ + comment: function(element) { + if (!wysiwyg.hasHtmlMarker(decodeURIComponent(element).substring(18))) { + return false; + } + } + }); +} + +// create an unused id +function createNewId(prefix) { + var i = 0; + while ($('#' + prefix + i).length > 0) { + i++; + } + return prefix + i; +} |
