Merge pull request #7185 from opf/fix/split_of_my_page

Fix/split off my page
pull/7195/head
ulferts 6 years ago committed by GitHub
commit 888d4114ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      Gemfile.lock
  2. 1
      Gemfile.modules
  3. 22
      app/assets/stylesheets/content/_grid.sass
  4. 11
      app/controllers/application_controller.rb
  5. 2
      app/controllers/concerns/redirect_after_login.rb
  6. 6
      app/controllers/my_controller.rb
  7. 1
      app/helpers/meta_tags_helper.rb
  8. 7
      config/initializers/menus.rb
  9. 2
      config/locales/js-en.yml
  10. 3
      config/routes.rb
  11. 12
      docs/api/apiv3/endpoints/grids.apib
  12. 13
      frontend/src/app/components/routing/my-page/my-page.component.ts
  13. 3
      frontend/src/app/components/wp-query/url-params-helper.ts
  14. 12
      frontend/src/app/modules/grids/grid/add-widget.service.ts
  15. 9
      frontend/src/app/modules/grids/grid/area.service.ts
  16. 4
      frontend/src/app/modules/grids/grid/grid.component.html
  17. 4
      frontend/src/app/modules/grids/grid/grid.component.ts
  18. 7
      frontend/src/app/modules/grids/openproject-grids.module.ts
  19. 4
      frontend/src/app/modules/grids/widgets/abstract-widget.component.ts
  20. 12
      frontend/src/app/modules/grids/widgets/add/add.modal.ts
  21. 23
      frontend/src/app/modules/grids/widgets/wp-table/wp-table.component.html
  22. 146
      frontend/src/app/modules/grids/widgets/wp-table/wp-table.component.ts
  23. 8
      frontend/src/app/modules/grids/widgets/wp-widget/wp-widget.component.css
  24. 1
      frontend/src/app/modules/grids/widgets/wp-widget/wp-widget.component.ts
  25. 2
      frontend/src/app/modules/work_packages/query-space/wp-isolated-query-space.directive.ts
  26. 17
      lib/redmine/menu_manager/menu_helper.rb
  27. 6
      lib/redmine/menu_manager/top_menu/projects_menu.rb
  28. 26
      modules/grids/app/contracts/grids/base_contract.rb
  29. 8
      modules/grids/app/controllers/api/v3/grids/schemas/grid_schema_representer.rb
  30. 10
      modules/grids/app/models/grids/widget.rb
  31. 14
      modules/grids/bin/rails
  32. 103
      modules/grids/lib/grids/configuration.rb
  33. 127
      modules/grids/lib/grids/configuration/registration.rb
  34. 55
      modules/grids/lib/grids/configuration/widget_strategy.rb
  35. 4
      modules/grids/lib/grids/engine.rb
  36. 45
      modules/grids/spec/contracts/grids/create_contract_spec.rb
  37. 256
      modules/grids/spec/contracts/grids/shared_examples.rb
  38. 2
      modules/grids/spec/contracts/grids/update_contract_spec.rb
  39. 24
      modules/grids/spec/factories/grid_factory.rb
  40. 4
      modules/grids/spec/lib/api/v3/grids/grid_payload_representer_parsing_spec.rb
  41. 25
      modules/grids/spec/lib/api/v3/grids/grid_representer_rendering_spec.rb
  42. 13
      modules/grids/spec/lib/api/v3/grids/schemas/grid_schema_representer_spec.rb
  43. 140
      modules/grids/spec/requests/api/v3/grids/grids_create_form_resource_spec.rb
  44. 353
      modules/grids/spec/requests/api/v3/grids/grids_resource_spec.rb
  45. 124
      modules/grids/spec/requests/api/v3/grids/grids_update_form_resource_spec.rb
  46. 4
      modules/grids/spec/services/grids/create_service_spec.rb
  47. 2
      modules/grids/spec/services/grids/set_attributes_service_spec.rb
  48. 2
      modules/grids/spec/services/grids/update_service_spec.rb
  49. 7
      modules/my_page/.gitignore
  50. 3
      modules/my_page/Gemfile
  51. 23
      modules/my_page/app/controllers/my_page/angular_controller.rb
  52. 0
      modules/my_page/app/models/grids/my_page.rb
  53. 2
      modules/my_page/app/views/my_page/angular/no_menu.html.erb
  54. 9
      modules/my_page/config/routes.rb
  55. 4
      modules/my_page/lib/my_page.rb
  56. 11
      modules/my_page/lib/my_page/engine.rb
  57. 11
      modules/my_page/lib/my_page/grid_registration.rb
  58. 12
      modules/my_page/my_page.gemspec
  59. 61
      modules/my_page/spec/contracts/grids/create_contract_spec.rb
  60. 320
      modules/my_page/spec/contracts/grids/shared_examples.rb
  61. 39
      modules/my_page/spec/contracts/grids/update_contract_spec.rb
  62. 25
      modules/my_page/spec/factories/grid_factory.rb
  63. 0
      modules/my_page/spec/features/my/accountable_spec.rb
  64. 4
      modules/my_page/spec/features/my/assigned_to_me_spec.rb
  65. 0
      modules/my_page/spec/features/my/documents_spec.rb
  66. 0
      modules/my_page/spec/features/my/my_page_spec.rb
  67. 0
      modules/my_page/spec/features/my/news_spec.rb
  68. 2
      modules/my_page/spec/features/my/time_entries_current_user_spec.rb
  69. 162
      modules/my_page/spec/features/my/work_package_table_spec.rb
  70. 76
      modules/my_page/spec/models/grids/my_page_spec.rb
  71. 77
      modules/my_page/spec/models/grids/shared_model.rb
  72. 0
      modules/my_page/spec/queries/grids/filters/scope_filter_spec.rb
  73. 0
      modules/my_page/spec/queries/grids/query_integration_spec.rb
  74. 193
      modules/my_page/spec/requests/api/v3/grids/grids_create_form_resource_spec.rb
  75. 414
      modules/my_page/spec/requests/api/v3/grids/grids_resource_spec.rb
  76. 174
      modules/my_page/spec/requests/api/v3/grids/grids_update_form_resource_spec.rb
  77. 3
      spec/support/components/work_packages/columns.rb
  78. 28
      spec/support/pages/my/page.rb

@ -180,6 +180,12 @@ PATH
openproject-meeting (1.0.0)
icalendar (~> 2.5.0)
PATH
remote: modules/my_page
specs:
my_page (1.0.0)
grids
PATH
remote: modules/my_project_page
specs:
@ -970,6 +976,7 @@ DEPENDENCIES
lograge (~> 0.10.0)
meta-tags (~> 2.11.0)
multi_json (~> 1.13.1)
my_page!
mysql2 (~> 0.5.0)
net-ldap (~> 0.16.0)
newrelic_rpm

@ -40,6 +40,7 @@ group :opf_plugins do
gem 'openproject-ldap_groups', path: 'modules/ldap_groups'
gem 'grids', path: 'modules/grids'
gem 'my_page', path: 'modules/my_page'
gem 'openproject-boards', path: 'modules/boards'
gem 'openproject-bim_seeder', path: 'modules/bim_seeder', require: !!(ENV['OPENPROJECT_EDITION'] == 'bim')

@ -27,6 +27,7 @@ $grid--header-width: 20px
.widget-box
height: 100%
&.-resizing
border: 1px solid $primary-color
z-index: 100
@ -46,6 +47,20 @@ $grid--header-width: 20px
.cdk-drag-handle
cursor: grab
.grid--area-drag-handle
margin-left: -19px
padding-right: 1px
margin-top: 3px
opacity: 0
cursor: grab
&:before
padding: 0
color: #888
.grid--area.-widgeted:hover &
opacity: 1
.grid--area-content
height: 100%
@ -54,6 +69,13 @@ $grid--header-width: 20px
flex-direction: column
height: 100%
input[type="text"].toolbar--editable-toolbar
font-size: 18px
font-weight: normal
.title-container
margin-bottom: 0px
.grid--widget-content
height: 100%
overflow-x: auto

@ -263,12 +263,11 @@ class ApplicationController < ActionController::Base
reset_session
respond_to do |format|
format.any(:html, :atom) do redirect_to signin_path(back_url: login_back_url) end
format.any(:html, :atom) { redirect_to main_app.signin_path(back_url: login_back_url) }
auth_header = OpenProject::Authentication::WWWAuthenticate.response_header(
request_headers: request.headers)
auth_header = OpenProject::Authentication::WWWAuthenticate.response_header(request_headers: request.headers)
format.any(:xml, :js, :json) do
format.any(:xml, :js, :json) do
head :unauthorized,
'X-Reason' => 'login needed',
'WWW-Authenticate' => auth_header
@ -581,9 +580,9 @@ class ApplicationController < ActionController::Base
# Converts the errors on an ActiveRecord object into a common JSON format
def object_errors_to_json(object)
object.errors.map { |attribute, error|
object.errors.map do |attribute, error|
{ attribute => error }
}.to_json
end.to_json
end
# Renders API response on validation failure

@ -45,7 +45,7 @@ module Concerns::RedirectAfterLogin
if url = OpenProject::Configuration.after_login_default_redirect_url
redirect_to url
else
redirect_back_or_default controller: '/my', action: 'page'
redirect_back_or_default my_page_path
end
end

@ -45,12 +45,6 @@ class MyController < ApplicationController
menu_item :access_token, only: [:access_token]
menu_item :mail_notifications, only: [:mail_notifications]
# Show user's page
def index
render action: 'page', layout: 'no_menu'
end
alias :page :index
def account; end
def update_account

@ -29,7 +29,6 @@
#++
module MetaTagsHelper
##
# Use meta-tags to output title and site name
def output_title_and_meta_tags

@ -69,7 +69,7 @@ end
Redmine::MenuManager.map :account_menu do |menu|
menu.push :my_page,
{ controller: '/my', action: 'page' },
:my_page_path,
if: Proc.new { User.current.logged? }
menu.push :my_account,
{ controller: '/my', action: 'account' },
@ -77,7 +77,8 @@ Redmine::MenuManager.map :account_menu do |menu|
menu.push :administration,
{ controller: '/users', action: 'index' },
if: Proc.new { User.current.admin? }
menu.push :logout, :signout_path,
menu.push :logout,
:signout_path,
if: Proc.new { User.current.logged? }
end
@ -113,7 +114,7 @@ Redmine::MenuManager.map :my_menu do |menu|
caption: I18n.t('activerecord.attributes.user.mail_notification'),
icon: 'icon2 icon-news'
menu.push :delete_account, :deletion_info_path,
menu.push :delete_account, :delete_my_account_info_path,
caption: I18n.t('account.delete'),
param: :user_id,
if: Proc.new { Setting.users_deletable_by_self? },

@ -208,6 +208,8 @@ en:
title: 'Work packages created by me'
work_packages_watched:
title: 'Work packages watched by me'
work_packages_table:
title: 'Work packages'
work_packages_calendar:
title: 'Calendar'

@ -516,10 +516,11 @@ OpenProject::Application.routes.draw do
match '/oauth/revoke_application/:application_id' => 'oauth/grants#revoke_application', via: :post, as: 'revoke_my_oauth_application'
end
mount MyPage::Engine, at: "/my/page"
scope controller: 'my' do
get '/my/password', action: 'password'
post '/my/change_password', action: 'change_password'
get '/my/page', action: 'page'
get '/my/account', action: 'account'
get '/my/settings', action: 'settings'

