diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index c92ee82c1f11..60a06c646d8a 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -301,7 +301,8 @@ {}, permissible_on: :project, require: :loggedin, - dependencies: :view_work_packages + dependencies: :view_work_packages, + contract_actions: { queries: %i[create] } # Watchers wpt.permission :view_work_package_watchers, {}, diff --git a/frontend/src/app/features/boards/board/board-list/board-list-menu.component.html b/frontend/src/app/features/boards/board/board-list/board-list-menu.component.html index ac5661935924..22966a2428a6 100644 --- a/frontend/src/app/features/boards/board/board-list/board-list-menu.component.html +++ b/frontend/src/app/features/boards/board/board-list/board-list-menu.component.html @@ -1,5 +1,5 @@ + [menuItemsFactory]="menuItems"> diff --git a/frontend/src/app/shared/components/grids/widgets/menu/widget-abstract-menu.component.ts b/frontend/src/app/shared/components/grids/widgets/menu/widget-abstract-menu.component.ts index 2c80f6ee68e7..e4d42f05515d 100644 --- a/frontend/src/app/shared/components/grids/widgets/menu/widget-abstract-menu.component.ts +++ b/frontend/src/app/shared/components/grids/widgets/menu/widget-abstract-menu.component.ts @@ -37,16 +37,18 @@ import { GridAreaService } from 'core-app/shared/components/grids/grid/area.serv export abstract class WidgetAbstractMenuComponent { @Input() resource:GridWidgetResource; - protected menuItemList:OpContextMenuItem[] = [this.removeItem]; - constructor(readonly injector:Injector, readonly i18n:I18nService, protected readonly remove:GridRemoveWidgetService, protected readonly layout:GridAreaService) { } - public get menuItems() { - return async () => this.menuItemList; + public get menuItemsFactory():() => Promise { + return this.buildItems.bind(this); + } + + protected async buildItems():Promise { + return [this.removeItem]; } protected get removeItem():OpContextMenuItem { diff --git a/frontend/src/app/shared/components/grids/widgets/menu/widget-menu.component.html b/frontend/src/app/shared/components/grids/widgets/menu/widget-menu.component.html index 7c5027236947..450fd0363d22 100644 --- a/frontend/src/app/shared/components/grids/widgets/menu/widget-menu.component.html +++ b/frontend/src/app/shared/components/grids/widgets/menu/widget-menu.component.html @@ -1,4 +1,4 @@ + [menuItemsFactory]="menuItemsFactory"> diff --git a/frontend/src/app/shared/components/grids/widgets/menu/wp-set-menu.component.ts b/frontend/src/app/shared/components/grids/widgets/menu/wp-set-menu.component.ts index 7678eb2c5e79..7f0b1347d30b 100644 --- a/frontend/src/app/shared/components/grids/widgets/menu/wp-set-menu.component.ts +++ b/frontend/src/app/shared/components/grids/widgets/menu/wp-set-menu.component.ts @@ -26,15 +26,20 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { - Directive, EventEmitter, Output, -} from '@angular/core'; +import { Directive, EventEmitter, Output } from '@angular/core'; import { OpModalService } from 'core-app/shared/components/modal/modal.service'; import { ComponentType } from '@angular/cdk/portal'; -import { WidgetAbstractMenuComponent } from 'core-app/shared/components/grids/widgets/menu/widget-abstract-menu.component'; -import { WpGraphConfigurationModalComponent } from 'core-app/shared/components/work-package-graphs/configuration-modal/wp-graph-configuration.modal'; -import { WpTableConfigurationModalComponent } from 'core-app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration.modal'; +import { + WidgetAbstractMenuComponent, +} from 'core-app/shared/components/grids/widgets/menu/widget-abstract-menu.component'; +import { + WpGraphConfigurationModalComponent, +} from 'core-app/shared/components/work-package-graphs/configuration-modal/wp-graph-configuration.modal'; +import { + WpTableConfigurationModalComponent, +} from 'core-app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration.modal'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; +import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op-context-menu.types'; @Directive() export abstract class WidgetWpSetMenuComponent extends WidgetAbstractMenuComponent { @@ -42,12 +47,20 @@ export abstract class WidgetWpSetMenuComponent extends WidgetAbstractMenuCompone @InjectField() opModalService:OpModalService; - @Output() onConfigured:EventEmitter = new EventEmitter(); + // eslint-disable-next-line @angular-eslint/no-output-on-prefix + @Output() onConfigured:EventEmitter = new EventEmitter(); + + protected async buildItems():Promise { + const items = [ + this.removeItem, + ]; - protected menuItemList = [ - this.removeItem, - this.configureItem, - ]; + if (await this.configurationAllowed()) { + items.push(this.configureItem); + } + + return items; + } protected get configureItem() { return { @@ -65,6 +78,10 @@ export abstract class WidgetWpSetMenuComponent extends WidgetAbstractMenuCompone }; } + protected configurationAllowed():Promise { + return Promise.resolve(true); + } + protected get locals() { return {}; } diff --git a/frontend/src/app/shared/components/grids/widgets/project-details/project-details-menu.component.ts b/frontend/src/app/shared/components/grids/widgets/project-details/project-details-menu.component.ts index 1085aa5705f6..6580e1d5973d 100644 --- a/frontend/src/app/shared/components/grids/widgets/project-details/project-details-menu.component.ts +++ b/frontend/src/app/shared/components/grids/widgets/project-details/project-details-menu.component.ts @@ -30,7 +30,9 @@ import { Component, OnInit, } from '@angular/core'; -import { WidgetAbstractMenuComponent } from 'core-app/shared/components/grids/widgets/menu/widget-abstract-menu.component'; +import { + WidgetAbstractMenuComponent, +} from 'core-app/shared/components/grids/widgets/menu/widget-abstract-menu.component'; import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op-context-menu.types'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; @@ -60,16 +62,14 @@ export class WidgetProjectDetailsMenuComponent extends WidgetAbstractMenuCompone ); } - public get menuItems() { - return async () => { - const items = [ - this.removeItem, - ]; - if (await this.capabilityPromise) { - items.push(this.projectActivityLinkItem); - } - return items; - }; + protected async buildItems():Promise { + const items = [ + this.removeItem, + ]; + if (await this.capabilityPromise) { + items.push(this.projectActivityLinkItem); + } + return items; } protected get projectActivityLinkItem():OpContextMenuItem { diff --git a/frontend/src/app/shared/components/grids/widgets/time-entries/current-user/time-entries-current-user-menu.component.ts b/frontend/src/app/shared/components/grids/widgets/time-entries/current-user/time-entries-current-user-menu.component.ts index 2a5e6c0cde2e..3803991f8b1e 100644 --- a/frontend/src/app/shared/components/grids/widgets/time-entries/current-user/time-entries-current-user-menu.component.ts +++ b/frontend/src/app/shared/components/grids/widgets/time-entries/current-user/time-entries-current-user-menu.component.ts @@ -30,9 +30,14 @@ import { Component, EventEmitter, Output, } from '@angular/core'; import { OpModalService } from 'core-app/shared/components/modal/modal.service'; -import { WidgetAbstractMenuComponent } from 'core-app/shared/components/grids/widgets/menu/widget-abstract-menu.component'; -import { TimeEntriesCurrentUserConfigurationModalComponent } from 'core-app/shared/components/grids/widgets/time-entries/current-user/configuration-modal/configuration.modal'; +import { + WidgetAbstractMenuComponent, +} from 'core-app/shared/components/grids/widgets/menu/widget-abstract-menu.component'; +import { + TimeEntriesCurrentUserConfigurationModalComponent, +} from 'core-app/shared/components/grids/widgets/time-entries/current-user/configuration-modal/configuration.modal'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; +import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op-context-menu.types'; @Component({ selector: 'widget-time-entries-current-user-menu', @@ -43,10 +48,12 @@ export class WidgetTimeEntriesCurrentUserMenuComponent extends WidgetAbstractMen @Output() onConfigured:EventEmitter = new EventEmitter(); - protected menuItemList = [ - this.removeItem, - this.configureItem, - ]; + protected async buildItems():Promise { + return [ + this.removeItem, + this.configureItem, + ]; + } protected get configureItem() { return { diff --git a/frontend/src/app/shared/components/grids/widgets/wp-table/wp-table-menu.component.ts b/frontend/src/app/shared/components/grids/widgets/wp-table/wp-table-menu.component.ts index 518b7e786976..a5f36239088e 100644 --- a/frontend/src/app/shared/components/grids/widgets/wp-table/wp-table-menu.component.ts +++ b/frontend/src/app/shared/components/grids/widgets/wp-table/wp-table-menu.component.ts @@ -27,13 +27,26 @@ //++ import { Component } from '@angular/core'; -import { WpTableConfigurationModalComponent } from 'core-app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration.modal'; +import { + WpTableConfigurationModalComponent, +} from 'core-app/features/work-packages/components/wp-table/configuration-modal/wp-table-configuration.modal'; import { WidgetWpSetMenuComponent } from 'core-app/shared/components/grids/widgets/menu/wp-set-menu.component'; +import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; +import { CurrentUserService } from 'core-app/core/current-user/current-user.service'; +import { firstValueFrom } from 'rxjs'; @Component({ selector: 'widget-wp-table-menu', templateUrl: '../menu/widget-menu.component.html', }) export class WidgetWpTableMenuComponent extends WidgetWpSetMenuComponent { + @InjectField() currentUser:CurrentUserService; + protected configurationComponent = WpTableConfigurationModalComponent; + + protected configurationAllowed():Promise { + return firstValueFrom( + this.currentUser.hasCapabilities$('queries/create', null), + ); + } } diff --git a/frontend/src/app/shared/components/op-context-menu/icon-triggered-context-menu/icon-triggered-context-menu.component.ts b/frontend/src/app/shared/components/op-context-menu/icon-triggered-context-menu/icon-triggered-context-menu.component.ts index dff1ff991c0a..9e5e151e10db 100644 --- a/frontend/src/app/shared/components/op-context-menu/icon-triggered-context-menu/icon-triggered-context-menu.component.ts +++ b/frontend/src/app/shared/components/op-context-menu/icon-triggered-context-menu/icon-triggered-context-menu.component.ts @@ -26,11 +26,11 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { - ChangeDetectorRef, Component, ElementRef, Injector, Input, -} from '@angular/core'; +import { ChangeDetectorRef, Component, ElementRef, Injector, Input } from '@angular/core'; import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { OpContextMenuTrigger } from 'core-app/shared/components/op-context-menu/handlers/op-context-menu-trigger.directive'; +import { + OpContextMenuTrigger, +} from 'core-app/shared/components/op-context-menu/handlers/op-context-menu-trigger.directive'; import { OPContextMenuService } from 'core-app/shared/components/op-context-menu/op-context-menu.service'; import { OpModalService } from 'core-app/shared/components/modal/modal.service'; import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op-context-menu.types'; @@ -41,16 +41,18 @@ import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op styleUrls: ['./icon-triggered-context-menu.component.sass'], }) export class IconTriggeredContextMenuComponent extends OpContextMenuTrigger { - constructor(readonly elementRef:ElementRef, + constructor( + readonly elementRef:ElementRef, readonly opContextMenu:OPContextMenuService, readonly opModalService:OpModalService, readonly injector:Injector, readonly cdRef:ChangeDetectorRef, - readonly I18n:I18nService) { + readonly I18n:I18nService, + ) { super(elementRef, opContextMenu); } - @Input('menu-items') menuItems:Function; + @Input() menuItemsFactory:() => Promise; protected async open(evt:JQuery.TriggeredEvent) { this.items = await this.buildItems(); @@ -78,8 +80,8 @@ export class IconTriggeredContextMenuComponent extends OpContextMenuTrigger { const items:OpContextMenuItem[] = []; // Add action specific menu entries - if (this.menuItems) { - const additional = await this.menuItems(); + if (this.menuItemsFactory) { + const additional = await this.menuItemsFactory(); return items.concat(additional); } diff --git a/modules/my_page/lib/my_page/grid_registration.rb b/modules/my_page/lib/my_page/grid_registration.rb index fca2a6304586..665f6e1bfb8b 100644 --- a/modules/my_page/lib/my_page/grid_registration.rb +++ b/modules/my_page/lib/my_page/grid_registration.rb @@ -23,11 +23,19 @@ class GridRegistration < ::Grids::Configuration::Registration options_representer "::API::V3::Grids::Widgets::QueryOptionsRepresenter" end + # Allow users without save_queries permission to access the widgets + # but they are not allowed to update the underlying query + wp_static_table_strategy_proc = Proc.new do + after_destroy -> { ::Query.find_by(id: options[:queryId])&.destroy } + + options_representer "::API::V3::Grids::Widgets::QueryOptionsRepresenter" + end + widget_strategy "work_packages_table", &wp_table_strategy_proc - widget_strategy "work_packages_assigned", &wp_table_strategy_proc - widget_strategy "work_packages_accountable", &wp_table_strategy_proc - widget_strategy "work_packages_watched", &wp_table_strategy_proc - widget_strategy "work_packages_created", &wp_table_strategy_proc + widget_strategy "work_packages_assigned", &wp_static_table_strategy_proc + widget_strategy "work_packages_accountable", &wp_static_table_strategy_proc + widget_strategy "work_packages_watched", &wp_static_table_strategy_proc + widget_strategy "work_packages_created", &wp_static_table_strategy_proc widget_strategy "time_entries_current_user" do options_representer "::API::V3::Grids::Widgets::TimeEntryCalendarOptionsRepresenter" diff --git a/modules/my_page/spec/features/my/work_package_watcher_widget_spec.rb b/modules/my_page/spec/features/my/work_package_watcher_widget_spec.rb new file mode 100644 index 000000000000..16dfbfde4ac5 --- /dev/null +++ b/modules/my_page/spec/features/my/work_package_watcher_widget_spec.rb @@ -0,0 +1,61 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +require_relative "../../support/pages/my/page" + +RSpec.describe "Work package watched widget on My page", :js do + shared_let(:user) { create(:user) } + shared_let(:non_member) { create(:non_member, permissions: [:view_work_packages]) } + shared_let(:project) { create(:project, public: true) } + shared_let(:work_package) do + create(:work_package, + project:, + subject: "Visible work package for non member", + author: user, + responsible: user) + end + + let(:my_page) do + Pages::My::Page.new + end + + before do + login_as user + work_package.add_watcher(user) + + my_page.visit! + end + + it "can add the watcher widget without being member anywhere (Regression #55838)" do + my_page.add_widget(1, 1, :within, "Work packages watched by me") + + expect(page).to have_text(work_package.subject) + end +end