diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 8d42dc403598..86d3af6284ed 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -173,6 +173,10 @@ import { } from 'core-app/features/work-packages/routing/wp-split-view/wp-split-view-entry.component'; import { InAppNotificationsDateAlertsUpsaleComponent } from 'core-app/features/in-app-notifications/date-alerts-upsale/ian-date-alerts-upsale.component'; import { ShareUpsaleComponent } from 'core-app/features/enterprise/share-upsale/share-upsale.component'; +import { + StorageLoginButtonComponent, +} from 'core-app/shared/components/storages/storage-login-button/storage-login-button.component'; +import { OpCustomModalOverlayComponent } from 'core-app/shared/components/modal/custom-modal-overlay.component'; export function initializeServices(injector:Injector) { return () => { @@ -362,6 +366,8 @@ export class OpenProjectModule { registerCustomElement('opce-attribute-help-text', AttributeHelpTextComponent, { injector }); registerCustomElement('opce-exclusion-info', OpExclusionInfoComponent, { injector }); registerCustomElement('opce-attachments', OpAttachmentsComponent, { injector }); + registerCustomElement('opce-storage-login-button', StorageLoginButtonComponent, { injector }); + registerCustomElement('opce-custom-modal-overlay', OpCustomModalOverlayComponent, { injector }); // TODO: These elements are now registered custom elements, but are actually single-use components. They should be removed when we move these pages to Rails. registerCustomElement('opce-new-project', NewProjectComponent, { injector }); diff --git a/frontend/src/app/shared/components/modal/custom-modal-overlay.component.ts b/frontend/src/app/shared/components/modal/custom-modal-overlay.component.ts new file mode 100644 index 000000000000..a919192fced3 --- /dev/null +++ b/frontend/src/app/shared/components/modal/custom-modal-overlay.component.ts @@ -0,0 +1,51 @@ +// -- 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. +//++ + +import { + ChangeDetectionStrategy, + Component, +} from '@angular/core'; + +import { ModalData } from 'core-app/shared/components/modal/modal.service'; +import { OpModalOverlayComponent } from 'core-app/shared/components/modal/modal-overlay.component'; +import { PortalOutletTarget } from 'core-app/shared/components/modal/portal-outlet-target.enum'; + +export const opCustomModalOverlaySelector = 'op-custom-modal-overlay'; + +@Component({ + selector: opCustomModalOverlaySelector, + templateUrl: './modal-overlay.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OpCustomModalOverlayComponent extends OpModalOverlayComponent { + protected isDefaultTarget(modalData:ModalData | null):boolean { + if (modalData === null) return true; + + return modalData.target === PortalOutletTarget.Custom; + } +} diff --git a/frontend/src/app/shared/components/modal/modal-overlay.component.ts b/frontend/src/app/shared/components/modal/modal-overlay.component.ts index 5fdd4dd9077a..d7e1c0c857cf 100644 --- a/frontend/src/app/shared/components/modal/modal-overlay.component.ts +++ b/frontend/src/app/shared/components/modal/modal-overlay.component.ts @@ -39,11 +39,12 @@ import { ComponentPortal, } from '@angular/cdk/portal'; import { combineLatest } from 'rxjs'; -import { distinctUntilChanged } from 'rxjs/operators'; +import { distinctUntilChanged, filter } from 'rxjs/operators'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { OpModalComponent } from 'core-app/shared/components/modal/modal.component'; import { ModalData, OpModalService } from 'core-app/shared/components/modal/modal.service'; +import { PortalOutletTarget } from 'core-app/shared/components/modal/portal-outlet-target.enum'; export const opModalOverlaySelector = 'op-modal-overlay'; @@ -84,7 +85,7 @@ export class OpModalOverlayComponent { combineLatest([ this.activeModalInstance$, // multiple 'closing' events in a row are squashed - this.activeModalData$.pipe(distinctUntilChanged()), + this.activeModalData$.pipe(distinctUntilChanged(), filter(this.isDefaultTarget.bind(this))), ]) .subscribe(([instance, data]) => { if (instance === null && data === null) { @@ -103,6 +104,12 @@ export class OpModalOverlayComponent { }); } + protected isDefaultTarget(modalData:ModalData | null):boolean { + if (modalData === null) return true; + + return modalData.target === PortalOutletTarget.Default; + } + public close($event:MouseEvent, includeChildClicks = true):void { if (!includeChildClicks && $event.currentTarget !== $event.target) { return; diff --git a/frontend/src/app/shared/components/modal/modal.module.ts b/frontend/src/app/shared/components/modal/modal.module.ts index 36012dedd726..ebae80a4730a 100644 --- a/frontend/src/app/shared/components/modal/modal.module.ts +++ b/frontend/src/app/shared/components/modal/modal.module.ts @@ -7,6 +7,7 @@ import { OpModalWrapperAugmentService } from './modal-wrapper-augment.service'; import { OpModalBannerComponent } from 'core-app/shared/components/modal/modal-banner/modal-banner.component'; import { OpModalOverlayComponent } from 'core-app/shared/components/modal/modal-overlay.component'; import { CommonModule } from '@angular/common'; +import { OpCustomModalOverlayComponent } from 'core-app/shared/components/modal/custom-modal-overlay.component'; @NgModule({ imports: [ @@ -18,6 +19,7 @@ import { CommonModule } from '@angular/common'; ], exports: [ OpModalOverlayComponent, + OpCustomModalOverlayComponent, OpModalBannerComponent, ], providers: [ @@ -26,6 +28,7 @@ import { CommonModule } from '@angular/common'; declarations: [ OpModalBannerComponent, OpModalOverlayComponent, + OpCustomModalOverlayComponent, ], }) export class OpenprojectModalModule { } diff --git a/frontend/src/app/shared/components/modal/modal.service.ts b/frontend/src/app/shared/components/modal/modal.service.ts index 436fe1944811..c495e3d8a3dc 100644 --- a/frontend/src/app/shared/components/modal/modal.service.ts +++ b/frontend/src/app/shared/components/modal/modal.service.ts @@ -26,16 +26,17 @@ // See COPYRIGHT and LICENSE files for more details. //++ +import { ComponentType, PortalInjector } from '@angular/cdk/portal'; import { Injectable, InjectionToken, Injector, } from '@angular/core'; -import { ComponentType, PortalInjector } from '@angular/cdk/portal'; import { BehaviorSubject, Observable } from 'rxjs'; import { filter, take } from 'rxjs/operators'; import { OpModalComponent } from 'core-app/shared/components/modal/modal.component'; +import { PortalOutletTarget } from 'core-app/shared/components/modal/portal-outlet-target.enum'; export const OpModalLocalsToken = new InjectionToken('OP_MODAL_LOCALS'); @@ -44,6 +45,7 @@ export interface ModalData { injector:Injector; notFullscreen:boolean; mobileTopPosition:boolean; + target:PortalOutletTarget; } @Injectable({ providedIn: 'root' }) @@ -74,6 +76,7 @@ export class OpModalService { * @param locals A map to be injected via token into the component. * @param notFullscreen * @param mobileTopPosition + * @param target An optional target override for the modal portal outlet */ public show( modal:ComponentType, @@ -81,6 +84,7 @@ export class OpModalService { locals:Record = {}, notFullscreen = false, mobileTopPosition = false, + target = PortalOutletTarget.Default, ):Observable { this.close(); @@ -94,6 +98,7 @@ export class OpModalService { injector: this.injectorFor(injector, locals), notFullscreen, mobileTopPosition, + target, }); return this.activeModalInstance$ diff --git a/frontend/src/app/shared/components/modal/portal-outlet-target.enum.ts b/frontend/src/app/shared/components/modal/portal-outlet-target.enum.ts new file mode 100644 index 000000000000..35fe06e77d92 --- /dev/null +++ b/frontend/src/app/shared/components/modal/portal-outlet-target.enum.ts @@ -0,0 +1,32 @@ +// -- 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. +//++ + +export enum PortalOutletTarget { + Default = 1, // targets 'op-modal-overlay' in base.html + Custom, // targets 'op-custom-modal-overlay' (can be anywhere) +} diff --git a/frontend/src/stimulus/controllers/dynamic/project-storage-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/project-storage-form.controller.ts index 4032b2fb8665..2e1385657e51 100644 --- a/frontend/src/stimulus/controllers/dynamic/project-storage-form.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/project-storage-form.controller.ts @@ -41,13 +41,14 @@ import { switchMap, } from 'rxjs/operators'; +import { IStorageFile } from 'core-app/core/state/storage-files/storage-file.model'; import { IStorage } from 'core-app/core/state/storages/storage.model'; import { OpModalService } from 'core-app/shared/components/modal/modal.service'; -import { IStorageFile } from 'core-app/core/state/storage-files/storage-file.model'; -import { storageConnected } from 'core-app/shared/components/storages/storages-constants.const'; +import { PortalOutletTarget } from 'core-app/shared/components/modal/portal-outlet-target.enum'; import { LocationPickerModalComponent, } from 'core-app/shared/components/storages/location-picker-modal/location-picker-modal.component'; +import { storageConnected } from 'core-app/shared/components/storages/storages-constants.const'; export default class ProjectStorageFormController extends Controller { static targets = [ @@ -68,29 +69,19 @@ export default class ProjectStorageFormController extends Controller { }; declare folderModeValue:string; - declare placeholderFolderNameValue:string; - declare notLoggedInValidationValue:string; - declare lastProjectFoldersValue:{ manual:string; automatic:string }; declare readonly storageTarget:HTMLElement; - declare readonly selectProjectFolderButtonTarget:HTMLButtonElement; - declare readonly loginButtonTarget:HTMLButtonElement; - declare readonly projectFolderSectionTarget:HTMLElement; - declare readonly projectFolderIdInputTarget:HTMLInputElement; - declare readonly projectFolderIdValidationTarget:HTMLSpanElement; - - declare readonly hasProjectFolderIdValidationTarget:boolean; - declare readonly selectedFolderTextTarget:HTMLSpanElement; + declare readonly hasProjectFolderIdValidationTarget:boolean; declare readonly hasProjectFolderSectionTarget:boolean; connect():void { @@ -98,18 +89,7 @@ export default class ProjectStorageFormController extends Controller { this.fetchStorageAuthorizationState(), this.fetchProjectFolder(), ]).subscribe(([isConnected, projectFolder]) => { - if (isConnected) { - this.selectProjectFolderButtonTarget.style.display = 'inline-block'; - this.loginButtonTarget.style.display = 'none'; - this.selectedFolderTextTarget.innerText = projectFolder === null - ? this.placeholderFolderNameValue - : projectFolder.name; - } else { - this.selectProjectFolderButtonTarget.style.display = 'none'; - this.loginButtonTarget.style.display = 'inline-block'; - this.selectedFolderTextTarget.innerText = this.notLoggedInValidationValue; - } - + this.displayFolderSelectionOrLoginButton(isConnected, projectFolder); this.toggleFolderDisplay(this.folderModeValue); this.setProjectFolderModeQueryParam(this.folderModeValue); }); @@ -123,7 +103,7 @@ export default class ProjectStorageFormController extends Controller { this.modalService .pipe( - switchMap((service) => service.show(LocationPickerModalComponent, 'global', locals)), + switchMap((service) => service.show(LocationPickerModalComponent, 'global', locals, false, false, this.OutletTarget)), switchMap((modal) => modal.closingEvent), filter((modal) => modal.submitted), ) @@ -163,16 +143,20 @@ export default class ProjectStorageFormController extends Controller { this.setProjectFolderModeQueryParam(mode); } - private get modalService():Observable { + protected get OutletTarget():PortalOutletTarget { + return PortalOutletTarget.Default; + } + + protected get modalService():Observable { return from(window.OpenProject.getPluginContext()) .pipe(map((pluginContext) => pluginContext.services.opModalService)); } - private get storage():IStorage { + protected get storage():IStorage { return JSON.parse(this.storageTarget.dataset.storage as string) as IStorage; } - private get projectFolderHref():string|null { + protected get projectFolderHref():string|null { const projectFolderId = this.projectFolderIdInputTarget.value; if (projectFolderId.length === 0) { @@ -182,7 +166,7 @@ export default class ProjectStorageFormController extends Controller { return `${this.storage._links.self.href}/files/${projectFolderId}`; } - private fetchStorageAuthorizationState():Observable { + protected fetchStorageAuthorizationState():Observable { return from(fetch(this.storage._links.self.href) .then((data) => data.json())) .pipe( @@ -190,7 +174,7 @@ export default class ProjectStorageFormController extends Controller { ); } - private fetchProjectFolder():Observable { + protected fetchProjectFolder():Observable { const href = this.projectFolderHref; if (href === null) { return of(null); @@ -209,7 +193,21 @@ export default class ProjectStorageFormController extends Controller { ); } - private setProjectFolderModeQueryParam(mode:string) { + protected displayFolderSelectionOrLoginButton(isConnected:boolean, projectFolder:IStorageFile|null):void { + if (isConnected) { + this.selectProjectFolderButtonTarget.classList.remove('d-none'); + this.loginButtonTarget.classList.add('d-none'); + this.selectedFolderTextTarget.innerText = projectFolder === null + ? this.placeholderFolderNameValue + : projectFolder.name; + } else { + this.selectProjectFolderButtonTarget.classList.add('d-none'); + this.loginButtonTarget.classList.remove('d-none'); + this.selectedFolderTextTarget.innerText = this.notLoggedInValidationValue; + } + } + + protected setProjectFolderModeQueryParam(mode:string) { const url = new URL(window.location.href); url.searchParams.set('storages_project_storage[project_folder_mode]', mode); window.history.replaceState(window.history.state, '', url); @@ -218,9 +216,9 @@ export default class ProjectStorageFormController extends Controller { private toggleFolderDisplay(value:string):void { // If the manual radio button is selected, show the manual folder selection section if (this.hasProjectFolderSectionTarget && value === 'manual') { - this.projectFolderSectionTarget.style.display = 'flex'; + this.projectFolderSectionTarget.classList.remove('d-none'); } else { - this.projectFolderSectionTarget.style.display = 'none'; + this.projectFolderSectionTarget.classList.add('d-none'); } } } diff --git a/frontend/src/stimulus/controllers/dynamic/storages/project-folder-mode-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/storages/project-folder-mode-form.controller.ts new file mode 100644 index 000000000000..ce5cdd449038 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/storages/project-folder-mode-form.controller.ts @@ -0,0 +1,49 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) 2023 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. + * ++ + */ + +import { IStorageFile } from 'core-app/core/state/storage-files/storage-file.model'; +import { PortalOutletTarget } from 'core-app/shared/components/modal/portal-outlet-target.enum'; +import ProjectStorageFormController from '../project-storage-form.controller'; + +export default class ProjectFolderModeFormController extends ProjectStorageFormController { + protected get OutletTarget():PortalOutletTarget { + return PortalOutletTarget.Custom; + } + + protected displayFolderSelectionOrLoginButton(isConnected:boolean, projectFolder:IStorageFile|null):void { + if (isConnected) { + this.selectedFolderTextTarget.innerText = projectFolder === null + ? this.placeholderFolderNameValue + : projectFolder.name; + } else { + this.selectedFolderTextTarget.innerText = this.notLoggedInValidationValue; + } + } +} diff --git a/lib/primer/open_project/forms/dsl/input_methods.rb b/lib/primer/open_project/forms/dsl/input_methods.rb index 5a4aa6148507..8b9a74cf3d9a 100644 --- a/lib/primer/open_project/forms/dsl/input_methods.rb +++ b/lib/primer/open_project/forms/dsl/input_methods.rb @@ -20,6 +20,10 @@ def project_autocompleter(**, &) def rich_text_area(**) add_input RichTextAreaInput.new(builder: @builder, form: @form, **) end + + def storage_manual_project_folder_selection(**) + add_input StorageManualProjectFolderSelectionInput.new(builder: @builder, form: @form, **) + end end end end diff --git a/lib/primer/open_project/forms/dsl/storage_manual_project_folder_selection_input.rb b/lib/primer/open_project/forms/dsl/storage_manual_project_folder_selection_input.rb new file mode 100644 index 000000000000..115271847a88 --- /dev/null +++ b/lib/primer/open_project/forms/dsl/storage_manual_project_folder_selection_input.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Primer + module OpenProject + module Forms + module Dsl + class StorageManualProjectFolderSelectionInput < Primer::Forms::Dsl::Input + attr_reader :name, :label + + def initialize(name:, label:, project_storage:, last_project_folders: {}, storage_login_button_options: {}, + select_folder_button_options: {}, wrapper_arguments: {}, **system_arguments) + @name = name + @label = label + @project_storage = project_storage + @last_project_folders = last_project_folders + @storage_login_button_options = storage_login_button_options + @select_folder_button_options = select_folder_button_options + @wrapper_arguments = wrapper_arguments + + super(**system_arguments) + end + + def to_component + StorageManualProjectFolderSelection.new( + input: self, + project_storage: @project_storage, + last_project_folders: @last_project_folders, + storage_login_button_options: @storage_login_button_options, + select_folder_button_options: @select_folder_button_options, + wrapper_arguments: @wrapper_arguments + ) + end + + def type + :storage_manual_project_folder_selection + end + + def focusable? + true + end + end + end + end + end +end diff --git a/lib/primer/open_project/forms/storage_manual_project_folder_selection.html.erb b/lib/primer/open_project/forms/storage_manual_project_folder_selection.html.erb new file mode 100644 index 000000000000..d4334c10240a --- /dev/null +++ b/lib/primer/open_project/forms/storage_manual_project_folder_selection.html.erb @@ -0,0 +1,31 @@ +<%= render(Primer::BaseComponent.new(tag: :div, data: @wrapper_data_attributes, classes: @wrapper_classes)) do %> + <%= render(Primer::BaseComponent.new(tag: :div, display: :inline_flex, direction: :row, align_items: :center)) do %> + <% if storage_oauth_access_granted? %> + <%= + render(Primer::Beta::Button.new( + scheme: :default, + display: :inline_block, + data: @select_folder_button_options.delete(:data) { {} } + )) do |button| + button.with_leading_visual_icon(icon: 'file-directory') + I18n.t(:"storages.buttons.select_folder") + end + %> + + <%= + render( + Primer::Beta::Text.new( + font_weight: :bold, + data: @selected_folder_label_options.delete(:data) { {} }, + pl: 2 + ) + ) { I18n.t(:"storages.label_no_selected_folder") } + %> + <% else %> + <%= angular_component_tag 'opce-storage-login-button', + data: @storage_login_button_options.delete(:data) { {} }, + inputs: @storage_login_button_options.delete(:inputs) { {} } + %> + <% end %> + <% end %> +<% end %> diff --git a/lib/primer/open_project/forms/storage_manual_project_folder_selection.rb b/lib/primer/open_project/forms/storage_manual_project_folder_selection.rb new file mode 100644 index 000000000000..1a8ceb9c6ee1 --- /dev/null +++ b/lib/primer/open_project/forms/storage_manual_project_folder_selection.rb @@ -0,0 +1,35 @@ +module Primer + module OpenProject + module Forms + # :nodoc: + class StorageManualProjectFolderSelection < Primer::Forms::BaseComponent + include AngularHelper + + delegate :builder, :form, to: :@input + + def initialize(input:, project_storage:, last_project_folders: {}, + storage_login_button_options: {}, select_folder_button_options: {}, wrapper_arguments: {}) + super() + @input = input + + @project_storage = project_storage + @last_project_folders = last_project_folders + + @storage_login_button_options = storage_login_button_options + @selected_folder_label_options = select_folder_button_options.delete(:selected_folder_label_options) { {} } + @select_folder_button_options = select_folder_button_options + + @wrapper_data_attributes = wrapper_arguments.delete(:data) { {} } + @wrapper_classes = wrapper_arguments.delete(:classes) { [] } + end + + private + + def storage_oauth_access_granted? + OAuthClientToken + .exists?(user: User.current, oauth_client: @project_storage.storage.oauth_client) + end + end + end + end +end diff --git a/modules/storages/app/components/storages/admin/storages/add_projects_form_modal_component.html.erb b/modules/storages/app/components/storages/admin/storages/add_projects_form_modal_component.html.erb index 5bc9e3c84210..a44507d96818 100644 --- a/modules/storages/app/components/storages/admin/storages/add_projects_form_modal_component.html.erb +++ b/modules/storages/app/components/storages/admin/storages/add_projects_form_modal_component.html.erb @@ -42,6 +42,10 @@ See COPYRIGHT and LICENSE files for more details. aria: { label: title } )) do flex_layout do |flex| + flex.with_row do + angular_component_tag 'opce-custom-modal-overlay' + end + flex.with_row(mb: 3) do render(Storages::Admin::Storages::AddProjectsAutocompleterForm.new(form, project_storage:)) end @@ -58,7 +62,16 @@ See COPYRIGHT and LICENSE files for more details. render(Primer::Beta::Text.new) { t(:"storages.help_texts.project_folder_bulk") } end - flex.with_row do + flex.with_row( + data: { + "application-target": "dynamic", + controller: "storages--project-folder-mode-form", + 'storages--project-folder-mode-form-folder-mode-value': @project_storage.project_folder_mode, + 'storages--project-folder-mode-form-placeholder-folder-name-value': t(:"storages.label_no_selected_folder"), + 'storages--project-folder-mode-form-not-logged-in-validation-value': t(:"storages.instructions.not_logged_into_storage"), + 'storages--project-folder-mode-form-last-project-folders-value': @last_project_folders + } + ) do render(Storages::Admin::ProjectStorages::ProjectFolderModeForm.new(form, project_storage:)) end end diff --git a/modules/storages/app/components/storages/admin/storages/add_projects_form_modal_component.rb b/modules/storages/app/components/storages/admin/storages/add_projects_form_modal_component.rb index 5809fb5c4304..9093db2cd38b 100644 --- a/modules/storages/app/components/storages/admin/storages/add_projects_form_modal_component.rb +++ b/modules/storages/app/components/storages/admin/storages/add_projects_form_modal_component.rb @@ -32,9 +32,12 @@ module Storages class AddProjectsFormModalComponent < ApplicationComponent include OpPrimer::ComponentHelpers include OpTurbo::Streamable + include StimulusHelper + include AngularHelper def initialize(project_storage:, **) @project_storage = project_storage + @last_project_folders = {} super(@project_storage, **) end diff --git a/modules/storages/app/controllers/storages/admin/storages/project_storages_controller.rb b/modules/storages/app/controllers/storages/admin/storages/project_storages_controller.rb index 181495260827..8b667704ab44 100644 --- a/modules/storages/app/controllers/storages/admin/storages/project_storages_controller.rb +++ b/modules/storages/app/controllers/storages/admin/storages/project_storages_controller.rb @@ -55,20 +55,19 @@ def new respond_with_dialog Storages::Admin::Storages::AddProjectsModalComponent.new(project_storage: @project_storage) end - def create + def create # rubocop:disable Metrics/AbcSize create_service = ::Storages::ProjectStorages::BulkCreateService .new(user: current_user, projects: @projects, storage: @storage, - project_folder_mode: params.to_unsafe_h[:storages_project_storage][:project_folder_mode], include_sub_projects: include_sub_projects?) - .call + .call(params.to_unsafe_h[:storages_project_storage]) create_service.on_success { update_project_list_via_turbo_stream(url_for_action: :index) } create_service.on_failure do - update_flash_message_via_turbo_stream( - message: join_flash_messages(create_service.errors), - full: true, dismiss_scheme: :hide, scheme: :danger - ) + project_storage = create_service.result + project_storage.errors.merge!(create_service.errors) + component = Storages::Admin::Storages::AddProjectsFormModalComponent.new(project_storage:) + update_via_turbo_stream(component:, status: :bad_request) end respond_with_turbo_streams(status: create_service.success? ? :ok : :unprocessable_entity) diff --git a/modules/storages/app/forms/storages/admin/project_storages/project_folder_mode_form.rb b/modules/storages/app/forms/storages/admin/project_storages/project_folder_mode_form.rb index c0639dc60c58..77fd0031a898 100644 --- a/modules/storages/app/forms/storages/admin/project_storages/project_folder_mode_form.rb +++ b/modules/storages/app/forms/storages/admin/project_storages/project_folder_mode_form.rb @@ -30,23 +30,108 @@ module Storages module Admin module ProjectStorages class ProjectFolderModeForm < ApplicationForm + include StorageLoginHelper + include APIV3Helper + form do |radio_form| - radio_form.radio_button_group(name: :project_folder_mode) do |radio_group| + radio_form.radio_button_group(name: :project_folder_mode, validation_message:) do |radio_group| if @project_storage.project_folder_mode_possible?("inactive") radio_group.radio_button(value: "inactive", label: I18n.t(:"storages.label_no_specific_folder"), - caption: I18n.t(:"storages.instructions.no_specific_folder")) + caption: I18n.t(:"storages.instructions.no_specific_folder"), + data: { action: "storages--project-folder-mode-form#updateForm" }) end if @project_storage.project_folder_mode_possible?("automatic") radio_group.radio_button(value: "automatic", label: I18n.t(:"storages.label_automatic_folder"), - caption: I18n.t(:"storages.instructions.automatic_folder")) + caption: I18n.t(:"storages.instructions.automatic_folder"), + data: { action: "storages--project-folder-mode-form#updateForm" }) + end + + if @project_storage.project_folder_mode_possible?("manual") + radio_group.radio_button(value: "manual", label: I18n.t(:"storages.label_existing_manual_folder"), + caption: I18n.t(:"storages.instructions.existing_manual_folder"), + data: { action: "storages--project-folder-mode-form#updateForm" }) end end + + radio_form.hidden( + name: :storage, + value: @project_storage.storage_id, + data: { + "storages--project-folder-mode-form-target": "storage", + storage: { + name: @project_storage.storage.name, + id: @project_storage.storage.id, + _links: { + self: { href: api_v3_paths.storage(@project_storage.storage.id) }, + type: { href: API::V3::Storages::URN_STORAGE_TYPE_NEXTCLOUD } + } + } + } + ) + + radio_form.hidden( + name: :project_folder_id, + data: { + "storages--project-folder-mode-form-target": "projectFolderIdInput" + } + ) + + if @project_storage.project_folder_mode_possible?("manual") + radio_form.storage_manual_project_folder_selection( + name: :project_folder, + label: nil, + project_storage: @project_storage, + last_project_folders: @last_project_folders, + storage_login_button_options: { + data: { + "storages--project-folder-mode-form-target": "loginButton" + }, + inputs: { + input: storage_login_input(@project_storage.storage) + } + }, + select_folder_button_options: { + data: { + "storages--project-folder-mode-form-target": "selectProjectFolderButton", + action: "storages--project-folder-mode-form#selectProjectFolder" + }, + selected_folder_label_options: { + data: { + "storages--project-folder-mode-form-target": "selectedFolderText" + } + } + }, + wrapper_arguments: { + data: { + "storages--project-folder-mode-form-target": "projectFolderSection" + }, + classes: project_folder_selection_classes + } + ) + end end - def initialize(project_storage:) + def initialize(project_storage:, last_project_folders: {}) super() @project_storage = project_storage + @last_project_folders = last_project_folders + end + + private + + def validation_message + @project_storage + .errors + .messages_for(:project_folder_id) + .to_sentence + .presence + end + + def project_folder_selection_classes + [].tap do |classes| + classes << "d-none" unless @project_storage.errors.include?(:project_folder_id) + end end end end diff --git a/modules/storages/app/services/storages/project_storages/bulk_create_service.rb b/modules/storages/app/services/storages/project_storages/bulk_create_service.rb index a5f40eb27793..3b392009c488 100644 --- a/modules/storages/app/services/storages/project_storages/bulk_create_service.rb +++ b/modules/storages/app/services/storages/project_storages/bulk_create_service.rb @@ -30,21 +30,20 @@ module Storages::ProjectStorages class BulkCreateService < ::BaseServices::BaseCallable - def initialize(user:, projects:, storage:, project_folder_mode:, include_sub_projects: false) + def initialize(user:, projects:, storage:, include_sub_projects: false) super() @user = user @projects = projects @storage = storage - @project_folder_mode = project_folder_mode @include_sub_projects = include_sub_projects end - def perform + def perform(params = {}) service_call = validate_permissions - service_call = validate_contract(service_call, incoming_activations_ids) if service_call.success? + service_call = validate_contract(service_call, incoming_activations_ids, params) if service_call.success? service_call = perform_bulk_create(service_call) if service_call.success? - service_call = create_last_project_folders(service_call) if service_call.success? - broadcast_project_storages_created if service_call.success? + service_call = create_last_project_folders(service_call, params) if service_call.success? + broadcast_project_storages_created(params) if service_call.success? service_call end @@ -61,14 +60,15 @@ def validate_permissions end end - def validate_contract(service_call, project_ids) + def validate_contract(service_call, project_ids, params) + project_folder_params = params.slice(:project_folder_mode, :project_folder_id) + set_attributes_results = project_ids.map do |id| - set_attributes(project_id: id, storage_id: @storage.id, project_folder_mode: @project_folder_mode) + set_attributes(project_id: id, storage_id: @storage.id, **project_folder_params) end if (failures = set_attributes_results.select(&:failure?)).any? - service_call.success = false - service_call.errors = failures.map(&:errors) + service_call = failures.first else service_call.result = set_attributes_results.map(&:result) end @@ -78,9 +78,7 @@ def validate_contract(service_call, project_ids) def perform_bulk_create(service_call) bulk_insertion = ::Storages::ProjectStorage.insert_all( - service_call.result.map do |model| - model.attributes.slice("project_id", "storage_id", "project_folder_mode", "creator_id") - end, + service_call.result.map { |model| model.attributes.compact }, unique_by: %i[project_id storage_id], returning: %w[id] ) @@ -89,8 +87,8 @@ def perform_bulk_create(service_call) service_call end - def create_last_project_folders(service_call) - return service_call if @project_folder_mode.to_sym == :inactive + def create_last_project_folders(service_call, params) + return service_call if params[:project_folder_mode].to_sym == :inactive last_project_folders = ::Storages::LastProjectFolders::BulkCreateService .new(user: @user, project_storages: service_call.result) @@ -100,10 +98,10 @@ def create_last_project_folders(service_call) service_call end - def broadcast_project_storages_created + def broadcast_project_storages_created(params) OpenProject::Notifications.send( OpenProject::Events::PROJECT_STORAGE_CREATED, - project_folder_mode: @project_folder_mode, + project_folder_mode: params[:project_folder_mode], storage: @storage ) end diff --git a/modules/storages/app/views/storages/project_settings/_project_folder_form.html.erb b/modules/storages/app/views/storages/project_settings/_project_folder_form.html.erb index 7341efc4f5f7..d667e379aadc 100644 --- a/modules/storages/app/views/storages/project_settings/_project_folder_form.html.erb +++ b/modules/storages/app/views/storages/project_settings/_project_folder_form.html.erb @@ -121,8 +121,7 @@ See COPYRIGHT and LICENSE files for more details. <% end %>