@ -582,6 +582,18 @@ A page link must be provided in the body when calling this end point.
"required": true,
"hasDefault": false,
"writable": true,
"_embedded": {
allowedValues: [
{
"_type": "GridWidget",
"identifier": "work_packages_assigned"
},
{
"_type": "GridWidget",
"identifier": "news"
}
]
},
"_links": {}
},
"_links": {}

@ -4,17 +4,20 @@ import {GridResource} from "core-app/modules/hal/resources/grid-resource";
import {PathHelperService} from "core-app/modules/common/path-helper/path-helper.service";
import {HalResourceService} from "core-app/modules/hal/services/hal-resource.service";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {Title} from '@angular/platform-browser';
@Component({
templateUrl: './my-page.component.html'
})
export class MyPageComponent implements OnInit {
public text = { title: this.i18n.t('js.label_my_page') };
public text = { title: this.i18n.t('js.label_my_page'),
html_title: this.i18n.t('js.label_my_page') };
constructor(readonly gridDm:GridDmService,
readonly pathHelper:PathHelperService,
readonly halResourceService:HalResourceService,
readonly i18n:I18nService) {}
readonly i18n:I18nService,
readonly title:Title) {}
public grid:GridResource;
@ -24,6 +27,8 @@ export class MyPageComponent implements OnInit {
.then((grid) => {
this.grid = grid;
});
this.setHtmlTitle();
}
// If a page with the current page exists (scoped to the current user by the backend)
@ -71,4 +76,8 @@ export class MyPageComponent implements OnInit {
});
});
}
private setHtmlTitle() {
this.title.setTitle(this.text.html_title);
}
}

@ -84,7 +84,8 @@ export class UrlParamsHelperService {
private encodeColumns(paramsData:any, query:QueryResource) {
paramsData.c = query.columns.map(function (column) {
return column.id!;
})
});
return paramsData;
}

