Skip to content

Commit

Permalink
Merge pull request #16181 from opf/implementation/55967-implement-fil…
Browse files Browse the repository at this point in the history
…e-picker-for-the-existing-folders-with-manually-managed-permissions-in-the-dialog

[#55967] Implement file picker for the existing folders with manually managed permissions in the dialog
  • Loading branch information
akabiru authored Aug 8, 2024
2 parents 9cd03ca + 2cff9fb commit 7798c4a
Show file tree
Hide file tree
Showing 21 changed files with 595 additions and 113 deletions.
6 changes: 6 additions & 0 deletions frontend/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/app/shared/components/modal/modal.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -18,6 +19,7 @@ import { CommonModule } from '@angular/common';
],
exports: [
OpModalOverlayComponent,
OpCustomModalOverlayComponent,
OpModalBannerComponent,
],
providers: [
Expand All @@ -26,6 +28,7 @@ import { CommonModule } from '@angular/common';
declarations: [
OpModalBannerComponent,
OpModalOverlayComponent,
OpCustomModalOverlayComponent,
],
})
export class OpenprojectModalModule { }
7 changes: 6 additions & 1 deletion frontend/src/app/shared/components/modal/modal.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never>('OP_MODAL_LOCALS');

Expand All @@ -44,6 +45,7 @@ export interface ModalData {
injector:Injector;
notFullscreen:boolean;
mobileTopPosition:boolean;
target:PortalOutletTarget;
}

@Injectable({ providedIn: 'root' })
Expand Down Expand Up @@ -74,13 +76,15 @@ 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<T extends OpModalComponent>(
modal:ComponentType<T>,
injector:Injector|'global',
locals:Record<string, unknown> = {},
notFullscreen = false,
mobileTopPosition = false,
target = PortalOutletTarget.Default,
):Observable<T> {
this.close();

Expand All @@ -94,6 +98,7 @@ export class OpModalService {
injector: this.injectorFor(injector, locals),
notFullscreen,
mobileTopPosition,
target,
});

return this.activeModalInstance$
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -68,48 +69,27 @@ 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 {
combineLatest([
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);
});
Expand All @@ -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),
)
Expand Down Expand Up @@ -163,16 +143,20 @@ export default class ProjectStorageFormController extends Controller {
this.setProjectFolderModeQueryParam(mode);
}

private get modalService():Observable<OpModalService> {
protected get OutletTarget():PortalOutletTarget {
return PortalOutletTarget.Default;
}

protected get modalService():Observable<OpModalService> {
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) {
Expand All @@ -182,15 +166,15 @@ export default class ProjectStorageFormController extends Controller {
return `${this.storage._links.self.href}/files/${projectFolderId}`;
}

private fetchStorageAuthorizationState():Observable<boolean> {
protected fetchStorageAuthorizationState():Observable<boolean> {
return from(fetch(this.storage._links.self.href)
.then((data) => data.json()))
.pipe(
map((storage:IStorage) => storage._links.authorizationState.href === storageConnected),
);
}

private fetchProjectFolder():Observable<IStorageFile|null> {
protected fetchProjectFolder():Observable<IStorageFile|null> {
const href = this.projectFolderHref;
if (href === null) {
return of(null);
Expand All @@ -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);
Expand All @@ -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');
}
}
}
Loading

0 comments on commit 7798c4a

Please sign in to comment.