diff --git a/config/.gitignore b/config/.gitignore new file mode 100644 index 00000000000..e1899191e15 --- /dev/null +++ b/config/.gitignore @@ -0,0 +1 @@ +appConfig.*.json diff --git a/config/appConfig.json b/config/appConfig.json new file mode 100644 index 00000000000..3390bbb4789 --- /dev/null +++ b/config/appConfig.json @@ -0,0 +1,14 @@ +{ + "ui": { + "ssl": false, + "host": "localhost", + "port": 4000, + "nameSpace": "/" + }, + "rest": { + "ssl": true, + "host": "api7.dspace.org", + "port": 443, + "nameSpace": "/server" + } +} diff --git a/scripts/set-env.ts b/scripts/set-env.ts index b3516ae68f3..fd806ee1d92 100644 --- a/scripts/set-env.ts +++ b/scripts/set-env.ts @@ -1,6 +1,6 @@ import { writeFile } from 'fs'; import { environment as commonEnv } from '../src/environments/environment.common'; -import { GlobalConfig } from '../src/config/global-config.interface'; +import { AppConfig } from '../src/config/app-config.interface'; import { ServerConfig } from '../src/config/server-config.interface'; import { hasValue } from '../src/app/shared/empty.util'; @@ -42,7 +42,7 @@ const processEnv = { process.env.DSPACE_REST_PORT, process.env.DSPACE_REST_NAMESPACE, process.env.DSPACE_REST_SSL) -} as GlobalConfig; +} as AppConfig; import(environmentFilePath) .then((file) => generateEnvironmentFile(merge.all([commonEnv, file.environment, processEnv], mergeOptions))) @@ -51,7 +51,7 @@ import(environmentFilePath) generateEnvironmentFile(merge(commonEnv, processEnv, mergeOptions)) }); -function generateEnvironmentFile(file: GlobalConfig): void { +function generateEnvironmentFile(file: AppConfig): void { file.production = production; buildBaseUrls(file); const contents = `export const environment = ` + JSON.stringify(file); @@ -86,7 +86,7 @@ function createServerConfig(host?: string, port?: string, nameSpace?: string, ss return result; } -function buildBaseUrls(config: GlobalConfig): void { +function buildBaseUrls(config: AppConfig): void { for (const key in config) { if (config.hasOwnProperty(key) && config[key].host) { config[key].baseUrl = [ diff --git a/src/app/shared/notifications/notification/notification.component.spec.ts b/src/app/shared/notifications/notification/notification.component.spec.ts index 2bded57636c..1d194645789 100644 --- a/src/app/shared/notifications/notification/notification.component.spec.ts +++ b/src/app/shared/notifications/notification/notification.component.spec.ts @@ -10,8 +10,6 @@ import { NotificationsService } from '../notifications.service'; import { NotificationType } from '../models/notification-type'; import { notificationsReducer } from '../notifications.reducers'; import { NotificationOptions } from '../models/notification-options.model'; -import { INotificationBoardOptions } from '../../../../config/notifications-config.interfaces'; -import { GlobalConfig } from '../../../../config/global-config.interface'; import { Notification } from '../models/notification.model'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateLoaderMock } from '../../mocks/translate-loader.mock'; @@ -33,16 +31,6 @@ describe('NotificationComponent', () => { /* tslint:disable:no-empty */ notifications: [] }); - const envConfig: GlobalConfig = { - notifications: { - rtl: false, - position: ['top', 'right'], - maxStack: 8, - timeOut: 5000, - clickToClose: true, - animate: 'scale' - } as INotificationBoardOptions, - } as any; TestBed.configureTestingModule({ imports: [ diff --git a/src/app/shared/notifications/notifications.service.spec.ts b/src/app/shared/notifications/notifications.service.spec.ts index bb0f94eedec..92c74e00170 100644 --- a/src/app/shared/notifications/notifications.service.spec.ts +++ b/src/app/shared/notifications/notifications.service.spec.ts @@ -8,7 +8,6 @@ import { of as observableOf } from 'rxjs'; import { NewNotificationAction, RemoveAllNotificationsAction, RemoveNotificationAction } from './notifications.actions'; import { Notification } from './models/notification.model'; import { NotificationType } from './models/notification-type'; -import { GlobalConfig } from '../../../config/global-config.interface'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateLoaderMock } from '../mocks/translate-loader.mock'; import { storeModuleConfig } from '../../app.reducer'; @@ -19,18 +18,6 @@ describe('NotificationsService test', () => { select: observableOf(true) }); let service: NotificationsService; - let envConfig: GlobalConfig; - - envConfig = { - notifications: { - rtl: false, - position: ['top', 'right'], - maxStack: 8, - timeOut: 5000, - clickToClose: true, - animate: 'scale' - }, - } as any; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ diff --git a/src/assets/.gitignore b/src/assets/.gitignore new file mode 100644 index 00000000000..c25cf8c104c --- /dev/null +++ b/src/assets/.gitignore @@ -0,0 +1 @@ +appConfig.json diff --git a/src/config/global-config.interface.ts b/src/config/app-config.interface.ts similarity index 87% rename from src/config/global-config.interface.ts rename to src/config/app-config.interface.ts index d46822eb61c..9a993cb4152 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -1,3 +1,4 @@ +import { InjectionToken } from '@angular/core'; import { Config } from './config.interface'; import { ServerConfig } from './server-config.interface'; import { CacheConfig } from './cache-config.interface'; @@ -14,7 +15,7 @@ import { AuthConfig } from './auth-config.interfaces'; import { UIServerConfig } from './ui-server-config.interface'; import { MediaViewerConfig } from './media-viewer-config.interface'; -export interface GlobalConfig extends Config { +interface AppConfig extends Config { ui: UIServerConfig; rest: ServerConfig; production: boolean; @@ -33,3 +34,10 @@ export interface GlobalConfig extends Config { themes: ThemeConfig[]; mediaViewer: MediaViewerConfig; } + +const APP_CONFIG = new InjectionToken('APP_CONFIG'); + +export { + AppConfig, + APP_CONFIG +}; diff --git a/src/config/config.ts b/src/config/config.ts new file mode 100644 index 00000000000..d695c141f8c --- /dev/null +++ b/src/config/config.ts @@ -0,0 +1,151 @@ +import * as colors from 'colors'; +import * as fs from 'fs'; +import { join } from 'path'; +import { AppConfig } from './app-config.interface'; +import { Config } from './config.interface'; +import { DefaultAppConfig } from './default-app-config'; +import { ServerConfig } from './server-config.interface'; + +const CONFIG_PATH = join(process.cwd(), 'config'); + +const APP_CONFIG_PATH = join(CONFIG_PATH, 'appConfig.json'); + +type Environment = 'production' | 'development' | 'test'; + +const getEnvironment = (): Environment => { + let environment: Environment = 'development'; + if (!!process.env.NODE_ENV) { + switch (process.env.NODE_ENV) { + case 'prod': + case 'production': + environment = 'production'; + break; + case 'test': + environment = 'test'; + break; + case 'dev': + case 'development': + break; + default: + console.warn(`Unknown NODE_ENV ${process.env.NODE_ENV}. Defaulting to development`); + } + } + + return environment; +}; + +const getDistConfigPath = (env: Environment) => { + // determine app config filename variations + let envVariations; + switch (env) { + case 'production': + envVariations = ['prod', 'production']; + break; + case 'test': + envVariations = ['test']; + break; + case 'development': + default: + envVariations = ['dev', 'development'] + } + + // check if any environment variations of app config exist + for (const envVariation of envVariations) { + const altDistConfigPath = join(CONFIG_PATH, `appConfig.${envVariation}.json`); + if (fs.existsSync(altDistConfigPath)) { + return altDistConfigPath; + } + } + + // return default config/appConfig.json + return APP_CONFIG_PATH; +}; + +const overrideWithConfig = (config: Config, pathToConfig: string) => { + try { + console.log(`Overriding app config with ${pathToConfig}`); + const externalConfig = fs.readFileSync(pathToConfig, 'utf8'); + Object.assign(config, JSON.parse(externalConfig)); + } catch (err) { + console.error(err); + } +}; + +const overrideWithEnvironment = (config: Config, key: string = '') => { + for (const property in config) { + const variable = `${key}${!!key ? '_' : ''}${property.toUpperCase()}`; + const innerConfig = config[property]; + if (!!innerConfig) { + if (typeof innerConfig === 'object') { + overrideWithEnvironment(innerConfig, variable); + } else { + if (!!process.env[variable]) { + console.log(`Applying environment variable ${variable} with value ${process.env[variable]}`); + config[property] = process.env[variable]; + } + } + } + } +}; + +const buildBaseUrl = (config: ServerConfig): void => { + config.baseUrl = [ + config.ssl ? 'https://' : 'http://', + config.host, + config.port && config.port !== 80 && config.port !== 443 ? `:${config.port}` : '', + config.nameSpace && config.nameSpace.startsWith('/') ? config.nameSpace : `/${config.nameSpace}` + ].join(''); +}; + +export const buildAppConfig = (destConfigPath: string): AppConfig => { + // start with default app config + const appConfig: AppConfig = new DefaultAppConfig(); + + // determine which dist app config by environment + const env = getEnvironment(); + + switch (env) { + case 'production': + console.log(`Building ${colors.red.bold(`production`)} app config`); + break; + case 'test': + console.log(`Building ${colors.blue.bold(`test`)} app config`); + break; + default: + console.log(`Building ${colors.green.bold(`development`)} app config`); + } + + // override with dist config + const distConfigPath = getDistConfigPath(env); + if (fs.existsSync(distConfigPath)) { + overrideWithConfig(appConfig, distConfigPath); + } else { + console.warn(`Unable to find dist config file at ${distConfigPath}`); + } + + // override with external config if specified by environment variable `APP_CONFIG_PATH` + const externalConfigPath = process.env.APP_CONFIG_PATH; + if (!!externalConfigPath) { + if (fs.existsSync(externalConfigPath)) { + overrideWithConfig(appConfig, externalConfigPath); + } else { + console.warn(`Unable to find external config file at ${externalConfigPath}`); + } + } + + // override with environment variables + overrideWithEnvironment(appConfig); + + // apply build defined production + appConfig.production = env === 'production'; + + // build base URLs + buildBaseUrl(appConfig.ui); + buildBaseUrl(appConfig.rest); + + fs.writeFileSync(destConfigPath, JSON.stringify(appConfig, null, 2)); + + console.log(`Angular ${colors.bold('appConfig.json')} file generated correctly at ${colors.bold(destConfigPath)} \n`); + + return appConfig; +} diff --git a/src/config/default-app-config.ts b/src/config/default-app-config.ts new file mode 100644 index 00000000000..12859db977a --- /dev/null +++ b/src/config/default-app-config.ts @@ -0,0 +1,306 @@ +import { BrowseByType } from '../app/browse-by/browse-by-switcher/browse-by-decorator'; +import { RestRequestMethod } from '../app/core/data/rest-request-method'; +import { NotificationAnimationsType } from '../app/shared/notifications/models/notification-animations-type'; +import { AppConfig } from './app-config.interface'; +import { AuthConfig } from './auth-config.interfaces'; +import { BrowseByConfig } from './browse-by-config.interface'; +import { CacheConfig } from './cache-config.interface'; +import { CollectionPageConfig } from './collection-page-config.interface'; +import { FormConfig } from './form-config.interfaces'; +import { ItemPageConfig } from './item-page-config.interface'; +import { LangConfig } from './lang-config.interface'; +import { MediaViewerConfig } from './media-viewer-config.interface'; +import { INotificationBoardOptions } from './notifications-config.interfaces'; +import { ServerConfig } from './server-config.interface'; +import { SubmissionConfig } from './submission-config.interface'; +import { ThemeConfig } from './theme.model'; +import { UIServerConfig } from './ui-server-config.interface'; +import { UniversalConfig } from './universal-config.interface'; + +export class DefaultAppConfig implements AppConfig { + production: boolean = false; + + // Angular Universal server settings. + // NOTE: these must be 'synced' with the 'dspace.ui.url' setting in your backend's local.cfg. + ui: UIServerConfig = { + ssl: false, + host: 'localhost', + port: 4000, + // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript + nameSpace: '/', + + // The rateLimiter settings limit each IP to a 'max' of 500 requests per 'windowMs' (1 minute). + rateLimiter: { + windowMs: 1 * 60 * 1000, // 1 minute + max: 500 // limit each IP to 500 requests per windowMs + } + }; + + // The REST API server settings. + // NOTE: these must be 'synced' with the 'dspace.server.url' setting in your backend's local.cfg. + rest: ServerConfig = { + ssl: false, + host: 'localhost', + port: 8080, + // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript + nameSpace: '/', + }; + + // Caching settings + cache: CacheConfig = { + // NOTE: how long should objects be cached for by default + msToLive: { + default: 15 * 60 * 1000 // 15 minutes + }, + control: 'max-age=60', // revalidate browser + autoSync: { + defaultTime: 0, + maxBufferSize: 100, + timePerMethod: { [RestRequestMethod.PATCH]: 3 } as any // time in seconds + } + }; + + // Authentication settings + auth: AuthConfig = { + // Authentication UI settings + ui: { + // the amount of time before the idle warning is shown + timeUntilIdle: 15 * 60 * 1000, // 15 minutes + // the amount of time the user has to react after the idle warning is shown before they are logged out. + idleGracePeriod: 5 * 60 * 1000 // 5 minutes + }, + // Authentication REST settings + rest: { + // If the rest token expires in less than this amount of time, it will be refreshed automatically. + // This is independent from the idle warning. + timeLeftBeforeTokenRefresh: 2 * 60 * 1000 // 2 minutes + } + }; + + // Form settings + form: FormConfig = { + // NOTE: Map server-side validators to comparative Angular form validators + validatorMap: { + required: 'required', + regex: 'pattern' + } + }; + + // Notifications + notifications: INotificationBoardOptions = { + rtl: false, + position: ['top', 'right'], + maxStack: 8, + // NOTE: after how many seconds notification is closed automatically. If set to zero notifications are not closed automatically + timeOut: 5000, // 5 second + clickToClose: true, + // NOTE: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' + animate: NotificationAnimationsType.Scale + }; + + // Submission settings + submission: SubmissionConfig = { + autosave: { + // NOTE: which metadata trigger an autosave + metadata: [], + /** + * NOTE: after how many time (milliseconds) submission is saved automatically + * eg. timer: 5 * (1000 * 60); // 5 minutes + */ + timer: 0 + }, + icons: { + metadata: [ + /** + * NOTE: example of configuration + * { + * // NOTE: metadata name + * name: 'dc.author', + * // NOTE: fontawesome (v5.x) icon classes and bootstrap utility classes can be used + * style: 'fa-user' + * } + */ + { + name: 'dc.author', + style: 'fas fa-user' + }, + // default configuration + { + name: 'default', + style: '' + } + ], + authority: { + confidence: [ + /** + * NOTE: example of configuration + * { + * // NOTE: confidence value + * value: 'dc.author', + * // NOTE: fontawesome (v4.x) icon classes and bootstrap utility classes can be used + * style: 'fa-user' + * } + */ + { + value: 600, + style: 'text-success' + }, + { + value: 500, + style: 'text-info' + }, + { + value: 400, + style: 'text-warning' + }, + // default configuration + { + value: 'default', + style: 'text-muted' + } + + ] + } + } + }; + + // Angular Universal settings + universal: UniversalConfig = { + preboot: true, + async: true, + time: false + }; + + // NOTE: will log all redux actions and transfers in console + debug: boolean = false; + + // Default Language in which the UI will be rendered if the user's browser language is not an active language + defaultLanguage: string = 'en'; + + // Languages. DSpace Angular holds a message catalog for each of the following languages. + // When set to active, users will be able to switch to the use of this language in the user interface. + languages: LangConfig[] = [ + { code: 'en', label: 'English', active: true }, + { code: 'cs', label: 'Čeština', active: true }, + { code: 'de', label: 'Deutsch', active: true }, + { code: 'es', label: 'Español', active: true }, + { code: 'fr', label: 'Français', active: true }, + { code: 'lv', label: 'Latviešu', active: true }, + { code: 'hu', label: 'Magyar', active: true }, + { code: 'nl', label: 'Nederlands', active: true }, + { code: 'pt-PT', label: 'Português', active: true }, + { code: 'pt-BR', label: 'Português do Brasil', active: true }, + { code: 'fi', label: 'Suomi', active: true } + ]; + + // Browse-By Pages + browseBy: BrowseByConfig = { + // Amount of years to display using jumps of one year (current year - oneYearLimit) + oneYearLimit: 10, + // Limit for years to display using jumps of five years (current year - fiveYearLimit) + fiveYearLimit: 30, + // The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items) + defaultLowerLimit: 1900, + // List of all the active Browse-By types + // Adding a type will activate their Browse-By page and add them to the global navigation menu, + // as well as community and collection pages + // Allowed fields and their purpose: + // id: The browse id to use for fetching info from the rest api + // type: The type of Browse-By page to display + // metadataField: The metadata-field used to create starts-with options (only necessary when the type is set to 'date') + types: [ + { + id: 'title', + type: BrowseByType.Title, + }, + { + id: 'dateissued', + type: BrowseByType.Date, + metadataField: 'dc.date.issued' + }, + { + id: 'author', + type: BrowseByType.Metadata + }, + { + id: 'subject', + type: BrowseByType.Metadata + } + ] + }; + + // Item Page Config + item: ItemPageConfig = { + edit: { + undoTimeout: 10000 // 10 seconds + } + }; + + // Collection Page Config + collection: CollectionPageConfig = { + edit: { + undoTimeout: 10000 // 10 seconds + } + }; + + // Theme Config + themes: ThemeConfig[] = [ + // Add additional themes here. In the case where multiple themes match a route, the first one + // in this list will get priority. It is advisable to always have a theme that matches + // every route as the last one + + // { + // // A theme with a handle property will match the community, collection or item with the given + // // handle, and all collections and/or items within it + // name: 'custom', + // handle: '10673/1233' + // }, + // { + // // A theme with a regex property will match the route using a regular expression. If it + // // matches the route for a community or collection it will also apply to all collections + // // and/or items within it + // name: 'custom', + // regex: 'collections\/e8043bc2.*' + // }, + // { + // // A theme with a uuid property will match the community, collection or item with the given + // // ID, and all collections and/or items within it + // name: 'custom', + // uuid: '0958c910-2037-42a9-81c7-dca80e3892b4' + // }, + // { + // // The extends property specifies an ancestor theme (by name). Whenever a themed component is not found + // // in the current theme, its ancestor theme(s) will be checked recursively before falling back to default. + // name: 'custom-A', + // extends: 'custom-B', + // // Any of the matching properties above can be used + // handle: '10673/34' + // }, + // { + // name: 'custom-B', + // extends: 'custom', + // handle: '10673/12' + // }, + // { + // // A theme with only a name will match every route + // name: 'custom' + // }, + // { + // // This theme will use the default bootstrap styling for DSpace components + // name: BASE_THEME_NAME + // }, + + { + // The default dspace theme + name: 'dspace' + } + ]; + + // Whether to enable media viewer for image and/or video Bitstreams (i.e. Bitstreams whose MIME type starts with 'image' or 'video'). + // For images, this enables a gallery viewer where you can zoom or page through images. + // For videos, this enables embedded video streaming + mediaViewer: MediaViewerConfig = { + image: false, + video: false + }; +} diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index b1cbd699a33..6bb2c8dc51f 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -1,9 +1,9 @@ -import { GlobalConfig } from '../config/global-config.interface'; +import { AppConfig } from '../config/app-config.interface'; import { NotificationAnimationsType } from '../app/shared/notifications/models/notification-animations-type'; import { BrowseByType } from '../app/browse-by/browse-by-switcher/browse-by-decorator'; import { RestRequestMethod } from '../app/core/data/rest-request-method'; -export const environment: GlobalConfig = { +export const environment: AppConfig = { production: true, // Angular Universal server settings. // NOTE: these must be "synced" with the 'dspace.ui.url' setting in your backend's local.cfg. diff --git a/src/environments/mock-environment.ts b/src/environments/mock-environment.ts index 824c8c8a83b..02c1012c68d 100644 --- a/src/environments/mock-environment.ts +++ b/src/environments/mock-environment.ts @@ -2,9 +2,9 @@ import { BrowseByType } from '../app/browse-by/browse-by-switcher/browse-by-decorator'; import { RestRequestMethod } from '../app/core/data/rest-request-method'; import { NotificationAnimationsType } from '../app/shared/notifications/models/notification-animations-type'; -import { GlobalConfig } from '../config/global-config.interface'; +import { AppConfig } from '../config/app-config.interface'; -export const environment: Partial = { +export const environment: Partial = { rest: { ssl: true, host: 'rest.com', diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index 734cd3c5f50..00000000000 --- a/src/main.ts +++ /dev/null @@ -1,20 +0,0 @@ -import 'core-js/es/reflect'; -import 'zone.js/dist/zone'; -import 'reflect-metadata'; - -import { enableProdMode } from '@angular/core'; -import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; - -import { AppModule } from './app/app.module'; -import { environment } from './environments/environment'; - -if (environment.production) { - enableProdMode(); -} - -document.addEventListener('DOMContentLoaded', () => { - document.addEventListener('DOMContentLoaded', () => { - platformBrowserDynamic().bootstrapModule(AppModule) - .catch((err) => console.error(err)); - }); -}); diff --git a/webpack/webpack.browser.ts b/webpack/webpack.browser.ts index bc281e9b43a..3c1d01dda01 100644 --- a/webpack/webpack.browser.ts +++ b/webpack/webpack.browser.ts @@ -1,8 +1,15 @@ +import { buildAppConfig } from '../src/config/config'; import { commonExports } from './webpack.common'; +import { join } from 'path'; module.exports = Object.assign({}, commonExports, { target: 'web', node: { module: 'empty' - } + }, + devServer: { + before(app, server) { + buildAppConfig(join(process.cwd(), 'src/assets/appConfig.json')); + } + } });