@ -8,6 +8,7 @@ import {GridWidgetArea} from "core-app/modules/grids/areas/grid-widget-area";
import {GridAreaService} from "core-app/modules/grids/grid/area.service";
import {GridDragAndDropService} from "core-app/modules/grids/grid/drag-and-drop.service";
import {GridResizeService} from "core-app/modules/grids/grid/resize.service";
import {SchemaResource} from "core-app/modules/hal/resources/schema-resource";
@Injectable()
export class GridAddWidgetService {
@ -27,9 +28,9 @@ export class GridAddWidgetService {
this.layout.widgetAreaIds.includes(area.guid);
}
public widget(area:GridArea) {
public widget(area:GridArea, schema:SchemaResource) {
this
.select(area)
.select(area, schema)
.then((widgetResource) => {
// try to set it to a 2 x 3 layout
// but shrink if that is outside the grid or
@ -75,9 +76,9 @@ export class GridAddWidgetService {
});
}
private select(area:GridArea) {
private select(area:GridArea, schema:SchemaResource) {
return new Promise<GridWidgetResource>((resolve, reject) => {
const modal = this.opModalService.show(AddGridWidgetModal, this.injector);
const modal = this.opModalService.show(AddGridWidgetModal, this.injector, { schema: schema });
modal.closingEvent.subscribe((modal:AddGridWidgetModal) => {
let registered = modal.chosenWidget;
@ -92,7 +93,8 @@ export class GridAddWidgetService {
startRow: area.startRow,
endRow: area.endRow,
startColumn: area.startColumn,
endColumn: area.endColumn
endColumn: area.endColumn,
options: {}
};
let resource = this.halResource.createHalResource(source) as GridWidgetResource;

@ -11,7 +11,7 @@ import {SchemaResource} from "core-app/modules/hal/resources/schema-resource";
export class GridAreaService {
private resource:GridResource;
private schema:SchemaResource;
public schema:SchemaResource;
public numColumns:number = 0;
public numRows:number = 0;
@ -48,13 +48,18 @@ export class GridAreaService {
this.resource.widgets = this.widgetResources;
this.resource.rowCount = this.numRows;
this.resource.columnCount = this.numColumns;
if (save) {
this.gridDm.update(this.resource, this.schema);
this.saveGrid();
}
}
public saveGrid() {
this.gridDm.update(this.resource, this.schema);
}
private buildGridAreas() {
let cells:GridArea[] = [];

@ -52,7 +52,7 @@
</div>
<ndc-dynamic [ndcDynamicComponent]="widgetComponent(area.widget)"
[ndcDynamicInputs]="{ resource: area.widget }"
[ndcDynamicOutputs]="{}">
[ndcDynamicOutputs]="widgetComponentOutput(area.widget)">
</ndc-dynamic>
</div>
<resizer *ngIf="!drag.currentlyDragging"
@ -82,7 +82,7 @@
[cdkDropListConnectedTo]="layout.widgetAreaIds">
<div class="grid--widget-add"
*ngIf="add.isAddable(area)"
(click)="add.widget(area)">
(click)="add.widget(area, layout.schema)">
</div>
</div>

@ -74,6 +74,10 @@ export class GridComponent implements OnDestroy, OnInit {
}
}
public widgetComponentOutput(resource:GridWidgetResource) {
return { resourceChanged: this.layout.saveGrid.bind(this.layout) };
}
public get gridColumnStyle() {
return this.sanitization.bypassSecurityTrustStyle(`repeat(${this.layout.numColumns}, 1fr)`);
}

@ -50,6 +50,7 @@ import {Ng2StateDeclaration, UIRouterModule} from '@uirouter/angular';
import {WidgetDocumentsComponent} from "core-app/modules/grids/widgets/documents/documents.component";
import {WidgetNewsComponent} from "core-app/modules/grids/widgets/news/news.component";
import {WidgetWpAccountableComponent} from './widgets/wp-accountable/wp-accountable.component';
import {WidgetWpTableComponent} from "core-app/modules/grids/widgets/wp-table/wp-table.component";
export const GRID_ROUTES:Ng2StateDeclaration[] = [
{
@ -76,6 +77,7 @@ export const GRID_ROUTES:Ng2StateDeclaration[] = [
WidgetWpAccountableComponent,
WidgetWpCreatedComponent,
WidgetWpWatchedComponent,
WidgetWpTableComponent,
WidgetWpCalendarComponent,
WidgetTimeEntriesCurrentUserComponent]),
@ -100,6 +102,7 @@ export const GRID_ROUTES:Ng2StateDeclaration[] = [
WidgetWpCreatedComponent,
WidgetWpWatchedComponent,
WidgetWpCalendarComponent,
WidgetWpTableComponent,
WidgetTimeEntriesCurrentUserComponent,
AddGridWidgetModal,
@ -142,6 +145,10 @@ export function registerWidgets(injector:Injector) {
identifier: 'work_packages_watched',
component: WidgetWpWatchedComponent
},
{
identifier: 'work_packages_table',
component: WidgetWpTableComponent
},
{
identifier: 'work_packages_calendar',
component: WidgetWpCalendarComponent

@ -1,4 +1,4 @@
import {HostBinding, Input} from "@angular/core";
import {HostBinding, Input, EventEmitter, Output, HostListener} from "@angular/core";
import {GridWidgetResource} from "app/modules/hal/resources/grid-widget-resource";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
@ -10,5 +10,7 @@ export abstract class AbstractWidgetComponent {
@Input() resource:GridWidgetResource;
@Output() resourceChanged = new EventEmitter<GridWidgetResource>();
constructor(protected i18n:I18nService) { }
}

@ -26,7 +26,7 @@ export class AddGridWidgetModal extends OpModalComponent {
}
public get selectable() {
return this.widgetsService.registered.map((widget) => {
return this.eligibleWidgets.map((widget) => {
return {
identifier: widget.identifier,
title: this.i18n.t(`js.grid.widgets.${widget.identifier}.title`),
@ -45,4 +45,14 @@ export class AddGridWidgetModal extends OpModalComponent {
public trackWidgetBy(widget:WidgetRegistration) {
return widget.identifier;
}
private get eligibleWidgets() {
let schemaWidgetIdentifiers = this.locals.schema.widgets.allowedValues.map((widget:any) => {
return widget.identifier;
});
return this.widgetsService.registered.filter((widget) => {
return schemaWidgetIdentifiers.includes(widget.identifier);
});
}
}

@ -0,0 +1,23 @@
<h3 class="widget-box--header"
*ngIf="query"
cdkDragHandle>
<span class="grid--area-drag-handle
icon
icon-drag-handle"
cdkDragHandle></span>
<i class="icon-context icon-view-timeline" aria-hidden="true"></i>
<editable-toolbar-title [title]="query.name"
[inFlight]="inFlight"
(onSave)="renameQuery(query, $event)"
[editable]="!!query.updateImmediately"
class="widget-box--header-title">
</editable-toolbar-title>
</h3>
<ng-container wp-isolated-query-space>
<wp-embedded-table [queryId]="queryId"
[configuration]="configuration"
[externalHeight]="true"
*ngIf="queryId">
</wp-embedded-table>
</ng-container>

@ -0,0 +1,146 @@
import {Component, OnInit, OnDestroy, ViewChild, AfterViewInit} from "@angular/core";
import {WidgetWpListComponent} from "core-app/modules/grids/widgets/wp-widget/wp-widget.component";
import {WorkPackageTableConfiguration} from "core-components/wp-table/wp-table-configuration";
import {QueryResource} from "core-app/modules/hal/resources/query-resource";
import {I18nService} from "core-app/modules/common/i18n/i18n.service";
import {untilComponentDestroyed} from 'ng2-rx-componentdestroyed';
import {WorkPackageIsolatedQuerySpaceDirective} from "core-app/modules/work_packages/query-space/wp-isolated-query-space.directive";
import {skip, take} from 'rxjs/operators';
import {UrlParamsHelperService} from "core-components/wp-query/url-params-helper";
import {QueryFormDmService} from "core-app/modules/hal/dm-services/query-form-dm.service";
import {QueryDmService} from "core-app/modules/hal/dm-services/query-dm.service";
import {QueryFormResource} from "core-app/modules/hal/resources/query-form-resource";
@Component({
templateUrl: './wp-table.component.html',
styleUrls: ['../wp-widget/wp-widget.component.css']
})
export class WidgetWpTableComponent extends WidgetWpListComponent implements OnInit, OnDestroy, AfterViewInit {
public text = { title: this.i18n.t('js.grid.widgets.work_packages_table.title') };
public queryId:string|null;
private queryForm:QueryFormResource;
public inFlight = false;
public query:QueryResource;
public configuration:Partial<WorkPackageTableConfiguration> = {
actionsColumnEnabled: false,
columnMenuEnabled: true,
hierarchyToggleEnabled: true,
contextMenuEnabled: false
};
constructor(protected i18n:I18nService,
protected urlParamsHelper:UrlParamsHelperService,
private readonly queryDm:QueryDmService,
private readonly queryFormDm:QueryFormDmService) {
super(i18n);
}
@ViewChild(WorkPackageIsolatedQuerySpaceDirective) public querySpaceDirective:WorkPackageIsolatedQuerySpaceDirective;
ngOnInit() {
if (!this.resource.options.queryId) {
this.createInitial()
.then((query) => {
this.resource.options = { queryId: query.id };
this.resourceChanged.emit(this.resource);
this.queryId = query.id;
super.ngOnInit();
});
} else {
this.queryId = this.resource.options.queryId as string;
super.ngOnInit();
}
}
ngAfterViewInit() {
this
.querySpaceDirective
.querySpace
.query
.values$()
.pipe(
take(1),
untilComponentDestroyed(this)
).subscribe((query) => {
this.query = query;
this.queryId = query.id;
});
this
.querySpaceDirective
.querySpace
.query
.values$()
.pipe(
// 2 because ... well it is a magic number and works
skip(2),
untilComponentDestroyed(this)
).subscribe((query) => {
this.queryId = query.id;
this.ensureFormAndSaveQuery(query);
});
}
ngOnDestroy() {
// nothing to do
}
private ensureFormAndSaveQuery(query:QueryResource) {
if (this.queryForm) {
this.saveQuery(query);
} else {
this.queryFormDm.load(query).then((form) => {
this.queryForm = form;
this.saveQuery(query);
});
}
}
public renameQuery(query:QueryResource, value:string) {
query.name = value;
this.ensureFormAndSaveQuery(query);
}
private saveQuery(query:QueryResource) {
this.inFlight = true;
this
.queryDm
.update(query, this.queryForm)
.toPromise()
.then((query) => {
this.inFlight = false;
return query;
})
.catch(() => this.inFlight = false);
}
private createInitial():Promise<QueryResource> {
return this.queryFormDm
.loadWithParams(
{pageSize: 0},
undefined,
null,
this.buildQueryRequest()
)
.then(form => {
const query = this.queryFormDm.buildQueryResource(form);
// set a default title
query.name = this.text.title;
return this.queryDm.create(query, form);
});
}
private buildQueryRequest() {
return {
hidden: true
};
}
}

@ -3,3 +3,11 @@ wp-embedded-table {
flex: 1 1 auto;
overflow: hidden;
}
.widget-box--header {
display: flex
}
.widget-box--header .icon-context {
padding-top: 5px
}

@ -2,7 +2,6 @@ import {AbstractWidgetComponent} from "app/modules/grids/widgets/abstract-widget
import {OnInit} from "@angular/core";
import {
WorkPackageTableConfiguration,
WorkPackageTableConfigurationObject
} from "core-components/wp-table/wp-table-configuration";
export class WidgetWpListComponent extends AbstractWidgetComponent implements OnInit {

@ -114,7 +114,7 @@ import {debugLog} from "core-app/helpers/debug_output";
export class WorkPackageIsolatedQuerySpaceDirective {
constructor(private elementRef:ElementRef,
private querySpace:IsolatedQuerySpace,
public querySpace:IsolatedQuerySpace,
private injector:Injector) {
debugLog("Opening isolated query space %O in %O", injector, elementRef.nativeElement);
}

@ -195,9 +195,8 @@ module Redmine::MenuManager::MenuHelper
link_text << ' '.html_safe + op_icon(item.icon_after) if item.icon_after.present?
html_options = item.html_options(selected: selected)
html_options[:title] ||= selected ? t(:description_current_position) + caption : caption
link_to url, html_options do
link_text
end
link_to link_text, main_app_url(url), html_options
end
def render_unattached_menu_item(menu_item, project)
@ -212,6 +211,7 @@ module Redmine::MenuManager::MenuHelper
def current_menu_item_part_of_menu?(menu, project = nil)
return true if no_menu_item_wiki_prefix? || wiki_prefix?
all_menu_items_for(menu, project).each do |node|
return true if node.name == current_menu_item
end
@ -231,6 +231,7 @@ module Redmine::MenuManager::MenuHelper
items = []
iteratable.each do |node|
next if node.name == :root
if allowed_node?(node, User.current, project) && visible_node?(menu, node)
items << node
if block_given?
@ -248,7 +249,7 @@ module Redmine::MenuManager::MenuHelper
when Hash
project.nil? ? item.url : { item.param => project }.merge(item.url)
when Symbol
send(item.url)
main_app.send(item.url)
else
item.url
end
@ -323,4 +324,12 @@ module Redmine::MenuManager::MenuHelper
end
badge
end
def main_app_url(url)
if url.is_a? Symbol
main_app.send(url)
else
main_app.url_for(url)
end
end
end

@ -59,7 +59,7 @@ module Redmine::MenuManager::TopMenu::ProjectsMenu
def project_index_item
Redmine::MenuManager::MenuItem.new(
:list_projects,
{ controller: '/projects', action: 'index' },
main_app.projects_path,
caption: t(:label_project_view_all),
icon: "icon-show-all-projects icon4",
html: {
@ -71,7 +71,7 @@ module Redmine::MenuManager::TopMenu::ProjectsMenu
def project_new_item
Redmine::MenuManager::MenuItem.new(
:new_project,
{ controller: '/projects', action: 'new' },
main_app.new_project_path,
caption: Project.model_name.human,
icon: "icon-add icon4",
html: {
@ -82,4 +82,6 @@ module Redmine::MenuManager::TopMenu::ProjectsMenu
if: Proc.new { User.current.allowed_to?(:add_project, nil, global: true) }
)
end
include OpenProject::StaticRouting::UrlHelpers
end

@ -63,12 +63,14 @@ module Grids
Grid
end
def assignable_values(_column, _user)
nil
def assignable_values(column, user)
if column == :widgets
all_allowed_widget_identifiers(user)
end
end
def edit_allowed?
Grids::Configuration.writable?(model, user)
config.writable?(model, user)
end
private
@ -81,10 +83,10 @@ module Grids
end
def validate_registered_widgets
return unless Grids::Configuration.registered_grid?(model.class)
return unless config.registered_grid?(grid_class)
undestroyed_widgets.each do |widget|
next if Grids::Configuration.allowed_widget?(model.class, widget.identifier)
next if config.allowed_widget?(grid_class, widget.identifier, user)
errors.add(:widgets, :inclusion)
end
@ -177,5 +179,19 @@ module Grids
def undestroyed_widgets
model.widgets.reject(&:marked_for_destruction?)
end
def all_allowed_widget_identifiers(user)
config.all_widget_identifiers(grid_class).select do |identifier|
config.allowed_widget?(grid_class, identifier, user)
end
end
def grid_class
model.class
end
def config
Grids::Configuration
end
end
end

@ -76,8 +76,7 @@ module API
value_representer: false,
link_factory: ->(path) {
{
href: path,
title: I18n.t(:label_my_page)
href: path
}
}
@ -86,6 +85,11 @@ module API
required: true,
has_default: false,
visibility: false,
values_callback: -> do
represented.assignable_values(:widgets, current_user).map do |identifier|
OpenStruct.new(identifier: identifier)
end
end,
value_representer: ::API::V3::Grids::WidgetRepresenter,
link_factory: false

@ -34,5 +34,15 @@ module Grids
belongs_to :grid
serialize :options, Hash
after_destroy :execute_after_destroy_strategy
private
def execute_after_destroy_strategy
proc = Grids::Configuration.widget_strategy(grid.class, identifier).after_destroy
instance_exec(&proc)
end
end
end

@ -1,14 +0,0 @@
#!/usr/bin/env ruby
# This command will automatically be run when you run "rails" with Rails gems
# installed from the root of your application.
ENGINE_ROOT = File.expand_path('../..', __FILE__)
ENGINE_PATH = File.expand_path('../../lib/grids/engine', __FILE__)
APP_PATH = File.expand_path('../../test/dummy/config/application', __FILE__)
# Set up gems listed in the Gemfile.
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
require 'rails/all'
require 'rails/engine/commands'

@ -28,7 +28,7 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
class Grids::Configuration
module Grids::Configuration
class << self
def register_grid(grid,
klass)
@ -89,10 +89,21 @@ class Grids::Configuration
@widget_register[identifier] = Array(grid_classes)
end
def allowed_widget?(grid, identifier)
def allowed_widget?(grid, identifier, user)
grid_classes = registered_widget_by_identifier[identifier]
(grid_classes || []).include?(grid)
(grid_classes || []).include?(grid) &&
widget_strategy(grid, identifier)&.allowed?(user)
end
def all_widget_identifiers(grid)
registered_widget_by_identifier.select do |_, grid_classes|
grid_classes.include?(grid)
end.keys
end
def widget_strategy(grid, identifier)
grid_register[grid.to_s]&.widget_strategy(identifier)
end
def writable?(grid, user)
@ -119,90 +130,4 @@ class Grids::Configuration
@url_helpers ||= OpenProject::StaticRouting::StaticUrlHelpers.new
end
end
class Registration
class << self
def grid_class(name_string = nil)
if name_string
@grid_class = name_string
end
@grid_class
end
def to_scope(path = nil)
if path
@to_scope = path
end
@to_scope
end
def widgets(*widgets)
if widgets.any?
@widgets = widgets
end
@widgets
end
def defaults(hash = nil)
# This is called during code load, which
# may not have the table available.
return unless Grids::Widget.table_exists?
if hash
@defaults = hash
end
params = @defaults.dup
params[:widgets] = (params[:widgets] || []).map do |widget|
Grids::Widget.new(widget)
end
params
end
def from_scope(_scope)
raise NotImplementedError
end
def all_scopes
Array(url_helpers.send(@to_scope))
end
def visible(_user = User.current)
::Grids::Grid
.where(type: grid_class)
end
def writable?(_grid, _user)
true
end
def register!
unless @grid_class
raise 'Need to define the grid class first. Use grid_class to do so.'
end
unless @widgets
raise 'Need to define at least one widget first. Use widgets to do so.'
end
unless @to_scope
raise 'Need to define a scope. Use to_scope to do so'
end
Grids::Configuration.register_grid(@grid_class, self)
widgets.each do |widget|
Grids::Configuration.register_widget(widget, @grid_class)
end
end
private
def url_helpers
@url_helpers ||= OpenProject::StaticRouting::StaticUrlHelpers.new
end
end
end
end

@ -0,0 +1,127 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 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 docs/COPYRIGHT.rdoc for more details.
#++
module Grids::Configuration
class Registration
class << self
def grid_class(name_string = nil)
if name_string
@grid_class = name_string
end
@grid_class
end
def to_scope(path = nil)
if path
@to_scope = path
end
@to_scope
end
def widgets(*widgets)
if widgets.any?
@widgets = widgets
end
@widgets
end
def widget_strategy(widget_name, &block)
@widget_strategies ||= {}
if block_given?
@widget_strategies[widget_name.to_s] = Class.new(Grids::Configuration::WidgetStrategy, &block)
end
@widget_strategies[widget_name.to_s] ||= Grids::Configuration::WidgetStrategy
end
def defaults(hash = nil)
# This is called during code load, which
# may not have the table available.
return unless Grids::Widget.table_exists?
if hash
@defaults = hash
end
params = @defaults.dup
params[:widgets] = (params[:widgets] || []).map do |widget|
Grids::Widget.new(widget)
end
params
end
def from_scope(_scope)
raise NotImplementedError
end
def all_scopes
Array(url_helpers.send(@to_scope))
end
def visible(_user = User.current)
::Grids::Grid
.where(type: grid_class)
end
def writable?(_grid, _user)
true
end
def register!
unless @grid_class
raise 'Need to define the grid class first. Use grid_class to do so.'
end
unless @widgets
raise 'Need to define at least one widget first. Use widgets to do so.'
end
unless @to_scope
raise 'Need to define a scope. Use to_scope to do so'
end
Grids::Configuration.register_grid(@grid_class, self)
widgets.each do |widget|
Grids::Configuration.register_widget(widget, @grid_class)
end
end
private
def url_helpers
@url_helpers ||= OpenProject::StaticRouting::StaticUrlHelpers.new
end
end
end
end

@ -0,0 +1,55 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 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 docs/COPYRIGHT.rdoc for more details.
#++
module Grids::Configuration
class WidgetStrategy
class << self
def after_destroy(proc = nil)
if proc
@after_destroy = proc
end
@after_destroy ||= -> {}
end
def allowed(proc = nil)
if proc
@allowed = proc
end
@allowed ||= ->(_user) { true }
end
def allowed?(user)
allowed.(user)
end
end
end
end

@ -9,9 +9,5 @@ module Grids
Queries::Register.filter query, Grids::Filters::ScopeFilter
end
config.to_prepare do
Grids::MyPageGridRegistration.register!
end
end
end

@ -38,9 +38,11 @@ describe Grids::CreateContract do
it_behaves_like 'shared grid contract attributes'
describe 'type' do
let(:grid) { FactoryBot.build_stubbed(:grid, default_values) }
it_behaves_like 'is writable' do
let(:attribute) { :type }
let(:value) { 'Grids::MyPage' }
let(:value) { 'Grids::Grid' }
end
end
@ -51,15 +53,6 @@ describe Grids::CreateContract do
let(:attribute) { :user_id }
let(:value) { 5 }
end
context 'for a Grids::MyPage' do
let(:grid) { FactoryBot.build_stubbed(:my_page, default_values) }
it_behaves_like 'is writable' do
let(:attribute) { :user_id }
let(:value) { 5 }
end
end
end
describe 'project_id' do
@ -69,15 +62,6 @@ describe Grids::CreateContract do
let(:attribute) { :project_id }
let(:value) { 5 }
end
context 'for a Grids::MyPage' do
let(:grid) { FactoryBot.build_stubbed(:my_page, default_values) }
it_behaves_like 'is not writable' do
let(:attribute) { :project_id }
let(:value) { 5 }
end
end
end
describe '#assignable_values' do
@ -94,6 +78,29 @@ describe Grids::CreateContract do
end
end
context 'for widgets' do
it 'calls the grid configuration for the available values but allows only those eligible' do
widgets = %i[widget1 widget2]
allow(Grids::Configuration)
.to receive(:all_widget_identifiers)
.and_return(widgets)
allow(Grids::Configuration)
.to receive(:allowed_widget?)
.with(Grids::Grid, :widget1, user)
.and_return(true)
allow(Grids::Configuration)
.to receive(:allowed_widget?)
.with(Grids::Grid, :widget2, user)
.and_return(false)
expect(instance.assignable_values(:widgets, user))
.to match_array [:widget1]
end
end
context 'for something else' do
it 'returns nil' do
expect(instance.assignable_values(:something, user))

@ -39,7 +39,7 @@ shared_context 'grid contract' do
}
end
let(:grid) do
FactoryBot.build_stubbed(:my_page, default_values)
FactoryBot.build_stubbed(:grid, default_values)
end
shared_examples_for 'validates positive integer' do
@ -113,261 +113,7 @@ shared_examples_for 'shared grid contract attributes' do
end
end
describe 'widgets' do
it_behaves_like 'is writable' do
let(:attribute) { :widgets }
let(:value) do
[
Grids::Widget.new(start_row: 1,
end_row: 4,
start_column: 2,
end_column: 5,
identifier: 'work_packages_assigned')
]
end
end
context 'invalid identifier' do
before do
grid.widgets.build(start_row: 1,
end_row: 4,
start_column: 2,
end_column: 5,
identifier: 'bogus_identifier')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :inclusion }]
end
end
context 'collisions between widgets' do
before do
grid.widgets.build(start_row: 1,
end_row: 3,
start_column: 1,
end_column: 3,
identifier: 'work_packages_assigned')
grid.widgets.build(start_row: 2,
end_row: 4,
start_column: 2,
end_column: 4,
identifier: 'work_packages_created')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :overlaps }, { error: :overlaps }]
end
end
context 'widgets having the same start column as another\'s end column' do
before do
grid.widgets.build(start_row: 1,
end_row: 3,
start_column: 1,
end_column: 3,
identifier: 'work_packages_assigned')
grid.widgets.build(start_row: 1,
end_row: 3,
start_column: 3,
end_column: 4,
identifier: 'work_packages_created')
end
it 'is valid' do
expect(instance.validate)
.to be_truthy
end
end
context 'widgets having the same start row as another\'s end row' do
before do
grid.widgets.build(start_row: 1,
end_row: 3,
start_column: 1,
end_column: 3,
identifier: 'work_packages_assigned')
grid.widgets.build(start_row: 3,
end_row: 4,
start_column: 1,
end_column: 3,
identifier: 'work_packages_created')
end
it 'is valid' do
expect(instance.validate)
.to be_truthy
end
end
context 'widgets being outside (max) of the grid' do
before do
grid.widgets.build(start_row: 1,
end_row: grid.row_count + 2,
start_column: 1,
end_column: 3,
identifier: 'work_packages_assigned')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :outside }]
end
end
context 'widgets being outside (min) of the grid' do
before do
grid.widgets.build(start_row: 1,
end_row: 2,
start_column: -1,
end_column: 3,
identifier: 'work_packages_assigned')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :outside }]
end
end
context 'widgets spanning the whole grid' do
before do
grid.widgets.build(start_row: 1,
end_row: grid.row_count + 1,
start_column: 1,
end_column: grid.column_count + 1,
identifier: 'work_packages_assigned')
end
it 'is valid' do
expect(instance.validate)
.to be_truthy
end
end
context 'widgets having start after end column' do
before do
grid.widgets.build(start_row: 1,
end_row: 2,
start_column: 4,
end_column: 3,
identifier: 'work_packages_assigned')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :end_before_start }]
end
end
context 'widgets having start after end row' do
before do
grid.widgets.build(start_row: 4,
end_row: 2,
start_column: 1,
end_column: 3,
identifier: 'work_packages_assigned')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :end_before_start }]
end
end
context 'widgets having start equals end column' do
before do
grid.widgets.build(start_row: 1,
end_row: 2,
start_column: 4,
end_column: 3,
identifier: 'work_packages_assigned')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :end_before_start }]
end
end
context 'widgets having start equals end row' do
before do
grid.widgets.build(start_row: 2,
end_row: 2,
start_column: 1,
end_column: 3,
identifier: 'work_packages_assigned')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :end_before_start }]
end
end
end
describe 'valid grid subclasses' do
context 'for a registered subclass' do
let(:grid) do
FactoryBot.build_stubbed(:my_page, default_values)
end
it 'is valid' do
expect(instance.validate)
.to be_truthy
end
end
context 'for the Grid superclass itself' do
let(:grid) do
FactoryBot.build_stubbed(:grid, default_values)

@ -51,7 +51,7 @@ describe Grids::UpdateContract do
instance.validate
# scope because that is what type is called on the outside for grids
expect(instance.errors.details[:scope])
.to match_array [{ error: :error_readonly }]
.to match_array [{ error: :error_readonly }, { error: :inclusion }]
end
end

@ -1,28 +1,4 @@
FactoryBot.define do
factory :grid, class: Grids::Grid do
end
factory :my_page, class: Grids::MyPage do
user
row_count { 7 }
column_count { 4 }
widgets do
[
Grids::Widget.new(
identifier: 'work_packages_assigned',
start_row: 1,
end_row: 7,
start_column: 1,
end_column: 3
),
Grids::Widget.new(
identifier: 'work_packages_created',
start_row: 1,
end_row: 7,
start_column: 3,
end_column: 5
)
]
end
end
end

@ -71,7 +71,7 @@ describe ::API::V3::Grids::GridPayloadRepresenter, 'parsing' do
],
"_links" => {
"scope" => {
"href" => my_page_path
"href" => 'some_path'
}
}
}
@ -82,7 +82,7 @@ describe ::API::V3::Grids::GridPayloadRepresenter, 'parsing' do
it 'updates page' do
grid = representer.from_hash(hash)
expect(grid.scope)
.to eql(my_page_path)
.to eql('some_path')
end
end
end

@ -33,7 +33,7 @@ describe ::API::V3::Grids::GridRepresenter, 'rendering' do
let(:grid) do
FactoryBot.build_stubbed(
:my_page,
:grid,
row_count: 4,
column_count: 5,
widgets: [
@ -68,6 +68,21 @@ describe ::API::V3::Grids::GridRepresenter, 'rendering' do
let(:current_user) { FactoryBot.build_stubbed(:user) }
let(:representer) { described_class.new(grid, current_user: current_user) }
let(:writable) { true }
let(:scope_path) { 'bogus_scope' }
before do
allow(::Grids::Configuration)
.to receive(:writable?)
.with(grid, current_user)
.and_return(writable)
allow(::Grids::Configuration)
.to receive(:to_scope)
.with(Grids::Grid, [])
.and_return(scope_path)
end
context 'generation' do
subject(:generated) { representer.to_json }
@ -78,12 +93,6 @@ describe ::API::V3::Grids::GridRepresenter, 'rendering' do
.at_path('_type')
end
it 'identifies the url the grid is stored for' do
is_expected
.to be_json_eql(my_page_path.to_json)
.at_path('_links/scope/href')
end
it 'has an id' do
is_expected
.to be_json_eql(grid.id)
@ -180,7 +189,7 @@ describe ::API::V3::Grids::GridRepresenter, 'rendering' do
context 'scope link' do
it_behaves_like 'has an untitled link' do
let(:link) { 'scope' }
let(:href) { my_page_path }
let(:href) { scope_path }
let(:type) { "text/html" }
it 'has a content type of html' do

@ -38,14 +38,7 @@ describe ::API::V3::Grids::Schemas::GridSchemaRepresenter do
let(:new_record) { true }
let(:allowed_scopes) { %w(/some/path /some/other/path) }
let(:allowed_widgets) do
[
OpenStruct.new(
identifier: 'first_widget'
),
OpenStruct.new(
identifier: 'second_widget'
)
]
%w(first_widget second_widget)
end
let(:contract) do
contract = double('contract')
@ -172,9 +165,9 @@ describe ::API::V3::Grids::Schemas::GridSchemaRepresenter do
end
it 'embeds the allowed values' do
allowed_widgets.each_with_index do |widget, index|
allowed_widgets.each_with_index do |identifier, index|
href_path = "#{path}/_embedded/allowedValues/#{index}/identifier"
is_expected.to be_json_eql(widget[:identifier].to_json).at_path(href_path)
is_expected.to be_json_eql(identifier.to_json).at_path(href_path)
end
end
end

@ -61,16 +61,6 @@ describe "POST /api/v3/grids/form", type: :request, content_type: :json do
.at_path('_type')
end
it 'contains a Schema embedding the available values' do
expect(subject.body)
.to be_json_eql("Schema".to_json)
.at_path('_embedded/schema/_type')
expect(subject.body)
.to be_json_eql(my_page_path.to_json)
.at_path('_embedded/schema/scope/_links/allowedValues/0/href')
end
it 'contains default data in the payload' do
expected = {
"rowCount": 4,
@ -95,135 +85,5 @@ describe "POST /api/v3/grids/form", type: :request, content_type: :json do
expect(subject.body)
.not_to have_json_path('_links/commit')
end
context 'with /my/page for the scope value' do
let(:params) do
{
'_links': {
'scope': {
'href': my_page_path
}
}
}
end
it 'contains default data in the payload' do
expected = {
"rowCount": 7,
"columnCount": 4,
"options": {},
"widgets": [
{
"_type": "GridWidget",
identifier: 'work_packages_assigned',
"options": {},
startRow: 1,
endRow: 7,
startColumn: 1,
endColumn: 3
},
{
"_type": "GridWidget",
identifier: 'work_packages_created',
"options": {},
startRow: 1,
endRow: 7,
startColumn: 3,
endColumn: 5
}
],
"_links": {
"scope": {
"href": "/my/page",
"type": "text/html"
}
}
}
expect(subject.body)
.to be_json_eql(expected.to_json)
.at_path('_embedded/payload')
end
it 'has no validationErrors' do
expect(subject.body)
.to be_json_eql({}.to_json)
.at_path('_embedded/validationErrors')
end
it 'has a commit link' do
expect(subject.body)
.to be_json_eql(api_v3_paths.grids.to_json)
.at_path('_links/commit/href')
end
end
context 'with an unsupported widget identifier' do
let(:params) do
{
'_links': {
'scope': {
'href': my_page_path
}
},
"widgets": [
{
"_type": "GridWidget",
"identifier": "bogus_identifier",
"startRow": 4,
"endRow": 5,
"startColumn": 1,
"endColumn": 2
}
]
}
end
it 'has a validationError on widget' do
expect(subject.body)
.to be_json_eql("Widgets is not set to one of the allowed values.".to_json)
.at_path('_embedded/validationErrors/widgets/message')
end
end
context 'with name set' do
let(:params) do
{
name: 'My custom grid 1',
'_links': {
'scope': {
'href': my_page_path
}
}
}
end
it 'feeds it back' do
expect(subject.body)
.to be_json_eql("My custom grid 1".to_json)
.at_path('_embedded/payload/name')
end
end
context 'with options set' do
let(:params) do
{
options: {
foo: 'bar'
},
'_links': {
'scope': {
'href': my_page_path
}
}
}
end
it 'feeds them back' do
expect(subject.body)
.to be_json_eql("bar".to_json)
.at_path('_embedded/payload/options/foo')
end
end
end
end

@ -37,12 +37,6 @@ describe 'API v3 Grids resource', type: :request, content_type: :json do
FactoryBot.create(:user)
end
let(:my_page_grid) { FactoryBot.create(:my_page, user: current_user) }
let(:other_user) do
FactoryBot.create(:user)
end
let(:other_my_page_grid) { FactoryBot.create(:my_page, user: other_user) }
before do
login_as(current_user)
end
@ -52,369 +46,22 @@ describe 'API v3 Grids resource', type: :request, content_type: :json do
describe '#get INDEX' do
let(:path) { api_v3_paths.grids }
let(:stored_grids) do
my_page_grid
other_my_page_grid
end
before do
stored_grids
get path
end
it 'responds with 200 OK' do
expect(subject.status).to eq(200)
end
it 'sends a collection of grids but only those visible to the current user' do
expect(subject.body)
.to be_json_eql('Collection'.to_json)
.at_path('_type')
expect(subject.body)
.to be_json_eql('Grid'.to_json)
.at_path('_embedded/elements/0/_type')
expect(subject.body)
.to be_json_eql(1.to_json)
.at_path('total')
end
context 'with a filter on the scope attribute' do
shared_let(:other_grid) do
grid = Grids::Grid.new(row_count: 20,
column_count: 20)
grid.save
Grids::Grid
.where(id: grid.id)
.update_all(user_id: current_user.id)
grid
end
let(:stored_grids) do
my_page_grid
other_my_page_grid
other_grid
end
let(:path) do
filter = [{ 'scope' =>
{
'operator' => '=',
'values' => [my_page_path]
} }]
"#{api_v3_paths.grids}?#{{ filters: filter.to_json }.to_query}"
end
it 'responds with 200 OK' do
expect(subject.status).to eq(200)
end
it 'sends only the my page of the current user' do
expect(subject.body)
.to be_json_eql('Collection'.to_json)
.at_path('_type')
expect(subject.body)
.to be_json_eql('Grid'.to_json)
.at_path('_embedded/elements/0/_type')
expect(subject.body)
.to be_json_eql(1.to_json)
.at_path('total')
end
end
end
describe '#get' do
let(:path) { api_v3_paths.grid(my_page_grid.id) }
let(:stored_grids) do
my_page_grid
end
before do
stored_grids
get path
end
it 'responds with 200 OK' do
expect(subject.status).to eq(200)
end
it 'sends a grid block' do
expect(subject.body)
.to be_json_eql('Grid'.to_json)
.at_path('_type')
end
it 'identifies the url the grid is stored for' do
expect(subject.body)
.to be_json_eql(my_page_path.to_json)
.at_path('_links/scope/href')
end
context 'with the page not existing' do
let(:path) { api_v3_paths.grid(5) }
it 'responds with 404 NOT FOUND' do
expect(subject.status).to eql 404
end
end
context 'with the grid belonging to someone else' do
let(:stored_grids) do
my_page_grid
other_my_page_grid
end
let(:path) { api_v3_paths.grid(other_my_page_grid.id) }
it 'responds with 404 NOT FOUND' do
expect(subject.status).to eql 404
end
end
end
describe '#patch' do
let(:path) { api_v3_paths.grid(my_page_grid.id) }
let(:params) do
{
"rowCount": 10,
"name": 'foo',
"columnCount": 15,
"widgets": [{
"identifier": "work_packages_assigned",
"startRow": 4,
"endRow": 8,
"startColumn": 2,
"endColumn": 5
}]
}.with_indifferent_access
end
let(:stored_grids) do
my_page_grid
end
before do
stored_grids
patch path, params.to_json, 'CONTENT_TYPE' => 'application/json'
end
it 'responds with 200 OK' do
expect(subject.status).to eq(200)
end
it 'returns the altered grid block' do
expect(subject.body)
.to be_json_eql('Grid'.to_json)
.at_path('_type')
expect(subject.body)
.to be_json_eql('foo'.to_json)
.at_path('name')
expect(subject.body)
.to be_json_eql(params['rowCount'].to_json)
.at_path('rowCount')
expect(subject.body)
.to be_json_eql(params['widgets'][0]['identifier'].to_json)
.at_path('widgets/0/identifier')
end
it 'perists the changes' do
expect(my_page_grid.reload.row_count)
.to eql params['rowCount']
end
context 'with invalid params' do
let(:params) do
{
"rowCount": -5,
"columnCount": 15,
"widgets": [{
"identifier": "work_packages_assigned",
"startRow": 4,
"endRow": 8,
"startColumn": 2,
"endColumn": 5
}]
}.with_indifferent_access
end
it 'responds with 422 and mentions the error' do
expect(subject.status).to eq 422
expect(subject.body)
.to be_json_eql('Error'.to_json)
.at_path('_type')
expect(subject.body)
.to be_json_eql("Widgets is outside of the grid.".to_json)
.at_path('_embedded/errors/0/message')
expect(subject.body)
.to be_json_eql("Number of rows must be greater than 0.".to_json)
.at_path('_embedded/errors/1/message')
end
it 'does not persist the changes to widgets' do
expect(my_page_grid.reload.widgets.count)
.to eql Grids::MyPageGridRegistration.defaults[:widgets].size
end
end
context 'with a scope param' do
let(:params) do
{
"_links": {
"scope": {
"href": ''
}
}
}.with_indifferent_access
end
it 'responds with 422 and mentions the error' do
expect(subject.status).to eq 422
expect(subject.body)
.to be_json_eql('Error'.to_json)
.at_path('_type')
expect(subject.body)
.to be_json_eql("You must not write a read-only attribute.".to_json)
.at_path('message')
expect(subject.body)
.to be_json_eql("scope".to_json)
.at_path('_embedded/details/attribute')
end
end
context 'with the page not existing' do
let(:path) { api_v3_paths.grid(5) }
it 'responds with 404 NOT FOUND' do
expect(subject.status).to eql 404
end
end
context 'with the grid belonging to someone else' do
let(:stored_grids) do
my_page_grid
other_my_page_grid
end
let(:path) { api_v3_paths.grid(other_my_page_grid.id) }
it 'responds with 404 NOT FOUND' do
expect(subject.status).to eql 404
end
end
end
describe '#post' do
let(:path) { api_v3_paths.grids }
let(:params) do
{
"rowCount": 10,
"columnCount": 15,
"widgets": [{
"identifier": "work_packages_assigned",
"startRow": 4,
"endRow": 8,
"startColumn": 2,
"endColumn": 5
}],
"_links": {
"scope": {
"href": my_page_path
}
}
}.with_indifferent_access
end
before do
post path, params.to_json, 'CONTENT_TYPE' => 'application/json'
end
it 'responds with 201 CREATED' do
expect(subject.status).to eq(201)
end
it 'returns the created grid block' do
expect(subject.body)
.to be_json_eql('Grid'.to_json)
.at_path('_type')
expect(subject.body)
.to be_json_eql(params['rowCount'].to_json)
.at_path('rowCount')
expect(subject.body)
.to be_json_eql(params['widgets'][0]['identifier'].to_json)
.at_path('widgets/0/identifier')
end
it 'persists the grid' do
expect(Grids::Grid.count)
.to eql(1)
end
context 'with invalid params' do
let(:params) do
{
"rowCount": -5,
"columnCount": "sdjfksdfsdfdsf",
"widgets": [{
"identifier": "work_packages_assigned",
"startRow": 4,
"endRow": 8,
"startColumn": 2,
"endColumn": 5
}],
"_links": {
"scope": {
"href": my_page_path
}
}
}.with_indifferent_access
end
it 'responds with 422' do
expect(subject.status).to eq(422)
end
it 'does not create a grid' do
expect(Grids::Grid.count)
.to eql(0)
end
it 'returns the errors' do
expect(subject.body)
.to be_json_eql('Error'.to_json)
.at_path('_type')
expect(subject.body)
.to be_json_eql("Widgets is outside of the grid.".to_json)
.at_path('_embedded/errors/0/message')
expect(subject.body)
.to be_json_eql("Number of rows must be greater than 0.".to_json)
.at_path('_embedded/errors/1/message')
expect(subject.body)
.to be_json_eql("Number of columns must be greater than 0.".to_json)
.at_path('_embedded/errors/2/message')
end
end
context 'without a page link' do
let(:params) do
{

@ -37,10 +37,6 @@ describe "PATCH /api/v3/grids/:id/form", type: :request, content_type: :json do
FactoryBot.create(:user)
end
let(:grid) do
FactoryBot.create(:my_page, user: current_user)
end
let(:path) { api_v3_paths.grid_form(grid.id) }
let(:params) { {} }
subject(:response) { last_response }
@ -53,126 +49,8 @@ describe "PATCH /api/v3/grids/:id/form", type: :request, content_type: :json do
post path, params.to_json, 'CONTENT_TYPE' => 'application/json'
end
it 'returns 200 OK' do
expect(subject.status)
.to eql 200
end
it 'is of type form' do
expect(subject.body)
.to be_json_eql("Form".to_json)
.at_path('_type')
end
it 'contains a Schema disallowing setting scope' do
expect(subject.body)
.to be_json_eql("Schema".to_json)
.at_path('_embedded/schema/_type')
expect(subject.body)
.to be_json_eql(false.to_json)
.at_path('_embedded/schema/scope/writable')
end
it 'contains the current data in the payload' do
expected = {
rowCount: 7,
columnCount: 4,
options: {},
widgets: [
{
"_type": "GridWidget",
identifier: 'work_packages_assigned',
options: {},
startRow: 1,
endRow: 7,
startColumn: 1,
endColumn: 3
},
{
"_type": "GridWidget",
identifier: 'work_packages_created',
options: {},
startRow: 1,
endRow: 7,
startColumn: 3,
endColumn: 5
}
],
"_links": {
"scope": {
"href": "/my/page",
"type": "text/html"
}
}
}
expect(subject.body)
.to be_json_eql(expected.to_json)
.at_path('_embedded/payload')
end
it 'has a commit link' do
expect(subject.body)
.to be_json_eql(api_v3_paths.grid(grid.id).to_json)
.at_path('_links/commit/href')
end
context 'with some value for the scope value' do
let(:params) do
{
'_links': {
'scope': {
'href': '/some/path'
}
}
}
end
it 'has a validation error on scope as the value is not writeable' do
expect(subject.body)
.to be_json_eql("You must not write a read-only attribute.".to_json)
.at_path('_embedded/validationErrors/scope/message')
end
end
context 'with an unsupported widget identifier' do
let(:params) do
{
"widgets": [
{
"_type": "GridWidget",
"identifier": "bogus_identifier",
"startRow": 4,
"endRow": 5,
"startColumn": 1,
"endColumn": 2
}
]
}
end
it 'has a validationError on widget' do
expect(subject.body)
.to be_json_eql("Widgets is not set to one of the allowed values.".to_json)
.at_path('_embedded/validationErrors/widgets/message')
end
end
context 'for a non existing grid' do
let(:path) { api_v3_paths.grid_form(grid.id + 5) }
it 'returns 404 NOT FOUND' do
expect(subject.status)
.to eql 404
end
end
context 'for another user\'s grid' do
let(:other_user) { FactoryBot.create(:user) }
let(:other_grid) { FactoryBot.create(:my_page, user: other_user) }
let(:path) { api_v3_paths.grid_form(other_grid.id) }
let(:path) { api_v3_paths.grid_form(5) }
it 'returns 404 NOT FOUND' do
expect(subject.status)

@ -50,7 +50,9 @@ describe Grids::CreateService, type: :model do
end
let(:scope) { "some/scope/url" }
let(:call_attributes) { { scope: scope } }
let(:grid_class) { Grids::MyPage }
let(:grid_class) do
Grids::Grid
end
let(:set_attributes_success) do
true
end

@ -56,7 +56,7 @@ describe Grids::SetAttributesService, type: :model do
contract_class: contract_class)
end
let(:call_attributes) { {} }
let(:grid_class) { Grids::MyPage }
let(:grid_class) { Grids::Grid }
let(:grid) do
FactoryBot.build_stubbed(grid_class.name.demodulize.underscore.to_sym, widgets: [])
end

@ -42,7 +42,7 @@ describe Grids::UpdateService, type: :model do
contract_class: contract_class)
end
let(:call_attributes) { {} }
let(:grid_class) { Grids::MyPage }
let(:grid_class) { Grids::Grid }
let(:set_attributes_success) do
true
end

@ -0,0 +1,7 @@
.bundle/
log/*.log
pkg/
test/dummy/db/*.sqlite3
test/dummy/db/*.sqlite3-journal
test/dummy/log/*.log
test/dummy/tmp/

@ -0,0 +1,3 @@
source 'https://rubygems.org'
gemspec

@ -1,3 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 the OpenProject Foundation (OPF)
@ -26,23 +28,10 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
require_relative './shared_model'
describe Grids::MyPage, type: :model do
let(:instance) { described_class.new }
let(:user) { FactoryBot.build_stubbed(:user) }
it_behaves_like 'grid attributes'
context 'attributes' do
let(:user) { FactoryBot.build_stubbed :user }
class MyPage::AngularController < ::ApplicationController
before_action :require_login
it '#user' do
instance.user = user
expect(instance.user)
.to eql user
end
def no_menu
render layout: 'no_menu'
end
end

@ -27,6 +27,4 @@ See docs/COPYRIGHT.rdoc for more details.
++#%>
<% html_title(t(:label_my_page)) -%>
<openproject-base></openproject-base>

@ -1,4 +1,5 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 the OpenProject Foundation (OPF)
@ -27,10 +28,6 @@
# See docs/COPYRIGHT.rdoc for more details.
#++
module MyHelper
include WorkPackagesFilterHelper
def deletion_info_path
url_for(:delete_my_account_info)
end
MyPage::Engine.routes.draw do
root to: 'angular#no_menu'
end

@ -0,0 +1,4 @@
require "my_page/engine"
module MyPage
end

@ -0,0 +1,11 @@
module MyPage
class Engine < ::Rails::Engine
isolate_namespace MyPage
include OpenProject::Plugins::ActsAsOpEngine
config.to_prepare do
MyPage::GridRegistration.register!
end
end
end

@ -1,5 +1,5 @@
module Grids
class MyPageGridRegistration < ::Grids::Configuration::Registration
module MyPage
class GridRegistration < ::Grids::Configuration::Registration
grid_class 'Grids::MyPage'
to_scope :my_page_path
@ -8,10 +8,17 @@ module Grids
'work_packages_watched',
'work_packages_created',
'work_packages_calendar',
'work_packages_table',
'time_entries_current_user',
'documents',
'news'
widget_strategy 'work_packages_table' do
after_destroy -> { ::Query.find_by(id: options[:queryId])&.destroy }
allowed ->(user) { user.allowed_to_globally?(:save_queries) }
end
defaults(
row_count: 7,
column_count: 4,

@ -0,0 +1,12 @@
# encoding: UTF-8
Gem::Specification.new do |s|
s.name = "my_page"
s.version = '1.0.0'
s.authors = ["OpenProject"]
s.summary = "OpenProject MyPage."
s.files = Dir["{app,config,db,lib}/**/*"]
s.add_dependency 'grids'
end

@ -0,0 +1,61 @@
#-- 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.
#++
require 'spec_helper'
require_relative './shared_examples'
describe Grids::CreateContract do
include_context 'grid contract'
include_context 'model contract'
it_behaves_like 'shared grid contract attributes'
describe 'user_id' do
context 'for a Grids::MyPage' do
let(:grid) { FactoryBot.build_stubbed(:my_page, default_values) }
it_behaves_like 'is writable' do
let(:attribute) { :user_id }
let(:value) { 5 }
end
end
end
describe 'project_id' do
context 'for a Grids::MyPage' do
let(:grid) { FactoryBot.build_stubbed(:my_page, default_values) }
it_behaves_like 'is not writable' do
let(:attribute) { :project_id }
let(:value) { 5 }
end
end
end
end

@ -0,0 +1,320 @@
#-- 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.
#++
shared_context 'grid contract' do
let(:user) { FactoryBot.build_stubbed(:user) }
let(:instance) { described_class.new(grid, user) }
let(:default_values) do
{
row_count: 6,
column_count: 7,
widgets: []
}
end
let(:grid) do
FactoryBot.build_stubbed(:my_page, default_values)
end
end
shared_examples_for 'shared grid contract attributes' do
include_context 'model contract'
let(:model) { grid }
describe 'widgets' do
it_behaves_like 'is writable' do
let(:attribute) { :widgets }
let(:value) do
[
Grids::Widget.new(start_row: 1,
end_row: 4,
start_column: 2,
end_column: 5,
identifier: 'work_packages_assigned')
]
end
end
context 'invalid identifier' do
before do
grid.widgets.build(start_row: 1,
end_row: 4,
start_column: 2,
end_column: 5,
identifier: 'bogus_identifier')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :inclusion }]
end
end
context 'collisions between widgets' do
before do
grid.widgets.build(start_row: 1,
end_row: 3,
start_column: 1,
end_column: 3,
identifier: 'work_packages_assigned')
grid.widgets.build(start_row: 2,
end_row: 4,
start_column: 2,
end_column: 4,
identifier: 'work_packages_created')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :overlaps }, { error: :overlaps }]
end
end
context 'widgets having the same start column as another\'s end column' do
before do
grid.widgets.build(start_row: 1,
end_row: 3,
start_column: 1,
end_column: 3,
identifier: 'work_packages_assigned')
grid.widgets.build(start_row: 1,
end_row: 3,
start_column: 3,
end_column: 4,
identifier: 'work_packages_created')
end
it 'is valid' do
expect(instance.validate)
.to be_truthy
end
end
context 'widgets having the same start row as another\'s end row' do
before do
grid.widgets.build(start_row: 1,
end_row: 3,
start_column: 1,
end_column: 3,
identifier: 'work_packages_assigned')
grid.widgets.build(start_row: 3,
end_row: 4,
start_column: 1,
end_column: 3,
identifier: 'work_packages_created')
end
it 'is valid' do
expect(instance.validate)
.to be_truthy
end
end
context 'widgets being outside (max) of the grid' do
before do
grid.widgets.build(start_row: 1,
end_row: grid.row_count + 2,
start_column: 1,
end_column: 3,
identifier: 'work_packages_assigned')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :outside }]
end
end
context 'widgets being outside (min) of the grid' do
before do
grid.widgets.build(start_row: 1,
end_row: 2,
start_column: -1,
end_column: 3,
identifier: 'work_packages_assigned')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :outside }]
end
end
context 'widgets spanning the whole grid' do
before do
grid.widgets.build(start_row: 1,
end_row: grid.row_count + 1,
start_column: 1,
end_column: grid.column_count + 1,
identifier: 'work_packages_assigned')
end
it 'is valid' do
expect(instance.validate)
.to be_truthy
end
end
context 'widgets having start after end column' do
before do
grid.widgets.build(start_row: 1,
end_row: 2,
start_column: 4,
end_column: 3,
identifier: 'work_packages_assigned')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :end_before_start }]
end
end
context 'widgets having start after end row' do
before do
grid.widgets.build(start_row: 4,
end_row: 2,
start_column: 1,
end_column: 3,
identifier: 'work_packages_assigned')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :end_before_start }]
end
end
context 'widgets having start equals end column' do
before do
grid.widgets.build(start_row: 1,
end_row: 2,
start_column: 4,
end_column: 3,
identifier: 'work_packages_assigned')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :end_before_start }]
end
end
context 'widgets having start equals end row' do
before do
grid.widgets.build(start_row: 2,
end_row: 2,
start_column: 1,
end_column: 3,
identifier: 'work_packages_assigned')
end
it 'is invalid' do
expect(instance.validate)
.to be_falsey
end
it 'notes the error' do
instance.validate
expect(instance.errors.details[:widgets])
.to match_array [{ error: :end_before_start }]
end
end
end
describe 'valid grid subclasses' do
context 'for a registered subclass' do
let(:grid) do
FactoryBot.build_stubbed(:my_page, default_values)
end
it 'is valid' do
expect(instance.validate)
.to be_truthy
end
end
context 'for the Grid superclass itself' do
let(:grid) do
FactoryBot.build_stubbed(:grid, default_values)
end
before do
instance.validate
end
it 'is invalid for the grid superclass itself' do
expect(instance.errors.details[:scope])
.to match_array [{ error: :inclusion }]
end
end
end
end

