diff --git a/.gitignore b/.gitignore index fca5b5041..d744f946e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ testem.log # System files .DS_Store Thumbs.db + +# Generated extensions config +src/app/extensions.config.ts diff --git a/docs/file-extensions.md b/docs/file-extensions.md new file mode 100644 index 000000000..566eaa976 --- /dev/null +++ b/docs/file-extensions.md @@ -0,0 +1,136 @@ +# File Extensions + +Plugin architecture for adding custom actions to the file browser. + +## Overview + +Extensions are placed in `src/app/extensions/` and managed via `extensions.config.ts`. + +## FileActionExtension + +```typescript +interface FileActionExtension { + id: string; + label: string; + icon: string; + command: (ctx: FileActionContext) => void; + parentId?: string; + position?: 'start' | 'end' | number; + visible?: (ctx: FileActionContext) => boolean; + disabled?: (ctx: FileActionContext) => boolean; +} +``` + +### parentId + +If `parentId` is set, the extension appears only in that submenu (e.g., `'share'`). +If not set, the extension appears in both menu and toolbar. + +### FileActionContext + +```typescript +interface FileActionContext { + target: FileModel | FileDetailsModel | FileFolderModel; + location: 'file-list' | 'file-detail'; + isViewOnly: boolean; + canWrite: boolean; +} +``` + +## Example: Submenu Extension + +```typescript +export function copyLinksExtensionFactory(): FileActionExtension[] { + return [ + { + id: 'copy-link', + label: 'Copy Link', + icon: 'fas fa-link', + command: (ctx) => { + navigator.clipboard.writeText(ctx.target.links.html); + }, + parentId: 'share', // appears in Share submenu only + position: 'end', + visible: (ctx) => ctx.target.kind === 'file', + disabled: (ctx) => ctx.isViewOnly, + }, + ]; +} +``` + +> **Note:** Context menus honor `position` exactly (for example, `'start'` inserts before built-in items). Top-level toolbars append extensions after the core OSF buttons but still keep extension-to-extension ordering based on `position`. Design UX with that constraint in mind. + +## Example: Menu + Toolbar Extension + +```typescript +export function createFileExtensionFactory(config: Config): FileActionExtension[] { + return [ + { + id: 'create-file', + label: 'Create File', + icon: 'fas fa-file-plus', + command: (ctx) => { + window.open(`${config.editorUrl}?path=${ctx.target.path}`, '_blank'); + }, + // no parentId: appears in both menu and toolbar + position: 'end', + visible: (ctx) => + ctx.location === 'file-list' && + ctx.target.kind === 'folder' && + !ctx.isViewOnly && + ctx.canWrite, + }, + ]; +} +``` + +## Configuration + +The extension configuration is managed via `extensions.config.ts`, which is generated at build time. + +### File Structure + +``` +src/app/ + extensions.config.ts # Generated (not in git) + extensions.config.default.ts # Default config (in git) +``` + +### Build-time Configuration + +By default, `extensions.config.default.ts` is used. To use a custom configuration: + +```bash +EXTENSIONS_CONFIG=/path/to/my-extensions.config.ts npm run build +``` + +The `scripts/setup-extensions.js` helper runs before `npm run build`, `npm start`, and `npm test` (see the `prebuild` / `prestart` / `pretest` npm scripts) and handles this: +- If `EXTENSIONS_CONFIG` is set, copies that file +- Otherwise, copies `extensions.config.default.ts` + +## Registration + +### extensions.config.ts + +```typescript +export const extensionConfig: ExtensionConfig[] = [ + { + load: () => import('./extensions/copy-links'), + factory: 'copyLinksExtensionFactory', + enabled: true, + }, + { + load: () => import('./extensions/onlyoffice'), + factory: 'editByOnlyOfficeExtensionFactory', + enabled: true, + config: { editorUrl: 'https://...' }, + }, + { + load: () => import('./extensions/onlyoffice'), + factory: 'createFileExtensionFactory', + enabled: true, + config: { editorUrl: 'https://...' }, + }, +]; +``` + diff --git a/package.json b/package.json index f25e4b715..2adeefe35 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "scripts": { "ng": "ng", "analyze-bundle": "ng build --configuration=analyze-bundle && source-map-explorer dist/**/*.js --no-border-checks", + "prebuild": "node scripts/setup-extensions.js", + "prestart": "node scripts/setup-extensions.js", "build": "ng build", "check:config": "node ./docker/check-config.js", "ci:test": "jest", @@ -23,6 +25,7 @@ "start:docker:local": "npm run check:config && ng serve --host 0.0.0.0 --port 4200 --poll 2000 --configuration docker", "start:test": "ng serve --configuration test-osf", "start:test:future": "ng serve --configuration test", + "pretest": "node scripts/setup-extensions.js", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage && npm run test:display", diff --git a/scripts/setup-extensions.js b/scripts/setup-extensions.js new file mode 100644 index 000000000..b2fab3d1f --- /dev/null +++ b/scripts/setup-extensions.js @@ -0,0 +1,18 @@ +const fs = require('fs'); +const path = require('path'); + +const targetPath = path.join(__dirname, '../src/app/extensions.config.ts'); +const defaultPath = path.join(__dirname, '../src/app/extensions.config.default.ts'); +const customPath = process.env.EXTENSIONS_CONFIG; + +if (customPath) { + const resolvedPath = path.resolve(customPath); + if (!fs.existsSync(resolvedPath)) { + throw new Error(`EXTENSIONS_CONFIG file not found: ${resolvedPath}`); + } + fs.copyFileSync(resolvedPath, targetPath); + console.log(`Extensions config: ${resolvedPath}`); +} else if (!fs.existsSync(targetPath)) { + fs.copyFileSync(defaultPath, targetPath); + console.log(`Extensions config: ${defaultPath} (default)`); +} diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 4ab27fe0b..99a43ee86 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -17,6 +17,7 @@ import { authInterceptor } from '@core/interceptors/auth.interceptor'; import { errorInterceptor } from '@core/interceptors/error.interceptor'; import { viewOnlyInterceptor } from '@core/interceptors/view-only.interceptor'; import { APPLICATION_INITIALIZATION_PROVIDER } from '@core/provider/application.initialization.provider'; +import { EXTENSION_INITIALIZATION_PROVIDER } from '@core/provider/extension.initialization.provider'; import { SENTRY_PROVIDER } from '@core/provider/sentry.provider'; import CustomPreset from './core/theme/custom-preset'; @@ -53,5 +54,8 @@ export const appConfig: ApplicationConfig = { provideStore(STATES), provideZoneChangeDetection({ eventCoalescing: true }), SENTRY_PROVIDER, + + // Dynamic Extension Loading (configured in extensions.config.ts) + EXTENSION_INITIALIZATION_PROVIDER, ], }; diff --git a/src/app/core/provider/extension.initialization.provider.ts b/src/app/core/provider/extension.initialization.provider.ts new file mode 100644 index 000000000..5a5f087f9 --- /dev/null +++ b/src/app/core/provider/extension.initialization.provider.ts @@ -0,0 +1,36 @@ +import { APP_INITIALIZER, Provider } from '@angular/core'; + +import { ExtensionRegistry } from '@core/services/extension-registry.service'; +import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token'; + +import { ExtensionConfig, extensionConfig } from '../../extensions.config'; + +type FactoryFunction = (config?: unknown) => FileActionExtension[]; + +async function loadExtensions(registry: ExtensionRegistry): Promise { + for (const ext of extensionConfig) { + if (!ext.enabled) continue; + await loadExtension(ext, registry); + } +} + +async function loadExtension(extConfig: ExtensionConfig, registry: ExtensionRegistry): Promise { + const module = await extConfig.load(); + const factory = (module as Record)[extConfig.factory]; + + if (typeof factory !== 'function') { + throw new Error(`Extension factory "${extConfig.factory}" not found in module`); + } + + const extensions = factory(extConfig.config); + registry.register(extensions); +} + +export const EXTENSION_INITIALIZATION_PROVIDER: Provider = { + provide: APP_INITIALIZER, + useFactory: (registry: ExtensionRegistry) => { + return () => loadExtensions(registry); + }, + deps: [ExtensionRegistry], + multi: true, +}; diff --git a/src/app/core/services/extension-registry.service.spec.ts b/src/app/core/services/extension-registry.service.spec.ts new file mode 100644 index 000000000..7916e005f --- /dev/null +++ b/src/app/core/services/extension-registry.service.spec.ts @@ -0,0 +1,34 @@ +import { TestBed } from '@angular/core/testing'; + +import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token'; + +import { ExtensionRegistry } from './extension-registry.service'; + +describe('ExtensionRegistry', () => { + let service: ExtensionRegistry; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ExtensionRegistry); + }); + + it('registers extensions cumulatively', () => { + const extA: FileActionExtension = { + id: 'a', + label: 'A', + icon: 'a', + command: jest.fn(), + }; + const extB: FileActionExtension = { + id: 'b', + label: 'B', + icon: 'b', + command: jest.fn(), + }; + + service.register([extA]); + service.register([extB]); + + expect(service.extensions()).toEqual([extA, extB]); + }); +}); diff --git a/src/app/core/services/extension-registry.service.ts b/src/app/core/services/extension-registry.service.ts new file mode 100644 index 000000000..f5d88faf8 --- /dev/null +++ b/src/app/core/services/extension-registry.service.ts @@ -0,0 +1,19 @@ +import { Injectable, signal } from '@angular/core'; + +import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token'; + +/** + * Central registry for all plugin extensions. + * Extensions are registered at app initialization time via APP_INITIALIZER. + */ +@Injectable({ providedIn: 'root' }) +export class ExtensionRegistry { + private readonly _extensions = signal([]); + + /** Read-only signal of all extensions */ + readonly extensions = this._extensions.asReadonly(); + + register(extensions: FileActionExtension[]): void { + this._extensions.update((current) => [...current, ...extensions]); + } +} diff --git a/src/app/extensions.config.default.ts b/src/app/extensions.config.default.ts new file mode 100644 index 000000000..ea031a92c --- /dev/null +++ b/src/app/extensions.config.default.ts @@ -0,0 +1,20 @@ +/** + * Extension Configuration + * + * See docs/file-extensions.md for details. + */ +export interface ExtensionConfig { + load: () => Promise; + factory: string; + enabled: boolean; + config?: unknown; +} + +export const extensionConfig: ExtensionConfig[] = [ + // Copy Link - adds "Copy Link" to Share submenu + { + load: () => import('./extensions/copy-links'), + factory: 'copyLinksExtensionFactory', + enabled: true, + }, +]; diff --git a/src/app/extensions/copy-links/copy-links-menu.ts b/src/app/extensions/copy-links/copy-links-menu.ts new file mode 100644 index 000000000..505ed34b3 --- /dev/null +++ b/src/app/extensions/copy-links/copy-links-menu.ts @@ -0,0 +1,22 @@ +import { FileModel } from '@osf/shared/models/files/file.model'; +import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token'; + +/** + * Factory function that creates Copy Link action extension. + */ +export function copyLinksExtensionFactory(): FileActionExtension[] { + return [ + { + id: 'copy-link', + label: 'Copy Link', + icon: 'fas fa-link', + command: (ctx) => { + const file = ctx.target as FileModel; + navigator.clipboard.writeText(file.links.html); + }, + parentId: 'share', + position: 'end', + visible: (ctx) => ctx.target.kind === 'file', + }, + ]; +} diff --git a/src/app/extensions/copy-links/index.ts b/src/app/extensions/copy-links/index.ts new file mode 100644 index 000000000..79c63cc9b --- /dev/null +++ b/src/app/extensions/copy-links/index.ts @@ -0,0 +1 @@ +export * from './copy-links-menu'; diff --git a/src/app/extensions/index.ts b/src/app/extensions/index.ts new file mode 100644 index 000000000..2928b50d6 --- /dev/null +++ b/src/app/extensions/index.ts @@ -0,0 +1,2 @@ +export * from './copy-links'; +export * from './onlyoffice'; diff --git a/src/app/extensions/onlyoffice/create-file.ts b/src/app/extensions/onlyoffice/create-file.ts new file mode 100644 index 000000000..c75ead10e --- /dev/null +++ b/src/app/extensions/onlyoffice/create-file.ts @@ -0,0 +1,29 @@ +import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token'; + +import { OnlyOfficeConfig } from './edit-by-onlyoffice'; + +/** + * Factory function for Create File toolbar extension. + * Adds a "Create File" button that opens OnlyOffice to create a new document. + * + * NOTE: This is a dummy implementation for demonstration purposes. + * The actual OnlyOffice integration requires a running OnlyOffice Document Server. + */ +export function createFileExtensionFactory(config: OnlyOfficeConfig): FileActionExtension[] { + return [ + { + id: 'create-file-onlyoffice', + label: 'Create File', + icon: 'fas fa-file-circle-plus', + command: (ctx) => { + const params = new URLSearchParams({ + new: 'true', + path: ctx.target.path!, + }); + window.open(`${config.editorUrl}?${params}`, '_blank'); + }, + position: 'end', + visible: (ctx) => ctx.location === 'file-list' && ctx.target.kind === 'folder' && !ctx.isViewOnly && ctx.canWrite, + }, + ]; +} diff --git a/src/app/extensions/onlyoffice/edit-by-onlyoffice.ts b/src/app/extensions/onlyoffice/edit-by-onlyoffice.ts new file mode 100644 index 000000000..a0c7602b7 --- /dev/null +++ b/src/app/extensions/onlyoffice/edit-by-onlyoffice.ts @@ -0,0 +1,30 @@ +import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token'; + +export interface OnlyOfficeConfig { + editorUrl: string; +} + +const SUPPORTED_EXTENSIONS = /\.(docx?|xlsx?|pptx?|odt|ods|odp)$/i; + +/** + * Factory function for Edit by OnlyOffice action extension. + * Adds "Edit by OnlyOffice" option to file context menu. + * + * NOTE: This is a dummy implementation for demonstration purposes. + * The actual OnlyOffice integration requires a running OnlyOffice Document Server. + */ +export function editByOnlyOfficeExtensionFactory(config: OnlyOfficeConfig): FileActionExtension[] { + return [ + { + id: 'edit-onlyoffice', + label: 'Edit by OnlyOffice', + icon: 'fas fa-edit', + command: (ctx) => { + const editorUrl = `${config.editorUrl}?fileId=${ctx.target.id}`; + window.open(editorUrl, '_blank'); + }, + position: 'start', + visible: (ctx) => ctx.target.kind === 'file' && ctx.canWrite && SUPPORTED_EXTENSIONS.test(ctx.target.name), + }, + ]; +} diff --git a/src/app/extensions/onlyoffice/index.ts b/src/app/extensions/onlyoffice/index.ts new file mode 100644 index 000000000..13b70661a --- /dev/null +++ b/src/app/extensions/onlyoffice/index.ts @@ -0,0 +1,2 @@ +export * from './create-file'; +export * from './edit-by-onlyoffice'; diff --git a/src/app/features/files/pages/extensions/file-detail.extensions.spec.ts b/src/app/features/files/pages/extensions/file-detail.extensions.spec.ts new file mode 100644 index 000000000..ce8865198 --- /dev/null +++ b/src/app/features/files/pages/extensions/file-detail.extensions.spec.ts @@ -0,0 +1,159 @@ +import { Store } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; + +import { ChangeDetectorRef, DestroyRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { ExtensionRegistry } from '@core/services/extension-registry.service'; +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token'; +import { FileModel } from '@shared/models/files/file.model'; +import { CustomConfirmationService } from '@shared/services/custom-confirmation.service'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; +import { ToastService } from '@shared/services/toast.service'; + +import { FilesSelectors } from '../../store'; +import { FileDetailComponent } from '../file-detail/file-detail.component'; + +import { MOCK_STORE } from '@testing/mocks/mock-store.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('FileDetailComponent (extensions)', () => { + let fixture: ComponentFixture; + let component: FileDetailComponent; + let registry: ExtensionRegistry | null; + + beforeEach(async () => { + window.open = jest.fn(); + (MOCK_STORE.selectSignal as jest.Mock).mockImplementation((selector) => { + switch (selector) { + case FilesSelectors.getOpenedFile: + return () => mockFile(); + case FilesSelectors.hasWriteAccess: + return () => true; + case FilesSelectors.isOpenedFileLoading: + return () => false; + default: + return () => undefined; + } + }); + + registry = null; + await TestBed.configureTestingModule({ + imports: [FileDetailComponent, OSFTestingModule], + providers: [ + TranslatePipe, + TranslateService, + { provide: ActivatedRoute, useValue: { params: of({ fileGuid: 'f1' }), snapshot: { params: {} } } }, + { provide: Router, useValue: { navigate: jest.fn(), url: '/' } }, + { provide: Store, useValue: MOCK_STORE }, + MockProvider(DataciteService, { + logIdentifiableView: jest.fn().mockReturnValue(of(void 0)), + logIdentifiableDownload: jest.fn().mockReturnValue(of(void 0)), + }), + MockProvider(CustomConfirmationService), + MockProvider(ToastService), + DestroyRef, + ChangeDetectorRef, + ], + }) + .overrideComponent(FileDetailComponent, { + set: { template: '' }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(FileDetailComponent); + component = fixture.componentInstance; + registry = TestBed.inject(ExtensionRegistry); + fixture.detectChanges(); + }); + + afterEach(() => { + (MOCK_STORE.selectSignal as jest.Mock).mockReset(); + if (registry) { + (registry as unknown as { _extensions: { set: (val: FileActionExtension[]) => void } })._extensions.set([]); + } + }); + + it('orders share menu extensions before built-in entries', () => { + registerExtensions([ + { + id: 'copy-link', + label: 'Copy Link', + icon: 'fas fa-link', + parentId: 'share', + position: 'start', + command: jest.fn(), + }, + ]); + + const shareItems = component.shareItems(); + expect(shareItems[0]?.id).toBe('copy-link'); + const labels = shareItems.slice(1).map((item) => item.label); + expect(labels).toContain('files.detail.actions.share.email'); + }); + + it('orders toolbar extensions based on position', () => { + registerExtensions([ + { + id: 'first', + label: 'First', + icon: 'fas fa-star', + position: 'start', + command: jest.fn(), + }, + { + id: 'middle', + label: 'Middle', + icon: 'fas fa-bolt', + position: 1, + command: jest.fn(), + }, + ]); + + const toolbarExt = component.fileDetailExtensions(); + expect(toolbarExt.map((ext) => ext.id)).toEqual(['first', 'middle']); + }); + + function registerExtensions(extensions: FileActionExtension[]): void { + (registry as unknown as { _extensions: { set: (val: FileActionExtension[]) => void } })._extensions.set([]); + registry.register(extensions); + fixture.detectChanges(); + } +}); + +function mockFile(): FileModel { + return { + id: 'f1', + guid: 'f1', + name: 'data.csv', + kind: FileKind.File, + path: '/data.csv', + size: 1, + materializedPath: '/data.csv', + dateModified: new Date().toISOString(), + extra: { + hashes: { md5: '', sha256: '' }, + downloads: 0, + }, + links: { + info: '', + move: '', + upload: '', + delete: '', + download: '', + render: '', + html: '', + self: '', + }, + filesLink: null, + previousFolder: false, + provider: 'osfstorage', + target: { id: 'n1', type: 'nodes' } as any, + } as FileModel; +} diff --git a/src/app/features/files/pages/file-detail/file-detail.component.html b/src/app/features/files/pages/file-detail/file-detail.component.html index 29b2da220..a2d8e4541 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.html +++ b/src/app/features/files/pages/file-detail/file-detail.component.html @@ -67,13 +67,29 @@ (click)="shareMenu.toggle($event)" /> - + - {{ item.label | translate }} + + {{ item.label | translate }} + } + @for (ext of fileDetailExtensions(); track ext.id) { + + } @if (showDeleteButton()) { hasViewOnlyParam(this.router)); + protected actionContext = computed((): FileActionContext => { + const file = this.file(); + if (!file) throw new Error('file is required for actionContext'); + return { + target: file, + location: 'file-detail', + isViewOnly: this.hasViewOnly(), + canWrite: this.hasWriteAccess(), + }; + }); + safeLink: SafeResourceUrl | null = null; resourceId = ''; resourceType = ''; @@ -170,20 +185,57 @@ export class FileDetailComponent { }, ]; - shareItems = [ - { - label: 'files.detail.actions.share.email', - command: () => this.handleEmailShare(), - }, - { - label: 'files.detail.actions.share.x', - command: () => this.handleXShare(), - }, - { - label: 'files.detail.actions.share.facebook', - command: () => this.handleFacebookShare(), - }, - ]; + shareItems = computed(() => { + const baseItems = [ + { + label: 'files.detail.actions.share.email', + command: () => this.handleEmailShare(), + }, + { + label: 'files.detail.actions.share.x', + command: () => this.handleXShare(), + }, + { + label: 'files.detail.actions.share.facebook', + command: () => this.handleFacebookShare(), + }, + ]; + + const ctx = this.actionContext(); + const shareExtensions = this.extensionRegistry + .extensions() + .filter((ext) => ext.parentId === 'share') + .filter((ext) => !ext.visible || ext.visible(ctx)); + + const positionedExtensions = shareExtensions.map((ext) => ({ + item: { + id: ext.id, + label: ext.label, + icon: ext.icon, + disabled: ext.disabled ? ext.disabled(ctx) : false, + command: () => ext.command(ctx), + }, + position: ext.position, + })); + + return insertByPosition(baseItems, positionedExtensions); + }); + + fileDetailExtensions = computed(() => { + if (!this.file()) return []; + const ctx = this.actionContext(); + const extensions = this.extensionRegistry + .extensions() + .filter((ext) => !ext.parentId) + .filter((ext) => !ext.visible || ext.visible(ctx)); + + const positionedExtensions = extensions.map((ext) => ({ + item: ext, + position: ext.position, + })); + + return insertByPosition([], positionedExtensions); + }); tabs = signal([]); diff --git a/src/app/features/files/pages/files/files.component.html b/src/app/features/files/pages/files/files.component.html index 631eceea2..2e288f38c 100644 --- a/src/app/features/files/pages/files/files.component.html +++ b/src/app/features/files/pages/files/files.component.html @@ -116,6 +116,18 @@ > } } + @if (currentFolder()) { + @for (ext of toolbarButtons(); track ext.id) { + + } + } diff --git a/src/app/features/files/pages/files/files.component.ts b/src/app/features/files/pages/files/files.component.ts index 3653caa67..3cd99b2be 100644 --- a/src/app/features/files/pages/files/files.component.ts +++ b/src/app/features/files/pages/files/files.component.ts @@ -38,6 +38,7 @@ import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { ExtensionRegistry } from '@core/services/extension-registry.service'; import { CreateFolder, DeleteEntry, @@ -67,12 +68,14 @@ import { SupportedFeature } from '@osf/shared/enums/addon-supported-features.enu import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { UserPermissions } from '@osf/shared/enums/user-permissions.enum'; +import { insertByPosition } from '@osf/shared/helpers/extension-order.helper'; import { getViewOnlyParamFromUrl, hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { FilesService } from '@osf/shared/services/files.service'; import { ToastService } from '@osf/shared/services/toast.service'; import { CurrentResourceSelectors, GetResourceDetails } from '@osf/shared/stores/current-resource'; +import { FileActionContext } from '@osf/shared/tokens/file-action-extensions.token'; import { ConfiguredAddonModel } from '@shared/models/addons/configured-addon.model'; import { StorageItem } from '@shared/models/addons/storage-item.model'; import { FileModel } from '@shared/models/files/file.model'; @@ -129,6 +132,7 @@ export class FilesComponent { private readonly environment = inject(ENVIRONMENT); private readonly customConfirmationService = inject(CustomConfirmationService); private readonly toastService = inject(ToastService); + private readonly extensionRegistry = inject(ExtensionRegistry); private readonly webUrl = this.environment.webUrl; private readonly apiDomainUrl = this.environment.apiDomainUrl; @@ -255,6 +259,32 @@ export class FilesComponent { () => this.isButtonDisabled() || (this.googleFilePickerComponent()?.isGFPDisabled() ?? false) ); + protected actionContext = computed((): FileActionContext => { + const folder = this.currentFolder(); + if (!folder) throw new Error('folder is required for actionContext'); + return { + target: folder, + location: 'file-list', + isViewOnly: this.hasViewOnly(), + canWrite: this.canUploadFiles(), + }; + }); + + toolbarButtons = computed(() => { + const ctx = this.actionContext(); + const extensions = this.extensionRegistry + .extensions() + .filter((ext) => !ext.parentId) + .filter((ext) => !ext.visible || ext.visible(ctx)); + + const positionedExtensions = extensions.map((ext) => ({ + item: ext, + position: ext.position, + })); + + return insertByPosition([], positionedExtensions); + }); + private route = inject(ActivatedRoute); readonly providerName = toSignal( this.route?.params?.pipe(map((params) => params['fileProvider'])) ?? of('osfstorage') diff --git a/src/app/features/files/pages/files/files.extensions.spec.ts b/src/app/features/files/pages/files/files.extensions.spec.ts new file mode 100644 index 000000000..0065dfc12 --- /dev/null +++ b/src/app/features/files/pages/files/files.extensions.spec.ts @@ -0,0 +1,134 @@ +import { MockProvider } from 'ng-mocks'; + +import { of } from 'rxjs'; + +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { ENVIRONMENT } from '@core/provider/environment.provider'; +import { ExtensionRegistry } from '@core/services/extension-registry.service'; +import { CustomConfirmationService } from '@osf/shared/services/custom-confirmation.service'; +import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; +import { FilesService } from '@osf/shared/services/files.service'; +import { ToastService } from '@osf/shared/services/toast.service'; +import { CurrentResourceSelectors } from '@osf/shared/stores/current-resource'; +import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token'; +import { DataciteService } from '@shared/services/datacite/datacite.service'; + +import { FilesSelectors } from '../../store'; + +import { FilesComponent } from './files.component'; + +import { getConfiguredAddonsMappedData } from '@testing/data/addons/addons.configured.data'; +import { getNodeFilesMappedData } from '@testing/data/files/node.data'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('FilesComponent (extensions)', () => { + let fixture: ComponentFixture; + let component: FilesComponent; + let registry: ExtensionRegistry | null; + + const currentFolder = getNodeFilesMappedData(0); + + beforeEach(async () => { + registry = null; + await TestBed.configureTestingModule({ + imports: [FilesComponent, OSFTestingModule], + providers: [ + FilesService, + MockProvider(CustomConfirmationService), + MockProvider(CustomDialogService), + MockProvider(ToastService), + MockProvider(DataciteService), + MockProvider(ActivatedRoute, { + params: of({ fileProvider: 'osfstorage' }), + parent: { + parent: { + snapshot: { data: { resourceType: 'nodes' } }, + parent: { params: of({ id: 'node' }) }, + }, + }, + }), + { provide: ENVIRONMENT, useValue: { webUrl: 'https://osf.io', apiDomainUrl: 'https://api.osf.io' } }, + MockProvider(Router, { navigate: jest.fn(), url: '/' }), + provideMockStore({ + signals: [ + { selector: FilesSelectors.getFiles, value: signal([]) }, + { selector: FilesSelectors.getFilesTotalCount, value: signal(0) }, + { selector: FilesSelectors.isFilesLoading, value: signal(false) }, + { selector: FilesSelectors.getCurrentFolder, value: signal(currentFolder) }, + { selector: FilesSelectors.getProvider, value: signal('osfstorage') }, + { selector: CurrentResourceSelectors.getResourceDetails, value: signal({ id: 'node', type: 'nodes' }) }, + { selector: CurrentResourceSelectors.getCurrentResource, value: signal({ id: 'node' }) }, + { selector: FilesSelectors.getRootFolders, value: signal(getNodeFilesMappedData()) }, + { selector: FilesSelectors.isRootFoldersLoading, value: signal(false) }, + { selector: FilesSelectors.getConfiguredStorageAddons, value: signal(getConfiguredAddonsMappedData()) }, + { selector: FilesSelectors.isConfiguredStorageAddonsLoading, value: signal(false) }, + { selector: FilesSelectors.getStorageSupportedFeatures, value: signal({ osfstorage: ['AddUpdateFiles'] }) }, + ], + }), + ], + }) + .overrideComponent(FilesComponent, { + set: { template: '' }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(FilesComponent); + component = fixture.componentInstance; + registry = TestBed.inject(ExtensionRegistry); + fixture.detectChanges(); + }); + + afterEach(() => { + if (registry) { + (registry as unknown as { _extensions: { set: (val: FileActionExtension[]) => void } })._extensions.set([]); + } + }); + + it('orders toolbar extensions according to position', () => { + registry.register([ + { + id: 'first', + label: 'First', + icon: 'fas fa-star', + position: 'start', + command: jest.fn(), + }, + { + id: 'second', + label: 'Second', + icon: 'fas fa-bolt', + position: 1, + command: jest.fn(), + }, + ]); + + const toolbar = component.toolbarButtons(); + expect(toolbar.map((ext) => ext.id)).toEqual(['first', 'second']); + }); + + it('respects disabled flag when rendering toolbar buttons', () => { + registry.register([ + { + id: 'disabled', + label: 'Disabled', + icon: 'fas fa-ban', + disabled: () => true, + command: jest.fn(), + }, + ]); + + const ext = component.toolbarButtons()[0]; + expect( + ext.disabled?.({ + target: currentFolder, + location: 'file-list', + isViewOnly: false, + canWrite: true, + }) + ).toBe(true); + }); +}); diff --git a/src/app/shared/components/file-menu-extension.component.spec.ts b/src/app/shared/components/file-menu-extension.component.spec.ts new file mode 100644 index 000000000..17ca52215 --- /dev/null +++ b/src/app/shared/components/file-menu-extension.component.spec.ts @@ -0,0 +1,130 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ExtensionRegistry } from '@core/services/extension-registry.service'; +import { FileKind } from '@osf/shared/enums/file-kind.enum'; +import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; +import { FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token'; +import { FileModel } from '@shared/models/files/file.model'; +import { FileMenuFlags } from '@shared/models/files/file-menu-action.model'; + +import { FileMenuComponent } from './file-menu/file-menu.component'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('FileMenuComponent (extensions)', () => { + let component: FileMenuComponent; + let fixture: ComponentFixture; + let registry: ExtensionRegistry; + + beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FileMenuComponent, OSFTestingModule], + }).compileComponents(); + + fixture = TestBed.createComponent(FileMenuComponent); + component = fixture.componentInstance; + registry = TestBed.inject(ExtensionRegistry); + + fixture.componentRef.setInput('file', mockFile()); + fixture.componentRef.setInput('allowedActions', allMenuFlags()); + fixture.componentRef.setInput('isFolder', false); + fixture.componentRef.setInput('hasWriteAccess', true); + }); + + it('orders submenu and top-level extensions by position', () => { + registerExtensions([ + { + id: 'share-first', + label: 'Share first', + icon: 'fas fa-star', + parentId: 'share', + position: 'start', + command: jest.fn(), + }, + { + id: 'toolbar-first', + label: 'Edit external', + icon: 'fas fa-edit', + position: 'start', + command: jest.fn(), + }, + { + id: 'toolbar-disabled', + label: 'Disabled', + icon: 'fas fa-ban', + disabled: () => true, + command: jest.fn(), + }, + ]); + + const menuItems = component.menuItems(); + const shareMenu = menuItems.find((item) => item.id === FileMenuType.Share); + expect(shareMenu?.items?.[0]?.id).toBe('share-first'); + + expect(menuItems[0].id).toBe('toolbar-first'); + const disabled = menuItems.find((item) => item.id === 'toolbar-disabled'); + expect(disabled?.disabled).toBe(true); + }); + + function registerExtensions(extensions: FileActionExtension[]): void { + (registry as unknown as { _extensions: { set: (val: FileActionExtension[]) => void } })._extensions.set([]); + registry.register(extensions); + fixture.detectChanges(); + } +}); + +function mockFile(): FileModel { + return { + id: 'f1', + guid: 'f1', + name: 'file.txt', + kind: FileKind.File, + path: '/file.txt', + size: 1, + materializedPath: '/file.txt', + dateModified: new Date().toISOString(), + extra: { + hashes: { md5: '', sha256: '' }, + downloads: 0, + }, + links: { + info: '', + move: '', + upload: '', + delete: '', + download: '', + render: '', + html: '', + self: '', + }, + filesLink: null, + previousFolder: false, + provider: 'osfstorage', + } as FileModel; +} + +function allMenuFlags(): FileMenuFlags { + return Object.values(FileMenuType).reduce( + (acc, action) => ({ + ...acc, + [action]: true, + }), + {} as FileMenuFlags + ); +} diff --git a/src/app/shared/components/file-menu/file-menu.component.html b/src/app/shared/components/file-menu/file-menu.component.html index cb17cb23f..0314cce1b 100644 --- a/src/app/shared/components/file-menu/file-menu.component.html +++ b/src/app/shared/components/file-menu/file-menu.component.html @@ -11,7 +11,12 @@ - + {{ item.label | translate }} @if (hasSubmenu) { diff --git a/src/app/shared/components/file-menu/file-menu.component.ts b/src/app/shared/components/file-menu/file-menu.component.ts index 6135fb4bc..d2e43aeb5 100644 --- a/src/app/shared/components/file-menu/file-menu.component.ts +++ b/src/app/shared/components/file-menu/file-menu.component.ts @@ -7,9 +7,13 @@ import { TieredMenu } from 'primeng/tieredmenu'; import { Component, computed, inject, input, output, viewChild } from '@angular/core'; import { Router } from '@angular/router'; +import { ExtensionRegistry } from '@core/services/extension-registry.service'; import { FileMenuType } from '@osf/shared/enums/file-menu-type.enum'; +import { insertByPosition } from '@osf/shared/helpers/extension-order.helper'; import { hasViewOnlyParam } from '@osf/shared/helpers/view-only.helper'; +import { FileModel } from '@osf/shared/models/files/file.model'; import { MenuManagerService } from '@osf/shared/services/menu-manager.service'; +import { FileActionContext, FileActionExtension } from '@osf/shared/tokens/file-action-extensions.token'; import { FileMenuAction, FileMenuData, FileMenuFlags } from '@shared/models/files/file-menu-action.model'; @Component({ @@ -21,13 +25,28 @@ import { FileMenuAction, FileMenuData, FileMenuFlags } from '@shared/models/file export class FileMenuComponent { private router = inject(Router); private menuManager = inject(MenuManagerService); + private extensionRegistry = inject(ExtensionRegistry); + + file = input(); isFolder = input(false); allowedActions = input({} as FileMenuFlags); + hasWriteAccess = input(false); menu = viewChild.required('menu'); action = output(); hasViewOnly = computed(() => hasViewOnlyParam(this.router)); + private actionContext = computed((): FileActionContext => { + const file = this.file(); + if (!file) throw new Error('file is required for actionContext'); + return { + target: file, + location: 'file-list', + isViewOnly: this.hasViewOnly(), + canWrite: this.hasWriteAccess(), + }; + }); + private readonly allMenuItems: MenuItem[] = [ { id: FileMenuType.Download, @@ -106,6 +125,8 @@ export class FileMenuComponent { ]; menuItems = computed(() => { + let items: MenuItem[]; + if (this.hasViewOnly()) { const allowedActionsForFiles = [ FileMenuType.Download, @@ -120,7 +141,7 @@ export class FileMenuComponent { const allowedActions = this.isFolder() ? allowedActionsForFolders : allowedActionsForFiles; - return this.allMenuItems.filter((item) => { + items = this.allMenuItems.filter((item) => { if (item.command) { return allowedActions.includes(item.id as FileMenuType); } @@ -131,17 +152,63 @@ export class FileMenuComponent { return false; }); - } - - if (this.isFolder()) { + } else if (this.isFolder()) { const disallowedActions = [FileMenuType.Share, FileMenuType.Embed]; - return this.allMenuItems.filter( + items = this.allMenuItems.filter( (item) => !disallowedActions.includes(item.id as FileMenuType) && this.allowedActions()[item.id as FileMenuType] ); + } else { + items = this.allMenuItems.filter((item) => this.allowedActions()[item.id as FileMenuType]); } - return this.allMenuItems.filter((item) => this.allowedActions()[item.id as FileMenuType]); + + return this.mergeExtensions(items); }); + private mergeExtensions(baseItems: MenuItem[]): MenuItem[] { + const ctx = this.actionContext(); + const applicableExtensions = this.extensionRegistry.extensions().filter((ext) => !ext.visible || ext.visible(ctx)); + + const topLevelExtensions = applicableExtensions.filter((ext) => !ext.parentId); + const subMenuExtensions = applicableExtensions.filter((ext) => ext.parentId); + + // 1. Apply submenu extensions + let items = baseItems.map((item) => { + const extensions = subMenuExtensions.filter((ext) => ext.parentId === item.id); + if (extensions.length === 0 || !item.items) { + return item; + } + + const positioned = extensions.map((ext) => ({ + item: this.createMenuItem(ext, ctx), + position: ext.position, + })); + + return { + ...item, + items: insertByPosition(item.items, positioned), + }; + }); + + // 2. Apply top-level extensions + const positionedTopLevel = topLevelExtensions.map((ext) => ({ + item: this.createMenuItem(ext, ctx), + position: ext.position, + })); + items = insertByPosition(items, positionedTopLevel); + + return items; + } + + private createMenuItem(ext: FileActionExtension, ctx: FileActionContext): MenuItem { + return { + id: ext.id, + label: ext.label, + icon: ext.icon, + disabled: ext.disabled ? ext.disabled(ctx) : false, + command: () => ext.command(ctx), + }; + } + onMenuToggle(event: Event): void { this.menuManager.openMenu(this.menu(), event); } diff --git a/src/app/shared/components/files-tree/files-tree.component.html b/src/app/shared/components/files-tree/files-tree.component.html index 24856541c..9a8aa6e2b 100644 --- a/src/app/shared/components/files-tree/files-tree.component.html +++ b/src/app/shared/components/files-tree/files-tree.component.html @@ -87,9 +87,11 @@ @if (isSomeFileActionAllowed && !selectedFiles().length) {
diff --git a/src/app/shared/helpers/extension-order.helper.spec.ts b/src/app/shared/helpers/extension-order.helper.spec.ts new file mode 100644 index 000000000..676f4fa76 --- /dev/null +++ b/src/app/shared/helpers/extension-order.helper.spec.ts @@ -0,0 +1,24 @@ +import { insertByPosition } from './extension-order.helper'; + +describe('insertByPosition', () => { + it('adds start items before existing entries', () => { + const base = ['download', 'share']; + const result = insertByPosition(base, [ + { item: 'edit', position: 'start' }, + { item: 'delete', position: 'end' }, + ]); + + expect(result).toEqual(['edit', 'download', 'share', 'delete']); + }); + + it('inserts by numeric index and clamps within bounds', () => { + const base = ['download']; + const result = insertByPosition(base, [ + { item: 'rename', position: 1 }, + { item: 'share', position: 10 }, + { item: 'preview', position: -5 }, + ]); + + expect(result).toEqual(['preview', 'download', 'rename', 'share']); + }); +}); diff --git a/src/app/shared/helpers/extension-order.helper.ts b/src/app/shared/helpers/extension-order.helper.ts new file mode 100644 index 000000000..02744751c --- /dev/null +++ b/src/app/shared/helpers/extension-order.helper.ts @@ -0,0 +1,33 @@ +export interface PositionedItem { + item: T; + position?: 'start' | 'end' | number; +} + +/** + * Insert extension-provided items into a base list respecting their desired position. + */ +export function insertByPosition(baseItems: T[], additions: PositionedItem[]): T[] { + return additions.reduce( + (acc, addition) => { + const { item, position } = addition; + if (position === 'start') { + return [item, ...acc]; + } + + if (typeof position === 'number') { + const index = clampIndex(position, acc.length); + return [...acc.slice(0, index), item, ...acc.slice(index)]; + } + + // Default to appending at the end ('end' or undefined) + return [...acc, item]; + }, + [...baseItems] + ); +} + +function clampIndex(position: number, length: number): number { + if (position < 0) return 0; + if (position > length) return length; + return position; +} diff --git a/src/app/shared/tokens/file-action-extensions.token.ts b/src/app/shared/tokens/file-action-extensions.token.ts new file mode 100644 index 000000000..12d66742f --- /dev/null +++ b/src/app/shared/tokens/file-action-extensions.token.ts @@ -0,0 +1,57 @@ +import { InjectionToken } from '@angular/core'; + +import { FileDetailsModel, FileModel } from '@osf/shared/models/files/file.model'; +import { FileFolderModel } from '@osf/shared/models/files/file-folder.model'; + +export type FileActionTarget = FileModel | FileDetailsModel | FileFolderModel; + +/** + * Context passed to visible, disabled, and command functions. + */ +export interface FileActionContext { + /** Current file or folder */ + target: FileActionTarget; + /** Where the action is being rendered */ + location: 'file-list' | 'file-detail'; + /** Whether the user is viewing via a view-only link */ + isViewOnly: boolean; + /** Whether the user has write access */ + canWrite: boolean; +} + +export interface FileActionExtension { + /** Unique identifier */ + id: string; + + /** Display label */ + label: string; + + /** Icon class (e.g., 'fas fa-link') */ + icon: string; + + /** Click handler */ + command: (ctx: FileActionContext) => void; + + /** + * Parent menu ID for submenu insertion. + * If set, extension appears only in that submenu (e.g., 'share'). + * If not set, extension appears in both menu and toolbar. + */ + parentId?: string; + + /** + * Position to insert + * - 'start': beginning + * - 'end': end (default) + * - number: specific index + */ + position?: 'start' | 'end' | number; + + /** Visibility condition */ + visible?: (ctx: FileActionContext) => boolean; + + /** Disabled condition */ + disabled?: (ctx: FileActionContext) => boolean; +} + +export const FILE_ACTION_EXTENSIONS = new InjectionToken('FileActionExtensions');