Merge pull request #6102 from opf/fix/project-autocomplete

[26277] Replace project autocompleter from select2 to jquery-ui
pull/6110/head
ulferts 7 years ago committed by GitHub
commit 011b6b199e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 92
      app/assets/javascripts/application.js.erb
  2. 10
      app/assets/stylesheets/content/_autocomplete.sass
  3. 2
      app/assets/stylesheets/content/_index.sass
  4. 90
      app/assets/stylesheets/content/menus/_project_autocompletion.sass
  5. 45
      app/assets/stylesheets/layout/_top_menu.sass
  6. 2
      app/assets/stylesheets/layout/_top_menu_mobile.sass
  7. 37
      app/helpers/angular_helper.rb
  8. 3
      config/locales/js-en.yml
  9. 249
      frontend/app/components/common/autocomplete/lazyloaded/lazyloaded-autocompleter.ts
  10. 4
      frontend/app/components/common/path-helper/path-helper.service.ts
  11. 240
      frontend/app/components/projects/project-menu-autocomplete/project-menu-autocomplete.component.ts
  12. 14
      frontend/app/components/projects/project-menu-autocomplete/project-menu-autocomplete.template.html
  13. 5
      frontend/app/vendors.js
  14. 10
      frontend/npm-shrinkwrap.json
  15. 2
      frontend/package.json
  16. 2
      lib/redmine/menu_manager/top_menu/projects_menu.rb
  17. 103
      spec/features/projects/project_autocomplete_spec.rb
  18. 80
      spec/support/components/projects/top_menu.rb
  19. 2
      spec/support/pages/members.rb

