Merge pull request #10364 from opf/feature/boards-ce

[41511] Make basic boards a CE feature
pull/10369/head
Henriette Darge 3 years ago committed by GitHub
commit 83afed93f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 17
      frontend/src/app/features/boards/board/board-actions/board-actions-registry.service.ts
  2. 8
      frontend/src/app/features/boards/board/board-partitioned-page/board-list-container.component.html
  3. 10
      frontend/src/app/features/boards/board/board-partitioned-page/board-list-container.component.ts
  4. 5
      frontend/src/app/features/boards/boards-sidebar/boards-menu.component.ts
  5. 33
      frontend/src/app/features/boards/index-page/boards-index-page.component.html
  6. 37
      frontend/src/app/features/boards/index-page/boards-index-page.component.ts
  7. 40
      frontend/src/app/features/boards/new-board-modal/new-board-modal.component.ts
  8. 17
      frontend/src/app/features/boards/new-board-modal/new-board-modal.html
  9. 16
      frontend/src/app/features/boards/tile-view/tile-view.component.html
  10. 75
      frontend/src/app/features/boards/tile-view/tile-view.component.sass
  11. 6
      frontend/src/app/features/boards/tile-view/tile-view.component.spec.ts
  12. 5
      frontend/src/app/features/boards/tile-view/tile-view.component.ts
  13. 16
      frontend/src/app/shared/components/enterprise-banner/enterprise-banner.component.ts
  14. 4
      modules/boards/config/locales/js-en.yml
  15. 84
      modules/boards/spec/features/board_enterprise_spec.rb
  16. 7
      modules/boards/spec/features/support/board_index_page.rb

@ -1,17 +1,28 @@
import { Injectable } from '@angular/core';
import { BoardActionService } from 'core-app/features/boards/board/board-actions/board-action.service';
import { ITileViewEntry } from 'core-app/features/boards/tile-view/tile-view.component';
import { BannersService } from 'core-app/core/enterprise/banners.service';
@Injectable({ providedIn: 'root' })
export class BoardActionsRegistryService {
constructor(
private bannersService:BannersService,
) {}
private mapping:{ [attribute:string]:BoardActionService } = {};
public add(attribute:string, service:BoardActionService) {
public add(attribute:string, service:BoardActionService):void {
this.mapping[attribute] = service;
}
public available() {
public available():ITileViewEntry[] {
return _.map(this.mapping, (service:BoardActionService, attribute:string) => ({
attribute, text: service.localizedName, icon: '', description: '', image: '',
attribute,
text: service.localizedName,
icon: '',
description: '',
image: '',
disabled: this.bannersService.eeShowBanners,
}));
}

@ -2,7 +2,6 @@
<div class="boards-list--container"
[ngClass]="{ '-free' : board.isFree }"
#container
*ngIf="showBoardListView()"
cdkDropList
[cdkDropListDisabled]="!board.editable"
cdkDropListOrientation="horizontal"
@ -39,11 +38,4 @@
</div>
</div>
</div>
<enterprise-banner *ngIf="!showBoardListView()"
[leftMargin]="true"
[linkMessage]="text.upsaleCheckOutLink"
[textMessage]="text.upsaleBoards"
[opReferrer]="opReferrer(board)">
</enterprise-banner>
</ng-container>

@ -39,8 +39,6 @@ export class BoardListContainerComponent extends UntilDestroyedMixin implements
updateSuccessful: this.I18n.t('js.notice_successful_update'),
loadingError: 'No such board found',
addList: this.I18n.t('js.boards.add_list'),
upsaleBoards: this.I18n.t('js.boards.upsale.teaser_text'),
upsaleCheckOutLink: this.I18n.t('js.work_packages.table_configuration.upsale.check_out_link'),
unnamedList: this.I18n.t('js.boards.label_unnamed_list'),
hiddenListWarning: this.I18n.t('js.boards.text_hidden_list_warning'),
};
@ -150,14 +148,6 @@ export class BoardListContainerComponent extends UntilDestroyedMixin implements
}
}
showBoardListView() {
return !this.Banner.eeShowBanners;
}
opReferrer(board:Board) {
return board.isFree ? 'boards#free' : 'boards#status';
}
saveBoard(board:Board):void {
this.boardComponent.boardSaver.request(board);
}