@ -0,0 +1,39 @@
#-- 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.
#++
require 'spec_helper'
require_relative './shared_examples'
describe Grids::UpdateContract do
include_context 'model contract'
include_context 'grid contract'
it_behaves_like 'shared grid contract attributes'
end

@ -0,0 +1,25 @@
FactoryBot.define do
factory :my_page, class: Grids::MyPage do
user
row_count { 7 }
column_count { 4 }
widgets do
[
Grids::Widget.new(
identifier: 'work_packages_assigned',
start_row: 1,
end_row: 7,
start_column: 1,
end_column: 3
),
Grids::Widget.new(
identifier: 'work_packages_created',
start_row: 1,
end_row: 7,
start_column: 3,
end_column: 5
)
]
end
end
end

@ -111,9 +111,13 @@ describe 'Assigned to me embedded query on my page', type: :feature, js: true do
hierarchies.disable_via_header
hierarchies.expect_no_hierarchies
sleep(0.2)
# re-enable
hierarchies.enable_via_header
sleep(0.2)
hierarchies.expect_mode_enabled
hierarchies.expect_hierarchy_at assigned_work_package, collapsed: true
end

@ -88,7 +88,7 @@ describe 'My page time entries current user widget spec', type: :feature, js: tr
sleep(0.5)
# within top-right area, add an additional widget
my_page.add_widget(1, 1, 'Spent time (last 7 days)')
my_page.add_widget(1, 1, 'Spent time \(last 7 days\)')
calendar_area = Components::Grids::GridArea.new('.grid--area', text: 'Spent time (last 7 days)')
calendar_area.expect_to_span(1, 1, 4, 3)