@ -267,6 +267,7 @@ jQuery(document).ready(function($) {
addClickEventToAllErrorMessages();
});
// TODO remove after select2
$.extend($.fn.select2.defaults, {
formatNoMatches: function () {
return I18n.t("js.select2.no_matches");
@ -289,97 +290,6 @@ jQuery(document).ready(function($) {
}
});
jQuery('#project-search-container .select2-select').each(function (ix, select) {
var PROJECT_JUMP_BOX_PAGE_SIZE = 50;
select = $(select);
var select2,
menu = select.parents('li.drop-down');
select.select2({
formatResult : OpenProject.Helpers.Search.formatter,
matcher : OpenProject.Helpers.Search.matcher,
query : OpenProject.Helpers.Search.projectQueryWithHierarchy(
jQuery.proxy(openProject, 'fetchProjects'),
PROJECT_JUMP_BOX_PAGE_SIZE),
dropdownCssClass : "project-search-results",
containerCssClass : "select2-select"
}).
on('change', function (e) {
// this handles expected 'new-tab behaviour'
if (e.val) {
// jQuery sets metaKey to true when pressing ctrl
if (e.metaKey) {
window.open(select2.data().project.url);
} else {
window.location = select2.data().project.url;
}
}
}).
on('close', function () {
if (menu.is('.open')) {
menu.trigger("closeMenu");
}
});
select2 = select.data('select2');
// Adding an event handler to change select2's default behavior concerning
// TAB and ESC
select2.search.keydown(function (e) {
switch (e.which) {
case 9: // TAB
closestVisible = select2.container.children(".select2-choice").closest(":visible");
if (e.shiftKey) {
closestVisible.previousElementInDom(":input:visible, a:visible").focus();
} else {
closestVisible.nextElementInDom(":input:visible, a:visible").focus();
}
e.stopImmediatePropagation();
e.preventDefault();
return false;
case 27: // ESC
e.stopImmediatePropagation();
e.preventDefault();
return false;
}
});
// Moving the newly attached handler to the beginning of the handler chain
// Warning: This uses the internal jQuery API.
// http://stackoverflow.com/a/2641047/420614
if (select2.search.length) {
var last_handler = jQuery._data(select2.search[0], 'events').keydown.pop();
jQuery._data(select2.search[0], 'events').keydown.unshift(last_handler);
}
menu.bind("closed", function () {
// Close select2 result list, when menu is closed
select2.close();
});
menu.bind("opened", function () {
// Open select2 element, when menu is opened
select2.open();
jQuery("#select2-drop-mask").hide();
// Include input in tab cycle by attaching keydown handlers to previous
// and next interactive DOM element.
select2.container.previousElementInDom(":input:visible, a:visible").keydown(function (e) {
if (!e.shiftKey && e.which === 9) {
select2.search.focus();
e.preventDefault();
}
});
select2.container.nextElementInDom(":input:visible:not(.select2-input), a:visible:not(.select2-input)").keydown(function (e) {
if (e.shiftKey && e.which === 9 && select2.search.is(":visible")) {
select2.search.focus();
e.preventDefault();
}
});
});
});
// file table thumbnails
jQuery("table a.has-thumb").hover(function() {
jQuery(this).removeAttr("title").toggleClass("active");

@ -82,8 +82,8 @@ div.autocomplete
&.ui-state-active, &.ui-state-active a
border: none
@include varprop(background, drop-down-hover-bg-color)
@include varprop(color, drop-down-hover-font-color)
@include varprop(background, drop-down-selected-bg-color)
@include varprop(color, drop-down-selected-font-color)
&.-inplace
position: relative
@ -104,6 +104,12 @@ div.autocomplete
background: $nm-color-error-background
border-color: $nm-color-error-border
mark.ui-autocomplete-match
background: none
@include varprop(color, primary-color)
text-decoration: underline
font-weight: bold !important
// Loading indicator
.ui-autocomplete--loading
background: white

@ -62,3 +62,5 @@
@import content/focus_within
@import content/help_texts
@import content/on_off_status
@import content/menus/_project_autocompletion

@ -0,0 +1,90 @@
#project-search-container
position: relative
.project-search-results
position: absolute
left: -1px
width: 400px !important
background: white
@include default-font($header-drop-down-projects-search-font-color, 13px)
li
padding: 0 10px
font-size: 15px
.select2-search
margin: 1px 0 0 0
color: #b3b3b3
border-top: 1px solid #D9D9D9
border-bottom: 1px solid #D9D9D9
&:before
color: #b3b3b3
top: 21px
right: 25px
font-size: 14px
.select2-results
margin: 6px 0
padding: 0
.select2-highlighted
@include varprop(background, drop-down-selected-bg-color, !important)
border-radius: 0 !important
font-weight: normal !important
@include varprop(color, drop-down-selected-font-color)
&:hover
@include varprop(background, drop-down-hover-bg-color, !important)
@include varprop(color, drop-down-hover-font-color, !important)
// Search input wrapper
.project-menu-autocomplete--input-container
padding: 12px 0
border: 1px solid $header-drop-down-border-color
// Border to complete the menu look
border-right: 2px solid $header-drop-down-border-color
// Search input
input.project-menu-autocomplete--input
margin: 0 10px
padding: 0px 32px 0px 10px
border: 1px solid #D9D9D9
box-shadow: inset 0px 1px 3px 0px rgba(0,0,0,0.1)
box-sizing: border-box
width: calc(100% - 20px)
height: 2.125rem
background: $header-drop-down-projects-search-input-bg-color
// Lens icon on the right
.project-menu-autocomplete--search-icon
position: absolute
color: #b3b3b3
top: 18px
right: 20px
font-size: 14px
// Override top menu height
ul.project-menu-autocomplete--results
max-height: 55vh
overflow-y: auto
// Override the computed width of the input, but span the entire width
// of the dropdown
width: 398px !important
padding-top: 5px
// Borders to complete the menu look
border-right: 2px solid $header-drop-down-border-color
border-bottom: 2px solid $header-drop-down-border-color
// Cut off result element width
.ui-menu-item-wrapper
overflow: hidden
white-space: nowrap
text-overflow: ellipsis
// Indent the no results pane
.project-menu-autocomplete--no-results
// Mirror border from ui results
border: 2px solid $header-drop-down-border-color
border-top: none
padding: 12px

@ -233,51 +233,6 @@ input.top-menu-search--input
.top-menu-items-right
float: right
#select2-drop.project-search-results
margin: 0 0 0 -2px
border: 2px solid $header-drop-down-border-color
border-top: 0
// Overwrite Selct2's width calculations
width: 396px !important
@include default-font($header-drop-down-projects-search-font-color, 13px)
li
padding: 0 10px
font-size: 15px
.select2-search
margin: 1px 0 0 0
color: #b3b3b3
border-top: 1px solid #D9D9D9
border-bottom: 1px solid #D9D9D9
&:before
color: #b3b3b3
top: 21px
right: 25px
font-size: 14px
.select2-search input.select2-input
margin: 12px 10px
padding: 0px 32px 0px 10px
border: 1px solid #D9D9D9
box-shadow: inset 0px 1px 3px 0px rgba(0,0,0,0.1)
width: calc(100% - 20px)
background: $header-drop-down-projects-search-input-bg-color
.select2-results
margin: 6px 0
padding: 0
.select2-highlighted
@include varprop(background, drop-down-selected-bg-color, !important)
border-radius: 0 !important
font-weight: normal !important
@include varprop(color, drop-down-selected-font-color)
&:hover
@include varprop(background, drop-down-hover-bg-color, !important)
@include varprop(color, drop-down-hover-font-color, !important)
#header
min-width: 840px
.chzn-container .chzn-results .highlighted

@ -109,7 +109,7 @@
border: none
top: $header-height-mobile
#select2-drop.project-search-results
.project-search-results
width: 100vw !important
left: 0 !important
border-right: 0

@ -0,0 +1,37 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
module AngularHelper
##
# Create a component element tag with the given attributes
def angular_component_tag(component, options = {})
options[:class] = options.fetch(:class, '') + ' op-angular-component'
tag(component, options)
end
end

@ -249,6 +249,9 @@ en:
work_package_belongs_to: 'This work package belongs to project %{projectname}.'
click_to_switch_context: 'Open this work package in that project.'
autocompleter:
label: 'Project autocompletion'
text_are_you_sure: "Are you sure?"
types:

@ -0,0 +1,249 @@
import * as fuzzy from 'fuzzy';
export interface IAutocompleteItem<T> {
label:string;
render:'match' | 'disabled';
object:T;
}
export abstract class ILazyAutocompleterBridge<T> {
// Current page the autocompleter is on
public currentPage:number;
// Input autocomplete element
public input:JQuery;
public constructor(public widgetName:string) {
LazyLoadedAutocompleter.register(widgetName, this);
}
/**
* Return the maximum number of items to render in this page.
* Note that for this value, the container must be setup that a scrollbar exists.
* @returns {number}
*/
public abstract get maxItemsPerPage():number;
/**
* Handler function for when an active item was selected through the autocompleter
* @param {T} item
*/
public abstract onItemSelected(item:T):void;
/**
* Handler function for when no results were matched through the search term.
* @param {JQueryUI.AutocompleteEvent} event
* @param {JQueryUI.AutocompleteUIParams} ui
*/
public abstract onNoResultsFound(event:JQueryUI.AutocompleteEvent, ui:any):void;
/**
* Customize the rendering of an inner item element.
*
* @param {IAutocompleteItem} item
* @param {JQuery} div
*/
public customizeItem(item:IAutocompleteItem<T>, div:JQuery):void {
// Do nothing
}
/**
* Returns the elements matched by the fuzzy search
*/
private fuzzySearch(items:IAutocompleteItem<T>[], term:string) {
if (term === '') {
return items;
}
const options = { extract: (i:IAutocompleteItem<T>) => i.label };
const results = fuzzy.filter(term, items, options);
return results.map(el => el.original);
}
/**
* Allows to augment the set of matched items (e.g., to add hierarchy).
* @param {IAutocompleteItem<T>[]} items
* @param {IAutocompleteItem<T>[]} matched
* @returns {IAutocompleteItem<T>[]}
*/
protected augmentedResultSet(items:IAutocompleteItem<T>[], matched:IAutocompleteItem<T>[]) {
// By default, set all to match
const results:IAutocompleteItem<T>[] = [];
matched.forEach(el => {
results.push({
label: el.label,
object: el.object,
render: 'match'
} as IAutocompleteItem<T>);
});
return results;
}
public setup(input:JQuery, items:IAutocompleteItem<T>[]) {
this.currentPage = 0;
this.input = input;
this.input[this.widgetName].call(this.input, this.setupParams(items));
}
protected setupParams(autocompleteValues:IAutocompleteItem<T>[]) {
const ctrl = this;
return {
delay: 0,
source: function (request:any, response:any) {
const fuzzyResults = ctrl.fuzzySearch(autocompleteValues, request.term);
response(ctrl.augmentedResultSet(autocompleteValues, fuzzyResults));
},
select: (ul:any, selected:{ item:IAutocompleteItem<T> }) => {
if (selected.item.render === 'match') {
ctrl.onItemSelected(selected.item.object);
}
},
create: () => ctrl.input.focus(),
response: (event:JQueryUI.AutocompleteEvent, ui:JQueryUI.AutocompleteUIParams) => {
ctrl.onNoResultsFound(event, ui);
},
autoFocus: true,
minLength: 0
};
}
}
export namespace LazyLoadedAutocompleter {
/**
* Returns whether the scrollbar is at a place where we should display additional elements
* @param ul
*/
function isScrollbarBottom(container:JQuery) {
var height = container.outerHeight();
var scrollHeight = container[0].scrollHeight;
var scrollTop = container.scrollTop();
return scrollTop >= (scrollHeight - height);
}
export function register<T>(name:string, ctrl:ILazyAutocompleterBridge<T>) {
jQuery.widget(`custom.${name}`, jQuery.ui.autocomplete, {
_create: function (this:any) {
ctrl.currentPage = 0;
this._super();
this.widget().menu('option', 'items', '> .ui-matched-item');
this._search('');
},
_renderMenu: function (this:any, ul:HTMLElement, items:IAutocompleteItem<T>[]) {
//remove scroll event to prevent attaching multiple scroll events to one container element
jQuery(ul).unbind('scroll');
this._renderLazyMenu(ul, items);
},
// Rener the menu for the current page
_renderMenuPage(this:any, ul:JQuery, items:IAutocompleteItem<T>[], page:number|null = null) {
let widget = this;
let rendered:number = items.length;
let pageElements = items;
let max = ctrl.maxItemsPerPage;
if (page !== null) {
pageElements = items.slice(page * max, (page * max) + max);
rendered = Math.min(items.length, (page * max) + max);
}
// Insert elements of this page
jQuery.each(pageElements, function (index, item) {
widget._renderItemData(ul, item);
});
// Ensure scrollbar is shown when more results exist
ul.css('height', 'auto');
if (rendered < items.length) {
const maxHeight = document.body.offsetHeight * 0.55;
const shownHeight = rendered * 32;
if (shownHeight < maxHeight) {
ul.css('height', shownHeight - 50);
}
}
},
/**
* Return the number of (lazy) pages for the curent set of results
* @param {IAutocompleteItem[]} items
* @returns {number}
*/
_pages(items:IAutocompleteItem<T>[]):number {
return Math.ceil(items.length / ctrl.maxItemsPerPage);
},
_repositionMenu: function (this:any, container:JQuery) {
const widget = this;
const menu = widget.menu;
menu.refresh();
// Call ui's own resize
widget._resizeMenu();
container.position(jQuery.extend({of: widget.element}, widget.options.position));
if (widget.options.autoFocus) {
menu.next(new jQuery.Event('mouseover'));
}
},
_resizeMenu: function () {
var ul = this.menu.element;
ul.outerWidth(this.element.outerWidth());
},
_renderItem: function (this:any, ul:JQuery, item:IAutocompleteItem<T>) {
const term = this.element.val();
const disabled = item.render === 'disabled';
const div = jQuery('<div>')
.text(item.label)
.addClass('ui-menu-item-wrapper');
ctrl.customizeItem(item, div);
const element = jQuery('<li>')
.toggleClass('ui-state-disabled', disabled)
.toggleClass('ui-matched-item', !disabled)
.append(div)
.appendTo(ul);
if (term !== '') {
element.mark(term, {className: 'ui-autocomplete-match'});
}
return element;
},
_renderLazyMenu: function (this:any, ul:Element, items:IAutocompleteItem<T>[]) {
const widget = this;
const container = jQuery(ul);
const pages = this._pages(items);
if (pages <= 1) {
return widget._renderMenuPage(ul, items);
}
widget._renderMenuPage(ul, items, 0);
container.scroll(function () {
if (isScrollbarBottom(container)) {
if (++ctrl.currentPage >= pages) {
return;
}
// Render the current menu page
widget._renderMenuPage(ul, items, ctrl.currentPage);
// Refresh the menu
widget._repositionMenu(ul);
}
});
}
});
}
}

@ -175,6 +175,10 @@ export class PathHelperService {
return this.apiV2 + '/projects';
}
public apiV2ProjectsList() {
return this.apiV2ProjectsPath() + '/level_list.json';
}
// API V3
public apiConfigurationPath() {
return this.apiV3 + '/configuration';

@ -0,0 +1,240 @@
//-- copyright
// OpenProject is a project management system.
// Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// 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.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
//
// See doc/COPYRIGHT.rdoc for more details.
//++
import {PathHelperService} from 'core-components/common/path-helper/path-helper.service';
import {wpControllersModule} from '../../../angular-modules';
import {
IAutocompleteItem,
ILazyAutocompleterBridge
} from 'core-components/common/autocomplete/lazyloaded/lazyloaded-autocompleter';
import {keyCodes} from 'core-components/common/keyCodes.enum';
interface IProjectMenuEntry {
id:number;
name:string;
identifier:string;
parents:IProjectMenuEntry[];
level:number;
}
type ProjectAutocompleteItem = IAutocompleteItem<IProjectMenuEntry>;
export class ProjectMenuAutocompleteController extends ILazyAutocompleterBridge<IProjectMenuEntry> {
public text:any;
// The project dropdown menu
public dropdownMenu:JQuery;
// The project filter input
public input:JQuery;
// No results element
public noResults:JQuery;
// The result set for the instance, loaded only once
public results:null|IProjectMenuEntry[] = null;
private loaded = false;
constructor(protected PathHelper:PathHelperService,
protected $element:ng.IAugmentedJQuery,
protected $q:ng.IQService,
protected $window:ng.IWindowService,
protected $http:ng.IHttpService,
public I18n:op.I18n) {
super('projectMenuAutocomplete');
this.text = {
label: I18n.t('js.projects.autocompleter.label'),
no_results: I18n.t('js.select2.no_matches'),
loading: I18n.t('js.ajax.loading')
};
}
public $onInit() {
this.dropdownMenu = this.$element.parents('li.drop-down');
this.input = this.$element.find('.project-menu-autocomplete--input') as JQuery;
this.noResults = angular.element('.project-menu-autocomplete--no-results');
this.dropdownMenu.on('opened', () => this.open());
this.dropdownMenu.on('closed', () => this.close());
}
public close() {
this.input.projectMenuAutocomplete('destroy');
this.$element.find('.project-search-results').css('visibility', 'hidden');
}
public open() {
this.$element.find('.project-search-results').css('visibility', 'visible');
this.loadProjects().then((results:IProjectMenuEntry[]) => {
let autocompleteValues = _.map(results, project => {
return { label: project.name, render: 'match', object: project } as ProjectAutocompleteItem;
});
this.setup(this.input, autocompleteValues);
this.addInputHandlers();
this.loaded = true;
});
}
// Items per page to show before using lazy load
// Please note that the max-height of the container is relevant here.
public get maxItemsPerPage() {
return 50;
}
onItemSelected(project:IProjectMenuEntry):void {
this.$window.location.href = this.PathHelper.projectPath(project.identifier);
}
onNoResultsFound(event:JQueryUI.AutocompleteEvent, ui:any):void {
// Show the noResults span if we don't have any matches
this.noResults.toggle(ui.content.length === 0);
}
public customizeItem(item:ProjectAutocompleteItem, div:JQuery):void {
// When in hierarchy, indent
if (item.object.level > 0) {
div
.text(`» ${item.label}`)
.css('padding-left', (4 + item.object.level * 16) + 'px');
}
}
public get loadingText():string {
if (this.loaded) {
return '';
} else {
return this.text.loading;
}
}
private loadProjects() {
if (this.results !== null) {
return this.$q.resolve(this.results);
}
const url = this.PathHelper.apiV2ProjectsList();
return this.$http
.get(url)
.then((result:{ data:{ projects:IProjectMenuEntry[] } }) => {
return this.results = this.augmentWithParents(result.data.projects);
});
}
/**
* Augment the level_list with the set of parents that belong to this project
*/
public augmentWithParents(projects:IProjectMenuEntry[]) {
const parents:IProjectMenuEntry[] = [];
let currentLevel = -1;
return projects.map((project) => {
while (currentLevel >= project.level) {
parents.pop();
currentLevel--;
}
parents.push(project);
currentLevel = project.level;
project.parents = parents.slice(0, -1); // make sure to pass a clone
return project;
});
}
/**
* Determines from the set of matched results, the elements we should render
* (ie. including the parents of the elements)
*/
protected augmentedResultSet(items:ProjectAutocompleteItem[], matched:ProjectAutocompleteItem[]) {
const matches = matched.map(el => el.object.identifier);
const matchedParents = _.flatten(matched.map(el => el.object.parents));
const results:ProjectAutocompleteItem[] = [];
items.forEach(el => {
const identifier = el.object.identifier;
let renderType:'disabled'|'match';
if (matches.indexOf(identifier) >= 0) {
renderType = 'match';
} else if (_.find(matchedParents, e => e.identifier === identifier)) {
renderType = 'disabled';
} else {
return;
}
results.push({
label: el.label,
object: el.object,
render: renderType
});
});
return results;
}
/**
* Avoid closing the results when the input has lost focus.
*/
protected addInputHandlers() {
this.input.off('blur');
this.input.keydown((evt:JQueryKeyEventObject) => {
if (evt.which === keyCodes.ESCAPE) {
this.input.val('');
this.input[this.widgetName].call(this.input, 'search', '');
return false;
}
return true;
});
}
protected setupParams(autocompleteValues:ProjectAutocompleteItem[]) {
const params:any = super.setupParams(autocompleteValues);
// Append to top-menu
params.appendTo = '.project-menu-autocomplete--wrapper';
params.classes = {
'ui-autocomplete': '-inplace project-menu-autocomplete--results'
};
params.position = {
of: '.project-menu-autocomplete--input-container'
}
return params;
}
}
wpControllersModule.component('projectMenuAutocomplete', {
templateUrl: '/components/projects/project-menu-autocomplete/project-menu-autocomplete.template.html',
controller: ProjectMenuAutocompleteController,
controllerAs: '$ctrl'
});

@ -0,0 +1,14 @@
<div class="dropdown dropdown-relative project-search-results" id="project_autocompletion_wrapper">
<div class="project-menu-autocomplete--wrapper">
<div class="project-menu-autocomplete--input-container">
<label for="project_autocompletion_input" class="hidden-for-sighted">{{ ::$ctrl.text.label }}</label>
<input type="text"
id="project_autocompletion_input"
name="project_autocompletion_input"
class="ui-autocomplete--input project-menu-autocomplete--input"
placeholder="{{ $ctrl.loadingText }}">
<i class="project-menu-autocomplete--search-icon icon-search"></i>
</div>
<p class="project-menu-autocomplete--no-results" hidden ng-bind="::$ctrl.text.no_results"></p>
</div>
</div>

@ -78,6 +78,11 @@ require('moment/locale/en-gb.js');
require('moment/locale/de.js');
require('jquery.caret');
// Text highlight for autocompleter
require('mark.js/dist/jquery.mark.min.js');
// Micro Text fuzzy search library
require('fuzzy');
require('at.js/jquery.atwho.min.js');

@ -3178,6 +3178,11 @@
}
}
},
"fuzzy": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/fuzzy/-/fuzzy-0.1.3.tgz",
"integrity": "sha1-THbsL/CsGjap3M+aAN+GIweNTtg="
},
"generate-function": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.0.0.tgz",
@ -4227,6 +4232,11 @@
"es5-ext": "0.10.12"
}
},
"mark.js": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.0.tgz",
"integrity": "sha1-cOsXe5pMQffgc9fW83G3B3UQigM="
},
"md5.js": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.4.tgz",

