Merge pull request #6102 from opf/fix/project-autocomplete
[26277] Replace project autocompleter from select2 to jquery-uipull/6110/head
commit
011b6b199e
@ -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 |
@ -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 |
@ -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); |
||||
} |
||||
}); |
||||
} |
||||
}); |
||||
} |
||||
} |
@ -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> |
@ -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 |
Loading…
Reference in new issue