diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000000..691bb2e1994 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +src/decorator-registries/ 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 a5449e17c9e..3c8b1245ac4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "serve": "ts-node --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..5c1d373b545 --- /dev/null +++ b/scripts/generate-decorator-registries.ts @@ -0,0 +1,408 @@ +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; +import { sync } from 'glob'; +import { + basename, + dirname, + join, + relative, + resolve, +} from 'path'; +import { + createSourceFile, + forEachChild, + getDecorators, + Identifier, + ImportDeclaration, + isCallExpression, + isClassDeclaration, + isEnumDeclaration, + isExpressionWithTypeArguments, + isIdentifier, + isNumericLiteral, + isPropertyAccessExpression, + isStringLiteral, + 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 = ''; + + // 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 => { + let absoluteImportPath = allImports.get(arg.text); + if (!absoluteImportPath.includes('src/app')) { + absoluteImportPath = resolve(dirname(filePath), allImports.get(arg.text)); + } + return relative(REGISTRY_OUTPUT_DIR, 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) => { + // 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-ldn-services/ldn-services-data/ldn-itemfilters-data.service.ts b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service.ts index d64d2ed5cbc..8ed0a9dc81b 100644 --- a/src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service.ts +++ b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-itemfilters-data.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; +import { dataService } from '../../../core/cache/builders/build-decorators'; import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { @@ -15,12 +16,14 @@ import { RequestService } from '../../../core/data/request.service'; import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { LDN_SERVICE_CONSTRAINT_FILTERS } from '../ldn-services-model/ldn-service.resource-type'; import { Itemfilter } from '../ldn-services-model/ldn-service-itemfilters'; /** * A service responsible for fetching/sending data from/to the REST API on the itemfilters endpoint */ @Injectable({ providedIn: 'root' }) +@dataService(LDN_SERVICE_CONSTRAINT_FILTERS) export class LdnItemfiltersService extends IdentifiableDataService implements FindAllData { private findAllData: FindAllDataImpl; diff --git a/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.ts b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.ts index 6e368dd6809..de40cfc1523 100644 --- a/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.ts +++ b/src/app/admin/admin-ldn-services/ldn-services-data/ldn-services-data.service.ts @@ -6,6 +6,7 @@ import { take, } from 'rxjs/operators'; +import { dataService } from '../../../core/cache/builders/build-decorators'; import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; import { RequestParam } from '../../../core/cache/models/request-param.model'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; @@ -41,6 +42,7 @@ import { URLCombiner } from '../../../core/url-combiner/url-combiner'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { LdnServiceConstrain } from '../ldn-services-model/ldn-service.constrain.model'; +import { LDN_SERVICE } from '../ldn-services-model/ldn-service.resource-type'; import { LdnService } from '../ldn-services-model/ldn-services.model'; /** @@ -55,6 +57,7 @@ import { LdnService } from '../ldn-services-model/ldn-services.model'; * @implements {CreateData} */ @Injectable({ providedIn: 'root' }) +@dataService(LDN_SERVICE) export class LdnServicesService extends IdentifiableDataService implements FindAllData, DeleteData, PatchData, CreateData { createData: CreateDataImpl; private findAllData: FindAllDataImpl; 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 7b0d3cd9335..c0ed3039133 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 @@ -17,8 +17,11 @@ import { } from 'rxjs'; import { PaginatedList } from '../../../core/data/paginated-list.model'; +import { Context } from '../../../core/shared/context.model'; import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service'; +import { ViewMode } from '../../../core/shared/view-mode.model'; 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 { TruncatableComponent } from '../../../shared/truncatable/truncatable.component'; import { TruncatablePartComponent } from '../../../shared/truncatable/truncatable-part/truncatable-part.component'; @@ -50,6 +53,7 @@ import { AdminNotifyMessagesService } from '../services/admin-notify-messages.se /** * 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-notify-dashboard/models/admin-notify-message-search-result.model.ts b/src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts index c4df75ef3ee..51151189936 100644 --- a/src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts +++ b/src/app/admin/admin-notify-dashboard/models/admin-notify-message-search-result.model.ts @@ -1,5 +1,7 @@ import { SearchResult } from '../../../shared/search/models/search-result.model'; +import { searchResultFor } from '../../../shared/search/search-result-element-decorator'; import { AdminNotifyMessage } from './admin-notify-message.model'; +@searchResultFor(AdminNotifyMessage) export class AdminNotifySearchResult extends SearchResult { } diff --git a/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts index 2211facfc87..b6c7789ca90 100644 --- a/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts +++ b/src/app/admin/admin-notify-dashboard/services/admin-notify-messages.service.ts @@ -13,6 +13,7 @@ import { tap, } from 'rxjs/operators'; +import { dataService } from '../../../core/cache/builders/build-decorators'; import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { IdentifiableDataService } from '../../../core/data/base/identifiable-data.service'; @@ -28,6 +29,7 @@ import { import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { LdnServicesService } from '../../admin-ldn-services/ldn-services-data/ldn-services-data.service'; import { AdminNotifyMessage } from '../models/admin-notify-message.model'; +import { ADMIN_NOTIFY_MESSAGE } from '../models/admin-notify-message.resource-type'; /** * Injectable service responsible for fetching/sending data from/to the REST API on the messages' endpoint. @@ -37,6 +39,7 @@ import { AdminNotifyMessage } from '../models/admin-notify-message.model'; * @extends {IdentifiableDataService} */ @Injectable({ providedIn: 'root' }) +@dataService(ADMIN_NOTIFY_MESSAGE) export class AdminNotifyMessagesService extends IdentifiableDataService { protected reprocessEndpoint = 'enqueueretry'; 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 7a6b58dea2f..3510dcfdf03 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 @@ -16,7 +16,6 @@ import { RemoteData } from '../../../../../core/data/remote-data'; import { Bitstream } from '../../../../../core/shared/bitstream.model'; import { FileService } from '../../../../../core/shared/file.service'; import { Item } from '../../../../../core/shared/item.model'; -import { ListableModule } from '../../../../../core/shared/listable.module'; import { ViewMode } from '../../../../../core/shared/view-mode.model'; import { mockTruncatableService } from '../../../../../shared/mocks/mock-trucatable.service'; import { getMockThemeService } from '../../../../../shared/mocks/theme-service.mock'; @@ -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 362bd5d54e5..07c2a39bc6a 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 @@ -6,6 +6,11 @@ import { OnInit, ViewChild, } from '@angular/core'; +import { + from, + Observable, +} from 'rxjs'; +import { take } from 'rxjs/operators'; import { DSONameService } from '../../../../../core/breadcrumbs/dso-name.service'; import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; @@ -60,25 +65,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 { @@ -92,7 +99,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 becdcbd17b0..418cb1da146 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 { TranslateModule } from '@ngx-translate/core'; import { MenuService } from '../../../shared/menu/menu.service'; +import { MenuSection } from '../../../shared/menu/menu-section.model'; +import { getMockThemeService } from '../../../shared/mocks/theme-service.mock'; import { CSSVariableService } from '../../../shared/sass-helper/css-variable.service'; import { CSSVariableServiceStub } from '../../../shared/testing/css-variable-service.stub'; import { MenuServiceStub } from '../../../shared/testing/menu-service.stub'; +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(); }); @@ -97,7 +115,7 @@ describe('AdminSidebarSectionComponent', () => { template: ``, standalone: true, 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 1e0c4292b22..ebcb4741d62 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 { isEmpty } from '../../../shared/empty.util'; 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'; /** @@ -35,13 +36,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 @@ -49,13 +50,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 68f3cc550f0..5c0bc2eb5f1 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 6300c80c9d2..4d001eef862 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 { AuthorizationDataService } from '../../core/data/feature-authorization/ 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'; @@ -48,9 +48,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 @@