@ -81,6 +81,7 @@
"file-loader": "^0.8.1",
"fork-ts-checker-webpack-plugin": "^0.2.8",
"foundation-apps": "1.1.0",
"fuzzy": "^0.1.3",
"glob": "^4.5.3",
"happypack": "^4.0.0",
"html-loader": "^0.2.3",
@ -91,6 +92,7 @@
"json5": "^0.5.0",
"karma": "^1.4.1",
"lodash": "^4.17.4",
"mark.js": "^8.11.0",
"moment": "^2.17.1",
"moment-timezone": "^0.5.11",
"mousetrap": "~1.6.0",

@ -57,7 +57,7 @@ module Redmine::MenuManager::TopMenu::ProjectsMenu
}
) do
content_tag(:li, id: 'project-search-container') do
hidden_field_tag('', '', class: 'select2-select')
angular_component_tag('project-menu-autocomplete')
end
end
end

@ -0,0 +1,103 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
require 'features/projects/projects_page'
describe 'Projects autocomplete page', type: :feature, js: true do
let!(:admin) { FactoryGirl.create :admin }
let!(:project) do
FactoryGirl.create(:project,
name: 'Plain project',
identifier: 'plain-project')
end
let!(:project2) do
FactoryGirl.create(:project,
name: '<strong>foobar</strong>',
identifier: 'foobar')
end
let!(:project3) do
FactoryGirl.create(:project,
name: 'Plain other project',
parent: project2,
identifier: 'plain-project-2')
end
let(:top_menu) { ::Components::Projects::TopMenu.new }
before do
login_as admin
visit root_path
end
it 'allows to filter and select projects' do
top_menu.toggle
top_menu.expect_open
# Filter for projects
top_menu.search '<strong'
# Expect highlights
within(top_menu.search_results) do
expect(page).to have_selector('mark', text: '<strong')
expect(page).to have_no_selector('strong')
end
# Expect fuzzy matches for plain
top_menu.search 'Plain pr'
within(top_menu.search_results) do
expect(page).to have_selector('.ui-menu-item-wrapper', text: 'Plain project')
expect(page).to have_selector('.ui-menu-item-wrapper', text: 'Plain other project')
end
# Expect hierarchy
top_menu.clear_search
within(top_menu.search_results) do
expect(page).to have_selector('.ui-menu-item-wrapper', text: 'Plain project')
expect(page).to have_selector('.ui-menu-item-wrapper', text: '<strong>foobar</strong>')
expect(page).to have_selector('.ui-menu-item-wrapper', text: '» Plain other project')
end
# Show hierarchy of project
top_menu.search 'Plain other project'
within(top_menu.search_results) do
expect(page).to have_no_selector('.ui-menu-item-wrapper', text: 'Plain project')
expect(page).to have_selector('.ui-state-disabled .ui-menu-item-wrapper', text: '<strong>foobar</strong>')
expect(page).to have_selector('.ui-menu-item-wrapper', text: '» Plain other project')
end
# Visit a project
top_menu.search_and_select '<strong'
top_menu.expect_current_project project2.name
end
end

@ -0,0 +1,80 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2017 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2017 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# 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.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'features/support/components/ui_autocomplete'
module Components
module Projects
class TopMenu
include Capybara::DSL
include RSpec::Matchers
include ::Components::UIAutocompleteHelpers
def toggle
page.find('#projects-menu').click
end
def expect_current_project(name)
expect(page).to have_selector('#projects-menu', text: name)
end
def expect_open
expect(page).to have_selector(autocompleter_selector)
end
def expect_closed
expect(page).to have_no_selector(autocompleter_selector)
end
def search(query)
search_autocomplete(autocompleter, query: query)
end
def clear_search
autocompleter.set ''
autocompleter.send_keys :backspace
end
def search_and_select(query)
select_autocomplete(autocompleter, query: query)
end
def search_results
page.find '.project-menu-autocomplete--results'
end
def autocompleter
page.find autocompleter_selector
end
def autocompleter_selector
'#project_autocompletion_input'
end
end
end
end

@ -166,7 +166,7 @@ module Pages
end
def search_principal!(query)
input = find '.select2-search-field input#s2id_autogen3'
input = find '#members_add_form .select2-search-field input'
input.set query
end

Loading…
Cancel
Save