@ -31,14 +31,11 @@ export const boardsMenuSelector = 'boards-menu';
export class BoardsMenuComponent extends UntilDestroyedMixin implements OnInit {
@HostBinding('class.op-sidebar') className = true;
selectedBoardId:string;
boardOptions$:Observable<IOpSidemenuItem[]> = this
.apiV3Service
.boards
.observeAll()
.pipe(
skip(1),
map((boards:Board[]) => {
const menuItems:IOpSidemenuItem[] = boards.map((board) => ({
title: board.name,
@ -88,7 +85,7 @@ export class BoardsMenuComponent extends UntilDestroyedMixin implements OnInit {
.onActivate('board_view')
.subscribe(() => {
this.focusBackArrow();
this.boardService.loadAllBoards();
void this.boardService.loadAllBoards();
});
}

@ -3,8 +3,7 @@
<div class="title-container">
<h2 [textContent]="text.boards"></h2>
</div>
<ul class="toolbar-items"
*ngIf="showBoardIndexView()">
<ul class="toolbar-items">
<li *ngIf="canAdd"
class="toolbar-item">
<a class="button -alt-highlight"
@ -22,7 +21,7 @@
<div class="boards--listing-group loading-indicator--location"
data-indicator-name="boards-module">
<div *ngIf="showBoardIndexView() && (boards$ | async) as boards"
<div *ngIf="(boards$ | async) as boards"
class="generic-table--container">
<div class="generic-table--results-container">
<table class="generic-table">
@ -108,31 +107,3 @@
</div>
</div>
</div>
<div *ngIf="!showBoardIndexView()"
class="boards--teaser-container">
<p>{{ text.teaser_text }}</p>
<p>{{ text.enterprise }}</p>
<a class="button -alt-highlight -with-icon icon-checkmark"
[href]="eeLink()"
target='blank'>
{{ text.upgrade }}
</a>
<a class="button -highlight-inverted"
[href]="demoLink()"
[textContent]="text.personal_demo"
target="_blank"
>
</a>
<div class="boards--teaser-video-container">
<iframe [src]="teaserVideoURL"
class="boards--teaser-video"
frameborder="0"
allow="autoplay; fullscreen"
allowfullscreen>
</iframe>
</div>
</div>

@ -1,5 +1,8 @@
import {
AfterViewInit, Component, Injector, OnInit,
AfterViewInit,
Component,
Injector,
OnInit,
} from '@angular/core';
import { Observable } from 'rxjs';
import { I18nService } from 'core-app/core/i18n/i18n.service';
@ -8,12 +11,9 @@ import { Board } from 'core-app/features/boards/board/board';
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
import { OpModalService } from 'core-app/shared/components/modal/modal.service';
import { NewBoardModalComponent } from 'core-app/features/boards/new-board-modal/new-board-modal.component';
import { BannersService } from 'core-app/core/enterprise/banners.service';
import { LoadingIndicatorService } from 'core-app/core/loading-indicator/loading-indicator.service';
import { AuthorisationService } from 'core-app/core/model-auth/model-auth.service';
import { contactUrl } from 'core-app/core/setup/globals/constants.const';
import { DomSanitizer } from '@angular/platform-browser';
import { boardTeaserVideoURL } from 'core-app/features/boards/board-constants.const';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { componentDestroyed } from '@w11k/ngx-componentdestroyed';
import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service';
@ -38,11 +38,6 @@ export class BoardsIndexPageComponent extends UntilDestroyedMixin implements OnI
areYouSure: this.I18n.t('js.text_are_you_sure'),
deleteSuccessful: this.I18n.t('js.notice_successful_delete'),
noResults: this.I18n.t('js.notice_no_results_to_display'),
teaser_text: this.I18n.t('js.boards.upsale.teaser_text'),
enterprise: this.I18n.t('js.boards.upsale.upgrade_to_ee_text'),
upgrade: this.I18n.t('js.boards.upsale.upgrade'),
personal_demo: this.I18n.t('js.boards.upsale.personal_demo'),
};
public canAdd = false;
@ -55,9 +50,8 @@ export class BoardsIndexPageComponent extends UntilDestroyedMixin implements OnI
map((boards:Board[]) => boards.sort((a, b) => a.name.localeCompare(b.name))),
);
teaserVideoURL = this.domSanitizer.bypassSecurityTrustResourceUrl(boardTeaserVideoURL);
constructor(private readonly boardService:BoardService,
constructor(
private readonly boardService:BoardService,
private readonly apiV3Service:ApiV3Service,
private readonly I18n:I18nService,
private readonly toastService:ToastService,
@ -65,8 +59,7 @@ export class BoardsIndexPageComponent extends UntilDestroyedMixin implements OnI
private readonly loadingIndicatorService:LoadingIndicatorService,
private readonly authorisationService:AuthorisationService,
private readonly injector:Injector,
private readonly bannerService:BannersService,
private readonly domSanitizer:DomSanitizer) {
) {
super();
}
@ -83,11 +76,11 @@ export class BoardsIndexPageComponent extends UntilDestroyedMixin implements OnI
loadingIndicator.promise = this.boardService.loadAllBoards();
}
newBoard() {
newBoard():void {
this.opModalService.show(NewBoardModalComponent, this.injector);
}
destroyBoard(board:Board) {
destroyBoard(board:Board):void {
if (!window.confirm(this.text.areYouSure)) {
return;
}
@ -99,16 +92,4 @@ export class BoardsIndexPageComponent extends UntilDestroyedMixin implements OnI
})
.catch((error) => this.toastService.addError(`Deletion failed: ${error}`));
}
public showBoardIndexView() {
return !this.bannerService.eeShowBanners;
}
public eeLink() {
return this.bannerService.getEnterPriseEditionUrl({ referrer: 'boards' });
}
public demoLink():string {
return contactUrl[this.I18n.locale] || contactUrl.en;
}
}

@ -27,7 +27,12 @@
//++
import {
ChangeDetectorRef, Component, ElementRef, Inject, ViewChild,
ChangeDetectorRef,
Component,
ElementRef,
Inject,
OnInit,
ViewChild,
} from '@angular/core';
import { OpModalComponent } from 'core-app/shared/components/modal/modal.component';
import { OpModalLocalsToken } from 'core-app/shared/components/modal/modal.service';
@ -41,11 +46,13 @@ import { LoadingIndicatorService } from 'core-app/core/loading-indicator/loading
import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service';
import { imagePath } from 'core-app/shared/helpers/images/path-helper';
import { ITileViewEntry } from '../tile-view/tile-view.component';
import { BannersService } from 'core-app/core/enterprise/banners.service';
import { ToastService } from 'core-app/shared/components/toaster/toast.service';
@Component({
templateUrl: './new-board-modal.html',
})
export class NewBoardModalComponent extends OpModalComponent {
export class NewBoardModalComponent extends OpModalComponent implements OnInit {
@ViewChild('actionAttributeSelect', { static: true }) actionAttributeSelect:ElementRef;
public showClose = true;
@ -56,7 +63,9 @@ export class NewBoardModalComponent extends OpModalComponent {
public inFlight = false;
public text:any = {
public eeShowBanners = false;
public text = {
close_popup: this.I18n.t('js.close_popup_title'),
free_board: this.I18n.t('js.boards.board_type.free'),
@ -69,10 +78,14 @@ export class NewBoardModalComponent extends OpModalComponent {
select_attribute: this.I18n.t('js.boards.board_type.select_attribute'),
select_board_type: this.I18n.t('js.boards.board_type.select_board_type'),
placeholder: this.I18n.t('js.placeholders.selection'),
teaser_text: this.I18n.t('js.boards.upsale.teaser_text'),
upgrade_to_ee_text: this.I18n.t('js.boards.upsale.upgrade'),
};
constructor(readonly elementRef:ElementRef,
constructor(
@Inject(OpModalLocalsToken) public locals:OpModalLocalsMap,
readonly elementRef:ElementRef,
readonly cdRef:ChangeDetectorRef,
readonly state:StateService,
readonly boardService:BoardService,
@ -80,12 +93,20 @@ export class NewBoardModalComponent extends OpModalComponent {
readonly halNotification:HalResourceNotificationService,
readonly loadingIndicatorService:LoadingIndicatorService,
readonly I18n:I18nService,
readonly boardActionRegistry:BoardActionsRegistryService) {
readonly boardActionRegistry:BoardActionsRegistryService,
readonly bannersService:BannersService,
readonly toastService:ToastService,
) {
super(locals, cdRef, elementRef);
this.initiateTiles();
}
public createBoard(attribute:string) {
ngOnInit():void {
super.ngOnInit();
this.eeShowBanners = this.bannersService.eeShowBanners;
}
public createBoard(attribute:string):void {
if (attribute === 'basic') {
this.createFree();
} else {
@ -111,7 +132,12 @@ export class NewBoardModalComponent extends OpModalComponent {
this.create({ type: 'free' });
}
private createAction(attribute:string) {
private createAction(attribute:string):void {
if (this.eeShowBanners) {
this.toastService.addError(this.I18n.t('js.upsale.ee_only'));
return;
}
this.create({ type: 'action', attribute });
}

@ -5,12 +5,21 @@
<op-modal-header (close)="closeMe($event)">{{text.board_type}}</op-modal-header>
<div class="op-modal--body">
<p [textContent]="text.select_board_type"></p>
<enterprise-banner
*ngIf="eeShowBanners"
[linkMessage]="text.upgrade_to_ee_text"
[textMessage]="text.teaser_text"
opReferrer="boards"
></enterprise-banner>
<p
*ngIf="!eeShowBanners"
[textContent]="text.select_board_type"></p>
<section class="new-board--section">
<tile-view
[tiles]="available"
[disable]="inFlight"
(create)="createBoard($event)">
[tiles]="available"
[disable]="inFlight"
(create)="createBoard($event)">
</tile-view>
</section>
</div>

@ -1,15 +1,19 @@
<div class="tile-blocks--container">
<div class="op-tile-block">
<button
*ngFor="let tile of tiles"
class="tile-block button"
class="op-tile-block--tile button"
data-qa-selector="op-tile-block"
type="button"
[disabled]="disabled()"
[disabled]="tile.disabled || disable"
(click)="created(tile.attribute)"
>
<img [src]="tile.image" class="tile-block-image"/>
<img [src]="tile.image" class="op-tile-block--image"/>
<div>
<span class="tile-block-title">{{ tile.text }}</span>
<p class="tile-block-description" [textContent]="tile.description"></p>
<span
data-qa-selector="op-tile-block-title"
class="op-tile-block--title"
>{{ tile.text }}</span>
<p class="op-tile-block--description" [textContent]="tile.description"></p>
</div>
</button>
</div>

@ -1,44 +1,47 @@
.tile-blocks--container
.op-tile-block
$block: &
display: grid
grid-template-rows: repeat(minmax(200px, auto))
grid-template-columns: auto auto
grid-column-gap: 10px
grid-row-gap: 10px
.tile-block
border-radius: 10px
display: grid
grid-template: 95px 1fr / 1fr
grid-template-columns: auto 1fr auto
grid-template-rows: auto
grid-row-gap: 5px
justify-items: left
background: #f7fafc
min-height: 150px
&:hover
text-decoration: none
border: 1px solid grey
border-radius: 10px !important
cursor: pointer
.tile-block-image
display: block
margin-top: auto
margin-bottom: auto
.tile-block-title
padding-top: 30px
padding-bottom: 5px
color: var(--primary-color-dark)
display: block
text-align: left
font-weight: bolder
font-size: large
.tile-block p
text-align: left
width: 90%
&--tile
border-radius: 10px
display: grid
grid-template: 95px 1fr / 1fr
grid-template-columns: auto 1fr auto
grid-template-rows: auto
grid-row-gap: 5px
justify-items: left
background: #f7fafc
min-height: 150px
&:disabled
background: #fafafa
&:hover
text-decoration: none
border: 1px solid grey
border-radius: 10px !important
cursor: pointer
&--image
display: block
margin-top: auto
margin-bottom: auto
&--title
padding-top: 30px
padding-bottom: 5px
color: var(--primary-color-dark)
display: block
text-align: left
font-weight: bolder
font-size: large
&--description
text-align: left
width: 90%

@ -34,19 +34,19 @@ describe('shows tiles', () => {
it('should render the componenet successfully', () => {
fixture.detectChanges();
const tile = document.querySelector('.tile-blocks--container');
const tile = document.querySelector('.op-tile-block--title');
expect(document.contains(tile)).toBeTruthy();
});
it('should show each tile', () => {
fixture.detectChanges();
const tile:HTMLElement = element.query(By.css('.tile-block')).nativeElement;
const tile:HTMLElement = element.query(By.css('.op-tile-block--title')).nativeElement;
expect(tile.textContent).toContain('Basic');
});
it('should show the image', () => {
fixture.detectChanges();
const tile = document.querySelector('.tile-block-image');
const tile = document.querySelector('.op-tile-block--image');
expect(document.contains(tile)).toBeTruthy();
});
});

@ -8,6 +8,7 @@ export interface ITileViewEntry {
icon:string;
description:string;
image:string;
disabled?:boolean;
}
@Component({
@ -23,10 +24,6 @@ export class TileViewComponent {
@Output() public create = new EventEmitter<string>();
public disabled() {
return this.disable;
}
public created(attribute:string) {
this.create.emit(attribute);
}

@ -1,4 +1,8 @@
import { Component, Input } from '@angular/core';
import {
Component,
Input,
OnInit,
} from '@angular/core';
import { BannersService } from 'core-app/core/enterprise/banners.service';
import { I18nService } from 'core-app/core/i18n/i18n.service';
@ -12,7 +16,7 @@ import { I18nService } from 'core-app/core/i18n/i18n.service';
<div class="op-toast--content">
<p class="-bold" [textContent]="text.enterpriseFeature"></p>
<p [textContent]="textMessage"></p>
<a [href]="eeLink()"
<a [href]="link"
target='blank'
[textContent]="linkMessage"></a>
</div>
@ -20,7 +24,7 @@ import { I18nService } from 'core-app/core/i18n/i18n.service';
</div>
`
})
export class EnterpriseBannerComponent {
export class EnterpriseBannerComponent implements OnInit {
@Input() public leftMargin = false;
@Input() public textMessage:string;
@ -29,6 +33,8 @@ export class EnterpriseBannerComponent {
@Input() public opReferrer:string;
public link:string;
public text:any = {
enterpriseFeature: this.I18n.t('js.upsale.ee_only'),
};
@ -38,7 +44,7 @@ export class EnterpriseBannerComponent {
protected bannersService:BannersService,
) {}
public eeLink() {
this.bannersService.getEnterPriseEditionUrl({ referrer: this.opReferrer });
ngOnInit():void {
this.link = this.bannersService.getEnterPriseEditionUrl({ referrer: this.opReferrer });
}
}

@ -8,10 +8,8 @@ en:
label_board_type: 'Board type'
upsale:
teaser_text: 'Improve your agile project management with this flexible Boards view. Create as many boards as you like for anything you would like to keep track of.'
upgrade_to_ee_text: 'Boards is an Enterprise feature. Please upgrade to a paid plan.'
teaser_text: 'Need a predefined board template? Advanced boards are Enterprise features. Please upgrade to a paid plan.'
upgrade: 'Upgrade now'
personal_demo: 'Contact us for a demo'
lists:
delete: 'Delete list'

@ -0,0 +1,84 @@
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2022 the OpenProject GmbH
#
# 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 COPYRIGHT and LICENSE files for more details.
#++
require 'spec_helper'
require_relative './support/board_index_page'
require_relative './support/board_page'
describe 'Boards enterprise spec', type: :feature, js: true do
shared_let(:admin) { create(:admin) }
shared_let(:project) { create(:project, enabled_module_names: %i[work_package_tracking board_view]) }
shared_let(:priority) { create :default_priority }
shared_let(:status) { create :default_status }
let(:board_index) { Pages::BoardIndex.new(project) }
shared_let(:manual_board) { create :board_grid_with_query, name: 'My board', project: project }
shared_let(:action_board) do
create(:subproject_board,
name: 'Subproject board',
project: project,
projects_columns: [])
end
context 'when EE inactive' do
before do
login_as(admin)
board_index.visit!
end
it 'disabled all action boards' do
# Expect both existing boards to show
expect(page).to have_content 'My board'
expect(page).to have_content 'Subproject board'
page.find('.toolbar-item a', text: I18n.t('js.button_create')).click
expect(page).to have_selector('[data-qa-selector="op-tile-block"]:not([disabled])', text: 'Basic')
expect(page).to have_selector('[data-qa-selector="op-tile-block"]:disabled', count: 5)
end
end
context 'when EE active' do
before do
with_enterprise_token :board_view
login_as(admin)
board_index.visit!
end
it 'enables all options' do
expect(page).to have_content 'My board'
expect(page).to have_content 'Subproject board'
page.find('.toolbar-item a', text: I18n.t('js.button_create')).click
expect(page).to have_selector('[data-qa-selector="op-tile-block"]:not([disabled])', count: 6)
end
end
end

@ -59,11 +59,8 @@ module Pages
def create_board(action: nil, expect_empty: false)
page.find('.toolbar-item a', text: I18n.t('js.button_create')).click
if action == nil
find('.tile-block-title', text: 'Basic').click
else
find('.tile-block-title', text: action.to_s[0..5]).click
end
text = action == nil ? 'Basic' : action.to_s[0..5]
find('[data-qa-selector="op-tile-block-title"]', text: text).click
if expect_empty
expect(page).to have_selector('.boards-list--add-item-text', wait: 10)

Loading…
Cancel
Save