@ -0,0 +1,162 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe 'Arbitrary WorkPackage query table widget on my page', type: :feature, js: true do
let!(:type) { FactoryBot.create :type }
let!(:other_type) { FactoryBot.create :type }
let!(:priority) { FactoryBot.create :default_priority }
let!(:project) { FactoryBot.create :project, types: [type] }
let!(:other_project) { FactoryBot.create :project, types: [type] }
let!(:open_status) { FactoryBot.create :default_status }
let!(:type_work_package) do
FactoryBot.create :work_package,
project: project,
type: type,
author: user,
responsible: user
end
let!(:other_type_work_package) do
FactoryBot.create :work_package,
project: project,
type: other_type,
author: user,
responsible: user
end
let(:permissions) { %i[view_work_packages add_work_packages save_queries] }
let(:user) do
FactoryBot.create(:user,
member_in_project: project,
member_with_permissions: permissions)
end
let(:my_page) do
Pages::My::Page.new
end
let(:modal) { ::Components::WorkPackages::TableConfigurationModal.new }
let(:filters) { ::Components::WorkPackages::TableConfiguration::Filters.new }
let(:columns) { ::Components::WorkPackages::Columns.new }
before do
login_as user
my_page.visit!
end
context 'with the permission to save queries' do
it 'can add the widget and see the work packages of the filtered for types' do
my_page.add_column(3, before_or_after: :before)
my_page.add_widget(2, 3, "Work packages")
sleep(1)
filter_area = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(3)')
created_area = Components::Grids::GridArea.new('.grid--area', text: "Work packages created by me")
filter_area.expect_to_span(2, 3, 5, 4)
filter_area.resize_to(6, 4)
filter_area.expect_to_span(2, 3, 7, 5)
## enlarging the table area will have moved the created area down
created_area.expect_to_span(7, 4, 13, 6)
# At the beginning, the default query is displayed
expect(filter_area.area)
.to have_selector('.subject', text: type_work_package.subject)
expect(filter_area.area)
.to have_selector('.subject', text: other_type_work_package.subject)
# User has the ability to modify the query
modal.open_and_switch_to('Filters')
filters.expect_filter_count(2)
filters.add_filter_by('Type', 'is', type.name)
modal.save
columns.remove 'Subject'
expect(filter_area.area)
.to have_selector('.id', text: type_work_package.id)
# as the Subject column is disabled
expect(filter_area.area)
.to have_no_selector('.subject', text: type_work_package.subject)
# As other_type is filtered out
expect(filter_area.area)
.to have_no_selector('.id', text: other_type_work_package.id)
scroll_to_element(filter_area.area)
within filter_area.area do
input = find('.editable-toolbar-title--input')
input.set('My WP Filter')
input.native.send_keys(:return)
end
sleep(1)
# The whole of the configuration survives a reload
# as it is persisted in the grid
visit root_path
my_page.visit!
filter_area = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(3)')
expect(filter_area.area)
.to have_selector('.id', text: type_work_package.id)
# as the Subject column is disabled
expect(filter_area.area)
.to have_no_selector('.subject', text: type_work_package.subject)
# As other_type is filtered out
expect(filter_area.area)
.to have_no_selector('.id', text: other_type_work_package.id)
within filter_area.area do
expect(find('.editable-toolbar-title--input').value)
.to eql('My WP Filter')
end
end
end
context 'without the permission to save queries' do
let(:permissions) { %i[view_work_packages add_work_packages] }
it 'cannot add the widget' do
my_page.add_column(3, before_or_after: :before)
my_page.expect_unable_to_add_widget(2, 3, "Work packages")
end
end
end

