diff --git a/.gitignore b/.gitignore index 7af424c50f3..ba7c09b7cdf 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ yarn-error.log junit.xml /src/mirador-viewer/config.local.js + +## ignore the auto-generated decorator registries +decorator-registries/ diff --git a/package.json b/package.json index 9b32803bdda..96234a4281f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "serve": "ts-node -r tsconfig-paths/register --project ./tsconfig.ts-node.json scripts/serve.ts", "serve:ssr": "node dist/server/main", "analyze": "webpack-bundle-analyzer dist/browser/stats.json", + "generate:decorator:registries": "ts-node --project ./tsconfig.ts-node.json scripts/generate-decorator-registries.ts", "build": "ng build --configuration development", "build:stats": "ng build --stats-json", "build:prod": "cross-env NODE_ENV=production npm run build:ssr", diff --git a/scripts/config/decorator-config.interface.ts b/scripts/config/decorator-config.interface.ts new file mode 100644 index 00000000000..f6a2ff4b04c --- /dev/null +++ b/scripts/config/decorator-config.interface.ts @@ -0,0 +1,16 @@ +import { DecoratorParam } from './decorator-param.interface'; + +/** + * The configuration for a dynamic component decorator. This is used to generate the registry files. + */ +export interface DecoratorConfig { + /** + * Name of the decorator + */ + name: string; + + /** + * List of DecoratorParams + */ + params: DecoratorParam[]; +} diff --git a/scripts/config/decorator-param.interface.ts b/scripts/config/decorator-param.interface.ts new file mode 100644 index 00000000000..168c6ba9344 --- /dev/null +++ b/scripts/config/decorator-param.interface.ts @@ -0,0 +1,26 @@ +/** + * The configuration for a decorator parameter. + */ +export interface DecoratorParam { + /** + * The name of the parameter + */ + name: string; + + /** + * The default value of the decorator param + * + * (Optional) + */ + default?: string; + + /** + * The property of the provided parameter value that should be used instead of the value itself. + * + * For example, if the parameter value is {@link ResourceType} 'BITSTREAM', you'll want to use 'BITSTREAM.value' + * instead of the whole {@link ResourceType} object. In this case the {@link DecoratorParam#property} is `value`. + * + * (Optional) + */ + property?: string; +} diff --git a/scripts/generate-decorator-registries.ts b/scripts/generate-decorator-registries.ts new file mode 100644 index 00000000000..963c0d3a8fc --- /dev/null +++ b/scripts/generate-decorator-registries.ts @@ -0,0 +1,413 @@ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { + basename, + dirname, + join, + relative, + resolve, +} from 'node:path'; + +import { sync } from 'glob'; +import { + createSourceFile, + forEachChild, + getDecorators, + Identifier, + ImportDeclaration, + isCallExpression, + isClassDeclaration, + isEnumDeclaration, + isExpressionWithTypeArguments, + isIdentifier, + isNumericLiteral, + isPropertyAccessExpression, + isStringLiteral, + Node, + ScriptTarget, + SourceFile, + StringLiteral, + SyntaxKind, +} from 'typescript'; + +import { DECORATORS } from '../src/app/decorators'; +import { DecoratorConfig } from './config/decorator-config.interface'; + +const COMPONENTS_DIR = resolve(__dirname, '../src'); +const REGISTRY_OUTPUT_DIR = resolve(__dirname, '../src/decorator-registries'); + +/** + * Scans the code base for enums and extracts their values, e.g. { FeatureID: { AdministratorOf: 'administratorOf' } }. + */ +const generateEnumValues = () => { + const enumValues = {}; + + const fileNames = sync(`${COMPONENTS_DIR}/**/*.ts`, { ignore: `${COMPONENTS_DIR}/**/*.spec.ts` }); + + fileNames.forEach((filePath: string) => { + const fileName = basename(filePath); + const sourceFile = createSourceFile(fileName, readFileSync(filePath, 'utf8'), ScriptTarget.Latest); + + if (!sourceFile.isDeclarationFile) { + forEachChild(sourceFile, node => { + if (isEnumDeclaration(node)) { + const enumName = node.name.text; + enumValues[enumName] = {}; + + for (const value of node.members) { + const valueName = value.name.getText(sourceFile); + if (value.initializer && isStringLiteral(value.initializer)) { + enumValues[enumName][valueName] = value.initializer.text; + } + } + } + }); + } + }); + + return enumValues; +}; + +const ENUM_VALUES = generateEnumValues(); + +/** + * For example, 'listableObjectComponent' becomes 'LISTABLE_OBJECT_COMPONENT'. + */ +export const getDecoratorConstName = (decorator: string): string => { + return decorator + .replace(/([A-Z])/g, '_$1') + .toUpperCase() + .replace(/^_/, ''); +}; + +/** + * For example, 'listableObjectComponent' becomes 'listable-object-component-registry.ts'. + */ +export const getDecoratorFileName = (decorator: string): string => { + return decorator + .replace(/([A-Z])/g, '-$1') + .toLowerCase() + .replace(/^-/, '') + .concat('-registry.ts'); +}; + +/** + * Parse map key depending on its object type. + * If a value had a {@link DecoratorParam#property}, use that instead of just the value. + */ +const parseKey = ( + key: any, decoratorConfig: DecoratorConfig, argsArray: any[], +): string => { + let keyString: string; + if (typeof key === 'string' && key.includes('${')) { + keyString = `\`${key.replace(/\\/g, '\\\\').replace(/`/g, '\\`')}\``; + } else if (typeof key === 'string') { + keyString = `'${key.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; + } else if (key && typeof key === 'object' && 'classRef' in key) { + const param = decoratorConfig.params[argsArray.length - 1]; + if (param.property) { + keyString = `${key.classRef}.${param.property}`; + } else { + keyString = key.classRef; + } + } else { + keyString = String(key); + } + return keyString; +}; + +/** + * Consolidate the imports and generate import statements strings. + * + * While the imports in the component metadata are stored as { object: path }, this method flips that. + * So objects originating from the same path, can be stored together under that same path key. + */ +const generateImportStatements = ( + components: Array<{ name: string, filePath: string, args: any[], imports: Map }>, +): string => { + let result = ''; + const imports: Map> = new Map(); + components.forEach(component => { + for (const componentImport of component.imports.keys()) { + const importPath: string = component.imports.get(componentImport); + if (!imports.get(importPath)) { + imports.set(importPath, new Set()); + } + imports.get(importPath).add(componentImport); + } + }); + + if (imports.size > 0) { + result += `${ Array.from(imports.keys()).sort().map((path: string) => `import { ${Array.from(imports.get(path)).join(', ')} } from '${path}';`).join('\n')}\n\n`; + } + return result; +}; + +/** + * Generate Map#set statements to create a nested map. + */ +const generateMapCreationStatements = ( + components: Array<{ name: string, filePath: string, args: any[], imports: Map }>, decoratorConfig: DecoratorConfig, mapVarName: string, +) => { + let result = ''; + // Use mapPathsSoFar to track for which levels a sub-map already exists. + const mapPathsSoFar = new Set(); + + for (const component of components) { + // Substitute every missing argument with the parameter default. + const argsArray = decoratorConfig.params.map((param, index) => + (index < component.args.length && component.args[index]?.classRef !== 'undefined') + ? component.args[index] + : param.default); + + let currentMapPath = mapVarName; + let currentPathKey = ''; + // Do not process the final decorator argument yet. That is used as key for the lazy import (the lowest level of the map). + for (let i = 0; i < argsArray.length - 1; i++) { + const key = argsArray[i]; + const keyString = parseKey(key, decoratorConfig, argsArray); + const newPath = currentPathKey + '|' + (typeof key === 'object' && 'classRef' in key ? (key.classRef ? key.classRef : key) : String(key)); + if (!mapPathsSoFar.has(newPath)) { + result += ` ${currentMapPath}.set(${keyString}, new Map());\n`; + mapPathsSoFar.add(newPath); + } + currentMapPath += `.get(${keyString})`; + currentPathKey = newPath; + } + + // Handle the lowest level of the map by generating a lazy import to the component. + const finalKey = argsArray[argsArray.length - 1]; + const finalKeyString = parseKey(finalKey, decoratorConfig, argsArray); + const lazyImport = `() => import('${component.filePath}').then(c => c.${component.name})`; + result += ` ${currentMapPath}.set(${finalKeyString}, ${lazyImport});\n`; + } + + return result; +}; + +/** + * Generates and writes a registry TypeScript file for decorator components. + * + * @param decoratorConfig - Decorator configuration that's currently being processed. + * @param {Array<{ name: string, filePath: string, args: any[], imports: Map }>} components - An array of objects, each representing a component. + * + * @returns {void} This function does not return a value. It writes a file to the output directory. + */ +const writeRegistryFile = ( + decoratorConfig: DecoratorConfig, components: Array<{ name: string, filePath: string, args: any[], imports: Map }>, +): void => { + const mapName = getDecoratorConstName(decoratorConfig.name) + '_MAP'; + const functionName = `${decoratorConfig.name}CreateMap`; + const mapVarName = `${decoratorConfig.name}Map`; + let content: string; + + // Start the registry file with the import statements, if any. + content = generateImportStatements(components); + + // Open the map creation function and initialize the decorator map within the function. + content += `function ${functionName}(): Map {\n`; + content += ` const ${mapVarName} = new Map();\n\n`; + + // Add the nested map statements. + content += generateMapCreationStatements(components, decoratorConfig, mapVarName); + + // Close off the map creation function and export the map as a constant. + content += `\n return ${mapVarName};\n`; + content += `}\n\n`; + content += `export const ${mapName} = ${functionName}();\n`; + + + // Actually write to the file. + const filePath = join(REGISTRY_OUTPUT_DIR, getDecoratorFileName(decoratorConfig.name)); + if (!existsSync(filePath) || readFileSync(filePath, 'utf8') !== content) { + writeFileSync(filePath, content, 'utf8'); + } +}; + +/** + * Generate a map of the import statements in the provided file. + * Keys are the imported objects, values are the paths to those objects. + */ +const generateImportsMap = (file: SourceFile): Map => { + const imports: Map = new Map(); + forEachChild(file, (node: ImportDeclaration) => { + if (node.kind === SyntaxKind.ImportDeclaration && node.importClause?.namedBindings?.kind === SyntaxKind.NamedImports) { + node.importClause.namedBindings.elements.forEach(element => { + imports.set(element.name.text, (node.moduleSpecifier as StringLiteral).text); + }); + } + }); + return imports; +}; + +/** + * First, retrieve the import of a decorator parameter from all the imports in its component file. + * If necessary, convert that import from a relative to an absolute path. + * Then resolve that to a relative path, relative to the decorator registries directory. + */ +const parseImportPath = (allImports: Map, arg: any, filePath: string): string => { + const absoluteImportPath = allImports.get(arg.text); + if (absoluteImportPath.startsWith('.')) { + return relative(REGISTRY_OUTPUT_DIR, resolve(dirname(filePath), allImports.get(arg.text))); + } + if (absoluteImportPath.startsWith('src/app')) { + return relative(REGISTRY_OUTPUT_DIR, absoluteImportPath); + } + return absoluteImportPath; +}; + +/** + * Parses the decorator arguments on a specific component, along with a map of imports. + * The map has the imported argument objects as keys, the import paths as values. + */ +const parseDecoratorArguments = ( + decorator: any, allImports: Map, filePath: string, sourceFile: SourceFile, +) => { + const args: any[] = []; + const argImports: Map = new Map(); + decorator.expression.arguments.forEach((arg: Node) => { + // e.g. @decorator('range') + if (isStringLiteral(arg)) { + args.push(arg.text); + // e.g. @decorator(ItemSearchResult) + } else if (isIdentifier(arg)) { + // Store this under classRef so we can extract a property from it later (if so configured). + args.push({ classRef: arg.text }); + if (allImports.has(arg.text)) { + argImports.set(arg.text, parseImportPath(allImports, arg, filePath)); + } + // e.g. @decorator(Enum.property) + } else if (isPropertyAccessExpression(arg)) { + const propertyName = arg.name.text; + const objectName = (arg.expression as Identifier).text; + const enumValue = ENUM_VALUES[objectName]?.[propertyName]; + args.push(enumValue || `${objectName}.${propertyName}`); + // e.g. @decorator(PaginatedList) + } else if (isExpressionWithTypeArguments(arg)) { + args.push(arg.typeArguments[0].getText(sourceFile)); + } else if (arg.kind === SyntaxKind.TrueKeyword) { + args.push(true); + } else if (arg.kind === SyntaxKind.FalseKeyword) { + args.push(false); + // e.g. @decorator(123) + } else if (isNumericLiteral(arg)) { + args.push(Number(arg.text)); + } + }); + return { args: args, imports: argImports }; +}; + +/** + * Generates a component metadata object which contains: + * - the name of the component + * - the full path of the component file + * - the arguments used in the decorator on that component + * - an import map, with the imported objects as keys, and the paths to those objects as values + */ +const generateComponentMetadataObject = ( + decorator: any, componentName: string, filePath: string, sourceFile: SourceFile, allImports: Map, +): { name: string, filePath: string, args: any[], imports: Map } => { + const parsedArgsAndImports = parseDecoratorArguments(decorator, allImports, filePath, sourceFile); + const args = parsedArgsAndImports.args; + const argImports = parsedArgsAndImports.imports; + + return { + name: componentName, + filePath: `../${relative(COMPONENTS_DIR, filePath).replace(/\.ts$/, '')}`, + args, + imports: argImports, + }; +}; + +/** + * Walk the AST of a file to find class declarations with decorators. + */ +const walkASTForDecorators = ( + sourceFile: SourceFile, filePath: string, allImports: Map, decoratorConfigs: DecoratorConfig[], +): Array<{ decoratorName: string, component: { name: string, filePath: string, args: any[], imports: Map } }> => { + const foundComponents: Array<{ decoratorName: string, component: { name: string, filePath: string, args: any[], imports: Map } }> = []; + + forEachChild(sourceFile, node => { + if (isClassDeclaration(node) && node.name) { + const decorators = getDecorators(node); + const componentName = node.name.text; + + decorators?.forEach((decorator) => { + if (isCallExpression(decorator.expression)) { + const currentDecoratorName = (decorator.expression.expression as Identifier).text; + const decoratorConfig = decoratorConfigs.find(config => config.name === currentDecoratorName); + + if (decoratorConfig) { + const component = generateComponentMetadataObject(decorator, componentName, filePath, sourceFile, allImports); + foundComponents.push({ + decoratorName: currentDecoratorName, + component, + }); + } + } + }); + } + }); + + return foundComponents; +}; + +/** + * Generate a map with decorator names as keys, lists of component metadata objects as values. + */ +const generateDecoratorMap = ( + decoratorConfigs: DecoratorConfig[], +): Map }>> => { + // Initialize the map using decorator names as keys, and empty lists as values. + const decoratorMap = new Map }>>(); + decoratorConfigs.forEach(config => { + decoratorMap.set(config.name, []); + }); + + // Get all TypeScript files recursively, excluding spec files. + const fileNames = sync(`${COMPONENTS_DIR}/**/*.ts`, { ignore: `${COMPONENTS_DIR}/**/*.spec.ts` }); + + fileNames.forEach((filePath: string) => { + const fileName = basename(filePath); + const sourceFile = createSourceFile(fileName, readFileSync(filePath, 'utf8'), ScriptTarget.Latest); + + // Get all imports in this file. + const allImports: Map = generateImportsMap(sourceFile); + + // Walk the AST of this file to find decorators and their component metadata. + const foundComponents = walkASTForDecorators(sourceFile, filePath, allImports, decoratorConfigs); + + // Add the found entries to the general decorator map. + foundComponents.forEach(({ decoratorName, component }) => { + decoratorMap.get(decoratorName)?.push(component); + }); + }); + + return decoratorMap; +}; + +const main = (): void => { + mkdirSync(REGISTRY_OUTPUT_DIR, { recursive: true }); + const registriesToDelete: Set = new Set(readdirSync(REGISTRY_OUTPUT_DIR)); + + const decoratorMap = generateDecoratorMap(DECORATORS); + + // Write registry files for each decorator + DECORATORS.forEach(decoratorConfig => { + registriesToDelete.delete(getDecoratorFileName(decoratorConfig.name)); + const componentsForDecorator = decoratorMap.get(decoratorConfig.name); + writeRegistryFile(decoratorConfig, componentsForDecorator); + }); + + registriesToDelete.forEach((fileName: string) => rmSync(join(REGISTRY_OUTPUT_DIR, fileName))); + + console.debug(`Generated decorator registry files in ${REGISTRY_OUTPUT_DIR}`); +}; + +main(); diff --git a/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts index 52df43be5f4..8b6df882855 100644 --- a/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts +++ b/src/app/admin/admin-notify-dashboard/admin-notify-search-result/admin-notify-search-result.component.ts @@ -13,6 +13,8 @@ import { AdminNotifyMessagesDataService } from '@dspace/core/coar-notify/notify- import { AdminNotifyMessage } from '@dspace/core/coar-notify/notify-info/models/admin-notify-message.model'; import { AdminNotifySearchResult } from '@dspace/core/coar-notify/notify-info/models/admin-notify-message-search-result.model'; import { PaginatedList } from '@dspace/core/data/paginated-list.model'; +import { Context } from '@dspace/core/shared/context.model'; +import { ViewMode } from '@dspace/core/shared/view-mode.model'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; import { @@ -21,6 +23,7 @@ import { } from 'rxjs'; import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-configuration.service'; +import { tabulatableObjectsComponent } from '../../../shared/object-collection/shared/tabulatable-objects/tabulatable-objects.decorator'; import { TabulatableResultListElementsComponent } from '../../../shared/object-list/search-result-list-element/tabulatable-search-result/tabulatable-result-list-elements.component'; import { SearchConfigurationService } from '../../../shared/search/search-configuration.service'; import { TruncatableComponent } from '../../../shared/truncatable/truncatable.component'; @@ -49,6 +52,7 @@ import { AdminNotifyDetailModalComponent } from '../admin-notify-detail-modal/ad /** * Component for visualization in table format of the search results related to the AdminNotifyDashboardComponent */ +@tabulatableObjectsComponent(AdminNotifySearchResult, ViewMode.Table, Context.CoarNotify) export class AdminNotifySearchResultComponent extends TabulatableResultListElementsComponent, AdminNotifySearchResult> implements OnInit, OnDestroy{ public messagesSubject$: BehaviorSubject = new BehaviorSubject([]); public reprocessStatus = 'QUEUE_STATUS_QUEUED_FOR_RETRY'; diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts index be463d6093c..8002d45dc8b 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.spec.ts @@ -23,7 +23,6 @@ import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote import { TranslateModule } from '@ngx-translate/core'; import { Observable } from 'rxjs'; -import { ListableModule } from '../../../../../shared/listable.module'; import { CollectionElementLinkType } from '../../../../../shared/object-collection/collection-element-link.type'; import { getMockThemeService } from '../../../../../shared/theme-support/test/theme-service.mock'; import { ThemeService } from '../../../../../shared/theme-support/theme.service'; @@ -59,7 +58,6 @@ describe('ItemAdminSearchResultGridElementComponent', () => { NoopAnimationsModule, TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), - ListableModule, ItemAdminSearchResultGridElementComponent, ], providers: [ diff --git a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts index 60d62eb7d16..129f7127964 100644 --- a/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts +++ b/src/app/admin/admin-search-page/admin-search-results/admin-search-result-grid-element/item-search-result/item-admin-search-result-grid-element.component.ts @@ -14,6 +14,11 @@ import { Item } from '@dspace/core/shared/item.model'; import { ItemSearchResult } from '@dspace/core/shared/object-collection/item-search-result.model'; import { ViewMode } from '@dspace/core/shared/view-mode.model'; import { hasValue } from '@dspace/shared/utils/empty.util'; +import { + from, + Observable, +} from 'rxjs'; +import { take } from 'rxjs/operators'; import { DynamicComponentLoaderDirective } from '../../../../../shared/abstract-component-loader/dynamic-component-loader.directive'; import { @@ -59,25 +64,27 @@ export class ItemAdminSearchResultGridElementComponent extends SearchResultGridE */ ngOnInit(): void { super.ngOnInit(); - const component: GenericConstructor = this.getComponent(); + const component$: Observable> = from(this.getComponent()); - const viewContainerRef = this.dynamicComponentLoaderDirective.viewContainerRef; - viewContainerRef.clear(); + component$.pipe(take(1)).subscribe((component) => { + const viewContainerRef = this.dynamicComponentLoaderDirective.viewContainerRef; + viewContainerRef.clear(); - this.compRef = viewContainerRef.createComponent( - component, { - index: 0, - injector: undefined, - projectableNodes: [ - [this.badges.nativeElement], - [this.buttons.nativeElement], - ], - }, - ); - this.compRef.setInput('object',this.object); - this.compRef.setInput('index', this.index); - this.compRef.setInput('linkType', this.linkType); - this.compRef.setInput('listID', this.listID); + this.compRef = viewContainerRef.createComponent( + component, { + index: 0, + injector: undefined, + projectableNodes: [ + [this.badges.nativeElement], + [this.buttons.nativeElement], + ], + }, + ); + this.compRef.setInput('object',this.object); + this.compRef.setInput('index', this.index); + this.compRef.setInput('linkType', this.linkType); + this.compRef.setInput('listID', this.listID); + }); } ngOnDestroy(): void { @@ -91,7 +98,7 @@ export class ItemAdminSearchResultGridElementComponent extends SearchResultGridE * Fetch the component depending on the item's entity type, view mode and context * @returns {GenericConstructor} */ - private getComponent(): GenericConstructor { + private getComponent(): Promise> { return getListableObjectComponent(this.object.getRenderTypes(), ViewMode.GridElement, undefined, this.themeService.getThemeName()); } } diff --git a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts index c8207c51a43..12b2a97eee6 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.spec.ts @@ -6,13 +6,16 @@ import { } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { RouterTestingModule } from '@angular/router/testing'; +import { RouterModule } from '@angular/router'; import { CSSVariableServiceStub } from '@dspace/core/testing/css-variable-service.stub'; import { TranslateModule } from '@ngx-translate/core'; import { MenuService } from '../../../shared/menu/menu.service'; +import { MenuSection } from '../../../shared/menu/menu-section.model'; import { MenuServiceStub } from '../../../shared/menu/menu-service.stub'; import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service'; +import { getMockThemeService } from '../../../shared/theme-support/test/theme-service.mock'; +import { ThemeService } from '../../../shared/theme-support/theme.service'; import { AdminSidebarSectionComponent } from './admin-sidebar-section.component'; describe('AdminSidebarSectionComponent', () => { @@ -25,11 +28,11 @@ describe('AdminSidebarSectionComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot(), AdminSidebarSectionComponent, TestComponent], + imports: [NoopAnimationsModule, RouterModule.forRoot([]), TranslateModule.forRoot(), AdminSidebarSectionComponent, TestComponent], providers: [ - { provide: 'sectionDataProvider', useValue: { model: { link: 'google.com' }, icon: iconString } }, { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, + { provide: ThemeService, useValue: getMockThemeService() }, ], }).compileComponents(); })); @@ -37,7 +40,14 @@ describe('AdminSidebarSectionComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(AdminSidebarSectionComponent); component = fixture.componentInstance; - spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + component.section = { + model: { + link: 'google.com', + }, + icon: iconString, + } as MenuSection; + component.itemModel = component.section.model; + spyOn(component, 'getMenuItemComponent').and.returnValue(Promise.resolve(TestComponent)); fixture.detectChanges(); }); @@ -59,11 +69,11 @@ describe('AdminSidebarSectionComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - imports: [NoopAnimationsModule, RouterTestingModule, TranslateModule.forRoot(), AdminSidebarSectionComponent, TestComponent], + imports: [NoopAnimationsModule, RouterModule.forRoot([]), TranslateModule.forRoot(), AdminSidebarSectionComponent, TestComponent], providers: [ - { provide: 'sectionDataProvider', useValue: { model: { link: 'google.com', disabled: true }, icon: iconString } }, { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, + { provide: ThemeService, useValue: getMockThemeService() }, ], }).compileComponents(); })); @@ -71,7 +81,15 @@ describe('AdminSidebarSectionComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(AdminSidebarSectionComponent); component = fixture.componentInstance; - spyOn(component as any, 'getMenuItemComponent').and.returnValue(TestComponent); + component.section = { + model: { + link: 'google.com', + disabled: true, + }, + icon: iconString, + } as MenuSection; + component.itemModel = component.section.model; + spyOn(component, 'getMenuItemComponent').and.returnValue(Promise.resolve(TestComponent)); fixture.detectChanges(); }); @@ -96,7 +114,7 @@ describe('AdminSidebarSectionComponent', () => { selector: 'ds-test-cmp', template: ``, imports: [ - RouterTestingModule, + RouterModule, ], }) class TestComponent { diff --git a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts index 5634b1c2620..8770b7c57e3 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component.ts @@ -1,8 +1,8 @@ import { NgClass } from '@angular/common'; import { Component, - Inject, Injector, + OnChanges, OnInit, } from '@angular/core'; import { @@ -14,9 +14,10 @@ import { TranslateModule } from '@ngx-translate/core'; import { MenuService } from '../../../shared/menu/menu.service'; import { MenuID } from '../../../shared/menu/menu-id.model'; -import { LinkMenuItemModel } from '../../../shared/menu/menu-item/models/link.model'; +import { rendersSectionForMenu } from '../../../shared/menu/menu-section.decorator'; import { MenuSection } from '../../../shared/menu/menu-section.model'; import { AbstractMenuSectionComponent } from '../../../shared/menu/menu-section/abstract-menu-section.component'; +import { ThemeService } from '../../../shared/theme-support/theme.service'; import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe'; /** @@ -34,13 +35,13 @@ import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe'; ], }) -export class AdminSidebarSectionComponent extends AbstractMenuSectionComponent implements OnInit { +@rendersSectionForMenu(MenuID.ADMIN, false) +export class AdminSidebarSectionComponent extends AbstractMenuSectionComponent implements OnInit, OnChanges { /** * This section resides in the Admin Sidebar */ menuID: MenuID = MenuID.ADMIN; - itemModel; /** * Boolean to indicate whether this section is disabled @@ -48,13 +49,16 @@ export class AdminSidebarSectionComponent extends AbstractMenuSectionComponent i isDisabled: boolean; constructor( - @Inject('sectionDataProvider') protected section: MenuSection, protected menuService: MenuService, protected injector: Injector, + protected themeService: ThemeService, protected router: Router, ) { - super(menuService, injector); - this.itemModel = section.model as LinkMenuItemModel; + super( + menuService, + injector, + themeService, + ); } ngOnInit(): void { diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.html b/src/app/admin/admin-sidebar/admin-sidebar.component.html index fc79c58b998..fc4d0ec08c3 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.html +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.html @@ -26,9 +26,11 @@

