@if (previousFolder()) {
-
-
+
-
+
{{ currentFolder()?.name ?? '' }}
-
+
}
@if (files().length) {
-
-
- @if (item.kind !== 'folder') {
-
-
{{ item?.name ?? '' }}
- } @else if (fileIdsInList().has(item.id) || !hasAddUpdateFeature()) {
-
-
-
- {{ item?.name ?? '' }}
-
- } @else {
-
- }
-
-
+ 0"
+ [isBlocked]="fileIdsInList().has(item.id) || !hasAddUpdateFeature()"
+ (openFolder)="openFolder($event)"
+ />
- @if (isLoadingMore()) {
-
-
-
- }
} @else {
{{ 'files.emptyState' | translate }}
diff --git a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.scss b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.scss
index 4e5ccf48b..487ab508d 100644
--- a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.scss
+++ b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.scss
@@ -5,53 +5,17 @@
flex: 1;
}
-.loading-overlay {
- @include mix.flex-center;
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background-color: rgba(0, 0, 0, 0.2);
- z-index: 1;
-}
-
.files-table {
border: 1px solid var(--grey-2);
- border-radius: mix.rem(8px);
+ border-radius: 0.5rem;
- &-row {
+ .files-table-row {
border-bottom: 1px solid var(--grey-2);
- color: var(--dark-blue-1);
- height: mix.rem(44px);
- }
-
- &-row:last-child {
- border-bottom: none;
- }
-}
-
-.filename-link {
- min-width: 0;
- max-width: 100%;
- cursor: pointer;
-
- &.disabled {
- color: var(--grey-1);
- cursor: not-allowed;
- }
-
- &:not(.disabled):hover {
- text-decoration: underline;
}
}
.link-btn-no-padding {
--p-button-label-font-weight: 400;
- --p-button-link-hover-color: var(--dark-blue-1);
--p-button-link-color: var(--dark-blue-1);
-}
-
-.disabled-icon {
- color: var(--grey-1);
+ --p-button-padding-y: 0.5rem;
}
diff --git a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.spec.ts b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.spec.ts
index a5cf75e5c..c986b4ae8 100644
--- a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.spec.ts
+++ b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.spec.ts
@@ -1,101 +1,292 @@
+import { Store } from '@ngxs/store';
+
import { MockComponents, MockProvider } from 'ng-mocks';
-import { DynamicDialogConfig } from 'primeng/dynamicdialog';
+import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
+
+import { Subject } from 'rxjs';
+
+import { Mock } from 'vitest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { FileSelectDestinationComponent } from '@osf/shared/components/file-select-destination/file-select-destination.component';
-import { IconComponent } from '@osf/shared/components/icon/icon.component';
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
-import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service';
-import { FilesService } from '@osf/shared/services/files.service';
-import { ToastService } from '@osf/shared/services/toast.service';
-import { CurrentResourceSelectors } from '@shared/stores/current-resource';
+import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum';
+import { FileKind } from '@osf/shared/enums/file-kind.enum';
+import { ResourceType } from '@osf/shared/enums/resource-type.enum';
+import { FileModel } from '@osf/shared/models/files/file.model';
+import { FileFolderModel } from '@osf/shared/models/files/file-folder.model';
+import { CurrentResourceSelectors, GetResourceWithChildren } from '@osf/shared/stores/current-resource';
+import { FileModelMock } from '@testing/mocks/file.model.mock';
+import { OSF_FILE_MOCK } from '@testing/mocks/osf-file.mock';
import { provideOSFCore } from '@testing/osf.testing.provider';
-import { CustomConfirmationServiceMock } from '@testing/providers/custom-confirmation-provider.mock';
import { provideDynamicDialogRefMock } from '@testing/providers/dynamic-dialog-ref.mock';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
-import { ToastServiceMock } from '@testing/providers/toast-provider.mock';
+import {
+ FilesMoveCopyServiceMock,
+ FilesMoveCopyServiceMockType,
+} from '@testing/providers/files-move-copy-service.mock';
+import {
+ BaseSetupOverrides,
+ mergeSignalOverrides,
+ provideMockStore,
+ SignalOverride,
+} from '@testing/providers/store-provider.mock';
-import { FilesSelectors } from '../../store';
+import { FileProvider } from '../../constants';
+import { MoveCopyAction } from '../../enums/move-copy-action.enum';
+import { MoveFilesOptions } from '../../models/files-actions-options.model';
+import { FilesMoveCopyService } from '../../services/files-move-copy.service';
+import { FilesSelectors, GetMoveDialogFiles, SetFilesCurrentFolder, SetMoveDialogCurrentFolder } from '../../store';
+import { FileSelectDestinationComponent } from '../file-select-destination/file-select-destination.component';
+import { MoveFileRowComponent } from '../move-file-row/move-file-row.component';
import { MoveFileDialogComponent } from './move-file-dialog.component';
describe('MoveFileDialogComponent', () => {
let component: MoveFileDialogComponent;
let fixture: ComponentFixture
;
+ let store: Store;
+ let dialogRef: DynamicDialogRef;
+ let filesMoveCopyService: FilesMoveCopyServiceMockType;
+ let dialogConfig: DynamicDialogConfig & { data: MoveFilesOptions };
+
+ const initialFolder: FileFolderModel = {
+ ...OSF_FILE_MOCK,
+ id: 'folder-1',
+ name: 'Folder 1',
+ node: 'node-1',
+ path: '/folder-1',
+ provider: FileProvider.OsfStorage,
+ links: {
+ ...OSF_FILE_MOCK.links,
+ filesLink: '/files-folder-1',
+ },
+ };
+
+ const nestedFolder: FileFolderModel = {
+ ...OSF_FILE_MOCK,
+ id: 'folder-2',
+ name: 'Folder 2',
+ node: 'node-1',
+ path: '/folder-2',
+ provider: FileProvider.OsfStorage,
+ links: {
+ ...OSF_FILE_MOCK.links,
+ filesLink: '/files-folder-2',
+ },
+ };
- beforeEach(() => {
- const dialogConfigMock = {
- data: { files: [], currentFolder: null },
+ const project = {
+ id: 'project-1',
+ rootResourceId: 'root-1',
+ };
+
+ interface SetupOverrides extends BaseSetupOverrides {
+ configData?: Partial;
+ }
+
+ function setup(overrides: SetupOverrides = {}) {
+ const file = FileModelMock.simple({ id: 'file-1', name: 'file-1.txt' });
+ filesMoveCopyService = FilesMoveCopyServiceMock.simple();
+ const defaultSignals: SignalOverride[] = [
+ { selector: FilesSelectors.getMoveDialogFiles, value: [] },
+ { selector: FilesSelectors.getMoveDialogFilesTotalCount, value: 0 },
+ { selector: FilesSelectors.isMoveDialogFilesLoading, value: false },
+ { selector: FilesSelectors.getMoveDialogCurrentFolder, value: initialFolder },
+ { selector: FilesSelectors.getProvider, value: FileProvider.OsfStorage },
+ {
+ selector: FilesSelectors.getStorageSupportedFeatures,
+ value: { [FileProvider.OsfStorage]: [SupportedFeature.AddUpdateFiles] },
+ },
+ { selector: FilesSelectors.isMoveDialogConfiguredStorageAddonsLoading, value: false },
+ { selector: FilesSelectors.isMoveDialogRootFoldersLoading, value: false },
+ { selector: CurrentResourceSelectors.getCurrentResource, value: project },
+ { selector: CurrentResourceSelectors.getResourceWithChildren, value: [] },
+ { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false },
+ ];
+
+ dialogConfig = {
+ header: 'files.dialogs.moveFile.title',
+ data: {
+ files: [file],
+ action: MoveCopyAction.Move,
+ resourceId: 'project-1',
+ storageProvider: FileProvider.OsfStorage,
+ foldersStack: [initialFolder],
+ initialFolder,
+ ...overrides.configData,
+ },
};
TestBed.configureTestingModule({
imports: [
MoveFileDialogComponent,
- ...MockComponents(IconComponent, LoadingSpinnerComponent, FileSelectDestinationComponent),
+ ...MockComponents(LoadingSpinnerComponent, FileSelectDestinationComponent, MoveFileRowComponent),
],
providers: [
provideOSFCore(),
provideDynamicDialogRefMock(),
- MockProvider(DynamicDialogConfig, dialogConfigMock),
- MockProvider(FilesService),
- MockProvider(ToastService, ToastServiceMock.simple()),
- MockProvider(CustomConfirmationService, CustomConfirmationServiceMock.simple()),
+ MockProvider(DynamicDialogConfig, dialogConfig),
+ MockProvider(FilesMoveCopyService, filesMoveCopyService),
provideMockStore({
- signals: [
- { selector: FilesSelectors.getMoveDialogFiles, value: [] },
- { selector: FilesSelectors.getMoveDialogFilesTotalCount, value: 0 },
- { selector: FilesSelectors.isMoveDialogFilesLoading, value: false },
- { selector: FilesSelectors.getMoveDialogCurrentFolder, value: null },
- { selector: CurrentResourceSelectors.getCurrentResource, value: null },
- { selector: CurrentResourceSelectors.getResourceWithChildren, value: [] },
- { selector: CurrentResourceSelectors.isResourceWithChildrenLoading, value: false },
- { selector: FilesSelectors.isMoveDialogConfiguredStorageAddonsLoading, value: false },
- ],
+ signals: mergeSignalOverrides(defaultSignals, overrides.selectorOverrides),
}),
],
});
+ store = TestBed.inject(Store);
fixture = TestBed.createComponent(MoveFileDialogComponent);
component = fixture.componentInstance;
+ dialogRef = TestBed.inject(DynamicDialogRef);
fixture.detectChanges();
- });
+ }
it('should create', () => {
+ setup();
expect(component).toBeTruthy();
});
- it('should initialize with correct properties', () => {
- expect(component.config).toBeDefined();
- expect(component.dialogRef).toBeDefined();
- expect(component.files).toBeDefined();
- expect(component.isLoading).toBeDefined();
- expect(component.currentFolder).toBeDefined();
+ it('should initialize previous folder from stack', () => {
+ setup({
+ configData: {
+ foldersStack: [initialFolder, nestedFolder],
+ },
+ });
+ expect(component.previousFolder()).toEqual(nestedFolder);
});
- it('should get files from store', () => {
- expect(component.files()).toEqual([]);
+ it('should open selected folder and update stack', () => {
+ setup();
+ (store.dispatch as Mock).mockClear();
+ const folderFile = FileModelMock.simple({
+ id: 'folder-3',
+ name: 'Folder 3',
+ kind: FileKind.Folder,
+ path: '/folder-3',
+ filesLink: '/files-folder-3',
+ provider: FileProvider.OsfStorage,
+ links: {
+ info: '',
+ move: '',
+ upload: '/upload',
+ delete: '',
+ download: '',
+ render: '',
+ html: '',
+ self: '',
+ },
+ });
+
+ component.openFolder(folderFile);
+
+ expect(component.foldersStack()).toEqual([initialFolder, initialFolder]);
+ expect(component.previousFolder()).toEqual(initialFolder);
+ expect(store.dispatch).toHaveBeenCalledWith(
+ new SetMoveDialogCurrentFolder({
+ id: 'folder-3',
+ kind: FileKind.Folder,
+ name: 'Folder 3',
+ node: '',
+ path: '/folder-3',
+ provider: FileProvider.OsfStorage,
+ links: {
+ newFolder: '/upload?kind=folder',
+ storageAddons: '',
+ upload: '/upload',
+ filesLink: '/files-folder-3',
+ download: '/upload',
+ },
+ })
+ );
});
- it('should get loading state from store', () => {
- expect(component.isLoading()).toBe(false);
+ it('should open parent folder and dispatch previous folder', () => {
+ setup({
+ configData: {
+ foldersStack: [initialFolder, nestedFolder],
+ },
+ selectorOverrides: [{ selector: FilesSelectors.getMoveDialogCurrentFolder, value: nestedFolder }],
+ });
+ (store.dispatch as Mock).mockClear();
+
+ component.openParentFolder();
+
+ expect(component.foldersStack()).toEqual([initialFolder]);
+ expect(component.previousFolder()).toEqual(initialFolder);
+ expect(store.dispatch).toHaveBeenCalledWith(new SetMoveDialogCurrentFolder(nestedFolder));
});
- it('should get current folder from store', () => {
- expect(component.currentFolder()).toBeNull();
+ it('should load next page on list end scroll', () => {
+ const files: FileModel[] = Array.from({ length: 10 }).map((_, index) =>
+ FileModelMock.simple({ id: `file-${index + 1}`, name: `file-${index + 1}.txt` })
+ );
+
+ setup({
+ selectorOverrides: [
+ { selector: FilesSelectors.getMoveDialogFiles, value: files },
+ { selector: FilesSelectors.getMoveDialogFilesTotalCount, value: 25 },
+ ],
+ });
+ (store.dispatch as Mock).mockClear();
+
+ component.onScrollIndexChange({ first: 0, last: 9 });
+
+ expect(component.isLoadingMore()).toBe(true);
+ expect(store.dispatch).toHaveBeenCalledWith(new GetMoveDialogFiles('/files-folder-1', 2));
});
- it('should have isFilesUpdating signal', () => {
+ it('should change project and reset folder state', () => {
+ setup({
+ configData: {
+ foldersStack: [initialFolder, nestedFolder],
+ },
+ });
+
+ component.onProjectChange('project-2');
+
+ expect(component.foldersStack()).toEqual([]);
+ expect(component.previousFolder()).toBeNull();
+ });
+
+ it('should execute move and close dialog on success', () => {
+ setup();
+ (store.dispatch as Mock).mockClear();
+
+ component.moveFiles();
+
+ expect(filesMoveCopyService.execute).toHaveBeenCalledWith({
+ files: dialogConfig.data.files,
+ destination: initialFolder,
+ resourceId: 'project-1',
+ storageProvider: FileProvider.OsfStorage,
+ action: MoveCopyAction.Move,
+ });
+ expect(store.dispatch).toHaveBeenCalledWith(new SetFilesCurrentFolder(initialFolder));
+ expect(store.dispatch).toHaveBeenCalledWith(new SetMoveDialogCurrentFolder(null));
+ expect(dialogRef.close).toHaveBeenCalledWith(true);
+ });
+
+ it('should keep updating state true until move request completes', () => {
+ setup();
+ const pending = new Subject();
+ filesMoveCopyService.execute.mockReturnValue(pending.asObservable());
+
+ component.moveFiles();
+
+ expect(component.isFilesUpdating()).toBe(true);
+
+ pending.next(true);
+ pending.complete();
+
expect(component.isFilesUpdating()).toBe(false);
+ expect(dialogConfig.header).toBe('files.dialogs.moveFile.title');
});
- it('should have all required selectors defined', () => {
- expect(component.filesTotalCount).toBeDefined();
- expect(component.currentProject).toBeDefined();
- expect(component.components).toBeDefined();
- expect(component.areComponentsLoading).toBeDefined();
- expect(component.isConfiguredStorageAddonsLoading).toBeDefined();
+ it('should request components tree on init', () => {
+ setup();
+ expect(store.dispatch).toHaveBeenCalledWith(
+ new GetResourceWithChildren('root-1', 'project-1', ResourceType.Project, true)
+ );
});
});
diff --git a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts
index d765183a3..9adc1686d 100644
--- a/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts
+++ b/src/app/features/files/components/move-file-dialog/move-file-dialog.component.ts
@@ -4,48 +4,39 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { Button } from 'primeng/button';
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
-import { ScrollerModule } from 'primeng/scroller';
-import { Tooltip } from 'primeng/tooltip';
+import { Scroller } from 'primeng/scroller';
import { TreeScrollIndexChangeEvent } from 'primeng/tree';
-import { finalize, forkJoin, of } from 'rxjs';
-import { catchError } from 'rxjs/operators';
+import { finalize, tap } from 'rxjs';
import { ChangeDetectionStrategy, Component, computed, DestroyRef, effect, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
-import {
- FilesSelectors,
- GetMoveDialogFiles,
- SetFilesCurrentFolder,
- SetMoveDialogCurrentFolder,
-} from '@osf/features/files/store';
-import { FileSelectDestinationComponent } from '@osf/shared/components/file-select-destination/file-select-destination.component';
-import { IconComponent } from '@osf/shared/components/icon/icon.component';
import { LoadingSpinnerComponent } from '@osf/shared/components/loading-spinner/loading-spinner.component';
import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enum';
import { FileKind } from '@osf/shared/enums/file-kind.enum';
import { ResourceType } from '@osf/shared/enums/resource-type.enum';
import { FilesMapper } from '@osf/shared/mappers/files/files.mapper';
-import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service';
-import { FilesService } from '@osf/shared/services/files.service';
-import { ToastService } from '@osf/shared/services/toast.service';
+import { FileModel } from '@osf/shared/models/files/file.model';
+import { FileFolderModel } from '@osf/shared/models/files/file-folder.model';
import { CurrentResourceSelectors, GetResourceWithChildren } from '@osf/shared/stores/current-resource';
-import { FileModel } from '@shared/models/files/file.model';
-import { FileFolderModel } from '@shared/models/files/file-folder.model';
import { FileProvider } from '../../constants';
+import { MoveCopyAction } from '../../enums/move-copy-action.enum';
+import { FilesMoveCopyService } from '../../services/files-move-copy.service';
+import { FilesSelectors, GetMoveDialogFiles, SetFilesCurrentFolder, SetMoveDialogCurrentFolder } from '../../store';
+import { FileSelectDestinationComponent } from '../file-select-destination/file-select-destination.component';
+import { MoveFileRowComponent } from '../move-file-row/move-file-row.component';
@Component({
selector: 'osf-move-file-dialog',
imports: [
Button,
- Tooltip,
- ScrollerModule,
+ Scroller,
TranslatePipe,
- IconComponent,
LoadingSpinnerComponent,
FileSelectDestinationComponent,
+ MoveFileRowComponent,
],
templateUrl: './move-file-dialog.component.html',
styleUrl: './move-file-dialog.component.scss',
@@ -55,11 +46,9 @@ export class MoveFileDialogComponent {
readonly config = inject(DynamicDialogConfig);
readonly dialogRef = inject(DynamicDialogRef);
- private readonly filesService = inject(FilesService);
private readonly destroyRef = inject(DestroyRef);
private readonly translateService = inject(TranslateService);
- private readonly toastService = inject(ToastService);
- private readonly customConfirmationService = inject(CustomConfirmationService);
+ private readonly filesMoveCopyService = inject(FilesMoveCopyService);
readonly files = select(FilesSelectors.getMoveDialogFiles);
readonly filesTotalCount = select(FilesSelectors.getMoveDialogFilesTotalCount);
@@ -85,17 +74,22 @@ export class MoveFileDialogComponent {
foldersStack = signal(this.config.data.foldersStack ?? []);
storageProvider = signal(this.config.data.storageProvider ?? FileProvider.OsfStorage);
previousFolder = signal(null);
- isLoadingMore = signal(false);
- itemsPerPage = 10;
+ readonly isLoadingMore = signal(false);
+
+ readonly itemsPerPage = 10;
+ readonly virtualScrollItemSize = 44;
+
private lastFolderId: string | null = null;
+ private lastLoadedComponentsProjectId: string | null = null;
private initialFolder = this.config.data.initialFolder;
private fileProjectId = this.config.data.resourceId;
- readonly isFolderSame = computed(() => this.currentFolder()?.id === this.initialFolder?.id);
+ readonly isMoveAction = this.config.data.action === MoveCopyAction.Move;
readonly fileIdsInList = computed(() => new Set((this.config.data.files as FileModel[]).map((f) => f.id)));
+ readonly isFolderSame = computed(() => this.currentFolder()?.id === this.initialFolder?.id);
readonly isDestinationLoading = computed(
() => this.isConfiguredStorageAddonsLoading() || this.areComponentsLoading() || this.isRootFoldersLoading()
);
@@ -118,54 +112,26 @@ export class MoveFileDialogComponent {
!this.hasAddUpdateFeature()
);
- get isMoveAction() {
- return this.config.data.action === 'move';
- }
-
constructor() {
this.initPreviousFolder();
- const currentProject = this.currentProject();
- if (currentProject) {
- const rootParentId = currentProject.rootResourceId ?? currentProject.id;
- this.actions.getComponentsTree(rootParentId, currentProject.id, ResourceType.Project, true);
- }
-
- effect(() => {
- const folder = this.currentFolder();
- const isLoading = this.isDestinationLoading();
-
- if (isLoading) return;
-
- if (!folder || folder.id === this.lastFolderId) return;
-
- this.lastFolderId = folder.id;
- this.actions.getMoveDialogFiles(folder.links.filesLink, 1);
- });
-
- effect(() => {
- if (!this.isLoading()) {
- this.isLoadingMore.set(false);
- }
- });
+ this.setupComponentsTreeLoader();
+ this.setupMoveDialogFilesLoader();
+ this.setupLoadingMoreReset();
}
initPreviousFolder() {
const stack = this.foldersStack();
- if (stack.length === 0) {
- this.previousFolder.set(null);
- } else {
- this.previousFolder.set(stack[stack.length - 1]);
- }
+ this.previousFolder.set(stack.at(-1) ?? null);
}
- openFolder(file: FileModel | FileFolderModel) {
+ openFolder(file: FileModel) {
if (file.kind === FileKind.Folder) {
const current = this.currentFolder();
if (current) {
this.previousFolder.set(current);
this.foldersStack.update((stack) => [...stack, current]);
}
- const folder = FilesMapper.mapFileToFolder(file as FileModel);
+ const folder = FilesMapper.mapFileToFolder(file);
this.actions.setMoveDialogCurrentFolder(folder);
}
}
@@ -183,132 +149,102 @@ export class MoveFileDialogComponent {
});
}
- moveFiles(): void {
- const path = this.currentFolder()?.path;
- if (!path) {
- throw new Error(this.translateService.instant('files.dialogs.moveFile.pathError'));
+ onScrollIndexChange(event: TreeScrollIndexChangeEvent) {
+ const loaded = this.files().length;
+ if (event.last >= loaded - 1) {
+ this.loadNextPage();
}
-
- this.isFilesUpdating.set(true);
- const headerKey = this.isMoveAction ? 'files.dialogs.moveFile.movingHeader' : 'files.dialogs.moveFile.copingHeader';
- this.config.header = this.translateService.instant(headerKey);
- const action = this.config.data.action;
- const files: FileModel[] = this.config.data.files;
- const totalFiles = files.length;
- let completed = 0;
- const conflictFiles: { file: FileModel; link: string }[] = [];
-
- files.forEach((file) => {
- const link = file.links.move;
- this.filesService
- .moveFile(link, path, this.fileProjectId, this.provider(), action)
- .pipe(
- takeUntilDestroyed(this.destroyRef),
- catchError((error) => {
- if (error.status === 409) {
- conflictFiles.push({ file, link });
- } else {
- this.showErrorToast(action, error.error?.message ?? 'Error');
- }
- return of(null);
- }),
- finalize(() => {
- completed++;
- if (completed === totalFiles) {
- if (conflictFiles.length > 0) {
- this.openReplaceMoveDialog(conflictFiles, path, action);
- } else {
- this.showSuccessToast(action);
- this.config.header = this.translateService.instant('files.dialogs.moveFile.title');
- this.completeMove();
- }
- }
- })
- )
- .subscribe();
- });
}
- private openReplaceMoveDialog(
- conflictFiles: { file: FileModel; link: string }[],
- path: string,
- action: string
- ): void {
- this.customConfirmationService.confirmDelete({
- headerKey: conflictFiles.length > 1 ? 'files.dialogs.replaceFile.multiple' : 'files.dialogs.replaceFile.single',
- messageKey: 'files.dialogs.replaceFile.message',
- messageParams: {
- name: conflictFiles.map((c) => c.file.name).join(', '),
- },
- acceptLabelKey: 'common.buttons.replace',
- onConfirm: () => {
- const replaceRequests$ = conflictFiles.map(({ link }) =>
- this.filesService.moveFile(link, path, this.fileProjectId, this.provider(), action, true).pipe(
- takeUntilDestroyed(this.destroyRef),
- catchError(() => of(null))
- )
- );
-
- forkJoin(replaceRequests$).subscribe({
- next: () => {
- this.showSuccessToast(action);
- this.completeMove();
- },
- });
- },
- onReject: () => {
- const totalFiles = this.config.data.files.length;
- if (totalFiles > conflictFiles.length) {
- this.showErrorToast(action);
- }
- this.completeMove();
- },
- });
+ onProjectChange(projectId: string) {
+ this.fileProjectId = projectId;
+ this.foldersStack.set([]);
+ this.previousFolder.set(null);
}
- private showSuccessToast(action: string) {
- const messageType = action === 'move' ? 'moveFile' : 'copyFile';
- this.toastService.showSuccess(`files.dialogs.${messageType}.success`);
+ onStorageChange() {
+ this.foldersStack.set([]);
+ this.previousFolder.set(null);
}
- private showErrorToast(action: string, errorMessage?: string) {
- const messageType = action === 'move' ? 'moveFile' : 'copyFile';
- this.toastService.showError(errorMessage ?? `files.dialogs.${messageType}.error`);
+ moveFiles(): void {
+ this.isFilesUpdating.set(true);
+ const headerKey = this.isMoveAction ? 'files.dialogs.moveFile.movingHeader' : 'files.dialogs.moveFile.copingHeader';
+ this.config.header = this.translateService.instant(headerKey);
+ const action = this.config.data.action as MoveCopyAction;
+ this.filesMoveCopyService
+ .execute({
+ files: this.config.data.files,
+ destination: this.currentFolder(),
+ resourceId: this.fileProjectId,
+ storageProvider: this.provider(),
+ action,
+ })
+ .pipe(
+ tap(() => this.completeMove()),
+ finalize(() => {
+ this.config.header = this.translateService.instant('files.dialogs.moveFile.title');
+ this.isFilesUpdating.set(false);
+ }),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe();
}
private completeMove(): void {
- this.isFilesUpdating.set(false);
this.actions.setCurrentFolder(this.initialFolder);
this.actions.setMoveDialogCurrentFolder(null);
this.dialogRef.close(true);
}
- private loadNextPage(): void {
- const total = this.filesTotalCount();
- const loaded = this.files().length;
- const nextPage = Math.floor(loaded / this.itemsPerPage) + 1;
+ private setupComponentsTreeLoader(): void {
+ effect(() => {
+ const currentProject = this.currentProject();
+ if (!currentProject || currentProject.id === this.lastLoadedComponentsProjectId) {
+ return;
+ }
- if (!this.isLoadingMore() && loaded < total) {
- this.isLoadingMore.set(true);
- this.actions.getMoveDialogFiles(this.currentFolder()?.links.filesLink ?? '', nextPage);
- }
+ this.lastLoadedComponentsProjectId = currentProject.id;
+ const rootParentId = currentProject.rootResourceId ?? currentProject.id;
+ this.actions.getComponentsTree(rootParentId, currentProject.id, ResourceType.Project, true);
+ });
}
- onScrollIndexChange(event: TreeScrollIndexChangeEvent) {
- const loaded = this.files().length;
- if (event.last >= loaded - 1) {
- this.loadNextPage();
- }
+ private setupMoveDialogFilesLoader(): void {
+ effect(() => {
+ const folder = this.currentFolder();
+ const isLoading = this.isDestinationLoading();
+
+ if (isLoading) {
+ return;
+ }
+
+ if (!folder || folder.id === this.lastFolderId) {
+ return;
+ }
+
+ this.lastFolderId = folder.id;
+ this.actions.getMoveDialogFiles(folder.links.filesLink, 1);
+ });
}
- onProjectChange(projectId: string) {
- this.fileProjectId = projectId;
- this.foldersStack.set([]);
- this.previousFolder.set(null);
+ private setupLoadingMoreReset(): void {
+ effect(() => {
+ if (!this.isLoading()) {
+ this.isLoadingMore.set(false);
+ }
+ });
}
- onStorageChange() {
- this.foldersStack.set([]);
- this.previousFolder.set(null);
+ private loadNextPage(): void {
+ const total = this.filesTotalCount();
+ const loaded = this.files().length;
+ const nextPage = Math.floor(loaded / this.itemsPerPage) + 1;
+ const filesLink = this.currentFolder()?.links.filesLink;
+
+ if (!this.isLoadingMore() && loaded < total && filesLink) {
+ this.isLoadingMore.set(true);
+ this.actions.getMoveDialogFiles(filesLink, nextPage);
+ }
}
}
diff --git a/src/app/features/files/components/move-file-row/move-file-row.component.html b/src/app/features/files/components/move-file-row/move-file-row.component.html
new file mode 100644
index 000000000..da4251893
--- /dev/null
+++ b/src/app/features/files/components/move-file-row/move-file-row.component.html
@@ -0,0 +1,25 @@
+@let rowItem = item();
+
+
+
+ @if (isFile()) {
+
+
{{ rowItem.name }}
+ } @else if (isBlocked()) {
+
+
+
+ {{ rowItem.name }}
+
+ } @else {
+
+ }
+
+
diff --git a/src/app/features/files/components/move-file-row/move-file-row.component.scss b/src/app/features/files/components/move-file-row/move-file-row.component.scss
new file mode 100644
index 000000000..1d6f80833
--- /dev/null
+++ b/src/app/features/files/components/move-file-row/move-file-row.component.scss
@@ -0,0 +1,30 @@
+.files-table-row {
+ border-bottom: 1px solid var(--grey-2);
+ color: var(--dark-blue-1);
+ height: 2.75rem;
+}
+
+.filename-link {
+ min-width: 0;
+ max-width: 100%;
+ cursor: pointer;
+
+ &.disabled {
+ color: var(--grey-1);
+ cursor: not-allowed;
+ }
+
+ &:not(.disabled):hover {
+ text-decoration: underline;
+ }
+}
+
+.disabled-icon {
+ color: var(--grey-1);
+}
+
+.link-btn-no-padding {
+ --p-button-label-font-weight: 400;
+ --p-button-link-hover-color: var(--dark-blue-1);
+ --p-button-link-color: var(--dark-blue-1);
+}
diff --git a/src/app/features/files/components/move-file-row/move-file-row.component.spec.ts b/src/app/features/files/components/move-file-row/move-file-row.component.spec.ts
new file mode 100644
index 000000000..7481a5e4c
--- /dev/null
+++ b/src/app/features/files/components/move-file-row/move-file-row.component.spec.ts
@@ -0,0 +1,77 @@
+import { MockComponent } from 'ng-mocks';
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { IconComponent } from '@osf/shared/components/icon/icon.component';
+import { FileKind } from '@osf/shared/enums/file-kind.enum';
+import { FileModel } from '@osf/shared/models/files/file.model';
+
+import { FileModelMock } from '@testing/mocks/file.model.mock';
+import { provideOSFCore } from '@testing/osf.testing.provider';
+
+import { MoveFileRowComponent } from './move-file-row.component';
+
+describe('MoveFileRowComponent', () => {
+ let component: MoveFileRowComponent;
+ let fixture: ComponentFixture;
+
+ const createFile = (kind: FileKind, name: string): FileModel =>
+ FileModelMock.simple({
+ id: `${kind}-id`,
+ name,
+ kind,
+ path: `/${name}`,
+ materializedPath: `/${name}`,
+ });
+
+ function setup(item: FileModel, isBlocked = false, isIndented = false) {
+ TestBed.configureTestingModule({
+ imports: [MoveFileRowComponent, MockComponent(IconComponent)],
+ providers: [provideOSFCore()],
+ });
+
+ fixture = TestBed.createComponent(MoveFileRowComponent);
+ component = fixture.componentInstance;
+ fixture.componentRef.setInput('item', item);
+ fixture.componentRef.setInput('isBlocked', isBlocked);
+ fixture.componentRef.setInput('isIndented', isIndented);
+ fixture.detectChanges();
+ }
+
+ it('should render file row as disabled', () => {
+ setup(createFile(FileKind.File, 'paper.pdf'));
+
+ expect(component.isFile()).toBe(true);
+ expect(fixture.nativeElement.textContent).toContain('paper.pdf');
+ expect(fixture.nativeElement.querySelector('.filename-link.disabled')?.textContent?.trim()).toBe('paper.pdf');
+ expect(fixture.nativeElement.querySelector('button')).toBeNull();
+ });
+
+ it('should render blocked folder row as disabled', () => {
+ setup(createFile(FileKind.Folder, 'docs'), true);
+
+ expect(component.isFile()).toBe(false);
+ expect(fixture.nativeElement.textContent).toContain('docs');
+ expect(fixture.nativeElement.querySelector('.filename-link.disabled')).toBeNull();
+ expect(fixture.nativeElement.querySelector('p-button')).toBeNull();
+ expect(fixture.nativeElement.querySelector('button')).toBeNull();
+ });
+
+ it('should emit openFolder when active folder row clicked', () => {
+ const folder = createFile(FileKind.Folder, 'images');
+ setup(folder);
+ const emitSpy = vi.spyOn(component.openFolder, 'emit');
+
+ const button = fixture.nativeElement.querySelector('button');
+ button.click();
+
+ expect(emitSpy).toHaveBeenCalledWith(folder);
+ });
+
+ it('should apply indented row class when enabled', () => {
+ setup(createFile(FileKind.Folder, 'nested'), false, true);
+
+ const row = fixture.nativeElement.querySelector('.files-table-row');
+ expect(row.classList.contains('pl-6')).toBe(true);
+ });
+});
diff --git a/src/app/features/files/components/move-file-row/move-file-row.component.ts b/src/app/features/files/components/move-file-row/move-file-row.component.ts
new file mode 100644
index 000000000..9f642d19c
--- /dev/null
+++ b/src/app/features/files/components/move-file-row/move-file-row.component.ts
@@ -0,0 +1,26 @@
+import { TranslatePipe } from '@ngx-translate/core';
+
+import { Button } from 'primeng/button';
+import { Tooltip } from 'primeng/tooltip';
+
+import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
+
+import { IconComponent } from '@osf/shared/components/icon/icon.component';
+import { FileKind } from '@osf/shared/enums/file-kind.enum';
+import { FileModel } from '@osf/shared/models/files/file.model';
+
+@Component({
+ selector: 'osf-move-file-row',
+ imports: [Button, Tooltip, TranslatePipe, IconComponent],
+ templateUrl: './move-file-row.component.html',
+ styleUrl: './move-file-row.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class MoveFileRowComponent {
+ readonly item = input.required();
+ readonly isIndented = input(false);
+ readonly isBlocked = input(false);
+ readonly openFolder = output();
+
+ readonly isFile = computed(() => this.item().kind === FileKind.File);
+}
diff --git a/src/app/features/files/components/rename-file-dialog/rename-file-dialog.component.html b/src/app/features/files/components/rename-file-dialog/rename-file-dialog.component.html
index b052e04d4..ff4dd52e5 100644
--- a/src/app/features/files/components/rename-file-dialog/rename-file-dialog.component.html
+++ b/src/app/features/files/components/rename-file-dialog/rename-file-dialog.component.html
@@ -1,7 +1,7 @@
+
+
{{ 'project.overview.metadata.resourceType' | translate }}
+
+
+ {{
+ customItemMetadata()?.resourceTypeGeneral
+ ? (customItemMetadata()?.resourceTypeGeneral | resourceTypeGeneralLabel)
+ : ('project.overview.metadata.noResourceType' | translate)
+ }}
+
+
+
{{ 'common.labels.description' | translate }}
@@ -50,6 +62,68 @@
{{ 'registry.overview.metadata.registry' | translate }}
{{ registryProvider()?.name }}
+
+
+
{{ 'project.overview.metadata.dateCreated' | translate }}
+
{{ resource.dateCreated | date: dateFormat }}
+
+
+
+
{{ 'registry.overview.metadata.registeredDate' | translate }}
+
{{ resource.dateRegistered | date: dateFormat }}
+
+
+
+
+
{{ 'common.labels.subjects' | translate }}
+
+
+
+
+
+
{{ 'shared.tags.title' | translate }}
+
+
+
+
+ @if (!isAnonymous()) {
+
+
{{ 'common.labels.affiliatedInstitutions' | translate }}
+
+
+
+ }
+
+
+
{{ 'project.overview.metadata.funderNames' | translate }}
+
+
+
+
+
+
{{ 'common.labels.language' | translate }}
+
+
+ {{
+ customItemMetadata()?.language
+ ? (customItemMetadata()?.language | languageLabel)
+ : ('project.overview.metadata.noLanguage' | translate)
+ }}
+
+
+
@if (resource.associatedProjectId) {
{{ 'registry.overview.metadata.associatedProject' | translate }}
@@ -64,17 +138,13 @@ {{ 'registry.overview.metadata.associatedProject' | translate }}
}
-
+ @if (resource.iaUrl) {
-
{{ 'project.overview.metadata.dateCreated' | translate }}
-
{{ resource.dateCreated | date: dateFormat }}
-
+
{{ 'project.overview.metadata.internetArchiveLink' | translate }}
-
-
{{ 'registry.overview.metadata.registeredDate' | translate }}
-
{{ resource.dateRegistered | date: dateFormat }}
+
{{ resource.iaUrl }}
-
+ }
{{ 'common.labels.license' | translate }}
@@ -86,14 +156,6 @@ {{ 'common.labels.license' | translate }}
>
- @if (resource.iaUrl) {
-
-
{{ 'project.overview.metadata.internetArchiveLink' | translate }}
-
-
{{ resource.iaUrl }}
-
- }
-
@if (!isAnonymous()) {
{{ 'registry.overview.metadata.doi' | translate }}
@@ -106,38 +168,6 @@ {{ 'registry.overview.metadata.doi' | translate }}
}
- @if (!isAnonymous()) {
-
-
{{ 'common.labels.affiliatedInstitutions' | translate }}
-
-
-
- }
-
-
-
{{ 'common.labels.subjects' | translate }}
-
-
-
-
-
-
{{ 'shared.tags.title' | translate }}
-
-
-
-
@if (!isAnonymous()) {
{
expect(store.dispatch).toHaveBeenCalledWith(expect.any(FetchSelectedSubjects));
expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetRegistryLicense));
expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetRegistryIdentifiers));
+ expect(store.dispatch).toHaveBeenCalledWith(expect.any(GetCustomItemMetadata));
});
it('should not dispatch init actions when registry is null', () => {
diff --git a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts
index 0fa1ff01a..38a43b958 100644
--- a/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts
+++ b/src/app/features/registry/components/registry-overview-metadata/registry-overview-metadata.component.ts
@@ -9,8 +9,10 @@ import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/cor
import { Router, RouterLink } from '@angular/router';
import { ENVIRONMENT } from '@core/provider/environment.provider';
+import { GetCustomItemMetadata, MetadataSelectors } from '@osf/features/metadata/store';
import { AffiliatedInstitutionsViewComponent } from '@osf/shared/components/affiliated-institutions-view/affiliated-institutions-view.component';
import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component';
+import { FundersListComponent } from '@osf/shared/components/funders-list/funders-list.component';
import { ResourceCitationsComponent } from '@osf/shared/components/resource-citations/resource-citations.component';
import { ResourceDoiComponent } from '@osf/shared/components/resource-doi/resource-doi.component';
import { ResourceLicenseComponent } from '@osf/shared/components/resource-license/resource-license.component';
@@ -18,6 +20,8 @@ import { SubjectsListComponent } from '@osf/shared/components/subjects-list/subj
import { TagsListComponent } from '@osf/shared/components/tags-list/tags-list.component';
import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component';
import { CurrentResourceType, ResourceType } from '@osf/shared/enums/resource-type.enum';
+import { LanguageLabelPipe } from '@osf/shared/pipes/language-label.pipe';
+import { ResourceTypeGeneralLabelPipe } from '@osf/shared/pipes/resource-type-general-label.pipe';
import { ContributorsSelectors, LoadMoreBibliographicContributors } from '@osf/shared/stores/contributors';
import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider';
import { FetchSelectedSubjects, SubjectsSelectors } from '@osf/shared/stores/subjects';
@@ -43,8 +47,11 @@ import {
ResourceLicenseComponent,
AffiliatedInstitutionsViewComponent,
ContributorsListComponent,
+ FundersListComponent,
SubjectsListComponent,
TagsListComponent,
+ LanguageLabelPipe,
+ ResourceTypeGeneralLabelPipe,
],
templateUrl: './registry-overview-metadata.component.html',
styleUrl: './registry-overview-metadata.component.scss',
@@ -57,6 +64,8 @@ export class RegistryOverviewMetadataComponent {
readonly registry = select(RegistrySelectors.getRegistry);
readonly registryProvider = select(RegistrationProviderSelectors.getBrandedProvider);
readonly isAnonymous = select(RegistrySelectors.isRegistryAnonymous);
+ readonly customItemMetadata = select(MetadataSelectors.getCustomItemMetadata);
+ readonly isCustomItemMetadataLoading = select(MetadataSelectors.isCustomItemMetadataLoading);
canEdit = select(RegistrySelectors.hasWriteAccess);
license = select(RegistrySelectors.getLicense);
@@ -81,6 +90,7 @@ export class RegistryOverviewMetadataComponent {
getInstitutions: GetRegistryInstitutions,
getIdentifiers: GetRegistryIdentifiers,
getLicense: GetRegistryLicense,
+ getCustomItemMetadata: GetCustomItemMetadata,
setCustomCitation: SetRegistryCustomCitation,
loadMoreBibliographicContributors: LoadMoreBibliographicContributors,
});
@@ -94,6 +104,7 @@ export class RegistryOverviewMetadataComponent {
this.actions.getSubjects(registry.id, ResourceType.Registration);
this.actions.getLicense(registry.licenseId);
this.actions.getIdentifiers(registry.id);
+ this.actions.getCustomItemMetadata(registry.id);
}
});
}
diff --git a/src/app/features/registry/components/registry-revisions/registry-revisions.component.html b/src/app/features/registry/components/registry-revisions/registry-revisions.component.html
index 794707631..6a50baf07 100644
--- a/src/app/features/registry/components/registry-revisions/registry-revisions.component.html
+++ b/src/app/features/registry/components/registry-revisions/registry-revisions.component.html
@@ -31,7 +31,7 @@
diff --git a/src/app/features/registry/components/registry-revisions/registry-revisions.component.spec.ts b/src/app/features/registry/components/registry-revisions/registry-revisions.component.spec.ts
index 24b0310b9..47d0b876a 100644
--- a/src/app/features/registry/components/registry-revisions/registry-revisions.component.spec.ts
+++ b/src/app/features/registry/components/registry-revisions/registry-revisions.component.spec.ts
@@ -168,6 +168,28 @@ describe('RegistryRevisionsComponent', () => {
expect(spy).toHaveBeenCalledWith(1);
});
+ it('should emit updateRegistration with registry id on startUpdateRegistration', () => {
+ const { component } = setup();
+ const spy = vi.fn();
+ component.updateRegistration.subscribe(spy);
+
+ component.startUpdateRegistration();
+
+ expect(spy).toHaveBeenCalledWith(MOCK_REGISTRY.id);
+ });
+
+ it('should not emit updateRegistration when registry id is missing on startUpdateRegistration', () => {
+ const { fixture, component } = setup();
+ const spy = vi.fn();
+ component.updateRegistration.subscribe(spy);
+ fixture.componentRef.setInput('registry', { ...MOCK_REGISTRY, id: '' });
+ fixture.detectChanges();
+
+ component.startUpdateRegistration();
+
+ expect(spy).not.toHaveBeenCalled();
+ });
+
it('should emit continueUpdate on continueUpdateHandler', () => {
const { component } = setup();
const spy = vi.fn();
diff --git a/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts b/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts
index 46560bbcf..1a88fab9e 100644
--- a/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts
+++ b/src/app/features/registry/components/registry-revisions/registry-revisions.component.ts
@@ -75,6 +75,16 @@ export class RegistryRevisionsComponent {
});
});
+ startUpdateRegistration() {
+ const registryId = this.registry()?.id;
+
+ if (!registryId) {
+ return;
+ }
+
+ this.updateRegistration.emit(registryId);
+ }
+
emitOpenRevision(index: number) {
this.openRevision.emit(index);
}
diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.html b/src/app/features/registry/pages/registry-overview/registry-overview.component.html
index b7d67b3ca..4712f0918 100644
--- a/src/app/features/registry/pages/registry-overview/registry-overview.component.html
+++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.html
@@ -84,7 +84,7 @@
(continueUpdate)="onContinueUpdateRegistration()"
[isModeration]="isModeration()"
[isSubmitting]="isSchemaResponsesLoading()"
- [canEdit]="hasAdminAccess()"
+ [canEdit]="canUpdate()"
>
diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts
index d4626ee13..d8833dd28 100644
--- a/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts
+++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.spec.ts
@@ -20,6 +20,7 @@ import { SchemaResponse } from '@osf/shared/models/registration/schema-response.
import { CustomDialogService } from '@osf/shared/services/custom-dialog.service';
import { ToastService } from '@osf/shared/services/toast.service';
import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service';
+import { RegistrationProviderSelectors } from '@osf/shared/stores/registration-provider';
import { MOCK_REGISTRATION_OVERVIEW_MODEL } from '@testing/mocks/registration-overview-model.mock';
import { createMockSchemaResponse } from '@testing/mocks/schema-response.mock';
@@ -28,7 +29,7 @@ import { CustomDialogServiceMock } from '@testing/providers/custom-dialog-provid
import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock';
import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
import { RouterMockBuilder } from '@testing/providers/router-provider.mock';
-import { provideMockStore } from '@testing/providers/store-provider.mock';
+import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
import { ToastServiceMock } from '@testing/providers/toast-provider.mock';
import { ViewOnlyLinkHelperMock } from '@testing/providers/view-only-link-helper.mock';
@@ -44,7 +45,7 @@ import { RegistrySelectors } from '../../store/registry';
import { RegistryOverviewComponent } from './registry-overview.component';
-interface SetupOverrides {
+interface SetupOverrides extends BaseSetupOverrides {
registry?: RegistrationOverviewModel | null;
schemaResponses?: SchemaResponse[];
queryParams?: Record