@ -0,0 +1,76 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
require_relative './shared_model'
describe Grids::MyPage, type: :model do
let(:instance) { described_class.new(row_count: 5, column_count: 5) }
let(:user) { FactoryBot.build_stubbed(:user) }
it_behaves_like 'grid attributes'
context 'attributes' do
it '#user' do
instance.user = user
expect(instance.user)
.to eql user
end
end
context 'altering widgets' do
context 'when removing a work_packages_table widget' do
let(:user) { FactoryBot.create(:user) }
let(:query) do
FactoryBot.create(:query,
user: user,
hidden: true)
end
before do
widget = Grids::Widget.new(identifier: 'work_packages_table',
start_row: 1,
end_row: 2,
start_column: 1,
end_column: 2,
options: { queryId: query.id })
instance.widgets = [widget]
instance.save!
end
it 'removes the widget\'s query' do
instance.widgets = []
expect(Query.find_by(id: query.id))
.to be_nil
end
end
end
end

@ -0,0 +1,77 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 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 docs/COPYRIGHT.rdoc for more details.
#++
shared_examples_for 'grid attributes' do
describe 'attributes' do
it '#row_count' do
instance.row_count = 5
expect(instance.row_count)
.to eql 5
end
it '#column_count' do
instance.column_count = 5
expect(instance.column_count)
.to eql 5
end
it '#name' do
instance.name = 'custom 123'
expect(instance.name)
.to eql 'custom 123'
# can be empty
instance.name = nil
expect(instance).to be_valid
end
it '#options' do
value = {
some: 'value',
and: {
also: 1
}
}
instance.options = value
expect(instance.options)
.to eql value
end
it '#widgets' do
widgets = [
Grids::Widget.new(start_row: 2),
Grids::Widget.new(start_row: 5)
]
instance.widgets = widgets
expect(instance.widgets)
.to match_array widgets
end
end
end