{{ 'menu.header.admin' | translate }}

diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts b/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts index 1c83edcd54b..b1981cf342c 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.spec.ts @@ -101,7 +101,7 @@ describe('AdminSidebarComponent', () => { spyOn(menuService, 'getMenuTopSections').and.returnValue(of([])); fixture = TestBed.createComponent(AdminSidebarComponent); comp = fixture.componentInstance; // SearchPageComponent test instance - comp.sections = of([]); + comp.sectionDTOs$ = of([]); fixture.detectChanges(); }); diff --git a/src/app/admin/admin-sidebar/admin-sidebar.component.ts b/src/app/admin/admin-sidebar/admin-sidebar.component.ts index 59c490992d9..0d30e0eee2c 100644 --- a/src/app/admin/admin-sidebar/admin-sidebar.component.ts +++ b/src/app/admin/admin-sidebar/admin-sidebar.component.ts @@ -1,7 +1,6 @@ import { AsyncPipe, NgClass, - NgComponentOutlet, } from '@angular/common'; import { Component, @@ -31,6 +30,7 @@ import { import { slideSidebar } from '../../shared/animations/slide'; import { MenuComponent } from '../../shared/menu/menu.component'; import { MenuService } from '../../shared/menu/menu.service'; +import { MenuComponentLoaderComponent } from '../../shared/menu/menu-component-loader/menu-component-loader.component'; import { MenuID } from '../../shared/menu/menu-id.model'; import { CSSVariableService } from '../../shared/sass-helper/css-variable.service'; import { ThemeService } from '../../shared/theme-support/theme.service'; @@ -47,9 +47,9 @@ import { BrowserOnlyPipe } from '../../shared/utils/browser-only.pipe'; imports: [ AsyncPipe, BrowserOnlyPipe, + MenuComponentLoaderComponent, NgbDropdownModule, NgClass, - NgComponentOutlet, TranslatePipe, ], }) diff --git a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html index f3bf20fc343..edbbc477ae4 100644 --- a/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html +++ b/src/app/admin/admin-sidebar/expandable-admin-sidebar-section/expandable-admin-sidebar-section.component.html @@ -22,8 +22,9 @@