@ -0,0 +1,193 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
require 'rack/test'
describe "POST /api/v3/grids/form", type: :request, content_type: :json do
include Rack::Test::Methods
include API::V3::Utilities::PathHelper
shared_let(:current_user) do
FactoryBot.create(:user)
end
let(:path) { api_v3_paths.create_grid_form }
let(:params) { {} }
subject(:response) { last_response }
before do
login_as(current_user)
end
describe '#post' do
before do
post path, params.to_json, 'CONTENT_TYPE' => 'application/json'
end
it 'contains a Schema embedding the available values' do
expect(subject.body)
.to be_json_eql("Schema".to_json)
.at_path('_embedded/schema/_type')
expect(subject.body)
.to be_json_eql(my_page_path.to_json)
.at_path('_embedded/schema/scope/_links/allowedValues/0/href')
end
context 'with /my/page for the scope value' do
let(:params) do
{
'_links': {
'scope': {
'href': my_page_path
}
}
}
end
it 'contains default data in the payload' do
expected = {
"rowCount": 7,
"columnCount": 4,
"options": {},
"widgets": [
{
"_type": "GridWidget",
identifier: 'work_packages_assigned',
"options": {},
startRow: 1,
endRow: 7,
startColumn: 1,
endColumn: 3
},
{
"_type": "GridWidget",
identifier: 'work_packages_created',
"options": {},
startRow: 1,
endRow: 7,
startColumn: 3,
endColumn: 5
}
],
"_links": {
"scope": {
"href": "/my/page",
"type": "text/html"
}
}
}
expect(subject.body)
.to be_json_eql(expected.to_json)
.at_path('_embedded/payload')
end
it 'has no validationErrors' do
expect(subject.body)
.to be_json_eql({}.to_json)
.at_path('_embedded/validationErrors')
end
it 'has a commit link' do
expect(subject.body)
.to be_json_eql(api_v3_paths.grids.to_json)
.at_path('_links/commit/href')
end
end
context 'with an unsupported widget identifier' do
let(:params) do
{
'_links': {
'scope': {
'href': my_page_path
}
},
"widgets": [
{
"_type": "GridWidget",
"identifier": "bogus_identifier",
"startRow": 4,
"endRow": 5,
"startColumn": 1,
"endColumn": 2
}
]
}
end
it 'has a validationError on widget' do
expect(subject.body)
.to be_json_eql("Widgets is not set to one of the allowed values.".to_json)
.at_path('_embedded/validationErrors/widgets/message')
end
end
context 'with name set' do
let(:params) do
{
name: 'My custom grid 1',
'_links': {
'scope': {
'href': my_page_path
}
}
}
end
it 'feeds it back' do
expect(subject.body)
.to be_json_eql("My custom grid 1".to_json)
.at_path('_embedded/payload/name')
end
end
context 'with options set' do
let(:params) do
{
options: {
foo: 'bar'
},
'_links': {
'scope': {
'href': my_page_path
}
}
}
end
it 'feeds them back' do
expect(subject.body)
.to be_json_eql("bar".to_json)
.at_path('_embedded/payload/options/foo')
end
end
end
end

@ -0,0 +1,414 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
require 'rack/test'
describe 'API v3 Grids resource', type: :request, content_type: :json do
include Rack::Test::Methods
include API::V3::Utilities::PathHelper
shared_let(:current_user) do
FactoryBot.create(:user)
end
let(:my_page_grid) { FactoryBot.create(:my_page, user: current_user) }
let(:other_user) do
FactoryBot.create(:user)
end
let(:other_my_page_grid) { FactoryBot.create(:my_page, user: other_user) }
before do
login_as(current_user)
end
subject(:response) { last_response }
describe '#get INDEX' do
let(:path) { api_v3_paths.grids }
let(:stored_grids) do
my_page_grid
other_my_page_grid
end
before do
stored_grids
get path
end
it 'sends a collection of grids but only those visible to the current user' do
expect(subject.body)
.to be_json_eql('Collection'.to_json)
.at_path('_type')
expect(subject.body)
.to be_json_eql('Grid'.to_json)
.at_path('_embedded/elements/0/_type')
expect(subject.body)
.to be_json_eql(1.to_json)
.at_path('total')
end
context 'with a filter on the scope attribute' do
shared_let(:other_grid) do
grid = Grids::Grid.new(row_count: 20,
column_count: 20)
grid.save
Grids::Grid
.where(id: grid.id)
.update_all(user_id: current_user.id)
grid
end
let(:stored_grids) do
my_page_grid
other_my_page_grid
other_grid
end
let(:path) do
filter = [{ 'scope' =>
{
'operator' => '=',
'values' => [my_page_path]
} }]
"#{api_v3_paths.grids}?#{{ filters: filter.to_json }.to_query}"
end
it 'responds with 200 OK' do
expect(subject.status).to eq(200)
end
it 'sends only the my page of the current user' do
expect(subject.body)
.to be_json_eql('Collection'.to_json)
.at_path('_type')
expect(subject.body)
.to be_json_eql('Grid'.to_json)
.at_path('_embedded/elements/0/_type')
expect(subject.body)
.to be_json_eql(1.to_json)
.at_path('total')
end
end
end
describe '#get' do
let(:path) { api_v3_paths.grid(my_page_grid.id) }
let(:stored_grids) do
my_page_grid
end
before do
stored_grids
get path
end
it 'responds with 200 OK' do
expect(subject.status).to eq(200)
end
it 'sends a grid block' do
expect(subject.body)
.to be_json_eql('Grid'.to_json)
.at_path('_type')
end
it 'identifies the url the grid is stored for' do
expect(subject.body)
.to be_json_eql(my_page_path.to_json)
.at_path('_links/scope/href')
end
context 'with the page not existing' do
let(:path) { api_v3_paths.grid(5) }
it 'responds with 404 NOT FOUND' do
expect(subject.status).to eql 404
end
end
context 'with the grid belonging to someone else' do
let(:stored_grids) do
my_page_grid
other_my_page_grid
end
let(:path) { api_v3_paths.grid(other_my_page_grid.id) }
it 'responds with 404 NOT FOUND' do
expect(subject.status).to eql 404
end
end
end
describe '#patch' do
let(:path) { api_v3_paths.grid(my_page_grid.id) }
let(:params) do
{
"rowCount": 10,
"name": 'foo',
"columnCount": 15,
"widgets": [{
"identifier": "work_packages_assigned",
"startRow": 4,
"endRow": 8,
"startColumn": 2,
"endColumn": 5
}]
}.with_indifferent_access
end
let(:stored_grids) do
my_page_grid
end
before do
stored_grids
patch path, params.to_json, 'CONTENT_TYPE' => 'application/json'
end
it 'responds with 200 OK' do
expect(subject.status).to eq(200)
end
it 'returns the altered grid block' do
expect(subject.body)
.to be_json_eql('Grid'.to_json)
.at_path('_type')
expect(subject.body)
.to be_json_eql('foo'.to_json)
.at_path('name')
expect(subject.body)
.to be_json_eql(params['rowCount'].to_json)
.at_path('rowCount')
expect(subject.body)
.to be_json_eql(params['widgets'][0]['identifier'].to_json)
.at_path('widgets/0/identifier')
end
it 'perists the changes' do
expect(my_page_grid.reload.row_count)
.to eql params['rowCount']
end
context 'with invalid params' do
let(:params) do
{
"rowCount": -5,
"columnCount": 15,
"widgets": [{
"identifier": "work_packages_assigned",
"startRow": 4,
"endRow": 8,
"startColumn": 2,
"endColumn": 5
}]
}.with_indifferent_access
end
it 'responds with 422 and mentions the error' do
expect(subject.status).to eq 422
expect(subject.body)
.to be_json_eql('Error'.to_json)
.at_path('_type')
expect(subject.body)
.to be_json_eql("Widgets is outside of the grid.".to_json)
.at_path('_embedded/errors/0/message')
expect(subject.body)
.to be_json_eql("Number of rows must be greater than 0.".to_json)
.at_path('_embedded/errors/1/message')
end
it 'does not persist the changes to widgets' do
expect(my_page_grid.reload.widgets.count)
.to eql MyPage::GridRegistration.defaults[:widgets].size
end
end
context 'with a scope param' do
let(:params) do
{
"_links": {
"scope": {
"href": ''
}
}
}.with_indifferent_access
end
it 'responds with 422 and mentions the error' do
expect(subject.status).to eq 422
expect(subject.body)
.to be_json_eql('Error'.to_json)
.at_path('_type')
expect(subject.body)
.to be_json_eql("You must not write a read-only attribute.".to_json)
.at_path('message')
expect(subject.body)
.to be_json_eql("scope".to_json)
.at_path('_embedded/details/attribute')
end
end
context 'with the page not existing' do
let(:path) { api_v3_paths.grid(5) }
it 'responds with 404 NOT FOUND' do
expect(subject.status).to eql 404
end
end
context 'with the grid belonging to someone else' do
let(:stored_grids) do
my_page_grid
other_my_page_grid
end
let(:path) { api_v3_paths.grid(other_my_page_grid.id) }
it 'responds with 404 NOT FOUND' do
expect(subject.status).to eql 404
end
end
end
describe '#post' do
let(:path) { api_v3_paths.grids }
let(:params) do
{
"rowCount": 10,
"columnCount": 15,
"widgets": [{
"identifier": "work_packages_assigned",
"startRow": 4,
"endRow": 8,
"startColumn": 2,
"endColumn": 5
}],
"_links": {
"scope": {
"href": my_page_path
}
}
}.with_indifferent_access
end
before do
post path, params.to_json, 'CONTENT_TYPE' => 'application/json'
end
it 'responds with 201 CREATED' do
expect(subject.status).to eq(201)
end
it 'returns the created grid block' do
expect(subject.body)
.to be_json_eql('Grid'.to_json)
.at_path('_type')
expect(subject.body)
.to be_json_eql(params['rowCount'].to_json)
.at_path('rowCount')
expect(subject.body)
.to be_json_eql(params['widgets'][0]['identifier'].to_json)
.at_path('widgets/0/identifier')
end
it 'persists the grid' do
expect(Grids::Grid.count)
.to eql(1)
end
context 'with invalid params' do
let(:params) do
{
"rowCount": -5,
"columnCount": "sdjfksdfsdfdsf",
"widgets": [{
"identifier": "work_packages_assigned",
"startRow": 4,
"endRow": 8,
"startColumn": 2,
"endColumn": 5
}],
"_links": {
"scope": {
"href": my_page_path
}
}
}.with_indifferent_access
end
it 'responds with 422' do
expect(subject.status).to eq(422)
end
it 'does not create a grid' do
expect(Grids::Grid.count)
.to eql(0)
end
it 'returns the errors' do
expect(subject.body)
.to be_json_eql('Error'.to_json)
.at_path('_type')
expect(subject.body)
.to be_json_eql("Widgets is outside of the grid.".to_json)
.at_path('_embedded/errors/0/message')
expect(subject.body)
.to be_json_eql("Number of rows must be greater than 0.".to_json)
.at_path('_embedded/errors/1/message')
expect(subject.body)
.to be_json_eql("Number of columns must be greater than 0.".to_json)
.at_path('_embedded/errors/2/message')
end
end
end
end

@ -0,0 +1,174 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2018 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 docs/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
require 'rack/test'
describe "PATCH /api/v3/grids/:id/form", type: :request, content_type: :json do
include Rack::Test::Methods
include API::V3::Utilities::PathHelper
shared_let(:current_user) do
FactoryBot.create(:user)
end
let(:grid) do
FactoryBot.create(:my_page, user: current_user)
end
let(:path) { api_v3_paths.grid_form(grid.id) }
let(:params) { {} }
subject(:response) { last_response }
before do
login_as(current_user)
end
describe '#post' do
before do
post path, params.to_json, 'CONTENT_TYPE' => 'application/json'
end
it 'returns 200 OK' do
expect(subject.status)
.to eql 200
end
it 'is of type form' do
expect(subject.body)
.to be_json_eql("Form".to_json)
.at_path('_type')
end
it 'contains a Schema disallowing setting scope' do
expect(subject.body)
.to be_json_eql("Schema".to_json)
.at_path('_embedded/schema/_type')
expect(subject.body)
.to be_json_eql(false.to_json)
.at_path('_embedded/schema/scope/writable')
end
it 'contains the current data in the payload' do
expected = {
rowCount: 7,
columnCount: 4,
options: {},
widgets: [
{
"_type": "GridWidget",
identifier: 'work_packages_assigned',
options: {},
startRow: 1,
endRow: 7,
startColumn: 1,
endColumn: 3
},
{
"_type": "GridWidget",
identifier: 'work_packages_created',
options: {},
startRow: 1,
endRow: 7,
startColumn: 3,
endColumn: 5
}
],
"_links": {
"scope": {
"href": "/my/page",
"type": "text/html"
}
}
}
expect(subject.body)
.to be_json_eql(expected.to_json)
.at_path('_embedded/payload')
end
it 'has a commit link' do
expect(subject.body)
.to be_json_eql(api_v3_paths.grid(grid.id).to_json)
.at_path('_links/commit/href')
end
context 'with some value for the scope value' do
let(:params) do
{
'_links': {
'scope': {
'href': '/some/path'
}
}
}
end
it 'has a validation error on scope as the value is not writeable' do
expect(subject.body)
.to be_json_eql("You must not write a read-only attribute.".to_json)
.at_path('_embedded/validationErrors/scope/message')
end
end
context 'with an unsupported widget identifier' do
let(:params) do
{
"widgets": [
{
"_type": "GridWidget",
"identifier": "bogus_identifier",
"startRow": 4,
"endRow": 5,
"startColumn": 1,
"endColumn": 2
}
]
}
end
it 'has a validationError on widget' do
expect(subject.body)
.to be_json_eql("Widgets is not set to one of the allowed values.".to_json)
.at_path('_embedded/validationErrors/widgets/message')
end
end
context 'for another user\'s grid' do
let(:other_user) { FactoryBot.create(:user) }
let(:other_grid) { FactoryBot.create(:my_page, user: other_user) }
let(:path) { api_v3_paths.grid_form(other_grid.id) }
it 'returns 404 NOT FOUND' do
expect(subject.status)
.to eql 404
end
end
end
end

@ -32,9 +32,6 @@ module Components
include Capybara::DSL
include RSpec::Matchers
def initialize
end
def expect_column_not_available(name)
modal_open? or open_modal

@ -62,21 +62,39 @@ module Pages
end
def add_widget(row_number, column_number, name)
area = area_of(row_number, column_number)
area.hover
area.find('.grid--widget-add').click
within_add_widget_modal(row_number, column_number) do
expect(page)
.to have_content(I18n.t('js.grid.add_modal.choose_widget'))
within '.op-modal--portal' do
page.find('.grid--addable-widget', text: Regexp.new("^#{name}$")).click
end
end
def expect_unable_to_add_widget(row_number, column_number, name)
within_add_widget_modal(row_number, column_number) do
expect(page)
.to have_content(I18n.t('js.grid.add_modal.choose_widget'))
page.find('.grid--addable-widget', text: name).click
expect(page)
.not_to have_selector('.grid--addable-widget', text: Regexp.new("^#{name}$"))
end
end
def area_of(row_number, column_number)
::Components::Grids::GridArea.of(row_number, column_number).area
end
private
def within_add_widget_modal(row_number, column_number)
area = area_of(row_number, column_number)
area.hover
area.find('.grid--widget-add').click
within '.op-modal--portal' do
yield
end
end
end
end
end

Loading…
Cancel
Save