Merge remote-tracking branch 'origin/main' into CST-4981-statistics-are-always-accessible-even-if-are-restricted-to-administrator

# Conflicts:
#	src/app/community-page/edit-community-page/community-authorizations/community-authorizations.component.ts
#	src/app/navbar/navbar.component.spec.ts
#	src/app/navbar/navbar.component.ts
#	src/app/shared/resource-policies/form/resource-policy-form.component.spec.ts
This commit is contained in:
Giuseppe Digilio
2021-12-24 12:25:03 +01:00
70 changed files with 2260 additions and 1526 deletions

4
.gitignore vendored
View File

@@ -7,10 +7,6 @@ npm-debug.log
/build/ /build/
/src/environments/environment.ts
/src/environments/environment.dev.ts
/src/environments/environment.prod.ts
/coverage /coverage
/dist/ /dist/

181
README.md
View File

@@ -102,48 +102,87 @@ Installing
### Configuring ### Configuring
Default configuration file is located in `src/environments/` folder. Default configuration file is located in `config/` folder.
To change the default configuration values, create local files that override the parameters you need to change. You can use `environment.template.ts` as a starting point. To override the default configuration values, create local files that override the parameters you need to change. You can use `config.example.yml` as a starting point.
- Create a new `environment.dev.ts` file in `src/environments/` for a `development` environment; - Create a new `config.(dev or development).yml` file in `config/` for a `development` environment;
- Create a new `environment.prod.ts` file in `src/environments/` for a `production` environment; - Create a new `config.(prod or production).yml` file in `config/` for a `production` environment;
The server settings can also be overwritten using an environment file. The settings can also be overwritten using an environment file or environment variables.
This file should be called `.env` and be placed in the project root. This file should be called `.env` and be placed in the project root.
The following settings can be overwritten in this file: The following non-convention settings:
```bash ```bash
DSPACE_HOST # The host name of the angular application DSPACE_HOST # The host name of the angular application
DSPACE_PORT # The port number of the angular application DSPACE_PORT # The port number of the angular application
DSPACE_NAMESPACE # The namespace of the angular application DSPACE_NAMESPACE # The namespace of the angular application
DSPACE_SSL # Whether the angular application uses SSL [true/false] DSPACE_SSL # Whether the angular application uses SSL [true/false]
```
DSPACE_REST_HOST # The host name of the REST application All other settings can be set using the following convention for naming the environment variables:
DSPACE_REST_PORT # The port number of the REST application
DSPACE_REST_NAMESPACE # The namespace of the REST application 1. replace all `.` with `_`
DSPACE_REST_SSL # Whether the angular REST uses SSL [true/false] 2. convert all characters to upper case
3. prefix with `DSPACE_`
e.g.
```bash
# The host name of the REST application
rest.host => DSPACE_REST_HOST
# The port number of the REST application
rest.port => DSPACE_REST_PORT
# The namespace of the REST application
rest.nameSpace => DSPACE_REST_NAMESPACE
# Whether the angular REST uses SSL [true/false]
rest.ssl => DSPACE_REST_SSL
cache.msToLive.default => DSPACE_CACHE_MSTOLIVE_DEFAULT
auth.ui.timeUntilIdle => DSPACE_AUTH_UI_TIMEUNTILIDLE
```
The equavelant to the non-conventional legacy settings:
```bash
DSPACE_UI_HOST => DSPACE_HOST
DSPACE_UI_PORT => DSPACE_PORT
DSPACE_UI_NAMESPACE => DSPACE_NAMESPACE
DSPACE_UI_SSL => DSPACE_SSL
``` ```
The same settings can also be overwritten by setting system environment variables instead, E.g.: The same settings can also be overwritten by setting system environment variables instead, E.g.:
```bash ```bash
export DSPACE_HOST=api7.dspace.org export DSPACE_HOST=api7.dspace.org
export DSPACE_UI_PORT=4200
``` ```
The priority works as follows: **environment variable** overrides **variable in `.env` file** overrides **`environment.(prod, dev or test).ts`** overrides **`environment.common.ts`** The priority works as follows: **environment variable** overrides **variable in `.env` file** overrides external config set by `DSPACE_APP_CONFIG_PATH` overrides **`config.(prod or dev).yml`**
These configuration sources are collected **at build time**, and written to `src/environments/environment.ts`. At runtime the configuration is fixed, and neither `.env` nor the process' environment will be consulted. These configuration sources are collected **at run time**, and written to `dist/browser/assets/config.json` for production and `src/app/assets/config.json` for development.
The configuration file can be externalized by using environment variable `DSPACE_APP_CONFIG_PATH`.
#### Using environment variables in code #### Using environment variables in code
To use environment variables in a UI component, use: To use environment variables in a UI component, use:
```typescript ```typescript
import { environment } from '../environment.ts'; import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface';
...
constructor(@Inject(APP_CONFIG) private appConfig: AppConfig) {}
...
``` ```
This file is generated by the script located in `scripts/set-env.ts`. This script will run automatically before every build, or can be manually triggered using the appropriate `config` script in `package.json` or
```typescript
import { environment } from '../environment.ts';
```
Running the app Running the app
@@ -231,7 +270,7 @@ E2E tests (aka integration tests) use [Cypress.io](https://www.cypress.io/). Con
The test files can be found in the `./cypress/integration/` folder. The test files can be found in the `./cypress/integration/` folder.
Before you can run e2e tests, two things are required: Before you can run e2e tests, two things are required:
1. You MUST have a running backend (i.e. REST API). By default, the e2e tests look for this at http://localhost:8080/server/ or whatever `rest` backend is defined in your `environment.prod.ts` or `environment.common.ts`. You may override this using env variables, see [Configuring](#configuring). 1. You MUST have a running backend (i.e. REST API). By default, the e2e tests look for this at http://localhost:8080/server/ or whatever `rest` backend is defined in your `config.prod.yml` or `config.yml`. You may override this using env variables, see [Configuring](#configuring).
2. Your backend MUST include our Entities Test Data set. Some tests run against a (currently hardcoded) Community/Collection/Item UUID. These UUIDs are all valid for our Entities Test Data set. The Entities Test Data set may be installed easily via Docker, see https://github.com/DSpace/DSpace/tree/main/dspace/src/main/docker-compose#ingest-option-2-ingest-entities-test-data 2. Your backend MUST include our Entities Test Data set. Some tests run against a (currently hardcoded) Community/Collection/Item UUID. These UUIDs are all valid for our Entities Test Data set. The Entities Test Data set may be installed easily via Docker, see https://github.com/DSpace/DSpace/tree/main/dspace/src/main/docker-compose#ingest-option-2-ingest-entities-test-data
Run `ng e2e` to kick off the tests. This will start Cypress and allow you to select the browser you wish to use, as well as whether you wish to run all tests or an individual test file. Once you click run on test(s), this opens the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner) to run your test(s) and show you the results. Run `ng e2e` to kick off the tests. This will start Cypress and allow you to select the browser you wish to use, as well as whether you wish to run all tests or an individual test file. Once you click run on test(s), this opens the [Cypress Test Runner](https://docs.cypress.io/guides/core-concepts/test-runner) to run your test(s) and show you the results.
@@ -311,49 +350,87 @@ File Structure
``` ```
dspace-angular dspace-angular
├── README.md * This document ├── config *
├── app.yaml * Application manifest file │ └── config.yml * Default app config
├── config * Folder for configuration files
│   ├── environment.default.js * Default configuration files
│   └── environment.test.js * Test configuration files
├── docs * Folder for documentation
├── cypress * Folder for Cypress (https://cypress.io/) / e2e tests ├── cypress * Folder for Cypress (https://cypress.io/) / e2e tests
   ├── integration * Folder for e2e/integration test files ├── downloads *
   ├── fixtures * Folder for any fixtures needed by e2e tests ├── fixtures * Folder for e2e/integration test files
   ├── plugins * Folder for Cypress plugins (if any) ├── integration * Folder for any fixtures needed by e2e tests
   ├── support * Folder for global e2e test actions/commands (run for all tests) ├── plugins * Folder for Cypress plugins (if any)
   └── tsconfig.json * TypeScript configuration file for e2e tests ├── support * Folder for global e2e test actions/commands (run for all tests)
│ └── tsconfig.json * TypeScript configuration file for e2e tests
├── docker *
│ ├── cli.assetstore.yml *
│ ├── cli.ingest.yml *
│ ├── cli.yml *
│ ├── db.entities.yml *
│ ├── docker-compose-ci.yml *
│ ├── docker-compose-rest.yml *
│ ├── docker-compose.yml *
│ ├── environment.dev.ts *
│ ├── local.cfg *
│ └── README.md *
├── docs * Folder for documentation
│ └── Configuration.md * Configuration documentation
├── scripts *
│ ├── merge-i18n-files.ts *
│ ├── serve.ts *
│ ├── sync-i18n-files.ts *
│ ├── test-rest.ts *
│ └── webpack.js *
├── src * The source of the application
│ ├── app * The source code of the application, subdivided by module/page.
│ ├── assets * Folder for static resources
│ │ ├── fonts * Folder for fonts
│ │ ├── i18n * Folder for i18n translations
│ │ └── images * Folder for images
│ ├── backend * Folder containing a mock of the REST API, hosted by the express server
│ ├── config *
│ ├── environments *
│ │ ├── environment.production.ts * Production configuration files
│ │ ├── environment.test.ts * Test configuration files
│ │ └── environment.ts * Default (development) configuration files
│ ├── mirador-viewer *
│ ├── modules *
│ ├── ngx-translate-loaders *
│ ├── styles * Folder containing global styles
│ ├── themes * Folder containing available themes
│ │ ├── custom * Template folder for creating a custom theme
│ │ └── dspace * Default 'dspace' theme
│ ├── index.csr.html * The index file for client side rendering fallback
│ ├── index.html * The index file
│ ├── main.browser.ts * The bootstrap file for the client
│ ├── main.server.ts * The express (http://expressjs.com/) config and bootstrap file for the server
│ ├── polyfills.ts *
│ ├── robots.txt * The robots.txt file
│ ├── test.ts *
│ └── typings.d.ts *
├── webpack *
│ ├── helpers.ts * Webpack helpers
│ ├── webpack.browser.ts * Webpack (https://webpack.github.io/) config for browser build
│ ├── webpack.common.ts * Webpack (https://webpack.github.io/) common build config
│ ├── webpack.mirador.config.ts * Webpack (https://webpack.github.io/) config for mirador config build
│ ├── webpack.prod.ts * Webpack (https://webpack.github.io/) config for prod build
│ └── webpack.test.ts * Webpack (https://webpack.github.io/) config for test build
├── angular.json * Angular CLI (https://angular.io/cli) configuration
├── cypress.json * Cypress Test (https://www.cypress.io/) configuration
├── Dockerfile *
├── karma.conf.js * Karma configuration file for Unit Test ├── karma.conf.js * Karma configuration file for Unit Test
├── LICENSE *
├── LICENSES_THIRD_PARTY *
├── nodemon.json * Nodemon (https://nodemon.io/) configuration ├── nodemon.json * Nodemon (https://nodemon.io/) configuration
├── package.json * This file describes the npm package for this project, its dependencies, scripts, etc. ├── package.json * This file describes the npm package for this project, its dependencies, scripts, etc.
├── postcss.config.js * PostCSS (http://postcss.org/) configuration file ├── postcss.config.js * PostCSS (http://postcss.org/) configuration
├── src * The source of the application ├── README.md * This document
│   ├── app * The source code of the application, subdivided by module/page. ├── SECURITY.md *
│   ├── assets * Folder for static resources ├── server.ts * Angular Universal Node.js Express server
│   │   ├── fonts * Folder for fonts ├── tsconfig.app.json * TypeScript config for browser (app)
│   │   ├── i18n * Folder for i18n translations ├── tsconfig.json * TypeScript common config
│   | └── en.json5 * i18n translations for English ├── tsconfig.server.json * TypeScript config for server
│   │   └── images * Folder for images ├── tsconfig.spec.json * TypeScript config for tests
│   ├── backend * Folder containing a mock of the REST API, hosted by the express server ├── tsconfig.ts-node.json * TypeScript config for using ts-node directly
│   ├── config *
│   ├── index.csr.html * The index file for client side rendering fallback
│   ├── index.html * The index file
│   ├── main.browser.ts * The bootstrap file for the client
│   ├── main.server.ts * The express (http://expressjs.com/) config and bootstrap file for the server
│   ├── robots.txt * The robots.txt file
│   ├── modules *
│   ├── styles * Folder containing global styles
│   └── themes * Folder containing available themes
│      ├── custom * Template folder for creating a custom theme
│      └── dspace * Default 'dspace' theme
├── tsconfig.json * TypeScript config
├── tslint.json * TSLint (https://palantir.github.io/tslint/) configuration ├── tslint.json * TSLint (https://palantir.github.io/tslint/) configuration
├── typedoc.json * TYPEDOC configuration ├── typedoc.json * TYPEDOC configuration
├── webpack * Webpack (https://webpack.github.io/) config directory
│   ├── webpack.browser.ts * Webpack (https://webpack.github.io/) config for client build
│   ├── webpack.common.ts *
│   ├── webpack.prod.ts * Webpack (https://webpack.github.io/) config for production build
│   └── webpack.test.ts * Webpack (https://webpack.github.io/) config for test build
└── yarn.lock * Yarn lockfile (https://yarnpkg.com/en/docs/yarn-lock) └── yarn.lock * Yarn lockfile (https://yarnpkg.com/en/docs/yarn-lock)
``` ```

View File

@@ -68,6 +68,12 @@
}, },
"configurations": { "configurations": {
"production": { "production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts"
}
],
"optimization": true, "optimization": true,
"outputHashing": "all", "outputHashing": "all",
"extractCss": true, "extractCss": true,
@@ -139,6 +145,16 @@
} }
], ],
"scripts": [] "scripts": []
},
"configurations": {
"test": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.test.ts"
}
]
}
} }
}, },
"lint": { "lint": {
@@ -183,7 +199,13 @@
"configurations": { "configurations": {
"production": { "production": {
"sourceMap": false, "sourceMap": false,
"optimization": true "optimization": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts"
}
]
} }
} }
}, },

2
config/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
config.*.yml
!config.example.yml

233
config/config.example.yml Normal file
View File

@@ -0,0 +1,233 @@
# NOTE: will log all redux actions and transfers in console
debug: false
# Angular Universal server settings
# NOTE: these must be 'synced' with the 'dspace.ui.url' setting in your backend's local.cfg.
ui:
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: 60000 # 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:
ssl: true
host: api7.dspace.org
port: 443
# NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: /server
# Caching settings
cache:
# NOTE: how long should objects be cached for by default
msToLive:
default: 900000 # 15 minutes
control: max-age=60 # revalidate browser
autoSync:
defaultTime: 0
maxBufferSize: 100
timePerMethod:
PATCH: 3 # time in seconds
# Authentication settings
auth:
# Authentication UI settings
ui:
# the amount of time before the idle warning is shown
timeUntilIdle: 900000 # 15 minutes
# the amount of time the user has to react after the idle warning is shown before they are logged out.
idleGracePeriod: 300000 # 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: 120000 # 2 minutes
# Form settings
form:
# NOTE: Map server-side validators to comparative Angular form validators
validatorMap:
required: required
regex: pattern
# Notification settings
notifications:
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: scale
# Submission settings
submission:
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: fas fa-user
- name: dc.author
style: fas fa-user
# default configuration
- name: default
style: ''
authority:
confidence:
# NOTE: example of configuration
# # NOTE: confidence value
# - name: dc.author
# # NOTE: fontawesome (v5.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
# Default Language in which the UI will be rendered if the user's browser language is not an active language
defaultLanguage: 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:
- 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:
# 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
# Item Page Config
item:
edit:
undoTimeout: 10000 # 10 seconds
# Collection Page Config
collection:
edit:
undoTimeout: 10000 # 10 seconds
# Theme Config
themes:
# 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
#
- name: dspace
headTags:
- tagName: link
attributes:
rel: icon
href: assets/dspace/images/favicons/favicon.ico
sizes: any
- tagName: link
attributes:
rel: icon
href: assets/dspace/images/favicons/favicon.svg
type: image/svg+xml
- tagName: link
attributes:
rel: apple-touch-icon
href: assets/dspace/images/favicons/apple-touch-icon.png
- tagName: link
attributes:
rel: manifest
href: assets/dspace/images/favicons/manifest.webmanifest
# 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:
image: false
video: false

5
config/config.yml Normal file
View File

@@ -0,0 +1,5 @@
rest:
ssl: true
host: api7.dspace.org
port: 443
nameSpace: /server

2
cypress/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
screenshots/
videos/

View File

@@ -1,26 +1,30 @@
# Configuration # Configuration
Default configuration file is located in `src/environments/` folder. All configuration options should be listed in the default configuration file `src/environments/environment.common.ts`. Please do not change this file directly! To change the default configuration values, create local files that override the parameters you need to change. You can use `environment.template.ts` as a starting point. Default configuration file is located at `config/config.yml`. All configuration options should be listed in the default typescript file `src/config/default-app-config.ts`. Please do not change this file directly! To override the default configuration values, create local files that override the parameters you need to change. You can use `config.example.yml` as a starting point.
- Create a new `environment.dev.ts` file in `src/environments/` for `development` environment; - Create a new `config.(dev or development).yml` file in `config/` for `development` environment;
- Create a new `environment.prod.ts` file in `src/environments/` for `production` environment; - Create a new `config.(prod or production).yml` file in `config/` for `production` environment;
Some few configuration options can be overridden by setting environment variables. These and the variable names are listed below. Alternatively, create a desired app config file at an external location and set the path as environment variable `DSPACE_APP_CONFIG_PATH`.
e.g.
```
DSPACE_APP_CONFIG_PATH=/usr/local/dspace/config/config.yml
```
Configuration options can be overridden by setting environment variables.
## Nodejs server ## Nodejs server
When you start dspace-angular on node, it spins up an http server on which it listens for incoming connections. You can define the ip address and port the server should bind itsself to, and if ssl should be enabled not. By default it listens on `localhost:4000`. If you want it to listen on all your network connections, configure it to bind itself to `0.0.0.0`. When you start dspace-angular on node, it spins up an http server on which it listens for incoming connections. You can define the ip address and port the server should bind itsself to, and if ssl should be enabled not. By default it listens on `localhost:4000`. If you want it to listen on all your network connections, configure it to bind itself to `0.0.0.0`.
To change this configuration, change the options `ui.host`, `ui.port` and `ui.ssl` in the appropriate configuration file (see above): To change this configuration, change the options `ui.host`, `ui.port` and `ui.ssl` in the appropriate configuration file (see above):
```
export const environment = { ```yaml
// Angular UI settings. ui:
ui: { ssl: false
ssl: false, host: localhost
host: 'localhost', port: 4000
port: 4000, nameSpace: /
nameSpace: '/'
}
};
``` ```
Alternately you can set the following environment variables. If any of these are set, it will override all configuration files: Alternately you can set the following environment variables. If any of these are set, it will override all configuration files:
@@ -30,21 +34,24 @@ Alternately you can set the following environment variables. If any of these are
DSPACE_PORT=4000 DSPACE_PORT=4000
DSPACE_NAMESPACE=/ DSPACE_NAMESPACE=/
``` ```
or
```
DSPACE_UI_SSL=true
DSPACE_UI_HOST=localhost
DSPACE_UI_PORT=4000
DSPACE_UI_NAMESPACE=/
```
## DSpace's REST endpoint ## DSpace's REST endpoint
dspace-angular connects to your DSpace installation by using its REST endpoint. To do so, you have to define the ip address, port and if ssl should be enabled. You can do this in a configuration file (see above) by adding the following options: dspace-angular connects to your DSpace installation by using its REST endpoint. To do so, you have to define the ip address, port and if ssl should be enabled. You can do this in a configuration file (see above) by adding the following options:
``` ```yaml
export const environment = { rest:
// The REST API server settings. ssl: true
rest: { host: api7.dspace.org
ssl: true, port: 443
host: 'api7.dspace.org', nameSpace: /server
port: 443, }
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/server'
}
};
``` ```
Alternately you can set the following environment variables. If any of these are set, it will override all configuration files: Alternately you can set the following environment variables. If any of these are set, it will override all configuration files:
@@ -55,6 +62,21 @@ Alternately you can set the following environment variables. If any of these are
DSPACE_REST_NAMESPACE=/server DSPACE_REST_NAMESPACE=/server
``` ```
## Environment variable naming convention
Settings can be set using the following convention for naming the environment variables:
1. replace all `.` with `_`
2. convert all characters to upper case
3. prefix with `DSPACE_`
e.g.
```
cache.msToLive.default => DSPACE_CACHE_MSTOLIVE_DEFAULT
auth.ui.timeUntilIdle => DSPACE_AUTH_UI_TIMEUNTILIDLE
```
## Supporting analytics services other than Google Analytics ## Supporting analytics services other than Google Analytics
This project makes use of [Angulartics](https://angulartics.github.io/angulartics2/) to track usage events and send them to Google Analytics. This project makes use of [Angulartics](https://angulartics.github.io/angulartics2/) to track usage events and send them to Google Analytics.

View File

@@ -1,5 +0,0 @@
{
"watch": ["src/environments/mock-environment.ts"],
"ext": "ts",
"exec": "ts-node --project ./tsconfig.ts-node.json scripts/set-mock-env.ts"
}

View File

@@ -1,6 +1,6 @@
{ {
"watch": ["src/environments"], "watch": [
"ext": "ts", "config"
"ignore": ["src/environments/environment.ts", "src/environments/mock-environment.ts"], ],
"exec": "ts-node --project ./tsconfig.ts-node.json scripts/set-env.ts --dev" "ext": "json"
} }

View File

@@ -3,53 +3,40 @@
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"config:dev": "ts-node --project ./tsconfig.ts-node.json scripts/set-env.ts --dev", "config:watch": "nodemon",
"config:prod": "ts-node --project ./tsconfig.ts-node.json scripts/set-env.ts --prod", "test:rest": "ts-node --project ./tsconfig.ts-node.json scripts/test-rest.ts",
"config:test": "ts-node --project ./tsconfig.ts-node.json scripts/set-mock-env.ts",
"config:test:watch": "nodemon --config mock-nodemon.json",
"config:dev:watch": "nodemon",
"config:check:rest": "yarn run config:prod && ts-node --project ./tsconfig.ts-node.json scripts/test-rest.ts",
"config:dev:check:rest": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/test-rest.ts",
"prestart:dev": "yarn run config:dev",
"prebuild": "yarn run config:dev",
"pretest": "yarn run config:test",
"pretest:watch": "yarn run config:test",
"pretest:headless": "yarn run config:test",
"prebuild:prod": "yarn run config:prod",
"pree2e": "yarn run config:prod",
"start": "yarn run start:prod", "start": "yarn run start:prod",
"serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts", "start:dev": "nodemon --exec \"cross-env NODE_ENV=development yarn run serve\"",
"start:dev": "npm-run-all --parallel config:dev:watch serve", "start:prod": "yarn run build:prod && cross-env NODE_ENV=production yarn run serve:ssr",
"start:prod": "yarn run build:prod && yarn run serve:ssr",
"start:mirador:prod": "yarn run build:mirador && yarn run start:prod", "start:mirador:prod": "yarn run build:mirador && yarn run start:prod",
"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", "analyze": "webpack-bundle-analyzer dist/browser/stats.json",
"build": "ng build", "build": "ng build",
"build:stats": "ng build --stats-json", "build:stats": "ng build --stats-json",
"build:prod": "yarn run build:ssr", "build:prod": "yarn run build:ssr",
"build:ssr": "ng build --configuration production && ng run dspace-angular:server:production", "build:ssr": "ng build --configuration production && ng run dspace-angular:server:production",
"test:watch": "npm-run-all --parallel config:test:watch test", "test": "ng test --sourceMap=true --watch=false --configuration test",
"test": "ng test --sourceMap=true --watch=true", "test:watch": "nodemon --exec \"ng test --sourceMap=true --watch=true --configuration test\"",
"test:headless": "ng test --watch=false --sourceMap=true --browsers=ChromeHeadless --code-coverage", "test:headless": "ng test --sourceMap=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage",
"lint": "ng lint", "lint": "ng lint",
"lint-fix": "ng lint --fix=true", "lint-fix": "ng lint --fix=true",
"e2e": "ng e2e", "e2e": "ng e2e",
"serve:ssr": "node dist/server/main", "clean:dev:config": "rimraf src/assets/config.json",
"clean:coverage": "rimraf coverage", "clean:coverage": "rimraf coverage",
"clean:dist": "rimraf dist", "clean:dist": "rimraf dist",
"clean:doc": "rimraf doc", "clean:doc": "rimraf doc",
"clean:log": "rimraf *.log*", "clean:log": "rimraf *.log*",
"clean:json": "rimraf *.records.json", "clean:json": "rimraf *.records.json",
"clean:bld": "rimraf build",
"clean:node": "rimraf node_modules", "clean:node": "rimraf node_modules",
"clean:prod": "yarn run clean:coverage && yarn run clean:doc && yarn run clean:dist && yarn run clean:log && yarn run clean:json && yarn run clean:bld", "clean:prod": "yarn run clean:dist && yarn run clean:log && yarn run clean:doc && yarn run clean:coverage && yarn run clean:json",
"clean": "yarn run clean:prod && yarn run clean:env && yarn run clean:node", "clean": "yarn run clean:prod && yarn run clean:node && yarn run clean:dev:config",
"clean:env": "rimraf src/environments/environment.ts", "sync-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts",
"sync-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/sync-i18n-files.ts",
"build:mirador": "webpack --config webpack/webpack.mirador.config.ts", "build:mirador": "webpack --config webpack/webpack.mirador.config.ts",
"merge-i18n": "yarn run config:dev && ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts", "merge-i18n": "ts-node --project ./tsconfig.ts-node.json scripts/merge-i18n-files.ts",
"postinstall": "ngcc",
"cypress:open": "cypress open", "cypress:open": "cypress open",
"cypress:run": "cypress run" "cypress:run": "cypress run",
"env:yaml": "ts-node --project ./tsconfig.ts-node.json scripts/env-to-yaml.ts"
}, },
"browser": { "browser": {
"fs": false, "fs": false,
@@ -74,7 +61,6 @@
"@angular/platform-browser-dynamic": "~11.2.14", "@angular/platform-browser-dynamic": "~11.2.14",
"@angular/platform-server": "~11.2.14", "@angular/platform-server": "~11.2.14",
"@angular/router": "~11.2.14", "@angular/router": "~11.2.14",
"@angularclass/bootloader": "1.0.1",
"@kolkov/ngx-gallery": "^1.2.3", "@kolkov/ngx-gallery": "^1.2.3",
"@ng-bootstrap/ng-bootstrap": "9.1.3", "@ng-bootstrap/ng-bootstrap": "9.1.3",
"@ng-dynamic-forms/core": "^13.0.0", "@ng-dynamic-forms/core": "^13.0.0",
@@ -102,9 +88,10 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"filesize": "^6.1.0", "filesize": "^6.1.0",
"font-awesome": "4.7.0", "font-awesome": "4.7.0",
"https": "1.0.0",
"http-proxy-middleware": "^1.0.5", "http-proxy-middleware": "^1.0.5",
"https": "1.0.0",
"js-cookie": "2.2.1", "js-cookie": "2.2.1",
"js-yaml": "^4.1.0",
"json5": "^2.1.3", "json5": "^2.1.3",
"jsonschema": "1.4.0", "jsonschema": "1.4.0",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
@@ -157,6 +144,7 @@
"codelyzer": "^6.0.0", "codelyzer": "^6.0.0",
"compression-webpack-plugin": "^3.0.1", "compression-webpack-plugin": "^3.0.1",
"copy-webpack-plugin": "^6.4.1", "copy-webpack-plugin": "^6.4.1",
"cross-env": "^7.0.3",
"css-loader": "3.4.0", "css-loader": "3.4.0",
"cssnano": "^4.1.10", "cssnano": "^4.1.10",
"cypress": "8.6.0", "cypress": "8.6.0",
@@ -176,8 +164,7 @@
"karma-jasmine": "~4.0.0", "karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0", "karma-jasmine-html-reporter": "^1.5.0",
"karma-mocha-reporter": "2.2.5", "karma-mocha-reporter": "2.2.5",
"nodemon": "^2.0.2", "nodemon": "^2.0.15",
"npm-run-all": "^4.1.5",
"optimize-css-assets-webpack-plugin": "^5.0.4", "optimize-css-assets-webpack-plugin": "^5.0.4",
"postcss-apply": "0.11.0", "postcss-apply": "0.11.0",
"postcss-import": "^12.0.1", "postcss-import": "^12.0.1",

39
scripts/env-to-yaml.ts Normal file
View File

@@ -0,0 +1,39 @@
import * as fs from 'fs';
import * as yaml from 'js-yaml';
import { join } from 'path';
/**
* Script to help convert previous version environment.*.ts to yaml.
*
* Usage (see package.json):
*
* yarn env:yaml [relative path to environment.ts file] (optional relative path to write yaml file) *
*/
const args = process.argv.slice(2);
if (args[0] === undefined) {
console.log(`Usage:\n\tyarn env:yaml [relative path to environment.ts file] (optional relative path to write yaml file)\n`);
process.exit(0);
}
const envFullPath = join(process.cwd(), args[0]);
if (!fs.existsSync(envFullPath)) {
console.error(`Error:\n${envFullPath} does not exist\n`);
process.exit(1);
}
try {
const env = require(envFullPath);
const config = yaml.dump(env);
if (args[1]) {
const ymlFullPath = join(process.cwd(), args[1]);
fs.writeFileSync(ymlFullPath, config);
} else {
console.log(config);
}
} catch (e) {
console.error(e);
}

View File

@@ -1,11 +1,14 @@
import { environment } from '../src/environments/environment';
import * as child from 'child_process'; import * as child from 'child_process';
import { AppConfig } from '../src/config/app-config.interface';
import { buildAppConfig } from '../src/config/config.server';
const appConfig: AppConfig = buildAppConfig();
/** /**
* Calls `ng serve` with the following arguments configured for the UI in the environment file: host, port, nameSpace, ssl * Calls `ng serve` with the following arguments configured for the UI in the app config: host, port, nameSpace, ssl
*/ */
child.spawn( child.spawn(
`ng serve --host ${environment.ui.host} --port ${environment.ui.port} --servePath ${environment.ui.nameSpace} --ssl ${environment.ui.ssl}`, `ng serve --host ${appConfig.ui.host} --port ${appConfig.ui.port} --serve-path ${appConfig.ui.nameSpace} --ssl ${appConfig.ui.ssl}`,
{ stdio:'inherit', shell: true } { stdio: 'inherit', shell: true }
); );

View File

@@ -1,116 +0,0 @@
import { writeFile } from 'fs';
import { environment as commonEnv } from '../src/environments/environment.common';
import { GlobalConfig } from '../src/config/global-config.interface';
import { ServerConfig } from '../src/config/server-config.interface';
import { hasValue } from '../src/app/shared/empty.util';
// Configure Angular `environment.ts` file path
const targetPath = './src/environments/environment.ts';
// Load node modules
const colors = require('colors');
require('dotenv').config();
const merge = require('deepmerge');
const mergeOptions = { arrayMerge: (destinationArray, sourceArray, options) => sourceArray };
const environment = process.argv[2];
let environmentFilePath;
let production = false;
switch (environment) {
case '--prod':
case '--production':
production = true;
console.log(`Building ${colors.red.bold(`production`)} environment`);
environmentFilePath = '../src/environments/environment.prod.ts';
break;
case '--test':
console.log(`Building ${colors.blue.bold(`test`)} environment`);
environmentFilePath = '../src/environments/environment.test.ts';
break;
default:
console.log(`Building ${colors.green.bold(`development`)} environment`);
environmentFilePath = '../src/environments/environment.dev.ts';
}
const processEnv = {
ui: createServerConfig(
process.env.DSPACE_HOST,
process.env.DSPACE_PORT,
process.env.DSPACE_NAMESPACE,
process.env.DSPACE_SSL),
rest: createServerConfig(
process.env.DSPACE_REST_HOST,
process.env.DSPACE_REST_PORT,
process.env.DSPACE_REST_NAMESPACE,
process.env.DSPACE_REST_SSL)
} as GlobalConfig;
import(environmentFilePath)
.then((file) => generateEnvironmentFile(merge.all([commonEnv, file.environment, processEnv], mergeOptions)))
.catch(() => {
console.log(colors.yellow.bold(`No specific environment file found for ` + environment));
generateEnvironmentFile(merge(commonEnv, processEnv, mergeOptions))
});
function generateEnvironmentFile(file: GlobalConfig): void {
file.production = production;
buildBaseUrls(file);
const contents = `export const environment = ` + JSON.stringify(file);
writeFile(targetPath, contents, (err) => {
if (err) {
throw console.error(err);
} else {
console.log(`Angular ${colors.bold('environment.ts')} file generated correctly at ${colors.bold(targetPath)} \n`);
}
});
}
// allow to override a few important options by environment variables
function createServerConfig(host?: string, port?: string, nameSpace?: string, ssl?: string): ServerConfig {
const result = {} as any;
if (hasValue(host)) {
result.host = host;
}
if (hasValue(nameSpace)) {
result.nameSpace = nameSpace;
}
if (hasValue(port)) {
result.port = Number(port);
}
if (hasValue(ssl)) {
result.ssl = ssl.trim().match(/^(true|1|yes)$/i) ? true : false;
}
return result;
}
function buildBaseUrls(config: GlobalConfig): void {
for (const key in config) {
if (config.hasOwnProperty(key) && config[key].host) {
config[key].baseUrl = [
getProtocol(config[key].ssl),
getHost(config[key].host),
getPort(config[key].port),
getNameSpace(config[key].nameSpace)
].join('');
}
}
}
function getProtocol(ssl: boolean): string {
return ssl ? 'https://' : 'http://';
}
function getHost(host: string): string {
return host;
}
function getPort(port: number): string {
return port ? (port !== 80 && port !== 443) ? ':' + port : '' : '';
}
function getNameSpace(nameSpace: string): string {
return nameSpace ? nameSpace.charAt(0) === '/' ? nameSpace : '/' + nameSpace : '';
}

View File

@@ -1,11 +0,0 @@
import { copyFile } from 'fs';
// Configure Angular `environment.ts` file path
const sourcePath = './src/environments/mock-environment.ts';
const targetPath = './src/environments/environment.ts';
// destination.txt will be created or overwritten by default.
copyFile(sourcePath, targetPath, (err) => {
if (err) throw err;
console.log(sourcePath + ' was copied to ' + targetPath);
});

View File

@@ -1,21 +1,25 @@
import * as http from 'http'; import * as http from 'http';
import * as https from 'https'; import * as https from 'https';
import { environment } from '../src/environments/environment';
import { AppConfig } from '../src/config/app-config.interface';
import { buildAppConfig } from '../src/config/config.server';
const appConfig: AppConfig = buildAppConfig();
/** /**
* Script to test the connection with the configured REST API (in the 'rest' settings of your environment.*.ts) * Script to test the connection with the configured REST API (in the 'rest' settings of your config.*.yaml)
* *
* This script is useful to test for any Node.js connection issues with your REST API. * This script is useful to test for any Node.js connection issues with your REST API.
* *
* Usage (see package.json): yarn test:rest-api * Usage (see package.json): yarn test:rest
*/ */
// Get root URL of configured REST API // Get root URL of configured REST API
const restUrl = environment.rest.baseUrl + '/api'; const restUrl = appConfig.rest.baseUrl + '/api';
console.log(`...Testing connection to REST API at ${restUrl}...\n`); console.log(`...Testing connection to REST API at ${restUrl}...\n`);
// If SSL enabled, test via HTTPS, else via HTTP // If SSL enabled, test via HTTPS, else via HTTP
if (environment.rest.ssl) { if (appConfig.rest.ssl) {
const req = https.request(restUrl, (res) => { const req = https.request(restUrl, (res) => {
console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`); console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`);
res.on('data', (data) => { res.on('data', (data) => {
@@ -55,7 +59,7 @@ function checkJSONResponse(responseData: any): any {
console.log(`\t"dspaceVersion" = ${parsedData.dspaceVersion}`); console.log(`\t"dspaceVersion" = ${parsedData.dspaceVersion}`);
console.log(`\t"dspaceUI" = ${parsedData.dspaceUI}`); console.log(`\t"dspaceUI" = ${parsedData.dspaceUI}`);
console.log(`\t"dspaceServer" = ${parsedData.dspaceServer}`); console.log(`\t"dspaceServer" = ${parsedData.dspaceServer}`);
console.log(`\t"dspaceServer" property matches UI's "rest" config? ${(parsedData.dspaceServer === environment.rest.baseUrl)}`); console.log(`\t"dspaceServer" property matches UI's "rest" config? ${(parsedData.dspaceServer === appConfig.rest.baseUrl)}`);
// Check for "authn" and "sites" in "_links" section as they should always exist (even if no data)! // Check for "authn" and "sites" in "_links" section as they should always exist (even if no data)!
const linksFound: string[] = Object.keys(parsedData._links); const linksFound: string[] = Object.keys(parsedData._links);
console.log(`\tDoes "/api" endpoint have HAL links ("_links" section)? ${linksFound.includes('authn') && linksFound.includes('sites')}`); console.log(`\tDoes "/api" endpoint have HAL links ("_links" section)? ${linksFound.includes('authn') && linksFound.includes('sites')}`);

View File

@@ -19,27 +19,34 @@ import 'zone.js/dist/zone-node';
import 'reflect-metadata'; import 'reflect-metadata';
import 'rxjs'; import 'rxjs';
import * as fs from 'fs';
import * as pem from 'pem'; import * as pem from 'pem';
import * as https from 'https'; import * as https from 'https';
import * as morgan from 'morgan'; import * as morgan from 'morgan';
import * as express from 'express'; import * as express from 'express';
import * as bodyParser from 'body-parser'; import * as bodyParser from 'body-parser';
import * as compression from 'compression'; import * as compression from 'compression';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { APP_BASE_HREF } from '@angular/common';
import { enableProdMode } from '@angular/core'; import { enableProdMode } from '@angular/core';
import { existsSync } from 'fs';
import { ngExpressEngine } from '@nguniversal/express-engine'; import { ngExpressEngine } from '@nguniversal/express-engine';
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import { environment } from './src/environments/environment'; import { environment } from './src/environments/environment';
import { createProxyMiddleware } from 'http-proxy-middleware'; import { createProxyMiddleware } from 'http-proxy-middleware';
import { hasValue, hasNoValue } from './src/app/shared/empty.util'; import { hasValue, hasNoValue } from './src/app/shared/empty.util';
import { APP_BASE_HREF } from '@angular/common';
import { UIServerConfig } from './src/config/ui-server-config.interface'; import { UIServerConfig } from './src/config/ui-server-config.interface';
import { ServerAppModule } from './src/main.server'; import { ServerAppModule } from './src/main.server';
import { buildAppConfig } from './src/config/config.server';
import { AppConfig, APP_CONFIG } from './src/config/app-config.interface';
import { extendEnvironmentWithAppConfig } from './src/config/config.util';
/* /*
* Set path for the browser application's dist folder * Set path for the browser application's dist folder
*/ */
@@ -51,6 +58,11 @@ const indexHtml = existsSync(join(DIST_FOLDER, 'index.html')) ? 'index.html' : '
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const appConfig: AppConfig = buildAppConfig(join(DIST_FOLDER, 'assets/config.json'));
// extend environment with app config for server
extendEnvironmentWithAppConfig(environment, appConfig);
// The Express app is exported so that it can be used by serverless Functions. // The Express app is exported so that it can be used by serverless Functions.
export function app() { export function app() {
@@ -100,7 +112,11 @@ export function app() {
provide: RESPONSE, provide: RESPONSE,
useValue: (options as any).req.res, useValue: (options as any).req.res,
}, },
], {
provide: APP_CONFIG,
useValue: environment
}
]
})(_, (options as any), callback) })(_, (options as any), callback)
); );
@@ -237,14 +253,14 @@ function start() {
if (environment.ui.ssl) { if (environment.ui.ssl) {
let serviceKey; let serviceKey;
try { try {
serviceKey = fs.readFileSync('./config/ssl/key.pem'); serviceKey = readFileSync('./config/ssl/key.pem');
} catch (e) { } catch (e) {
console.warn('Service key not found at ./config/ssl/key.pem'); console.warn('Service key not found at ./config/ssl/key.pem');
} }
let certificate; let certificate;
try { try {
certificate = fs.readFileSync('./config/ssl/cert.pem'); certificate = readFileSync('./config/ssl/cert.pem');
} catch (e) { } catch (e) {
console.warn('Certificate not found at ./config/ssl/key.pem'); console.warn('Certificate not found at ./config/ssl/key.pem');
} }

View File

@@ -36,6 +36,8 @@ import { GoogleAnalyticsService } from './statistics/google-analytics.service';
import { ThemeService } from './shared/theme-support/theme.service'; import { ThemeService } from './shared/theme-support/theme.service';
import { getMockThemeService } from './shared/mocks/theme-service.mock'; import { getMockThemeService } from './shared/mocks/theme-service.mock';
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
import { APP_CONFIG } from '../config/app-config.interface';
import { environment } from '../environments/environment';
let comp: AppComponent; let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>; let fixture: ComponentFixture<AppComponent>;
@@ -83,6 +85,7 @@ describe('App component', () => {
{ provide: LocaleService, useValue: getMockLocaleService() }, { provide: LocaleService, useValue: getMockLocaleService() },
{ provide: ThemeService, useValue: getMockThemeService() }, { provide: ThemeService, useValue: getMockThemeService() },
{ provide: BreadcrumbsService, useValue: breadcrumbsServiceSpy }, { provide: BreadcrumbsService, useValue: breadcrumbsServiceSpy },
{ provide: APP_CONFIG, useValue: environment },
provideMockStore({ initialState }), provideMockStore({ initialState }),
AppComponent, AppComponent,
RouteService RouteService

View File

@@ -1,4 +1,5 @@
import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators'; import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -17,8 +18,10 @@ import {
Router, Router,
} from '@angular/router'; } from '@angular/router';
import { isEqual } from 'lodash';
import { BehaviorSubject, Observable, of } from 'rxjs'; import { BehaviorSubject, Observable, of } from 'rxjs';
import { select, Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga';
@@ -39,13 +42,12 @@ import { LocaleService } from './core/locale/locale.service';
import { hasNoValue, hasValue, isNotEmpty } from './shared/empty.util'; import { hasNoValue, hasValue, isNotEmpty } from './shared/empty.util';
import { KlaroService } from './shared/cookies/klaro.service'; import { KlaroService } from './shared/cookies/klaro.service';
import { GoogleAnalyticsService } from './statistics/google-analytics.service'; import { GoogleAnalyticsService } from './statistics/google-analytics.service';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { ThemeService } from './shared/theme-support/theme.service'; import { ThemeService } from './shared/theme-support/theme.service';
import { BASE_THEME_NAME } from './shared/theme-support/theme.constants'; import { BASE_THEME_NAME } from './shared/theme-support/theme.constants';
import { DEFAULT_THEME_CONFIG } from './shared/theme-support/theme.effects';
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { getDefaultThemeConfig } from '../config/config.util';
import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface';
@Component({ @Component({
selector: 'ds-app', selector: 'ds-app',
@@ -59,7 +61,7 @@ export class AppComponent implements OnInit, AfterViewInit {
collapsedSidebarWidth: Observable<string>; collapsedSidebarWidth: Observable<string>;
totalSidebarWidth: Observable<string>; totalSidebarWidth: Observable<string>;
theme: Observable<ThemeConfig> = of({} as any); theme: Observable<ThemeConfig> = of({} as any);
notificationOptions = environment.notifications; notificationOptions;
models; models;
/** /**
@@ -88,6 +90,7 @@ export class AppComponent implements OnInit, AfterViewInit {
@Inject(NativeWindowService) private _window: NativeWindowRef, @Inject(NativeWindowService) private _window: NativeWindowRef,
@Inject(DOCUMENT) private document: any, @Inject(DOCUMENT) private document: any,
@Inject(PLATFORM_ID) private platformId: any, @Inject(PLATFORM_ID) private platformId: any,
@Inject(APP_CONFIG) private appConfig: AppConfig,
private themeService: ThemeService, private themeService: ThemeService,
private translate: TranslateService, private translate: TranslateService,
private store: Store<HostWindowState>, private store: Store<HostWindowState>,
@@ -106,6 +109,12 @@ export class AppComponent implements OnInit, AfterViewInit {
@Optional() private googleAnalyticsService: GoogleAnalyticsService, @Optional() private googleAnalyticsService: GoogleAnalyticsService,
) { ) {
if (!isEqual(environment, this.appConfig)) {
throw new Error('environment does not match app config!');
}
this.notificationOptions = environment.notifications;
/* Use models object so all decorators are actually called */ /* Use models object so all decorators are actually called */
this.models = models; this.models = models;
@@ -116,11 +125,14 @@ export class AppComponent implements OnInit, AfterViewInit {
} }
if (hasValue(themeName)) { if (hasValue(themeName)) {
this.loadGlobalThemeConfig(themeName); this.loadGlobalThemeConfig(themeName);
} else if (hasValue(DEFAULT_THEME_CONFIG)) { } else {
this.loadGlobalThemeConfig(DEFAULT_THEME_CONFIG.name); const defaultThemeConfig = getDefaultThemeConfig();
if (hasValue(defaultThemeConfig)) {
this.loadGlobalThemeConfig(defaultThemeConfig.name);
} else { } else {
this.loadGlobalThemeConfig(BASE_THEME_NAME); this.loadGlobalThemeConfig(BASE_THEME_NAME);
} }
}
}); });
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {
@@ -305,8 +317,8 @@ export class AppComponent implements OnInit, AfterViewInit {
// inherit the head tags of the parent theme // inherit the head tags of the parent theme
return this.createHeadTags(parentThemeName); return this.createHeadTags(parentThemeName);
} }
const defaultThemeConfig = getDefaultThemeConfig();
const defaultThemeName = DEFAULT_THEME_CONFIG.name; const defaultThemeName = defaultThemeConfig.name;
if ( if (
hasNoValue(defaultThemeName) || hasNoValue(defaultThemeName) ||
themeName === defaultThemeName || themeName === defaultThemeName ||
@@ -326,7 +338,7 @@ export class AppComponent implements OnInit, AfterViewInit {
} }
// inherit the head tags of the default theme // inherit the head tags of the default theme
return this.createHeadTags(DEFAULT_THEME_CONFIG.name); return this.createHeadTags(defaultThemeConfig.name);
} }
return headTagConfigs.map(this.createHeadTag.bind(this)); return headTagConfigs.map(this.createHeadTag.bind(this));

View File

@@ -1,6 +1,8 @@
import { APP_BASE_HREF, CommonModule } from '@angular/common'; import { APP_BASE_HREF, CommonModule } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
import { APP_INITIALIZER, NgModule } from '@angular/core'; import { APP_INITIALIZER, NgModule } from '@angular/core';
import { AbstractControl } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { EffectsModule } from '@ngrx/effects'; import { EffectsModule } from '@ngrx/effects';
@@ -37,7 +39,6 @@ import { NotificationsBoardComponent } from './shared/notifications/notification
import { SharedModule } from './shared/shared.module'; import { SharedModule } from './shared/shared.module';
import { BreadcrumbsComponent } from './breadcrumbs/breadcrumbs.component'; import { BreadcrumbsComponent } from './breadcrumbs/breadcrumbs.component';
import { environment } from '../environments/environment'; import { environment } from '../environments/environment';
import { BrowserModule } from '@angular/platform-browser';
import { ForbiddenComponent } from './forbidden/forbidden.component'; import { ForbiddenComponent } from './forbidden/forbidden.component';
import { AuthInterceptor } from './core/auth/auth.interceptor'; import { AuthInterceptor } from './core/auth/auth.interceptor';
import { LocaleInterceptor } from './core/locale/locale.interceptor'; import { LocaleInterceptor } from './core/locale/locale.interceptor';
@@ -56,14 +57,19 @@ import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
import { UUIDService } from './core/shared/uuid.service'; import { UUIDService } from './core/shared/uuid.service';
import { CookieService } from './core/services/cookie.service'; import { CookieService } from './core/services/cookie.service';
import { AbstractControl } from '@angular/forms';
export function getBase() { import { AppConfig, APP_CONFIG } from '../config/app-config.interface';
return environment.ui.nameSpace;
export function getConfig() {
return environment;
} }
export function getMetaReducers(): MetaReducer<AppState>[] { export function getBase(appConfig: AppConfig) {
return environment.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers; return appConfig.ui.nameSpace;
}
export function getMetaReducers(appConfig: AppConfig): MetaReducer<AppState>[] {
return appConfig.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers;
} }
/** /**
@@ -98,13 +104,19 @@ IMPORTS.push(
); );
const PROVIDERS = [ const PROVIDERS = [
{
provide: APP_CONFIG,
useFactory: getConfig
},
{ {
provide: APP_BASE_HREF, provide: APP_BASE_HREF,
useFactory: (getBase) useFactory: getBase,
deps: [APP_CONFIG]
}, },
{ {
provide: USER_PROVIDED_META_REDUCERS, provide: USER_PROVIDED_META_REDUCERS,
useFactory: getMetaReducers, useFactory: getMetaReducers,
deps: [APP_CONFIG]
}, },
{ {
provide: RouterStateSerializer, provide: RouterStateSerializer,
@@ -117,7 +129,7 @@ const PROVIDERS = [
useFactory: (store: Store<AppState>,) => { useFactory: (store: Store<AppState>,) => {
return () => store.dispatch(new CheckAuthenticationTokenAction()); return () => store.dispatch(new CheckAuthenticationTokenAction());
}, },
deps: [ Store ], deps: [Store],
multi: true multi: true
}, },
// register AuthInterceptor as HttpInterceptor // register AuthInterceptor as HttpInterceptor
@@ -157,7 +169,7 @@ const PROVIDERS = [
return () => true; return () => true;
}, },
multi: true, multi: true,
deps: [ CookieService, UUIDService ] deps: [CookieService, UUIDService]
}, },
{ {
provide: DYNAMIC_ERROR_MESSAGES_MATCHER, provide: DYNAMIC_ERROR_MESSAGES_MATCHER,
@@ -195,7 +207,7 @@ const EXPORTS = [
@NgModule({ @NgModule({
imports: [ imports: [
BrowserModule.withServerTransition({ appId: 'serverApp' }), BrowserModule.withServerTransition({ appId: 'dspace-angular' }),
...IMPORTS ...IMPORTS
], ],
providers: [ providers: [

View File

@@ -12,7 +12,7 @@ import { ActivatedRoute, Params, Router } from '@angular/router';
import { BrowseService } from '../../core/browse/browse.service'; import { BrowseService } from '../../core/browse/browse.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; import { StartsWithType } from '../../shared/starts-with/starts-with-decorator';
import { BrowseByType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationService } from '../../core/pagination/pagination.service';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
@@ -29,13 +29,13 @@ import { SortDirection, SortOptions } from '../../core/cache/models/sort-options
* A metadata definition (a.k.a. browse id) is a short term used to describe one or multiple metadata fields. * A metadata definition (a.k.a. browse id) is a short term used to describe one or multiple metadata fields.
* An example would be 'dateissued' for 'dc.date.issued' * An example would be 'dateissued' for 'dc.date.issued'
*/ */
@rendersBrowseBy(BrowseByType.Date) @rendersBrowseBy(BrowseByDataType.Date)
export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent { export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
/** /**
* The default metadata-field to use for determining the lower limit of the StartsWith dropdown options * The default metadata keys to use for determining the lower limit of the StartsWith dropdown options
*/ */
defaultMetadataField = 'dc.date.issued'; defaultMetadataKeys = ['dc.date.issued'];
public constructor(protected route: ActivatedRoute, public constructor(protected route: ActivatedRoute,
protected browseService: BrowseService, protected browseService: BrowseService,
@@ -59,13 +59,13 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
return [Object.assign({}, routeParams, queryParams, data), currentPage, currentSort]; return [Object.assign({}, routeParams, queryParams, data), currentPage, currentSort];
}) })
).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => { ).subscribe(([params, currentPage, currentSort]: [Params, PaginationComponentOptions, SortOptions]) => {
const metadataField = params.metadataField || this.defaultMetadataField; const metadataKeys = params.browseDefinition ? params.browseDefinition.metadataKeys : this.defaultMetadataKeys;
this.browseId = params.id || this.defaultBrowseId; this.browseId = params.id || this.defaultBrowseId;
this.startsWith = +params.startsWith || params.startsWith; this.startsWith = +params.startsWith || params.startsWith;
const searchOptions = browseParamsToOptions(params, currentPage, currentSort, this.browseId); const searchOptions = browseParamsToOptions(params, currentPage, currentSort, this.browseId);
this.updatePageWithItems(searchOptions, this.value); this.updatePageWithItems(searchOptions, this.value);
this.updateParent(params.scope); this.updateParent(params.scope);
this.updateStartsWithOptions(this.browseId, metadataField, params.scope); this.updateStartsWithOptions(this.browseId, metadataKeys, params.scope);
})); }));
} }
@@ -76,15 +76,15 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
* extremely long lists with a one-year difference. * extremely long lists with a one-year difference.
* To determine the change in years, the config found under GlobalConfig.BrowseBy is used for this. * To determine the change in years, the config found under GlobalConfig.BrowseBy is used for this.
* @param definition The metadata definition to fetch the first item for * @param definition The metadata definition to fetch the first item for
* @param metadataField The metadata field to fetch the earliest date from (expects a date field) * @param metadataKeys The metadata fields to fetch the earliest date from (expects a date field)
* @param scope The scope under which to fetch the earliest item for * @param scope The scope under which to fetch the earliest item for
*/ */
updateStartsWithOptions(definition: string, metadataField: string, scope?: string) { updateStartsWithOptions(definition: string, metadataKeys: string[], scope?: string) {
this.subs.push( this.subs.push(
this.browseService.getFirstItemFor(definition, scope).subscribe((firstItemRD: RemoteData<Item>) => { this.browseService.getFirstItemFor(definition, scope).subscribe((firstItemRD: RemoteData<Item>) => {
let lowerLimit = environment.browseBy.defaultLowerLimit; let lowerLimit = environment.browseBy.defaultLowerLimit;
if (hasValue(firstItemRD.payload)) { if (hasValue(firstItemRD.payload)) {
const date = firstItemRD.payload.firstMetadataValue(metadataField); const date = firstItemRD.payload.firstMetadataValue(metadataKeys);
if (hasValue(date)) { if (hasValue(date)) {
const dateObj = new Date(date); const dateObj = new Date(date);
// TODO: it appears that getFullYear (based on local time) is sometimes unreliable. Switching to UTC. // TODO: it appears that getFullYear (based on local time) is sometimes unreliable. Switching to UTC.
@@ -120,5 +120,4 @@ export class BrowseByDatePageComponent extends BrowseByMetadataPageComponent {
}) })
); );
} }
} }

View File

@@ -1,20 +1,25 @@
import { first } from 'rxjs/operators'; import { first } from 'rxjs/operators';
import { BrowseByGuard } from './browse-by-guard'; import { BrowseByGuard } from './browse-by-guard';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { BrowseDefinitionDataService } from '../core/browse/browse-definition-data.service';
import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
import { BrowseDefinition } from '../core/shared/browse-definition.model';
import { BrowseByDataType } from './browse-by-switcher/browse-by-decorator';
describe('BrowseByGuard', () => { describe('BrowseByGuard', () => {
describe('canActivate', () => { describe('canActivate', () => {
let guard: BrowseByGuard; let guard: BrowseByGuard;
let dsoService: any; let dsoService: any;
let translateService: any; let translateService: any;
let browseDefinitionService: any;
const name = 'An interesting DSO'; const name = 'An interesting DSO';
const title = 'Author'; const title = 'Author';
const field = 'Author'; const field = 'Author';
const id = 'author'; const id = 'author';
const metadataField = 'dc.contributor';
const scope = '1234-65487-12354-1235'; const scope = '1234-65487-12354-1235';
const value = 'Filter'; const value = 'Filter';
const browseDefinition = Object.assign(new BrowseDefinition(), { type: BrowseByDataType.Metadata, metadataKeys: ['dc.contributor'] });
beforeEach(() => { beforeEach(() => {
dsoService = { dsoService = {
@@ -24,14 +29,19 @@ describe('BrowseByGuard', () => {
translateService = { translateService = {
instant: () => field instant: () => field
}; };
guard = new BrowseByGuard(dsoService, translateService);
browseDefinitionService = {
findById: () => createSuccessfulRemoteDataObject$(browseDefinition)
};
guard = new BrowseByGuard(dsoService, translateService, browseDefinitionService);
}); });
it('should return true, and sets up the data correctly, with a scope and value', () => { it('should return true, and sets up the data correctly, with a scope and value', () => {
const scopedRoute = { const scopedRoute = {
data: { data: {
title: field, title: field,
metadataField, browseDefinition,
}, },
params: { params: {
id, id,
@@ -48,7 +58,7 @@ describe('BrowseByGuard', () => {
const result = { const result = {
title, title,
id, id,
metadataField, browseDefinition,
collection: name, collection: name,
field, field,
value: '"' + value + '"' value: '"' + value + '"'
@@ -63,7 +73,7 @@ describe('BrowseByGuard', () => {
const scopedNoValueRoute = { const scopedNoValueRoute = {
data: { data: {
title: field, title: field,
metadataField, browseDefinition,
}, },
params: { params: {
id, id,
@@ -80,7 +90,7 @@ describe('BrowseByGuard', () => {
const result = { const result = {
title, title,
id, id,
metadataField, browseDefinition,
collection: name, collection: name,
field, field,
value: '' value: ''
@@ -95,7 +105,7 @@ describe('BrowseByGuard', () => {
const route = { const route = {
data: { data: {
title: field, title: field,
metadataField, browseDefinition,
}, },
params: { params: {
id, id,
@@ -111,7 +121,7 @@ describe('BrowseByGuard', () => {
const result = { const result = {
title, title,
id, id,
metadataField, browseDefinition,
collection: '', collection: '',
field, field,
value: '"' + value + '"' value: '"' + value + '"'

View File

@@ -2,11 +2,12 @@ import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angul
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { DSpaceObjectDataService } from '../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../core/data/dspace-object-data.service';
import { hasNoValue, hasValue } from '../shared/empty.util'; import { hasNoValue, hasValue } from '../shared/empty.util';
import { map } from 'rxjs/operators'; import { map, switchMap } from 'rxjs/operators';
import { getFirstSucceededRemoteData } from '../core/shared/operators'; import { getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload } from '../core/shared/operators';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { environment } from '../../environments/environment'; import { BrowseDefinitionDataService } from '../core/browse/browse-definition-data.service';
import { BrowseDefinition } from '../core/shared/browse-definition.model';
@Injectable() @Injectable()
/** /**
@@ -15,42 +16,46 @@ import { environment } from '../../environments/environment';
export class BrowseByGuard implements CanActivate { export class BrowseByGuard implements CanActivate {
constructor(protected dsoService: DSpaceObjectDataService, constructor(protected dsoService: DSpaceObjectDataService,
protected translate: TranslateService) { protected translate: TranslateService,
protected browseDefinitionService: BrowseDefinitionDataService) {
} }
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const title = route.data.title; const title = route.data.title;
const id = route.params.id || route.queryParams.id || route.data.id; const id = route.params.id || route.queryParams.id || route.data.id;
let metadataField = route.data.metadataField; let browseDefinition$: Observable<BrowseDefinition>;
if (hasNoValue(metadataField) && hasValue(id)) { if (hasNoValue(route.data.browseDefinition) && hasValue(id)) {
const config = environment.browseBy.types.find((conf) => conf.id === id); browseDefinition$ = this.browseDefinitionService.findById(id).pipe(getFirstSucceededRemoteDataPayload());
if (hasValue(config) && hasValue(config.metadataField)) { } else {
metadataField = config.metadataField; browseDefinition$ = observableOf(route.data.browseDefinition);
}
} }
const scope = route.queryParams.scope; const scope = route.queryParams.scope;
const value = route.queryParams.value; const value = route.queryParams.value;
const metadataTranslated = this.translate.instant('browse.metadata.' + id); const metadataTranslated = this.translate.instant('browse.metadata.' + id);
return browseDefinition$.pipe(
switchMap((browseDefinition) => {
if (hasValue(scope)) { if (hasValue(scope)) {
const dsoAndMetadata$ = this.dsoService.findById(scope).pipe(getFirstSucceededRemoteData()); const dsoAndMetadata$ = this.dsoService.findById(scope).pipe(getFirstSucceededRemoteData());
return dsoAndMetadata$.pipe( return dsoAndMetadata$.pipe(
map((dsoRD) => { map((dsoRD) => {
const name = dsoRD.payload.name; const name = dsoRD.payload.name;
route.data = this.createData(title, id, metadataField, name, metadataTranslated, value, route); route.data = this.createData(title, id, browseDefinition, name, metadataTranslated, value, route);
return true; return true;
}) })
); );
} else { } else {
route.data = this.createData(title, id, metadataField, '', metadataTranslated, value, route); route.data = this.createData(title, id, browseDefinition, '', metadataTranslated, value, route);
return observableOf(true); return observableOf(true);
} }
})
);
} }
private createData(title, id, metadataField, collection, field, value, route) { private createData(title, id, browseDefinition, collection, field, value, route) {
return Object.assign({}, route.data, { return Object.assign({}, route.data, {
title: title, title: title,
id: id, id: id,
metadataField: metadataField, browseDefinition: browseDefinition,
collection: collection, collection: collection,
field: field, field: field,
value: hasValue(value) ? `"${value}"` : '' value: hasValue(value) ? `"${value}"` : ''

View File

@@ -14,7 +14,7 @@ import { getFirstSucceededRemoteData } from '../../core/shared/operators';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { StartsWithType } from '../../shared/starts-with/starts-with-decorator'; import { StartsWithType } from '../../shared/starts-with/starts-with-decorator';
import { BrowseByType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator';
import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationService } from '../../core/pagination/pagination.service';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
@@ -28,7 +28,7 @@ import { map } from 'rxjs/operators';
* A metadata definition (a.k.a. browse id) is a short term used to describe one or multiple metadata fields. * A metadata definition (a.k.a. browse id) is a short term used to describe one or multiple metadata fields.
* An example would be 'author' for 'dc.contributor.*' * An example would be 'author' for 'dc.contributor.*'
*/ */
@rendersBrowseBy(BrowseByType.Metadata) @rendersBrowseBy(BrowseByDataType.Metadata)
export class BrowseByMetadataPageComponent implements OnInit { export class BrowseByMetadataPageComponent implements OnInit {
/** /**

View File

@@ -1,9 +1,9 @@
import { BrowseByType, rendersBrowseBy } from './browse-by-decorator'; import { BrowseByDataType, rendersBrowseBy } from './browse-by-decorator';
describe('BrowseByDecorator', () => { describe('BrowseByDecorator', () => {
const titleDecorator = rendersBrowseBy(BrowseByType.Title); const titleDecorator = rendersBrowseBy(BrowseByDataType.Title);
const dateDecorator = rendersBrowseBy(BrowseByType.Date); const dateDecorator = rendersBrowseBy(BrowseByDataType.Date);
const metadataDecorator = rendersBrowseBy(BrowseByType.Metadata); const metadataDecorator = rendersBrowseBy(BrowseByDataType.Metadata);
it('should have a decorator for all types', () => { it('should have a decorator for all types', () => {
expect(titleDecorator.length).not.toEqual(0); expect(titleDecorator.length).not.toEqual(0);
expect(dateDecorator.length).not.toEqual(0); expect(dateDecorator.length).not.toEqual(0);

View File

@@ -2,13 +2,13 @@ import { hasNoValue } from '../../shared/empty.util';
import { InjectionToken } from '@angular/core'; import { InjectionToken } from '@angular/core';
import { GenericConstructor } from '../../core/shared/generic-constructor'; import { GenericConstructor } from '../../core/shared/generic-constructor';
export enum BrowseByType { export enum BrowseByDataType {
Title = 'title', Title = 'title',
Metadata = 'metadata', Metadata = 'text',
Date = 'date' Date = 'date'
} }
export const DEFAULT_BROWSE_BY_TYPE = BrowseByType.Metadata; export const DEFAULT_BROWSE_BY_TYPE = BrowseByDataType.Metadata;
export const BROWSE_BY_COMPONENT_FACTORY = new InjectionToken<(browseByType) => GenericConstructor<any>>('getComponentByBrowseByType', { export const BROWSE_BY_COMPONENT_FACTORY = new InjectionToken<(browseByType) => GenericConstructor<any>>('getComponentByBrowseByType', {
providedIn: 'root', providedIn: 'root',
@@ -21,7 +21,7 @@ const map = new Map();
* Decorator used for rendering Browse-By pages by type * Decorator used for rendering Browse-By pages by type
* @param browseByType The type of page * @param browseByType The type of page
*/ */
export function rendersBrowseBy(browseByType: BrowseByType) { export function rendersBrowseBy(browseByType: BrowseByDataType) {
return function decorator(component: any) { return function decorator(component: any) {
if (hasNoValue(map.get(browseByType))) { if (hasNoValue(map.get(browseByType))) {
map.set(browseByType, component); map.set(browseByType, component);

View File

@@ -2,20 +2,46 @@ import { BrowseBySwitcherComponent } from './browse-by-switcher.component';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core'; import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { BehaviorSubject } from 'rxjs'; import { BROWSE_BY_COMPONENT_FACTORY, BrowseByDataType } from './browse-by-decorator';
import { environment } from '../../../environments/environment'; import { BrowseDefinition } from '../../core/shared/browse-definition.model';
import { BROWSE_BY_COMPONENT_FACTORY } from './browse-by-decorator'; import { BehaviorSubject, of as observableOf } from 'rxjs';
describe('BrowseBySwitcherComponent', () => { describe('BrowseBySwitcherComponent', () => {
let comp: BrowseBySwitcherComponent; let comp: BrowseBySwitcherComponent;
let fixture: ComponentFixture<BrowseBySwitcherComponent>; let fixture: ComponentFixture<BrowseBySwitcherComponent>;
const types = environment.browseBy.types; const types = [
Object.assign(
new BrowseDefinition(), {
id: 'title',
dataType: BrowseByDataType.Title,
}
),
Object.assign(
new BrowseDefinition(), {
id: 'dateissued',
dataType: BrowseByDataType.Date,
metadataKeys: ['dc.date.issued']
}
),
Object.assign(
new BrowseDefinition(), {
id: 'author',
dataType: BrowseByDataType.Metadata,
}
),
Object.assign(
new BrowseDefinition(), {
id: 'subject',
dataType: BrowseByDataType.Metadata,
}
),
];
const params = new BehaviorSubject(createParamsWithId('initialValue')); const data = new BehaviorSubject(createDataWithBrowseDefinition(new BrowseDefinition()));
const activatedRouteStub = { const activatedRouteStub = {
params: params data
}; };
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
@@ -34,20 +60,20 @@ describe('BrowseBySwitcherComponent', () => {
comp = fixture.componentInstance; comp = fixture.componentInstance;
})); }));
types.forEach((type) => { types.forEach((type: BrowseDefinition) => {
describe(`when switching to a browse-by page for "${type.id}"`, () => { describe(`when switching to a browse-by page for "${type.id}"`, () => {
beforeEach(() => { beforeEach(() => {
params.next(createParamsWithId(type.id)); data.next(createDataWithBrowseDefinition(type));
fixture.detectChanges(); fixture.detectChanges();
}); });
it(`should call getComponentByBrowseByType with type "${type.type}"`, () => { it(`should call getComponentByBrowseByType with type "${type.dataType}"`, () => {
expect((comp as any).getComponentByBrowseByType).toHaveBeenCalledWith(type.type); expect((comp as any).getComponentByBrowseByType).toHaveBeenCalledWith(type.dataType);
}); });
}); });
}); });
}); });
export function createParamsWithId(id) { export function createDataWithBrowseDefinition(browseDefinition) {
return { id: id }; return { browseDefinition: browseDefinition };
} }

View File

@@ -1,11 +1,10 @@
import { Component, Inject, OnInit } from '@angular/core'; import { Component, Inject, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { BrowseByTypeConfig } from '../../../config/browse-by-type-config.interface';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { BROWSE_BY_COMPONENT_FACTORY } from './browse-by-decorator'; import { BROWSE_BY_COMPONENT_FACTORY } from './browse-by-decorator';
import { environment } from '../../../environments/environment';
import { GenericConstructor } from '../../core/shared/generic-constructor'; import { GenericConstructor } from '../../core/shared/generic-constructor';
import { BrowseDefinition } from '../../core/shared/browse-definition.model';
@Component({ @Component({
selector: 'ds-browse-by-switcher', selector: 'ds-browse-by-switcher',
@@ -26,15 +25,11 @@ export class BrowseBySwitcherComponent implements OnInit {
} }
/** /**
* Fetch the correct browse-by component by using the relevant config from environment.js * Fetch the correct browse-by component by using the relevant config from the route data
*/ */
ngOnInit(): void { ngOnInit(): void {
this.browseByComponent = this.route.params.pipe( this.browseByComponent = this.route.data.pipe(
map((params) => { map((data: { browseDefinition: BrowseDefinition }) => this.getComponentByBrowseByType(data.browseDefinition.dataType))
const id = params.id;
return environment.browseBy.types.find((config: BrowseByTypeConfig) => config.id === id);
}),
map((config: BrowseByTypeConfig) => this.getComponentByBrowseByType(config.type))
); );
} }

View File

@@ -10,7 +10,7 @@ import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { BrowseService } from '../../core/browse/browse.service'; import { BrowseService } from '../../core/browse/browse.service';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { BrowseByType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator'; import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-by-decorator';
import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationService } from '../../core/pagination/pagination.service';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
@@ -23,7 +23,7 @@ import { PaginationComponentOptions } from '../../shared/pagination/pagination-c
/** /**
* Component for browsing items by title (dc.title) * Component for browsing items by title (dc.title)
*/ */
@rendersBrowseBy(BrowseByType.Title) @rendersBrowseBy(BrowseByDataType.Title)
export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent { export class BrowseByTitlePageComponent extends BrowseByMetadataPageComponent {
public constructor(protected route: ActivatedRoute, public constructor(protected route: ActivatedRoute,

View File

@@ -2,8 +2,8 @@ import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { first, map } from 'rxjs/operators'; import { first, map } from 'rxjs/operators';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
@Component({ @Component({
selector: 'ds-community-authorizations', selector: 'ds-community-authorizations',

View File

@@ -9,9 +9,11 @@ describe(`BrowseDefinitionDataService`, () => {
findAll: EMPTY, findAll: EMPTY,
findByHref: EMPTY, findByHref: EMPTY,
findAllByHref: EMPTY, findAllByHref: EMPTY,
findById: EMPTY,
}); });
const hrefAll = 'https://rest.api/server/api/discover/browses'; const hrefAll = 'https://rest.api/server/api/discover/browses';
const hrefSingle = 'https://rest.api/server/api/discover/browses/author'; const hrefSingle = 'https://rest.api/server/api/discover/browses/author';
const id = 'author';
const options = new FindListOptions(); const options = new FindListOptions();
const linksToFollow = [ const linksToFollow = [
followLink('entries'), followLink('entries'),
@@ -44,4 +46,10 @@ describe(`BrowseDefinitionDataService`, () => {
}); });
}); });
describe(`findById`, () => {
it(`should call findById on DataServiceImpl`, () => {
service.findAllByHref(id, options, true, false, ...linksToFollow);
expect(dataServiceImplSpy.findAllByHref).toHaveBeenCalledWith(id, options, true, false, ...linksToFollow);
});
});
}); });

View File

@@ -106,6 +106,21 @@ export class BrowseDefinitionDataService {
findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<PaginatedList<BrowseDefinition>>> { findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<PaginatedList<BrowseDefinition>>> {
return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} }
/**
* Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
* @param id ID of object we want to retrieve
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<BrowseDefinition>> {
return this.dataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
} }
/* tslint:enable:max-classes-per-file */ /* tslint:enable:max-classes-per-file */

View File

@@ -20,7 +20,7 @@ export enum IdentifierType {
} }
export abstract class RestRequest { export abstract class RestRequest {
public responseMsToLive = environment.cache.msToLive.default; public responseMsToLive;
public isMultipart = false; public isMultipart = false;
constructor( constructor(
@@ -30,6 +30,7 @@ export abstract class RestRequest {
public body?: any, public body?: any,
public options?: HttpOptions, public options?: HttpOptions,
) { ) {
this.responseMsToLive = environment.cache.msToLive.default;
} }
getResponseParser(): GenericConstructor<ResponseParsingService> { getResponseParser(): GenericConstructor<ResponseParsingService> {

View File

@@ -6,6 +6,7 @@ import { BROWSE_DEFINITION } from './browse-definition.resource-type';
import { HALLink } from './hal-link.model'; import { HALLink } from './hal-link.model';
import { ResourceType } from './resource-type'; import { ResourceType } from './resource-type';
import { SortOption } from './sort-option.model'; import { SortOption } from './sort-option.model';
import { BrowseByDataType } from '../../browse-by/browse-by-switcher/browse-by-decorator';
@typedObject @typedObject
export class BrowseDefinition extends CacheableObject { export class BrowseDefinition extends CacheableObject {
@@ -33,6 +34,9 @@ export class BrowseDefinition extends CacheableObject {
@autoserializeAs('metadata') @autoserializeAs('metadata')
metadataKeys: string[]; metadataKeys: string[];
@autoserialize
dataType: BrowseByDataType;
get self(): string { get self(): string {
return this._links.self.href; return this._links.self.href;
} }

View File

@@ -89,7 +89,7 @@ describe('HALEndpointService', () => {
describe('getRootEndpointMap', () => { describe('getRootEndpointMap', () => {
it('should send a new EndpointMapRequest', () => { it('should send a new EndpointMapRequest', () => {
(service as any).getRootEndpointMap(); (service as any).getRootEndpointMap();
const expected = new EndpointMapRequest(requestService.generateRequestId(), environment.rest.baseUrl + 'api'); const expected = new EndpointMapRequest(requestService.generateRequestId(), `${environment.rest.baseUrl}/api`);
expect(requestService.send).toHaveBeenCalledWith(expected, true); expect(requestService.send).toHaveBeenCalledWith(expected, true);
}); });

View File

@@ -36,9 +36,10 @@ export class ItemComponent implements OnInit {
*/ */
iiifQuery$: Observable<string>; iiifQuery$: Observable<string>;
mediaViewer = environment.mediaViewer; mediaViewer;
constructor(protected routeService: RouteService) { constructor(protected routeService: RouteService) {
this.mediaViewer = environment.mediaViewer;
} }
ngOnInit(): void { ngOnInit(): void {

View File

@@ -13,9 +13,12 @@ import { MenuService } from '../shared/menu/menu.service';
import { MenuServiceStub } from '../shared/testing/menu-service.stub'; import { MenuServiceStub } from '../shared/testing/menu-service.stub';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { BrowseService } from '../core/browse/browse.service';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils';
import { buildPaginatedList } from '../core/data/paginated-list.model';
import { BrowseDefinition } from '../core/shared/browse-definition.model';
import { BrowseByDataType } from '../browse-by/browse-by-switcher/browse-by-decorator';
import { Item } from '../core/shared/item.model'; import { Item } from '../core/shared/item.model';
import { createSuccessfulRemoteDataObject } from '../shared/remote-data.utils';
import { By } from '@angular/platform-browser';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
let comp: NavbarComponent; let comp: NavbarComponent;
@@ -48,9 +51,37 @@ const routeStub = {
describe('NavbarComponent', () => { describe('NavbarComponent', () => {
const menuService = new MenuServiceStub(); const menuService = new MenuServiceStub();
let browseDefinitions;
// waitForAsync beforeEach // waitForAsync beforeEach
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
browseDefinitions = [
Object.assign(
new BrowseDefinition(), {
id: 'title',
dataType: BrowseByDataType.Title,
}
),
Object.assign(
new BrowseDefinition(), {
id: 'dateissued',
dataType: BrowseByDataType.Date,
metadataKeys: ['dc.date.issued']
}
),
Object.assign(
new BrowseDefinition(), {
id: 'author',
dataType: BrowseByDataType.Metadata,
}
),
Object.assign(
new BrowseDefinition(), {
id: 'subject',
dataType: BrowseByDataType.Metadata,
}
),
];
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
TranslateModule.forRoot(), TranslateModule.forRoot(),
@@ -62,8 +93,9 @@ describe('NavbarComponent', () => {
Injector, Injector,
{ provide: MenuService, useValue: menuService }, { provide: MenuService, useValue: menuService },
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: ActivatedRoute, useValue: routeStub }, { provide: ActivatedRoute, useValue: routeStub },
{ provide: BrowseService, useValue: { getBrowseDefinitions: createSuccessfulRemoteDataObject$(buildPaginatedList(undefined, browseDefinitions)) } },
{ provide: AuthorizationDataService, useValue: authorizationService },
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}) })

View File

@@ -6,7 +6,11 @@ import { MenuID, MenuItemType } from '../shared/menu/initial-menus-state';
import { TextMenuItemModel } from '../shared/menu/menu-item/models/text.model'; import { TextMenuItemModel } from '../shared/menu/menu-item/models/text.model';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
import { HostWindowService } from '../shared/host-window.service'; import { HostWindowService } from '../shared/host-window.service';
import { environment } from '../../environments/environment'; import { BrowseService } from '../core/browse/browse.service';
import { getFirstCompletedRemoteData } from '../core/shared/operators';
import { PaginatedList } from '../core/data/paginated-list.model';
import { BrowseDefinition } from '../core/shared/browse-definition.model';
import { RemoteData } from '../core/data/remote-data';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
@@ -29,6 +33,7 @@ export class NavbarComponent extends MenuComponent {
constructor(protected menuService: MenuService, constructor(protected menuService: MenuService,
protected injector: Injector, protected injector: Injector,
public windowService: HostWindowService, public windowService: HostWindowService,
public browseService: BrowseService,
public authorizationService: AuthorizationDataService, public authorizationService: AuthorizationDataService,
public route: ActivatedRoute public route: ActivatedRoute
) { ) {
@@ -56,8 +61,28 @@ export class NavbarComponent extends MenuComponent {
text: `menu.section.browse_global_communities_and_collections`, text: `menu.section.browse_global_communities_and_collections`,
link: `/community-list` link: `/community-list`
} as LinkMenuItemModel } as LinkMenuItemModel
}, }
/* News */ ];
// Read the different Browse-By types from config and add them to the browse menu
this.browseService.getBrowseDefinitions()
.pipe(getFirstCompletedRemoteData<PaginatedList<BrowseDefinition>>())
.subscribe((browseDefListRD: RemoteData<PaginatedList<BrowseDefinition>>) => {
if (browseDefListRD.hasSucceeded) {
browseDefListRD.payload.page.forEach((browseDef: BrowseDefinition) => {
menuList.push({
id: `browse_global_by_${browseDef.id}`,
parentID: 'browse_global',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: `menu.section.browse_global_by_${browseDef.id}`,
link: `/browse/${browseDef.id}`
} as LinkMenuItemModel
});
});
menuList.push(
/* Browse */
{ {
id: 'browse_global', id: 'browse_global',
active: false, active: false,
@@ -67,26 +92,13 @@ export class NavbarComponent extends MenuComponent {
type: MenuItemType.TEXT, type: MenuItemType.TEXT,
text: 'menu.section.browse_global' text: 'menu.section.browse_global'
} as TextMenuItemModel, } as TextMenuItemModel,
}, }
]; );
// Read the different Browse-By types from config and add them to the browse menu }
const types = environment.browseBy.types;
types.forEach((typeConfig) => {
menuList.push({
id: `browse_global_by_${typeConfig.id}`,
parentID: 'browse_global',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: `menu.section.browse_global_by_${typeConfig.id}`,
link: `/browse/${typeConfig.id}`
} as LinkMenuItemModel
});
});
menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, { menuList.forEach((menuSection) => this.menuService.addSection(this.menuID, Object.assign(menuSection, {
shouldPersistOnRouteChange: true shouldPersistOnRouteChange: true
}))); })));
});
} }
} }

View File

@@ -32,7 +32,7 @@ export class RootComponent implements OnInit {
collapsedSidebarWidth: Observable<string>; collapsedSidebarWidth: Observable<string>;
totalSidebarWidth: Observable<string>; totalSidebarWidth: Observable<string>;
theme: Observable<ThemeConfig> = of({} as any); theme: Observable<ThemeConfig> = of({} as any);
notificationOptions = environment.notifications; notificationOptions;
models; models;
/** /**
@@ -58,6 +58,7 @@ export class RootComponent implements OnInit {
private menuService: MenuService, private menuService: MenuService,
private windowService: HostWindowService private windowService: HostWindowService
) { ) {
this.notificationOptions = environment.notifications;
} }
ngOnInit() { ngOnInit() {

View File

@@ -2,10 +2,13 @@ import { Component, Input, OnInit } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { ActivatedRoute, Params, Router } from '@angular/router'; import { ActivatedRoute, Params, Router } from '@angular/router';
import { BrowseByTypeConfig } from '../../../config/browse-by-type-config.interface';
import { environment } from '../../../environments/environment';
import { getCommunityPageRoute } from '../../community-page/community-page-routing-paths'; import { getCommunityPageRoute } from '../../community-page/community-page-routing-paths';
import { getCollectionPageRoute } from '../../collection-page/collection-page-routing-paths'; import { getCollectionPageRoute } from '../../collection-page/collection-page-routing-paths';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { PaginatedList } from '../../core/data/paginated-list.model';
import { BrowseDefinition } from '../../core/shared/browse-definition.model';
import { RemoteData } from '../../core/data/remote-data';
import { BrowseService } from '../../core/browse/browse.service';
export interface ComColPageNavOption { export interface ComColPageNavOption {
id: string; id: string;
@@ -29,10 +32,6 @@ export class ComcolPageBrowseByComponent implements OnInit {
*/ */
@Input() id: string; @Input() id: string;
@Input() contentType: string; @Input() contentType: string;
/**
* List of currently active browse configurations
*/
types: BrowseByTypeConfig[];
allOptions: ComColPageNavOption[]; allOptions: ComColPageNavOption[];
@@ -40,12 +39,18 @@ export class ComcolPageBrowseByComponent implements OnInit {
constructor( constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router) { private router: Router,
private browseService: BrowseService
) {
} }
ngOnInit(): void { ngOnInit(): void {
this.allOptions = environment.browseBy.types this.browseService.getBrowseDefinitions()
.map((config: BrowseByTypeConfig) => ({ .pipe(getFirstCompletedRemoteData<PaginatedList<BrowseDefinition>>())
.subscribe((browseDefListRD: RemoteData<PaginatedList<BrowseDefinition>>) => {
if (browseDefListRD.hasSucceeded) {
this.allOptions = browseDefListRD.payload.page
.map((config: BrowseDefinition) => ({
id: config.id, id: config.id,
label: `browse.comcol.by.${config.id}`, label: `browse.comcol.by.${config.id}`,
routerLink: `/browse/${config.id}`, routerLink: `/browse/${config.id}`,
@@ -53,18 +58,20 @@ export class ComcolPageBrowseByComponent implements OnInit {
})); }));
if (this.contentType === 'collection') { if (this.contentType === 'collection') {
this.allOptions = [ { this.allOptions = [{
id: this.id, id: this.id,
label: 'collection.page.browse.recent.head', label: 'collection.page.browse.recent.head',
routerLink: getCollectionPageRoute(this.id) routerLink: getCollectionPageRoute(this.id)
}, ...this.allOptions ]; }, ...this.allOptions];
} else if (this.contentType === 'community') { } else if (this.contentType === 'community') {
this.allOptions = [{ this.allOptions = [{
id: this.id, id: this.id,
label: 'community.all-lists.head', label: 'community.all-lists.head',
routerLink: getCommunityPageRoute(this.id) routerLink: getCommunityPageRoute(this.id)
}, ...this.allOptions ]; }, ...this.allOptions];
} }
}
});
this.currentOptionId$ = this.route.params.pipe( this.currentOptionId$ = this.route.params.pipe(
map((params: Params) => params.id) map((params: Params) => params.id)

View File

@@ -10,8 +10,6 @@ import { NotificationsService } from '../notifications.service';
import { NotificationType } from '../models/notification-type'; import { NotificationType } from '../models/notification-type';
import { notificationsReducer } from '../notifications.reducers'; import { notificationsReducer } from '../notifications.reducers';
import { NotificationOptions } from '../models/notification-options.model'; 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 { Notification } from '../models/notification.model';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../../mocks/translate-loader.mock'; import { TranslateLoaderMock } from '../../mocks/translate-loader.mock';
@@ -33,16 +31,6 @@ describe('NotificationComponent', () => {
/* tslint:disable:no-empty */ /* tslint:disable:no-empty */
notifications: [] notifications: []
}); });
const envConfig: GlobalConfig = {
notifications: {
rtl: false,
position: ['top', 'right'],
maxStack: 8,
timeOut: 5000,
clickToClose: true,
animate: 'scale'
} as INotificationBoardOptions,
} as any;
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [

View File

@@ -8,7 +8,6 @@ import { of as observableOf } from 'rxjs';
import { NewNotificationAction, RemoveAllNotificationsAction, RemoveNotificationAction } from './notifications.actions'; import { NewNotificationAction, RemoveAllNotificationsAction, RemoveNotificationAction } from './notifications.actions';
import { Notification } from './models/notification.model'; import { Notification } from './models/notification.model';
import { NotificationType } from './models/notification-type'; import { NotificationType } from './models/notification-type';
import { GlobalConfig } from '../../../config/global-config.interface';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../mocks/translate-loader.mock'; import { TranslateLoaderMock } from '../mocks/translate-loader.mock';
import { storeModuleConfig } from '../../app.reducer'; import { storeModuleConfig } from '../../app.reducer';
@@ -19,18 +18,6 @@ describe('NotificationsService test', () => {
select: observableOf(true) select: observableOf(true)
}); });
let service: NotificationsService; 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(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({

View File

@@ -32,13 +32,13 @@ import { RESOURCE_POLICY } from '../../../core/resource-policy/models/resource-p
import { EPersonMock } from '../../testing/eperson.mock'; import { EPersonMock } from '../../testing/eperson.mock';
import { isNotEmptyOperator } from '../../empty.util'; import { isNotEmptyOperator } from '../../empty.util';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { RemoteData } from '../../../core/data/remote-data';
import { RouterMock } from '../../mocks/router.mock'; import { RouterMock } from '../../mocks/router.mock';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { PaginationServiceStub } from '../../testing/pagination-service.stub'; import { PaginationServiceStub } from '../../testing/pagination-service.stub';
import { PaginationService } from '../../../core/pagination/pagination.service';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { StoreMock } from '../../testing/store.mock'; import { StoreMock } from '../../testing/store.mock';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginationService } from '../../../core/pagination/pagination.service';
export const mockResourcePolicyFormData = { export const mockResourcePolicyFormData = {
name: [ name: [

View File

@@ -2,15 +2,9 @@ import { Injectable } from '@angular/core';
import { createEffect, Actions, ofType, ROOT_EFFECTS_INIT } from '@ngrx/effects'; import { createEffect, Actions, ofType, ROOT_EFFECTS_INIT } from '@ngrx/effects';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { SetThemeAction } from './theme.actions'; import { SetThemeAction } from './theme.actions';
import { environment } from '../../../environments/environment'; import { hasValue } from '../empty.util';
import { hasValue, hasNoValue } from '../empty.util';
import { BASE_THEME_NAME } from './theme.constants'; import { BASE_THEME_NAME } from './theme.constants';
import { getDefaultThemeConfig } from '../../../config/config.util';
export const DEFAULT_THEME_CONFIG = environment.themes.find((themeConfig: any) =>
hasNoValue(themeConfig.regex) &&
hasNoValue(themeConfig.handle) &&
hasNoValue(themeConfig.uuid)
);
@Injectable() @Injectable()
export class ThemeEffects { export class ThemeEffects {
@@ -21,8 +15,9 @@ export class ThemeEffects {
this.actions$.pipe( this.actions$.pipe(
ofType(ROOT_EFFECTS_INIT), ofType(ROOT_EFFECTS_INIT),
map(() => { map(() => {
if (hasValue(DEFAULT_THEME_CONFIG)) { const defaultThemeConfig = getDefaultThemeConfig();
return new SetThemeAction(DEFAULT_THEME_CONFIG.name); if (hasValue(defaultThemeConfig)) {
return new SetThemeAction(defaultThemeConfig.name);
} else { } else {
return new SetThemeAction(BASE_THEME_NAME); return new SetThemeAction(BASE_THEME_NAME);
} }

View File

@@ -1,9 +1,21 @@
<div> <div>
<div class="modal-header">
<h4 class="modal-title">{{'submission.sections.upload.edit.title' | translate}}</h4>
<button type="button" class="close" (click)="onModalClose()" aria-label="Close" [disabled]="isSaving">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<ds-form *ngIf="formModel" <ds-form *ngIf="formModel"
#formRef="formComponent" #formRef="formComponent"
[formId]="formId" [formId]="formId"
[formModel]="formModel" [formModel]="formModel"
[displaySubmit]="false" [displaySubmit]="!isSaving"
[displayCancel]="false" [displayCancel]="!isSaving"
(submitForm)="onSubmit()"
(cancel)="onModalClose()"
(dfChange)="onChange($event)"></ds-form> (dfChange)="onChange($event)"></ds-form>
</div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core';
import { waitForAsync, ComponentFixture, inject, TestBed } from '@angular/core/testing'; import { waitForAsync, ComponentFixture, inject, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
@@ -17,19 +17,37 @@ import { SubmissionService } from '../../../../submission.service';
import { SubmissionSectionUploadFileEditComponent } from './section-upload-file-edit.component'; import { SubmissionSectionUploadFileEditComponent } from './section-upload-file-edit.component';
import { POLICY_DEFAULT_WITH_LIST } from '../../section-upload.component'; import { POLICY_DEFAULT_WITH_LIST } from '../../section-upload.component';
import { import {
mockGroup,
mockSubmissionCollectionId, mockSubmissionCollectionId,
mockSubmissionId, mockSubmissionId,
mockUploadConfigResponse, mockUploadConfigResponse,
mockUploadConfigResponseMetadata, mockUploadConfigResponseMetadata,
mockUploadFiles mockUploadFiles,
mockFileFormData,
mockSubmissionObject,
} from '../../../../../shared/mocks/submission.mock'; } from '../../../../../shared/mocks/submission.mock';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { FormComponent } from '../../../../../shared/form/form.component'; import { FormComponent } from '../../../../../shared/form/form.component';
import { FormService } from '../../../../../shared/form/form.service'; import { FormService } from '../../../../../shared/form/form.service';
import { getMockFormService } from '../../../../../shared/mocks/form-service.mock'; import { getMockFormService } from '../../../../../shared/mocks/form-service.mock';
import { Group } from '../../../../../core/eperson/models/group.model';
import { createTestComponent } from '../../../../../shared/testing/utils.test'; import { createTestComponent } from '../../../../../shared/testing/utils.test';
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { JsonPatchOperationsBuilder } from '../../../../../core/json-patch/builder/json-patch-operations-builder';
import { SubmissionJsonPatchOperationsServiceStub } from '../../../../../shared/testing/submission-json-patch-operations-service.stub';
import { SubmissionJsonPatchOperationsService } from '../../../../../core/submission/submission-json-patch-operations.service';
import { SectionUploadService } from '../../section-upload.service';
import { getMockSectionUploadService } from '../../../../../shared/mocks/section-upload.service.mock';
import { FormFieldMetadataValueObject } from '../../../../../shared/form/builder/models/form-field-metadata-value.model';
import { JsonPatchOperationPathCombiner } from '../../../../../core/json-patch/builder/json-patch-operation-path-combiner';
import { dateToISOFormat } from '../../../../../shared/date.util';
import { of } from 'rxjs';
const jsonPatchOpBuilder: any = jasmine.createSpyObj('jsonPatchOpBuilder', {
add: jasmine.createSpy('add'),
replace: jasmine.createSpy('replace'),
remove: jasmine.createSpy('remove'),
});
const formMetadataMock = ['dc.title', 'dc.description'];
describe('SubmissionSectionUploadFileEditComponent test suite', () => { describe('SubmissionSectionUploadFileEditComponent test suite', () => {
@@ -38,7 +56,12 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => {
let fixture: ComponentFixture<SubmissionSectionUploadFileEditComponent>; let fixture: ComponentFixture<SubmissionSectionUploadFileEditComponent>;
let submissionServiceStub: SubmissionServiceStub; let submissionServiceStub: SubmissionServiceStub;
let formbuilderService: any; let formbuilderService: any;
let operationsBuilder: any;
let operationsService: any;
let formService: any;
let uploadService: any;
const submissionJsonPatchOperationsServiceStub = new SubmissionJsonPatchOperationsServiceStub();
const submissionId = mockSubmissionId; const submissionId = mockSubmissionId;
const sectionId = 'upload'; const sectionId = 'upload';
const collectionId = mockSubmissionCollectionId; const collectionId = mockSubmissionCollectionId;
@@ -48,6 +71,7 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => {
const fileIndex = '0'; const fileIndex = '0';
const fileId = '123456-test-upload'; const fileId = '123456-test-upload';
const fileData: any = mockUploadFiles[0]; const fileData: any = mockUploadFiles[0];
const pathCombiner = new JsonPatchOperationPathCombiner('sections', sectionId, 'files', fileIndex);
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -66,9 +90,15 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => {
providers: [ providers: [
{ provide: FormService, useValue: getMockFormService() }, { provide: FormService, useValue: getMockFormService() },
{ provide: SubmissionService, useClass: SubmissionServiceStub }, { provide: SubmissionService, useClass: SubmissionServiceStub },
{ provide: SubmissionJsonPatchOperationsService, useValue: submissionJsonPatchOperationsServiceStub },
{ provide: JsonPatchOperationsBuilder, useValue: jsonPatchOpBuilder },
{ provide: SectionUploadService, useValue: getMockSectionUploadService() },
FormBuilderService, FormBuilderService,
ChangeDetectorRef, ChangeDetectorRef,
SubmissionSectionUploadFileEditComponent SubmissionSectionUploadFileEditComponent,
NgbModal,
NgbActiveModal,
FormComponent,
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).compileComponents().then(); }).compileComponents().then();
@@ -114,6 +144,10 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => {
compAsAny = comp; compAsAny = comp;
submissionServiceStub = TestBed.inject(SubmissionService as any); submissionServiceStub = TestBed.inject(SubmissionService as any);
formbuilderService = TestBed.inject(FormBuilderService); formbuilderService = TestBed.inject(FormBuilderService);
operationsBuilder = TestBed.inject(JsonPatchOperationsBuilder);
operationsService = TestBed.inject(SubmissionJsonPatchOperationsService);
formService = TestBed.inject(FormService);
uploadService = TestBed.inject(SectionUploadService);
comp.submissionId = submissionId; comp.submissionId = submissionId;
comp.collectionId = collectionId; comp.collectionId = collectionId;
@@ -123,6 +157,9 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => {
comp.fileIndex = fileIndex; comp.fileIndex = fileIndex;
comp.fileId = fileId; comp.fileId = fileId;
comp.configMetadataForm = configMetadataForm; comp.configMetadataForm = configMetadataForm;
comp.formMetadata = formMetadataMock;
formService.isValid.and.returnValue(of(true));
}); });
afterEach(() => { afterEach(() => {
@@ -135,7 +172,7 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => {
comp.fileData = fileData; comp.fileData = fileData;
comp.formId = 'testFileForm'; comp.formId = 'testFileForm';
comp.ngOnChanges(); comp.ngOnInit();
expect(comp.formModel).toBeDefined(); expect(comp.formModel).toBeDefined();
expect(comp.formModel.length).toBe(2); expect(comp.formModel.length).toBe(2);
@@ -165,7 +202,7 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => {
comp.fileData = fileData; comp.fileData = fileData;
comp.formId = 'testFileForm'; comp.formId = 'testFileForm';
comp.ngOnChanges(); comp.ngOnInit();
const model: DynamicSelectModel<string> = formbuilderService.findById('name', comp.formModel, 0); const model: DynamicSelectModel<string> = formbuilderService.findById('name', comp.formModel, 0);
const formGroup = formbuilderService.createFormGroup(comp.formModel); const formGroup = formbuilderService.createFormGroup(comp.formModel);
@@ -186,6 +223,82 @@ describe('SubmissionSectionUploadFileEditComponent test suite', () => {
comp.setOptions(model, control); comp.setOptions(model, control);
expect(formbuilderService.findById).toHaveBeenCalledWith('startDate', (model.parent as DynamicFormArrayGroupModel).group); expect(formbuilderService.findById).toHaveBeenCalledWith('startDate', (model.parent as DynamicFormArrayGroupModel).group);
}); });
it('should retrieve Value From Field properly', () => {
let field;
expect(compAsAny.retrieveValueFromField(field)).toBeUndefined();
field = new FormFieldMetadataValueObject('test');
expect(compAsAny.retrieveValueFromField(field)).toBe('test');
field = [new FormFieldMetadataValueObject('test')];
expect(compAsAny.retrieveValueFromField(field)).toBe('test');
});
it('should save Bitstream File data properly when form is valid', fakeAsync(() => {
compAsAny.formRef = {formGroup: null};
compAsAny.fileData = fileData;
compAsAny.pathCombiner = pathCombiner;
formService.validateAllFormFields.and.callFake(() => null);
formService.isValid.and.returnValue(of(true));
formService.getFormData.and.returnValue(of(mockFileFormData));
const response = [
Object.assign(mockSubmissionObject, {
sections: {
upload: {
files: mockUploadFiles
}
}
})
];
operationsService.jsonPatchByResourceID.and.returnValue(of(response));
const accessConditionsToSave = [
{ name: 'openaccess' },
{ name: 'lease', endDate: dateToISOFormat('2019-01-16T00:00:00Z') },
{ name: 'embargo', startDate: dateToISOFormat('2019-01-16T00:00:00Z') },
];
comp.saveBitstreamData();
tick();
let path = 'metadata/dc.title';
expect(operationsBuilder.add).toHaveBeenCalledWith(
pathCombiner.getPath(path),
mockFileFormData.metadata['dc.title'],
true
);
path = 'metadata/dc.description';
expect(operationsBuilder.add).toHaveBeenCalledWith(
pathCombiner.getPath(path),
mockFileFormData.metadata['dc.description'],
true
);
path = 'accessConditions';
expect(operationsBuilder.add).toHaveBeenCalledWith(
pathCombiner.getPath(path),
accessConditionsToSave,
true
);
expect(uploadService.updateFileData).toHaveBeenCalledWith(submissionId, sectionId, mockUploadFiles[0].uuid, mockUploadFiles[0]);
}));
it('should not save Bitstream File data properly when form is not valid', fakeAsync(() => {
compAsAny.formRef = {formGroup: null};
compAsAny.pathCombiner = pathCombiner;
formService.validateAllFormFields.and.callFake(() => null);
formService.isValid.and.returnValue(of(false));
comp.saveBitstreamData();
tick();
expect(uploadService.updateFileData).not.toHaveBeenCalled();
}));
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, Input, OnChanges, ViewChild } from '@angular/core'; import { ChangeDetectorRef, Component, OnInit, ViewChild } from '@angular/core';
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import { import {
@@ -32,13 +32,23 @@ import {
BITSTREAM_METADATA_FORM_GROUP_LAYOUT BITSTREAM_METADATA_FORM_GROUP_LAYOUT
} from './section-upload-file-edit.model'; } from './section-upload-file-edit.model';
import { POLICY_DEFAULT_WITH_LIST } from '../../section-upload.component'; import { POLICY_DEFAULT_WITH_LIST } from '../../section-upload.component';
import { isNotEmpty } from '../../../../../shared/empty.util'; import { hasNoValue, hasValue, isNotEmpty, isNotNull } from '../../../../../shared/empty.util';
import { SubmissionFormsModel } from '../../../../../core/config/models/config-submission-forms.model'; import { SubmissionFormsModel } from '../../../../../core/config/models/config-submission-forms.model';
import { FormFieldModel } from '../../../../../shared/form/builder/models/form-field.model'; import { FormFieldModel } from '../../../../../shared/form/builder/models/form-field.model';
import { AccessConditionOption } from '../../../../../core/config/models/config-access-condition-option.model'; import { AccessConditionOption } from '../../../../../core/config/models/config-access-condition-option.model';
import { SubmissionService } from '../../../../submission.service'; import { SubmissionService } from '../../../../submission.service';
import { FormService } from '../../../../../shared/form/form.service'; import { FormService } from '../../../../../shared/form/form.service';
import { FormComponent } from '../../../../../shared/form/form.component'; import { FormComponent } from '../../../../../shared/form/form.component';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { filter, mergeMap, take } from 'rxjs/operators';
import { dateToISOFormat } from '../../../../../shared/date.util';
import { SubmissionObject } from '../../../../../core/submission/models/submission-object.model';
import { WorkspaceitemSectionUploadObject } from '../../../../../core/submission/models/workspaceitem-section-upload.model';
import { JsonPatchOperationsBuilder } from '../../../../../core/json-patch/builder/json-patch-operations-builder';
import { SubmissionJsonPatchOperationsService } from '../../../../../core/submission/submission-json-patch-operations.service';
import { JsonPatchOperationPathCombiner } from '../../../../../core/json-patch/builder/json-patch-operation-path-combiner';
import { SectionUploadService } from '../../section-upload.service';
import { Subscription } from 'rxjs';
/** /**
* This component represents the edit form for bitstream * This component represents the edit form for bitstream
@@ -48,105 +58,246 @@ import { FormComponent } from '../../../../../shared/form/form.component';
styleUrls: ['./section-upload-file-edit.component.scss'], styleUrls: ['./section-upload-file-edit.component.scss'],
templateUrl: './section-upload-file-edit.component.html', templateUrl: './section-upload-file-edit.component.html',
}) })
export class SubmissionSectionUploadFileEditComponent implements OnChanges { export class SubmissionSectionUploadFileEditComponent implements OnInit {
/**
* The list of available access condition
* @type {Array}
*/
@Input() availableAccessConditionOptions: any[];
/**
* The submission id
* @type {string}
*/
@Input() collectionId: string;
/**
* Define if collection access conditions policy type :
* POLICY_DEFAULT_NO_LIST : is not possible to define additional access group/s for the single file
* POLICY_DEFAULT_WITH_LIST : is possible to define additional access group/s for the single file
* @type {number}
*/
@Input() collectionPolicyType: number;
/**
* The configuration for the bitstream's metadata form
* @type {SubmissionFormsModel}
*/
@Input() configMetadataForm: SubmissionFormsModel;
/**
* The bitstream's metadata data
* @type {WorkspaceitemSectionUploadFileObject}
*/
@Input() fileData: WorkspaceitemSectionUploadFileObject;
/**
* The bitstream id
* @type {string}
*/
@Input() fileId: string;
/**
* The bitstream array key
* @type {string}
*/
@Input() fileIndex: string;
/**
* The form id
* @type {string}
*/
@Input() formId: string;
/**
* The section id
* @type {string}
*/
@Input() sectionId: string;
/**
* The submission id
* @type {string}
*/
@Input() submissionId: string;
/**
* The form model
* @type {DynamicFormControlModel[]}
*/
public formModel: DynamicFormControlModel[];
/** /**
* The FormComponent reference * The FormComponent reference
*/ */
@ViewChild('formRef') public formRef: FormComponent; @ViewChild('formRef') public formRef: FormComponent;
/**
* The list of available access condition
* @type {Array}
*/
public availableAccessConditionOptions: any[];
/**
* The submission id
* @type {string}
*/
public collectionId: string;
/**
* Define if collection access conditions policy type :
* POLICY_DEFAULT_NO_LIST : is not possible to define additional access group/s for the single file
* POLICY_DEFAULT_WITH_LIST : is possible to define additional access group/s for the single file
* @type {number}
*/
public collectionPolicyType: number;
/**
* The configuration for the bitstream's metadata form
* @type {SubmissionFormsModel}
*/
public configMetadataForm: SubmissionFormsModel;
/**
* The bitstream's metadata data
* @type {WorkspaceitemSectionUploadFileObject}
*/
public fileData: WorkspaceitemSectionUploadFileObject;
/**
* The bitstream id
* @type {string}
*/
public fileId: string;
/**
* The bitstream array key
* @type {string}
*/
public fileIndex: string;
/**
* The form id
* @type {string}
*/
public formId: string;
/**
* The section id
* @type {string}
*/
public sectionId: string;
/**
* The submission id
* @type {string}
*/
public submissionId: string;
/**
* The list of all available metadata
*/
formMetadata: string[] = [];
/**
* The form model
* @type {DynamicFormControlModel[]}
*/
formModel: DynamicFormControlModel[];
/**
* When `true` form controls are deactivated
*/
isSaving = false;
/**
* The [JsonPatchOperationPathCombiner] object
* @type {JsonPatchOperationPathCombiner}
*/
protected pathCombiner: JsonPatchOperationPathCombiner;
protected subscriptions: Subscription[] = [];
/** /**
* Initialize instance variables * Initialize instance variables
* *
* @param activeModal
* @param {ChangeDetectorRef} cdr * @param {ChangeDetectorRef} cdr
* @param {FormBuilderService} formBuilderService * @param {FormBuilderService} formBuilderService
* @param {FormService} formService * @param {FormService} formService
* @param {SubmissionService} submissionService * @param {SubmissionService} submissionService
* @param {JsonPatchOperationsBuilder} operationsBuilder
* @param {SubmissionJsonPatchOperationsService} operationsService
* @param {SectionUploadService} uploadService
*/ */
constructor(private cdr: ChangeDetectorRef, constructor(
protected activeModal: NgbActiveModal,
private cdr: ChangeDetectorRef,
private formBuilderService: FormBuilderService, private formBuilderService: FormBuilderService,
private formService: FormService, private formService: FormService,
private submissionService: SubmissionService) { private submissionService: SubmissionService,
private operationsBuilder: JsonPatchOperationsBuilder,
private operationsService: SubmissionJsonPatchOperationsService,
private uploadService: SectionUploadService,
) {
}
/**
* Initialize form model values
*
* @param formModel
* The form model
*/
public initModelData(formModel: DynamicFormControlModel[]) {
this.fileData.accessConditions.forEach((accessCondition, index) => {
Array.of('name', 'startDate', 'endDate')
.filter((key) => accessCondition.hasOwnProperty(key) && isNotEmpty(accessCondition[key]))
.forEach((key) => {
const metadataModel: any = this.formBuilderService.findById(key, formModel, index);
if (metadataModel) {
if (metadataModel.type === DYNAMIC_FORM_CONTROL_TYPE_DATEPICKER) {
const date = new Date(accessCondition[key]);
metadataModel.value = {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate()
};
} else {
metadataModel.value = accessCondition[key];
}
}
});
});
}
/**
* Dispatch form model update when changing an access condition
*
* @param event
* The event emitted
*/
onChange(event: DynamicFormControlEvent) {
if (event.model.id === 'name') {
this.setOptions(event.model, event.control);
}
}
onModalClose() {
this.activeModal.dismiss();
}
onSubmit() {
this.isSaving = true;
this.saveBitstreamData();
}
/**
* Update `startDate`, 'groupUUID' and 'endDate' model
*
* @param model
* The [[DynamicFormControlModel]] object
* @param control
* The [[FormControl]] object
*/
public setOptions(model: DynamicFormControlModel, control: FormControl) {
let accessCondition: AccessConditionOption = null;
this.availableAccessConditionOptions.filter((element) => element.name === control.value)
.forEach((element) => accessCondition = element );
if (isNotEmpty(accessCondition)) {
const showGroups: boolean = accessCondition.hasStartDate === true || accessCondition.hasEndDate === true;
const startDateControl: FormControl = control.parent.get('startDate') as FormControl;
const endDateControl: FormControl = control.parent.get('endDate') as FormControl;
// Clear previous state
startDateControl?.markAsUntouched();
endDateControl?.markAsUntouched();
startDateControl?.setValue(null);
control.parent.markAsDirty();
endDateControl?.setValue(null);
if (showGroups) {
if (accessCondition.hasStartDate) {
const startDateModel = this.formBuilderService.findById(
'startDate',
(model.parent as DynamicFormArrayGroupModel).group) as DynamicDateControlModel;
const min = new Date(accessCondition.maxStartDate);
startDateModel.max = {
year: min.getUTCFullYear(),
month: min.getUTCMonth() + 1,
day: min.getUTCDate()
};
}
if (accessCondition.hasEndDate) {
const endDateModel = this.formBuilderService.findById(
'endDate',
(model.parent as DynamicFormArrayGroupModel).group) as DynamicDateControlModel;
const max = new Date(accessCondition.maxEndDate);
endDateModel.max = {
year: max.getUTCFullYear(),
month: max.getUTCMonth() + 1,
day: max.getUTCDate()
};
}
}
}
} }
/** /**
* Dispatch form model init * Dispatch form model init
*/ */
ngOnChanges() { ngOnInit() {
if (this.fileData && this.formId) { if (this.fileData && this.formId) {
this.formModel = this.buildFileEditForm(); this.formModel = this.buildFileEditForm();
this.cdr.detectChanges(); this.cdr.detectChanges();
} }
} }
ngOnDestroy(): void {
this.unsubscribeAll();
}
protected retrieveValueFromField(field: any) {
const temp = Array.isArray(field) ? field[0] : field;
return (temp) ? temp.value : undefined;
}
/** /**
* Initialize form model * Initialize form model
*/ */
@@ -193,17 +344,17 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges {
const showEnd: boolean = condition.hasEndDate === true; const showEnd: boolean = condition.hasEndDate === true;
const showGroups: boolean = showStart || showEnd; const showGroups: boolean = showStart || showEnd;
if (showStart) { if (showStart) {
hasStart.push({ id: 'name', value: condition.name }); hasStart.push({id: 'name', value: condition.name});
} }
if (showEnd) { if (showEnd) {
hasEnd.push({ id: 'name', value: condition.name }); hasEnd.push({id: 'name', value: condition.name});
} }
if (showGroups) { if (showGroups) {
hasGroups.push({ id: 'name', value: condition.name }); hasGroups.push({id: 'name', value: condition.name});
} }
}); });
const confStart = { relations: [{ match: MATCH_ENABLED, operator: OR_OPERATOR, when: hasStart }] }; const confStart = {relations: [{match: MATCH_ENABLED, operator: OR_OPERATOR, when: hasStart}]};
const confEnd = { relations: [{ match: MATCH_ENABLED, operator: OR_OPERATOR, when: hasEnd }] }; const confEnd = {relations: [{match: MATCH_ENABLED, operator: OR_OPERATOR, when: hasEnd}]};
accessConditionsArrayConfig.groupFactory = () => { accessConditionsArrayConfig.groupFactory = () => {
const type = new DynamicSelectModel(accessConditionTypeModelConfig, BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT); const type = new DynamicSelectModel(accessConditionTypeModelConfig, BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT);
@@ -213,7 +364,9 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges {
const startDate = new DynamicDatePickerModel(startDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT); const startDate = new DynamicDatePickerModel(startDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_LAYOUT);
const endDate = new DynamicDatePickerModel(endDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT); const endDate = new DynamicDatePickerModel(endDateConfig, BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_LAYOUT);
const accessConditionGroupConfig = Object.assign({}, BITSTREAM_ACCESS_CONDITION_GROUP_CONFIG); const accessConditionGroupConfig = Object.assign({}, BITSTREAM_ACCESS_CONDITION_GROUP_CONFIG);
accessConditionGroupConfig.group = [type, startDate, endDate]; accessConditionGroupConfig.group = [type];
if (hasStart.length > 0) { accessConditionGroupConfig.group.push(startDate); }
if (hasEnd.length > 0) { accessConditionGroupConfig.group.push(endDate); }
return [new DynamicFormGroupModel(accessConditionGroupConfig, BITSTREAM_ACCESS_CONDITION_GROUP_LAYOUT)]; return [new DynamicFormGroupModel(accessConditionGroupConfig, BITSTREAM_ACCESS_CONDITION_GROUP_LAYOUT)];
}; };
@@ -229,98 +382,95 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges {
} }
/** /**
* Initialize form model values * Save bitstream metadata
*
* @param formModel
* The form model
*/ */
public initModelData(formModel: DynamicFormControlModel[]) { saveBitstreamData() {
this.fileData.accessConditions.forEach((accessCondition, index) => { // validate form
Array.of('name', 'startDate', 'endDate') this.formService.validateAllFormFields(this.formRef.formGroup);
.filter((key) => accessCondition.hasOwnProperty(key) && isNotEmpty(accessCondition[key])) const saveBitstreamDataSubscription = this.formService.isValid(this.formId).pipe(
take(1),
filter((isValid) => isValid),
mergeMap(() => this.formService.getFormData(this.formId)),
take(1),
mergeMap((formData: any) => {
// collect bitstream metadata
Object.keys((formData.metadata))
.filter((key) => isNotEmpty(formData.metadata[key]))
.forEach((key) => { .forEach((key) => {
const metadataModel: any = this.formBuilderService.findById(key, formModel, index); const metadataKey = key.replace(/_/g, '.');
if (metadataModel) { const path = `metadata/${metadataKey}`;
if (metadataModel.type === DYNAMIC_FORM_CONTROL_TYPE_DATEPICKER) { this.operationsBuilder.add(this.pathCombiner.getPath(path), formData.metadata[key], true);
const date = new Date(accessCondition[key]); });
metadataModel.value = { Object.keys((this.fileData.metadata))
year: date.getUTCFullYear(), .filter((key) => isNotEmpty(this.fileData.metadata[key]))
month: date.getUTCMonth() + 1, .filter((key) => hasNoValue(formData.metadata[key]))
day: date.getUTCDate() .filter((key) => this.formMetadata.includes(key))
}; .forEach((key) => {
} else { const metadataKey = key.replace(/_/g, '.');
metadataModel.value = accessCondition[key]; const path = `metadata/${metadataKey}`;
this.operationsBuilder.remove(this.pathCombiner.getPath(path));
});
const accessConditionsToSave = [];
formData.accessConditions
.map((accessConditions) => accessConditions.accessConditionGroup)
.filter((accessCondition) => isNotEmpty(accessCondition))
.forEach((accessCondition) => {
let accessConditionOpt;
this.availableAccessConditionOptions
.filter((element) => isNotNull(accessCondition.name) && element.name === accessCondition.name[0].value)
.forEach((element) => accessConditionOpt = element);
if (accessConditionOpt) {
const currentAccessCondition = Object.assign({}, accessCondition);
currentAccessCondition.name = this.retrieveValueFromField(accessCondition.name);
/* When start and end date fields are deactivated, their values may be still present in formData,
therefore it is necessary to delete them if they're not allowed by the current access condition option. */
if (!accessConditionOpt.hasStartDate) {
delete currentAccessCondition.startDate;
} else if (accessCondition.startDate) {
const startDate = this.retrieveValueFromField(accessCondition.startDate);
currentAccessCondition.startDate = dateToISOFormat(startDate);
} }
if (!accessConditionOpt.hasEndDate) {
delete currentAccessCondition.endDate;
} else if (accessCondition.endDate) {
const endDate = this.retrieveValueFromField(accessCondition.endDate);
currentAccessCondition.endDate = dateToISOFormat(endDate);
}
accessConditionsToSave.push(currentAccessCondition);
} }
}); });
if (isNotEmpty(accessConditionsToSave)) {
this.operationsBuilder.add(this.pathCombiner.getPath('accessConditions'), accessConditionsToSave, true);
}
// dispatch a PATCH request to save metadata
return this.operationsService.jsonPatchByResourceID(
this.submissionService.getSubmissionObjectLinkName(),
this.submissionId,
this.pathCombiner.rootElement,
this.pathCombiner.subRootElement);
})
).subscribe((result: SubmissionObject[]) => {
if (result[0].sections[this.sectionId]) {
const uploadSection = (result[0].sections[this.sectionId] as WorkspaceitemSectionUploadObject);
Object.keys(uploadSection.files)
.filter((key) => uploadSection.files[key].uuid === this.fileId)
.forEach((key) => this.uploadService.updateFileData(
this.submissionId, this.sectionId, this.fileId, uploadSection.files[key])
);
}
this.isSaving = false;
this.activeModal.close();
}); });
this.subscriptions.push(saveBitstreamDataSubscription);
} }
/** private unsubscribeAll() {
* Dispatch form model update when changing an access condition this.subscriptions.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
*
* @param event
* The event emitted
*/
public onChange(event: DynamicFormControlEvent) {
if (event.model.id === 'name') {
this.setOptions(event.model, event.control);
}
}
/**
* Update `startDate`, 'groupUUID' and 'endDate' model
*
* @param model
* The [[DynamicFormControlModel]] object
* @param control
* The [[FormControl]] object
*/
public setOptions(model: DynamicFormControlModel, control: FormControl) {
let accessCondition: AccessConditionOption = null;
this.availableAccessConditionOptions.filter((element) => element.name === control.value)
.forEach((element) => accessCondition = element);
if (isNotEmpty(accessCondition)) {
const showGroups: boolean = accessCondition.hasStartDate === true || accessCondition.hasEndDate === true;
const startDateControl: FormControl = control.parent.get('startDate') as FormControl;
const endDateControl: FormControl = control.parent.get('endDate') as FormControl;
// Clear previous state
startDateControl.markAsUntouched();
endDateControl.markAsUntouched();
startDateControl.setValue(null);
control.parent.markAsDirty();
endDateControl.setValue(null);
if (showGroups) {
if (accessCondition.hasStartDate) {
const startDateModel = this.formBuilderService.findById(
'startDate',
(model.parent as DynamicFormArrayGroupModel).group) as DynamicDateControlModel;
const min = new Date(accessCondition.maxStartDate);
startDateModel.max = {
year: min.getUTCFullYear(),
month: min.getUTCMonth() + 1,
day: min.getUTCDate()
};
}
if (accessCondition.hasEndDate) {
const endDateModel = this.formBuilderService.findById(
'endDate',
(model.parent as DynamicFormArrayGroupModel).group) as DynamicDateControlModel;
const max = new Date(accessCondition.maxEndDate);
endDateModel.max = {
year: max.getUTCFullYear(),
month: max.getUTCMonth() + 1,
day: max.getUTCDate()
};
}
}
}
} }
} }

View File

@@ -8,15 +8,15 @@
<div class="float-left w-75"> <div class="float-left w-75">
<h3>{{fileName}} <span class="text-muted">({{fileData?.sizeBytes | dsFileSize}})</span></h3> <h3>{{fileName}} <span class="text-muted">({{fileData?.sizeBytes | dsFileSize}})</span></h3>
</div> </div>
<div class="float-right w-15" [class.sticky-buttons]="!readMode"> <div class="float-right w-15">
<ng-container *ngIf="readMode"> <ng-container>
<ds-file-download-link [cssClasses]="'btn btn-link-focus'" [isBlank]="true" [bitstream]="getBitstream()" [enableRequestACopy]="false"> <ds-file-download-link [cssClasses]="'btn btn-link-focus'" [isBlank]="true" [bitstream]="getBitstream()" [enableRequestACopy]="false">
<i class="fa fa-download fa-2x text-normal" aria-hidden="true"></i> <i class="fa fa-download fa-2x text-normal" aria-hidden="true"></i>
</ds-file-download-link> </ds-file-download-link>
<button class="btn btn-link-focus" <button class="btn btn-link-focus"
[attr.aria-label]="'submission.sections.upload.edit.title' | translate" [attr.aria-label]="'submission.sections.upload.edit.title' | translate"
title="{{ 'submission.sections.upload.edit.title' | translate }}" title="{{ 'submission.sections.upload.edit.title' | translate }}"
(click)="$event.preventDefault();switchMode();"> (click)="$event.preventDefault();editBitstreamData();">
<i class="fa fa-edit fa-2x text-normal"></i> <i class="fa fa-edit fa-2x text-normal"></i>
</button> </button>
<button class="btn btn-link-focus" <button class="btn btn-link-focus"
@@ -28,40 +28,9 @@
<i *ngIf="!(processingDelete$ | async)" class="fa fa-trash fa-2x text-danger"></i> <i *ngIf="!(processingDelete$ | async)" class="fa fa-trash fa-2x text-danger"></i>
</button> </button>
</ng-container> </ng-container>
<ng-container *ngIf="!readMode">
<button class="btn btn-link-focus"
[attr.aria-label]="'submission.sections.upload.save-metadata' | translate"
title="{{ 'submission.sections.upload.save-metadata' | translate }}"
(click)="saveBitstreamData($event);">
<i class="fa fa-save fa-2x text-success"></i>
</button>
<button class="btn btn-link-focus"
[attr.aria-label]="'submission.sections.upload.undo' | translate"
title="{{ 'submission.sections.upload.undo' | translate }}"
(click)="$event.preventDefault();switchMode();"><i class="fa fa-ban fa-2x text-warning"></i></button>
<button class="btn btn-link-focus"
[attr.aria-label]="'submission.sections.upload.delete.confirm.title' | translate"
title="{{ 'submission.sections.upload.delete.confirm.title' | translate }}"
[disabled]="(processingDelete$ | async)"
(click)="$event.preventDefault();confirmDelete(content);">
<i *ngIf="(processingDelete$ | async)" class="fas fa-circle-notch fa-spin fa-2x text-danger"></i>
<i *ngIf="!(processingDelete$ | async)" class="fa fa-trash fa-2x text-danger"></i>
</button>
</ng-container>
</div> </div>
<div class="clearfix"></div> <div class="clearfix"></div>
<ds-submission-section-upload-file-view *ngIf="readMode" <ds-submission-section-upload-file-view [fileData]="fileData"></ds-submission-section-upload-file-view>
[fileData]="fileData"></ds-submission-section-upload-file-view>
<ds-submission-section-upload-file-edit *ngIf="!readMode"
[availableAccessConditionOptions]="availableAccessConditionOptions"
[collectionId]="collectionId"
[collectionPolicyType]="collectionPolicyType"
[configMetadataForm]="configMetadataForm"
[fileData]="fileData"
[fileId]="fileId"
[fileIndex]="fileIndex"
[formId]="formId"
[sectionId]="sectionId"></ds-submission-section-upload-file-edit>
</div> </div>
</div> </div>
</ng-container> </ng-container>

View File

@@ -1,6 +0,0 @@
.sticky-buttons {
position: sticky;
top: calc(var(--bs-dropdown-item-padding-x) * 3);
z-index: var(--ds-submission-footer-z-index);
background-color: rgba(255, 255, 255, .97);
}

View File

@@ -1,9 +1,9 @@
import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, fakeAsync, inject, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { ComponentFixture, inject, TestBed, waitForAsync } from '@angular/core/testing';
import { BrowserModule, By } from '@angular/platform-browser'; import { BrowserModule, By } from '@angular/platform-browser';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { of as observableOf } from 'rxjs'; import { of, of as observableOf } from 'rxjs';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { FormService } from '../../../../shared/form/form.service'; import { FormService } from '../../../../shared/form/form.service';
@@ -17,10 +17,8 @@ import { SubmissionJsonPatchOperationsService } from '../../../../core/submissio
import { SubmissionSectionUploadFileComponent } from './section-upload-file.component'; import { SubmissionSectionUploadFileComponent } from './section-upload-file.component';
import { SubmissionServiceStub } from '../../../../shared/testing/submission-service.stub'; import { SubmissionServiceStub } from '../../../../shared/testing/submission-service.stub';
import { import {
mockFileFormData,
mockSubmissionCollectionId, mockSubmissionCollectionId,
mockSubmissionId, mockSubmissionId,
mockSubmissionObject,
mockUploadConfigResponse, mockUploadConfigResponse,
mockUploadFiles mockUploadFiles
} from '../../../../shared/mocks/submission.mock'; } from '../../../../shared/mocks/submission.mock';
@@ -32,10 +30,19 @@ import { FileSizePipe } from '../../../../shared/utils/file-size-pipe';
import { POLICY_DEFAULT_WITH_LIST } from '../section-upload.component'; import { POLICY_DEFAULT_WITH_LIST } from '../section-upload.component';
import { JsonPatchOperationPathCombiner } from '../../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { JsonPatchOperationPathCombiner } from '../../../../core/json-patch/builder/json-patch-operation-path-combiner';
import { getMockSectionUploadService } from '../../../../shared/mocks/section-upload.service.mock'; import { getMockSectionUploadService } from '../../../../shared/mocks/section-upload.service.mock';
import { FormFieldMetadataValueObject } from '../../../../shared/form/builder/models/form-field-metadata-value.model';
import { SubmissionSectionUploadFileEditComponent } from './edit/section-upload-file-edit.component'; import { SubmissionSectionUploadFileEditComponent } from './edit/section-upload-file-edit.component';
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service'; import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
import { dateToISOFormat } from '../../../../shared/date.util';
const configMetadataFormMock = {
rows: [{
fields: [{
selectableMetadata: [
{metadata: 'dc.title', label: null, closed: false},
{metadata: 'dc.description', label: null, closed: false}
]
}]
}]
};
describe('SubmissionSectionUploadFileComponent test suite', () => { describe('SubmissionSectionUploadFileComponent test suite', () => {
@@ -117,6 +124,7 @@ describe('SubmissionSectionUploadFileComponent test suite', () => {
testFixture = createTestComponent(html, TestComponent) as ComponentFixture<TestComponent>; testFixture = createTestComponent(html, TestComponent) as ComponentFixture<TestComponent>;
testComp = testFixture.componentInstance; testComp = testFixture.componentInstance;
}); });
afterEach(() => { afterEach(() => {
@@ -124,9 +132,7 @@ describe('SubmissionSectionUploadFileComponent test suite', () => {
}); });
it('should create SubmissionSectionUploadFileComponent', inject([SubmissionSectionUploadFileComponent], (app: SubmissionSectionUploadFileComponent) => { it('should create SubmissionSectionUploadFileComponent', inject([SubmissionSectionUploadFileComponent], (app: SubmissionSectionUploadFileComponent) => {
expect(app).toBeDefined(); expect(app).toBeDefined();
})); }));
}); });
@@ -135,6 +141,7 @@ describe('SubmissionSectionUploadFileComponent test suite', () => {
fixture = TestBed.createComponent(SubmissionSectionUploadFileComponent); fixture = TestBed.createComponent(SubmissionSectionUploadFileComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
compAsAny = comp; compAsAny = comp;
compAsAny.configMetadataForm = configMetadataFormMock;
submissionServiceStub = TestBed.inject(SubmissionService as any); submissionServiceStub = TestBed.inject(SubmissionService as any);
uploadService = TestBed.inject(SectionUploadService); uploadService = TestBed.inject(SectionUploadService);
formService = TestBed.inject(FormService); formService = TestBed.inject(FormService);
@@ -210,96 +217,20 @@ describe('SubmissionSectionUploadFileComponent test suite', () => {
pathCombiner.subRootElement); pathCombiner.subRootElement);
}); });
it('should save Bitstream File data properly when form is valid', fakeAsync(() => { it('should open edit modal when edit button is clicked', () => {
compAsAny.fileEditComp = TestBed.inject(SubmissionSectionUploadFileEditComponent); spyOn(compAsAny, 'editBitstreamData').and.callThrough();
compAsAny.fileEditComp.formRef = {formGroup: null}; comp.fileData = fileData;
compAsAny.pathCombiner = pathCombiner;
const event = new Event('click', null);
spyOn(comp, 'switchMode');
formService.validateAllFormFields.and.callFake(() => null);
formService.isValid.and.returnValue(observableOf(true));
formService.getFormData.and.returnValue(observableOf(mockFileFormData));
const response = [ fixture.detectChanges();
Object.assign(mockSubmissionObject, {
sections: {
upload: {
files: mockUploadFiles
}
}
})
];
operationsService.jsonPatchByResourceID.and.returnValue(observableOf(response));
const accessConditionsToSave = [ const modalBtn = fixture.debugElement.query(By.css('.fa-edit '));
{ name: 'openaccess' },
{ name: 'lease', endDate: dateToISOFormat('2019-01-16T00:00:00Z') },
{ name: 'embargo', startDate: dateToISOFormat('2019-01-16T00:00:00Z') },
];
comp.saveBitstreamData(event);
tick();
let path = 'metadata/dc.title'; modalBtn.nativeElement.click();
expect(operationsBuilder.add).toHaveBeenCalledWith( fixture.detectChanges();
pathCombiner.getPath(path),
mockFileFormData.metadata['dc.title'],
true
);
path = 'metadata/dc.description'; expect(compAsAny.editBitstreamData).toHaveBeenCalled();
expect(operationsBuilder.add).toHaveBeenCalledWith(
pathCombiner.getPath(path),
mockFileFormData.metadata['dc.description'],
true
);
path = 'accessConditions';
expect(operationsBuilder.add).toHaveBeenCalledWith(
pathCombiner.getPath(path),
accessConditionsToSave,
true
);
expect(comp.switchMode).toHaveBeenCalled();
expect(uploadService.updateFileData).toHaveBeenCalledWith(submissionId, sectionId, mockUploadFiles[0].uuid, mockUploadFiles[0]);
}));
it('should not save Bitstream File data properly when form is not valid', fakeAsync(() => {
compAsAny.fileEditComp = TestBed.inject(SubmissionSectionUploadFileEditComponent);
compAsAny.fileEditComp.formRef = {formGroup: null};
compAsAny.pathCombiner = pathCombiner;
const event = new Event('click', null);
spyOn(comp, 'switchMode');
formService.validateAllFormFields.and.callFake(() => null);
formService.isValid.and.returnValue(observableOf(false));
expect(comp.switchMode).not.toHaveBeenCalled();
expect(uploadService.updateFileData).not.toHaveBeenCalled();
}));
it('should retrieve Value From Field properly', () => {
let field;
expect(compAsAny.retrieveValueFromField(field)).toBeUndefined();
field = new FormFieldMetadataValueObject('test');
expect(compAsAny.retrieveValueFromField(field)).toBe('test');
field = [new FormFieldMetadataValueObject('test')];
expect(compAsAny.retrieveValueFromField(field)).toBe('test');
}); });
it('should switch read mode', () => {
comp.readMode = false;
comp.switchMode();
expect(comp.readMode).toBeTruthy();
comp.switchMode();
expect(comp.readMode).toBeFalsy();
});
}); });
}); });
@@ -314,7 +245,7 @@ class TestComponent {
availableAccessConditionOptions; availableAccessConditionOptions;
collectionId = mockSubmissionCollectionId; collectionId = mockSubmissionCollectionId;
collectionPolicyType; collectionPolicyType;
configMetadataForm$; configMetadataForm$ = of(configMetadataFormMock);
fileIndexes = []; fileIndexes = [];
fileList = []; fileList = [];
fileNames = []; fileNames = [];

View File

@@ -1,25 +1,23 @@
import { ChangeDetectorRef, Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core'; import { ChangeDetectorRef, Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core';
import { BehaviorSubject, Subscription } from 'rxjs'; import { BehaviorSubject, Subscription } from 'rxjs';
import { filter, mergeMap, take } from 'rxjs/operators'; import { filter } from 'rxjs/operators';
import { DynamicFormControlModel, } from '@ng-dynamic-forms/core'; import { DynamicFormControlModel, } from '@ng-dynamic-forms/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { SectionUploadService } from '../section-upload.service'; import { SectionUploadService } from '../section-upload.service';
import { isNotEmpty, isNotNull, isNotUndefined } from '../../../../shared/empty.util'; import { hasValue, isNotUndefined } from '../../../../shared/empty.util';
import { FormService } from '../../../../shared/form/form.service'; import { FormService } from '../../../../shared/form/form.service';
import { JsonPatchOperationsBuilder } from '../../../../core/json-patch/builder/json-patch-operations-builder'; import { JsonPatchOperationsBuilder } from '../../../../core/json-patch/builder/json-patch-operations-builder';
import { JsonPatchOperationPathCombiner } from '../../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { JsonPatchOperationPathCombiner } from '../../../../core/json-patch/builder/json-patch-operation-path-combiner';
import { WorkspaceitemSectionUploadFileObject } from '../../../../core/submission/models/workspaceitem-section-upload-file.model'; import { WorkspaceitemSectionUploadFileObject } from '../../../../core/submission/models/workspaceitem-section-upload-file.model';
import { SubmissionFormsModel } from '../../../../core/config/models/config-submission-forms.model'; import { SubmissionFormsModel } from '../../../../core/config/models/config-submission-forms.model';
import { dateToISOFormat } from '../../../../shared/date.util';
import { SubmissionService } from '../../../submission.service'; import { SubmissionService } from '../../../submission.service';
import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service'; import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
import { SubmissionJsonPatchOperationsService } from '../../../../core/submission/submission-json-patch-operations.service'; import { SubmissionJsonPatchOperationsService } from '../../../../core/submission/submission-json-patch-operations.service';
import { SubmissionObject } from '../../../../core/submission/models/submission-object.model';
import { WorkspaceitemSectionUploadObject } from '../../../../core/submission/models/workspaceitem-section-upload.model';
import { SubmissionSectionUploadFileEditComponent } from './edit/section-upload-file-edit.component'; import { SubmissionSectionUploadFileEditComponent } from './edit/section-upload-file-edit.component';
import { Bitstream } from '../../../../core/shared/bitstream.model'; import { Bitstream } from '../../../../core/shared/bitstream.model';
import { NgbModalOptions } from '@ng-bootstrap/ng-bootstrap/modal/modal-config';
/** /**
* This component represents a single bitstream contained in the submission * This component represents a single bitstream contained in the submission
@@ -87,6 +85,13 @@ export class SubmissionSectionUploadFileComponent implements OnChanges, OnInit {
*/ */
@Input() submissionId: string; @Input() submissionId: string;
/**
* The [[SubmissionSectionUploadFileEditComponent]] reference
* @type {SubmissionSectionUploadFileEditComponent}
*/
@ViewChild(SubmissionSectionUploadFileEditComponent) fileEditComp: SubmissionSectionUploadFileEditComponent;
/** /**
* The bitstream's metadata data * The bitstream's metadata data
* @type {WorkspaceitemSectionUploadFileObject} * @type {WorkspaceitemSectionUploadFileObject}
@@ -130,10 +135,10 @@ export class SubmissionSectionUploadFileComponent implements OnChanges, OnInit {
protected subscriptions: Subscription[] = []; protected subscriptions: Subscription[] = [];
/** /**
* The [[SubmissionSectionUploadFileEditComponent]] reference * Array containing all the form metadata defined in configMetadataForm
* @type {SubmissionSectionUploadFileEditComponent} * @type {Array}
*/ */
@ViewChild(SubmissionSectionUploadFileEditComponent) fileEditComp: SubmissionSectionUploadFileEditComponent; protected formMetadata: string[] = [];
/** /**
* Initialize instance variables * Initialize instance variables
@@ -147,14 +152,16 @@ export class SubmissionSectionUploadFileComponent implements OnChanges, OnInit {
* @param {SubmissionService} submissionService * @param {SubmissionService} submissionService
* @param {SectionUploadService} uploadService * @param {SectionUploadService} uploadService
*/ */
constructor(private cdr: ChangeDetectorRef, constructor(
private cdr: ChangeDetectorRef,
private formService: FormService, private formService: FormService,
private halService: HALEndpointService, private halService: HALEndpointService,
private modalService: NgbModal, private modalService: NgbModal,
private operationsBuilder: JsonPatchOperationsBuilder, private operationsBuilder: JsonPatchOperationsBuilder,
private operationsService: SubmissionJsonPatchOperationsService, private operationsService: SubmissionJsonPatchOperationsService,
private submissionService: SubmissionService, private submissionService: SubmissionService,
private uploadService: SectionUploadService) { private uploadService: SectionUploadService,
) {
this.readMode = true; this.readMode = true;
} }
@@ -182,22 +189,7 @@ export class SubmissionSectionUploadFileComponent implements OnChanges, OnInit {
ngOnInit() { ngOnInit() {
this.formId = this.formService.getUniqueId(this.fileId); this.formId = this.formService.getUniqueId(this.fileId);
this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionId, 'files', this.fileIndex); this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionId, 'files', this.fileIndex);
} this.loadFormMetadata();
/**
* Delete bitstream from submission
*/
protected deleteFile() {
this.operationsBuilder.remove(this.pathCombiner.getPath());
this.subscriptions.push(this.operationsService.jsonPatchByResourceID(
this.submissionService.getSubmissionObjectLinkName(),
this.submissionId,
this.pathCombiner.rootElement,
this.pathCombiner.subRootElement)
.subscribe(() => {
this.uploadService.removeUploadedFile(this.submissionId, this.sectionId, this.fileId);
this.processingDelete$.next(false);
}));
} }
/** /**
@@ -225,98 +217,63 @@ export class SubmissionSectionUploadFileComponent implements OnChanges, OnInit {
}); });
} }
editBitstreamData() {
const options: NgbModalOptions = {
size: 'xl',
backdrop: 'static',
};
const activeModal = this.modalService.open(SubmissionSectionUploadFileEditComponent, options);
activeModal.componentInstance.availableAccessConditionOptions = this.availableAccessConditionOptions;
activeModal.componentInstance.collectionId = this.collectionId;
activeModal.componentInstance.collectionPolicyType = this.collectionPolicyType;
activeModal.componentInstance.configMetadataForm = this.configMetadataForm;
activeModal.componentInstance.fileData = this.fileData;
activeModal.componentInstance.fileId = this.fileId;
activeModal.componentInstance.fileIndex = this.fileIndex;
activeModal.componentInstance.formId = this.formId;
activeModal.componentInstance.sectionId = this.sectionId;
activeModal.componentInstance.formMetadata = this.formMetadata;
activeModal.componentInstance.pathCombiner = this.pathCombiner;
activeModal.componentInstance.submissionId = this.submissionId;
}
ngOnDestroy(): void {
this.unsubscribeAll();
}
unsubscribeAll() {
this.subscriptions.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
}
protected loadFormMetadata() {
this.configMetadataForm.rows.forEach((row) => {
row.fields.forEach((field) => {
field.selectableMetadata.forEach((metadatum) => {
this.formMetadata.push(metadatum.metadata);
});
});
}
);
}
/** /**
* Save bitstream metadata * Delete bitstream from submission
*
* @param event
* the click event emitted
*/ */
public saveBitstreamData(event) { protected deleteFile() {
event.preventDefault(); this.operationsBuilder.remove(this.pathCombiner.getPath());
this.subscriptions.push(this.operationsService.jsonPatchByResourceID(
// validate form
this.formService.validateAllFormFields(this.fileEditComp.formRef.formGroup);
this.subscriptions.push(this.formService.isValid(this.formId).pipe(
take(1),
filter((isValid) => isValid),
mergeMap(() => this.formService.getFormData(this.formId)),
take(1),
mergeMap((formData: any) => {
// collect bitstream metadata
Object.keys((formData.metadata))
.filter((key) => isNotEmpty(formData.metadata[key]))
.forEach((key) => {
const metadataKey = key.replace(/_/g, '.');
const path = `metadata/${metadataKey}`;
this.operationsBuilder.add(this.pathCombiner.getPath(path), formData.metadata[key], true);
});
const accessConditionsToSave = [];
formData.accessConditions
.map((accessConditions) => accessConditions.accessConditionGroup)
.filter((accessCondition) => isNotEmpty(accessCondition))
.forEach((accessCondition) => {
let accessConditionOpt;
this.availableAccessConditionOptions
.filter((element) => isNotNull(accessCondition.name) && element.name === accessCondition.name[0].value)
.forEach((element) => accessConditionOpt = element);
if (accessConditionOpt) {
accessConditionOpt = Object.assign({}, accessCondition);
accessConditionOpt.name = this.retrieveValueFromField(accessCondition.name);
if (accessCondition.startDate) {
const startDate = this.retrieveValueFromField(accessCondition.startDate);
accessConditionOpt.startDate = dateToISOFormat(startDate);
}
if (accessCondition.endDate) {
const endDate = this.retrieveValueFromField(accessCondition.endDate);
accessConditionOpt.endDate = dateToISOFormat(endDate);
}
accessConditionsToSave.push(accessConditionOpt);
}
});
if (isNotEmpty(accessConditionsToSave)) {
this.operationsBuilder.add(this.pathCombiner.getPath('accessConditions'), accessConditionsToSave, true);
}
// dispatch a PATCH request to save metadata
return this.operationsService.jsonPatchByResourceID(
this.submissionService.getSubmissionObjectLinkName(), this.submissionService.getSubmissionObjectLinkName(),
this.submissionId, this.submissionId,
this.pathCombiner.rootElement, this.pathCombiner.rootElement,
this.pathCombiner.subRootElement); this.pathCombiner.subRootElement)
}) .subscribe(() => {
).subscribe((result: SubmissionObject[]) => { this.uploadService.removeUploadedFile(this.submissionId, this.sectionId, this.fileId);
if (result[0].sections[this.sectionId]) { this.processingDelete$.next(false);
const uploadSection = (result[0].sections[this.sectionId] as WorkspaceitemSectionUploadObject);
Object.keys(uploadSection.files)
.filter((key) => uploadSection.files[key].uuid === this.fileId)
.forEach((key) => this.uploadService.updateFileData(
this.submissionId, this.sectionId, this.fileId, uploadSection.files[key])
);
}
this.switchMode();
})); }));
} }
/**
* Retrieve field value
*
* @param field
* the specified field object
*/
private retrieveValueFromField(field: any) {
const temp = Array.isArray(field) ? field[0] : field;
return (temp) ? temp.value : undefined;
}
/**
* Switch from edit form to metadata view
*/
public switchMode() {
this.readMode = !this.readMode;
this.cdr.detectChanges();
}
} }

1
src/assets/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
config.json

View File

@@ -1,3 +1,5 @@
import { InjectionToken } from '@angular/core';
import { makeStateKey } from '@angular/platform-browser';
import { Config } from './config.interface'; import { Config } from './config.interface';
import { ServerConfig } from './server-config.interface'; import { ServerConfig } from './server-config.interface';
import { CacheConfig } from './cache-config.interface'; import { CacheConfig } from './cache-config.interface';
@@ -6,15 +8,15 @@ import { INotificationBoardOptions } from './notifications-config.interfaces';
import { SubmissionConfig } from './submission-config.interface'; import { SubmissionConfig } from './submission-config.interface';
import { FormConfig } from './form-config.interfaces'; import { FormConfig } from './form-config.interfaces';
import { LangConfig } from './lang-config.interface'; import { LangConfig } from './lang-config.interface';
import { BrowseByConfig } from './browse-by-config.interface';
import { ItemPageConfig } from './item-page-config.interface'; import { ItemPageConfig } from './item-page-config.interface';
import { CollectionPageConfig } from './collection-page-config.interface'; import { CollectionPageConfig } from './collection-page-config.interface';
import { ThemeConfig } from './theme.model'; import { ThemeConfig } from './theme.model';
import { AuthConfig } from './auth-config.interfaces'; import { AuthConfig } from './auth-config.interfaces';
import { UIServerConfig } from './ui-server-config.interface'; import { UIServerConfig } from './ui-server-config.interface';
import { MediaViewerConfig } from './media-viewer-config.interface'; import { MediaViewerConfig } from './media-viewer-config.interface';
import { BrowseByConfig } from './browse-by-config.interface';
export interface GlobalConfig extends Config { interface AppConfig extends Config {
ui: UIServerConfig; ui: UIServerConfig;
rest: ServerConfig; rest: ServerConfig;
production: boolean; production: boolean;
@@ -33,3 +35,13 @@ export interface GlobalConfig extends Config {
themes: ThemeConfig[]; themes: ThemeConfig[];
mediaViewer: MediaViewerConfig; mediaViewer: MediaViewerConfig;
} }
const APP_CONFIG = new InjectionToken<AppConfig>('APP_CONFIG');
const APP_CONFIG_STATE = makeStateKey('APP_CONFIG_STATE');
export {
AppConfig,
APP_CONFIG,
APP_CONFIG_STATE
};

View File

@@ -1,5 +1,4 @@
import { Config } from './config.interface'; import { Config } from './config.interface';
import { BrowseByTypeConfig } from './browse-by-type-config.interface';
/** /**
* Config that determines how the dropdown list of years are created for browse-by-date components * Config that determines how the dropdown list of years are created for browse-by-date components
@@ -19,9 +18,4 @@ export interface BrowseByConfig extends Config {
* The absolute lowest year to display in the dropdown when no lowest date can be found for all items * The absolute lowest year to display in the dropdown when no lowest date can be found for all items
*/ */
defaultLowerLimit: number; defaultLowerLimit: number;
/**
* A list of all the active Browse-By pages
*/
types: BrowseByTypeConfig[];
} }

View File

@@ -1,23 +0,0 @@
import { Config } from './config.interface';
import { BrowseByType } from '../app/browse-by/browse-by-switcher/browse-by-decorator';
/**
* Config used for rendering Browse-By pages and links
*/
export interface BrowseByTypeConfig extends Config {
/**
* The browse id used for fetching browse data from the rest api
* e.g. author
*/
id: string;
/**
* The type of Browse-By page to render
*/
type: BrowseByType | string;
/**
* The metadata field to use for rendering starts-with options (only necessary when type is set to BrowseByType.Date)
*/
metadataField?: string;
}

223
src/config/config.server.ts Normal file
View File

@@ -0,0 +1,223 @@
import * as colors from 'colors';
import * as fs from 'fs';
import * as yaml from 'js-yaml';
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';
import { mergeConfig } from './config.util';
import { isNotEmpty } from '../app/shared/empty.util';
const CONFIG_PATH = join(process.cwd(), 'config');
type Environment = 'production' | 'development' | 'test';
const DSPACE = (key: string): string => {
return `DSPACE_${key}`;
};
const ENV = (key: string, prefix = false): any => {
return prefix ? process.env[DSPACE(key)] : process.env[key];
};
const getBooleanFromString = (variable: string): boolean => {
return variable === 'true' || variable === '1';
};
const getNumberFromString = (variable: string): number => {
return Number(variable);
};
const getEnvironment = (): Environment => {
// default to production
let environment: Environment = 'production';
if (isNotEmpty(ENV('NODE_ENV'))) {
switch (ENV('NODE_ENV')) {
case 'prod':
case 'production':
environment = 'production';
break;
case 'test':
environment = 'test';
break;
case 'dev':
case 'development':
environment = 'development';
break;
default:
console.warn(`Unknown NODE_ENV ${ENV('NODE_ENV')}. Defaulting to production.`);
}
}
return environment;
};
const getLocalConfigPath = (env: Environment) => {
// default to config/config.yml
let localConfigPath = join(CONFIG_PATH, 'config.yml');
if (!fs.existsSync(localConfigPath)) {
localConfigPath = join(CONFIG_PATH, 'config.yaml');
}
// 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) {
let envLocalConfigPath = join(CONFIG_PATH, `config.${envVariation}.yml`);
if (fs.existsSync(envLocalConfigPath)) {
localConfigPath = envLocalConfigPath;
break;
} else {
envLocalConfigPath = join(CONFIG_PATH, `config.${envVariation}.yaml`);
if (fs.existsSync(envLocalConfigPath)) {
localConfigPath = envLocalConfigPath;
break;
}
}
}
return localConfigPath;
};
const overrideWithConfig = (config: Config, pathToConfig: string) => {
try {
console.log(`Overriding app config with ${pathToConfig}`);
const externalConfig = fs.readFileSync(pathToConfig, 'utf8');
mergeConfig(config, yaml.load(externalConfig));
} catch (err) {
console.error(err);
}
};
const overrideWithEnvironment = (config: Config, key: string = '') => {
for (const property in config) {
const variable = `${key}${isNotEmpty(key) ? '_' : ''}${property.toUpperCase()}`;
const innerConfig = config[property];
if (isNotEmpty(innerConfig)) {
if (typeof innerConfig === 'object') {
overrideWithEnvironment(innerConfig, variable);
} else {
const value = ENV(variable, true);
if (isNotEmpty(value)) {
console.log(`Applying environment variable ${DSPACE(variable)} with value ${value}`);
switch (typeof innerConfig) {
case 'number':
config[property] = getNumberFromString(value);
break;
case 'boolean':
config[property] = getBooleanFromString(value);
break;
case 'string':
config[property] = value;
break;
default:
console.warn(`Unsupported environment variable type ${typeof innerConfig} ${DSPACE(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('');
};
/**
* Build app config with the following chain of override.
*
* local config -> environment local config -> external config -> environment variable
*
* Optionally save to file.
*
* @param destConfigPath optional path to save config file
* @returns app config
*/
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 localConfigPath = getLocalConfigPath(env);
if (fs.existsSync(localConfigPath)) {
overrideWithConfig(appConfig, localConfigPath);
} else {
console.warn(`Unable to find dist config file at ${localConfigPath}`);
}
// override with external config if specified by environment variable `DSPACE_APP_CONFIG_PATH`
const externalConfigPath = ENV('APP_CONFIG_PATH', true);
if (isNotEmpty(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 existing non convention UI environment variables
appConfig.ui.host = isNotEmpty(ENV('HOST', true)) ? ENV('HOST', true) : appConfig.ui.host;
appConfig.ui.port = isNotEmpty(ENV('PORT', true)) ? getNumberFromString(ENV('PORT', true)) : appConfig.ui.port;
appConfig.ui.nameSpace = isNotEmpty(ENV('NAMESPACE', true)) ? ENV('NAMESPACE', true) : appConfig.ui.nameSpace;
appConfig.ui.ssl = isNotEmpty(ENV('SSL', true)) ? getBooleanFromString(ENV('SSL', true)) : appConfig.ui.ssl;
// apply existing non convention REST environment variables
appConfig.rest.host = isNotEmpty(ENV('REST_HOST', true)) ? ENV('REST_HOST', true) : appConfig.rest.host;
appConfig.rest.port = isNotEmpty(ENV('REST_PORT', true)) ? getNumberFromString(ENV('REST_PORT', true)) : appConfig.rest.port;
appConfig.rest.nameSpace = isNotEmpty(ENV('REST_NAMESPACE', true)) ? ENV('REST_NAMESPACE', true) : appConfig.rest.nameSpace;
appConfig.rest.ssl = isNotEmpty(ENV('REST_SSL', true)) ? getBooleanFromString(ENV('REST_SSL', true)) : appConfig.rest.ssl;
// apply build defined production
appConfig.production = env === 'production';
// build base URLs
buildBaseUrl(appConfig.ui);
buildBaseUrl(appConfig.rest);
if (isNotEmpty(destConfigPath)) {
fs.writeFileSync(destConfigPath, JSON.stringify(appConfig, null, 2));
console.log(`Angular ${colors.bold('config.json')} file generated correctly at ${colors.bold(destConfigPath)} \n`);
}
return appConfig;
};

View File

@@ -0,0 +1,56 @@
import { environment } from '../environments/environment.production';
import { extendEnvironmentWithAppConfig } from './config.util';
import { DefaultAppConfig } from './default-app-config';
import { HandleThemeConfig } from './theme.model';
describe('Config Util', () => {
describe('extendEnvironmentWithAppConfig', () => {
it('should extend prod environment with app config', () => {
const appConfig = new DefaultAppConfig();
expect(appConfig.cache.msToLive.default).toEqual(15 * 60 * 1000); // 15 minute
expect(appConfig.ui.rateLimiter.windowMs).toEqual(1 * 60 * 1000); // 1 minute
expect(appConfig.ui.rateLimiter.max).toEqual(500);
expect(appConfig.submission.autosave.metadata).toEqual([]);
expect(appConfig.themes.length).toEqual(1);
expect(appConfig.themes[0].name).toEqual('dspace');
const msToLive = 1 * 60 * 1000; // 1 minute
appConfig.cache.msToLive.default = msToLive;
const rateLimiter = {
windowMs: 5 * 50 * 1000, // 5 minutes
max: 1000
};
appConfig.ui.rateLimiter = rateLimiter;
const autoSaveMetadata = [
'dc.author',
'dc.title'
];
appConfig.submission.autosave.metadata = autoSaveMetadata;
const customTheme: HandleThemeConfig = {
name: 'custom',
handle: '10673/1233'
};
appConfig.themes.push(customTheme);
extendEnvironmentWithAppConfig(environment, appConfig);
expect(environment.cache.msToLive.default).toEqual(msToLive);
expect(environment.ui.rateLimiter.windowMs).toEqual(rateLimiter.windowMs);
expect(environment.ui.rateLimiter.max).toEqual(rateLimiter.max);
expect(environment.submission.autosave.metadata[0]).toEqual(autoSaveMetadata[0]);
expect(environment.submission.autosave.metadata[1]).toEqual(autoSaveMetadata[1]);
expect(environment.themes.length).toEqual(2);
expect(environment.themes[0].name).toEqual('dspace');
expect(environment.themes[1].name).toEqual(customTheme.name);
expect((environment.themes[1] as HandleThemeConfig).handle).toEqual(customTheme.handle);
});
});
});

54
src/config/config.util.ts Normal file
View File

@@ -0,0 +1,54 @@
import * as merge from 'deepmerge';
import { environment } from '../environments/environment';
import { hasNoValue } from '../app/shared/empty.util';
import { AppConfig } from './app-config.interface';
import { ThemeConfig } from './theme.model';
/**
* Extend Angular environment with app config.
*
* @param env environment object
* @param appConfig app config
*/
const extendEnvironmentWithAppConfig = (env: any, appConfig: AppConfig): void => {
mergeConfig(env, appConfig);
console.log(`Environment extended with app config`);
};
/**
* Merge one config into another.
*
* @param destinationConfig destination config
* @param sourceConfig source config
*/
const mergeConfig = (destinationConfig: any, sourceConfig: AppConfig): void => {
const mergeOptions = {
arrayMerge: (destinationArray, sourceArray, options) => sourceArray
};
Object.assign(destinationConfig, merge.all([
destinationConfig,
sourceConfig
], mergeOptions));
};
/**
* Get default theme config from environment.
*
* @returns default theme config
*/
const getDefaultThemeConfig = (): ThemeConfig => {
return environment.themes.find((themeConfig: any) =>
hasNoValue(themeConfig.regex) &&
hasNoValue(themeConfig.handle) &&
hasNoValue(themeConfig.uuid)
);
};
export {
extendEnvironmentWithAppConfig,
mergeConfig,
getDefaultThemeConfig
};

View File

@@ -1,73 +1,102 @@
import { GlobalConfig } from '../config/global-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'; 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 const environment: GlobalConfig = { export class DefaultAppConfig implements AppConfig {
production: true, production = false;
// Angular Universal server settings.
// NOTE: these must be "synced" with the 'dspace.ui.url' setting in your backend's local.cfg. // Angular Universal settings
ui: { universal: UniversalConfig = {
preboot: true,
async: true,
time: false
};
// NOTE: will log all redux actions and transfers in console
debug = 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, ssl: false,
host: 'localhost', host: 'localhost',
port: 4000, port: 4000,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/', nameSpace: '/',
// The rateLimiter settings limit each IP to a "max" of 500 requests per "windowMs" (1 minute).
// The rateLimiter settings limit each IP to a 'max' of 500 requests per 'windowMs' (1 minute).
rateLimiter: { rateLimiter: {
windowMs: 1 * 60 * 1000, // 1 minute windowMs: 1 * 60 * 1000, // 1 minute
max: 500 // limit each IP to 500 requests per windowMs 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. // The REST API server settings
rest: { // NOTE: these must be 'synced' with the 'dspace.server.url' setting in your backend's local.cfg.
ssl: true, rest: ServerConfig = {
host: 'api7.dspace.org', ssl: false,
port: 443, host: 'localhost',
port: 8080,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/server', nameSpace: '/',
}, };
// Caching settings // Caching settings
cache: { cache: CacheConfig = {
// NOTE: how long should objects be cached for by default // NOTE: how long should objects be cached for by default
msToLive: { msToLive: {
default: 15 * 60 * 1000, // 15 minutes default: 15 * 60 * 1000 // 15 minutes
}, },
// msToLive: 1000, // 15 minutes
control: 'max-age=60', // revalidate browser control: 'max-age=60', // revalidate browser
autoSync: { autoSync: {
defaultTime: 0, defaultTime: 0,
maxBufferSize: 100, maxBufferSize: 100,
timePerMethod: {[RestRequestMethod.PATCH]: 3} as any // time in seconds timePerMethod: { [RestRequestMethod.PATCH]: 3 } as any // time in seconds
} }
}, };
// Authentication settings // Authentication settings
auth: { auth: AuthConfig = {
// Authentication UI settings // Authentication UI settings
ui: { ui: {
// the amount of time before the idle warning is shown // the amount of time before the idle warning is shown
timeUntilIdle: 15 * 60 * 1000, // 15 minutes 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. // 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 idleGracePeriod: 5 * 60 * 1000 // 5 minutes
}, },
// Authentication REST settings // Authentication REST settings
rest: { rest: {
// If the rest token expires in less than this amount of time, it will be refreshed automatically. // If the rest token expires in less than this amount of time, it will be refreshed automatically.
// This is independent from the idle warning. // This is independent from the idle warning.
timeLeftBeforeTokenRefresh: 2 * 60 * 1000, // 2 minutes timeLeftBeforeTokenRefresh: 2 * 60 * 1000 // 2 minutes
}, }
}, };
// Form settings // Form settings
form: { form: FormConfig = {
// NOTE: Map server-side validators to comparative Angular form validators // NOTE: Map server-side validators to comparative Angular form validators
validatorMap: { validatorMap: {
required: 'required', required: 'required',
regex: 'pattern' regex: 'pattern'
} }
}, };
// Notifications // Notifications
notifications: { notifications: INotificationBoardOptions = {
rtl: false, rtl: false,
position: ['top', 'right'], position: ['top', 'right'],
maxStack: 8, maxStack: 8,
@@ -76,9 +105,10 @@ export const environment: GlobalConfig = {
clickToClose: true, clickToClose: true,
// NOTE: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' // NOTE: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale'
animate: NotificationAnimationsType.Scale animate: NotificationAnimationsType.Scale
}, };
// Submission settings // Submission settings
submission: { submission: SubmissionConfig = {
autosave: { autosave: {
// NOTE: which metadata trigger an autosave // NOTE: which metadata trigger an autosave
metadata: [], metadata: [],
@@ -136,115 +166,58 @@ export const environment: GlobalConfig = {
{ {
value: 'default', value: 'default',
style: 'text-muted' style: 'text-muted'
}, }
] ]
} }
} }
}, };
// Angular Universal settings
universal: {
preboot: true,
async: true,
time: false
},
// NOTE: will log all redux actions and transfers in console
debug: false,
// Default Language in which the UI will be rendered if the user's browser language is not an active language // Default Language in which the UI will be rendered if the user's browser language is not an active language
defaultLanguage: 'en', defaultLanguage = 'en';
// Languages. DSpace Angular holds a message catalog for each of the following languages. // 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. // When set to active, users will be able to switch to the use of this language in the user interface.
languages: [{ languages: LangConfig[] = [
code: 'en', { code: 'en', label: 'English', active: true },
label: 'English', { code: 'cs', label: 'Čeština', active: true },
active: true, { code: 'de', label: 'Deutsch', active: true },
}, { { code: 'es', label: 'Español', active: true },
code: 'cs', { code: 'fr', label: 'Français', active: true },
label: 'Čeština', { code: 'lv', label: 'Latviešu', active: true },
active: true, { code: 'hu', label: 'Magyar', active: true },
}, { { code: 'nl', label: 'Nederlands', active: true },
code: 'de', { code: 'pt-PT', label: 'Português', active: true },
label: 'Deutsch', { code: 'pt-BR', label: 'Português do Brasil', active: true },
active: true, { code: 'fi', label: 'Suomi', 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 // Browse-By Pages
browseBy: { browseBy: BrowseByConfig = {
// Amount of years to display using jumps of one year (current year - oneYearLimit) // Amount of years to display using jumps of one year (current year - oneYearLimit)
oneYearLimit: 10, oneYearLimit: 10,
// Limit for years to display using jumps of five years (current year - fiveYearLimit) // Limit for years to display using jumps of five years (current year - fiveYearLimit)
fiveYearLimit: 30, fiveYearLimit: 30,
// The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items) // The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items)
defaultLowerLimit: 1900, 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 // Item Page Config
// Allowed fields and their purpose: item: ItemPageConfig = {
// 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: {
edit: { edit: {
undoTimeout: 10000 // 10 seconds undoTimeout: 10000 // 10 seconds
} }
}, };
collection: {
// Collection Page Config
collection: CollectionPageConfig = {
edit: { edit: {
undoTimeout: 10000 // 10 seconds undoTimeout: 10000 // 10 seconds
} }
}, };
themes: [
// Theme Config
themes: ThemeConfig[] = [
// Add additional themes here. In the case where multiple themes match a route, the first one // 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 // in this list will get priority. It is advisable to always have a theme that matches
// every route as the last one // every route as the last one
@@ -274,12 +247,12 @@ export const environment: GlobalConfig = {
// name: 'custom-A', // name: 'custom-A',
// extends: 'custom-B', // extends: 'custom-B',
// // Any of the matching properties above can be used // // Any of the matching properties above can be used
// handle: '10673/34', // handle: '10673/34'
// }, // },
// { // {
// name: 'custom-B', // name: 'custom-B',
// extends: 'custom', // extends: 'custom',
// handle: '10673/12', // handle: '10673/12'
// }, // },
// { // {
// // A theme with only a name will match every route // // A theme with only a name will match every route
@@ -332,12 +305,12 @@ export const environment: GlobalConfig = {
}, },
] ]
}, },
], ];
// Whether to enable media viewer for image and/or video Bitstreams (i.e. Bitstreams whose MIME type starts with "image" or "video"). // 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 images, this enables a gallery viewer where you can zoom or page through images.
// For videos, this enables embedded video streaming // For videos, this enables embedded video streaming
mediaViewer: { mediaViewer: MediaViewerConfig = {
image: false, image: false,
video: false, video: false
}, };
}; }

View File

@@ -0,0 +1,12 @@
import { AppConfig } from '../config/app-config.interface';
export const environment: Partial<AppConfig> = {
production: true,
// Angular Universal settings
universal: {
preboot: true,
async: true,
time: false
}
};

View File

@@ -1,10 +0,0 @@
export const environment = {
/**
* TODO add the sections from environment.common.ts you want to override here
* e.g.
* rest: {
* host: 'rest.api',
* nameSpace: '/rest',
* }
*/
};

View File

@@ -1,26 +1,43 @@
// This configuration is only used for unit tests, end-to-end tests use environment.prod.ts // This configuration is only used for unit tests, end-to-end tests use environment.production.ts
import { BrowseByType } from '../app/browse-by/browse-by-switcher/browse-by-decorator';
import { RestRequestMethod } from '../app/core/data/rest-request-method'; import { RestRequestMethod } from '../app/core/data/rest-request-method';
import { NotificationAnimationsType } from '../app/shared/notifications/models/notification-animations-type'; 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<GlobalConfig> = { export const environment: AppConfig = {
rest: { production: false,
ssl: true,
host: 'rest.com', // Angular Universal settings
port: 443, universal: {
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript preboot: true,
nameSpace: '/api', async: true,
baseUrl: 'https://rest.api/' time: false
}, },
// Angular Universal server settings.
ui: { ui: {
ssl: false, ssl: false,
host: 'dspace.com', host: 'dspace.com',
port: 80, port: 80,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/angular-dspace', nameSpace: '/angular-dspace',
rateLimiter: undefined baseUrl: 'http://dspace.com/angular-dspace',
// 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.
rest: {
ssl: true,
host: 'rest.com',
port: 443,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/api',
baseUrl: 'https://rest.com/api'
},
// Caching settings // Caching settings
cache: { cache: {
// NOTE: how long should objects be cached for by default // NOTE: how long should objects be cached for by default
@@ -28,19 +45,21 @@ export const environment: Partial<GlobalConfig> = {
default: 15 * 60 * 1000, // 15 minutes default: 15 * 60 * 1000, // 15 minutes
}, },
// msToLive: 1000, // 15 minutes // msToLive: 1000, // 15 minutes
control: 'max-age=60', // revalidate browser control: 'max-age=60',
autoSync: { autoSync: {
defaultTime: 0, defaultTime: 0,
maxBufferSize: 100, maxBufferSize: 100,
timePerMethod: {[RestRequestMethod.PATCH]: 3} as any // time in seconds timePerMethod: { [RestRequestMethod.PATCH]: 3 } as any // time in seconds
} }
}, },
// Authentication settings // Authentication settings
auth: { auth: {
// Authentication UI settings // Authentication UI settings
ui: { ui: {
// the amount of time before the idle warning is shown // the amount of time before the idle warning is shown
timeUntilIdle: 20000, // 20 sec timeUntilIdle: 20000,
// the amount of time the user has to react after the idle warning is shown before they are logged out. // the amount of time the user has to react after the idle warning is shown before they are logged out.
idleGracePeriod: 20000, // 20 sec idleGracePeriod: 20000, // 20 sec
}, },
@@ -51,6 +70,7 @@ export const environment: Partial<GlobalConfig> = {
timeLeftBeforeTokenRefresh: 20000, // 20 sec timeLeftBeforeTokenRefresh: 20000, // 20 sec
}, },
}, },
// Form settings // Form settings
form: { form: {
// NOTE: Map server-side validators to comparative Angular form validators // NOTE: Map server-side validators to comparative Angular form validators
@@ -59,17 +79,19 @@ export const environment: Partial<GlobalConfig> = {
regex: 'pattern' regex: 'pattern'
} }
}, },
// Notifications // Notifications
notifications: { notifications: {
rtl: false, rtl: false,
position: ['top', 'right'], position: ['top', 'right'],
maxStack: 8, maxStack: 8,
// NOTE: after how many seconds notification is closed automatically. If set to zero notifications are not closed automatically // NOTE: after how many seconds notification is closed automatically. If set to zero notifications are not closed automatically
timeOut: 5000, // 5 second timeOut: 5000,
clickToClose: true, clickToClose: true,
// NOTE: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale' // NOTE: 'fade' | 'fromTop' | 'fromRight' | 'fromBottom' | 'fromLeft' | 'rotate' | 'scale'
animate: NotificationAnimationsType.Scale animate: NotificationAnimationsType.Scale
}, },
// Submission settings // Submission settings
submission: { submission: {
autosave: { autosave: {
@@ -115,21 +137,17 @@ export const environment: Partial<GlobalConfig> = {
value: 'default', value: 'default',
style: 'text-muted' style: 'text-muted'
}, },
] ]
} }
} }
}, },
// Angular Universal settings
universal: {
preboot: true,
async: true,
time: false
},
// NOTE: will log all redux actions and transfers in console // NOTE: will log all redux actions and transfers in console
debug: false, debug: false,
// Default Language in which the UI will be rendered if the user's browser language is not an active language // Default Language in which the UI will be rendered if the user's browser language is not an active language
defaultLanguage: 'en', defaultLanguage: 'en',
// Languages. DSpace Angular holds a message catalog for each of the following languages. // 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. // When set to active, users will be able to switch to the use of this language in the user interface.
languages: [{ languages: [{
@@ -161,6 +179,7 @@ export const environment: Partial<GlobalConfig> = {
label: 'Latviešu', label: 'Latviešu',
active: true, active: true,
}], }],
// Browse-By Pages // Browse-By Pages
browseBy: { browseBy: {
// Amount of years to display using jumps of one year (current year - oneYearLimit) // Amount of years to display using jumps of one year (current year - oneYearLimit)
@@ -169,32 +188,6 @@ export const environment: Partial<GlobalConfig> = {
fiveYearLimit: 30, fiveYearLimit: 30,
// The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items) // The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items)
defaultLowerLimit: 1900, 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: { item: {
edit: { edit: {
@@ -234,5 +227,5 @@ export const environment: Partial<GlobalConfig> = {
mediaViewer: { mediaViewer: {
image: true, image: true,
video: true video: true
}, }
}; };

View File

@@ -0,0 +1,26 @@
// This file can be replaced during build by using the `fileReplacements` array.
// `ng build --configuration production` replaces `environment.ts` with `environment.production.ts`.
// `ng test --configuration test` replaces `environment.ts` with `environment.test.ts`.
// The list of file replacements can be found in `angular.json`.
import { AppConfig } from '../config/app-config.interface';
export const environment: Partial<AppConfig> = {
production: false,
// Angular Universal settings
universal: {
preboot: true,
async: true,
time: false
}
};
/*
* For easier debugging in development mode, you can import the following file
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
*
* This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown.
*/
// import 'zone.js/plugins/zone-error'; // Included with Angular CLI.

View File

@@ -1,22 +1,25 @@
import 'zone.js/dist/zone'; import 'zone.js/dist/zone';
import 'reflect-metadata'; import 'reflect-metadata';
import 'core-js/es/reflect'; import 'core-js/es/reflect';
import { enableProdMode } from '@angular/core'; import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { bootloader } from '@angularclass/bootloader';
import { load as loadWebFont } from 'webfontloader'; import { load as loadWebFont } from 'webfontloader';
import { hasValue } from './app/shared/empty.util'; import { hasValue } from './app/shared/empty.util';
import { BrowserAppModule } from './modules/app/browser-app.module'; import { BrowserAppModule } from './modules/app/browser-app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
import { AppConfig } from './config/app-config.interface';
import { extendEnvironmentWithAppConfig } from './config/config.util';
if (environment.production) { const bootstrap = () => platformBrowserDynamic()
enableProdMode(); .bootstrapModule(BrowserAppModule, {
} preserveWhitespaces: true
});
export function main() { const main = () => {
// Load fonts async // Load fonts async
// https://github.com/typekit/webfontloader#configuration // https://github.com/typekit/webfontloader#configuration
loadWebFont({ loadWebFont({
@@ -25,13 +28,27 @@ export function main() {
} }
}); });
return platformBrowserDynamic().bootstrapModule(BrowserAppModule, {preserveWhitespaces:true}); if (environment.production) {
} enableProdMode();
return bootstrap();
} else {
return fetch('assets/config.json')
.then((response) => response.json())
.then((appConfig: AppConfig) => {
// extend environment with app config for browser when not prerendered
extendEnvironmentWithAppConfig(environment, appConfig);
return bootstrap();
});
}
};
// support async tag or hmr // support async tag or hmr
if (hasValue(environment.universal) && environment.universal.preboot === false) { if (hasValue(environment.universal) && environment.universal.preboot === false) {
bootloader(main); main();
} else { } else {
document.addEventListener('DOMContentLoaded', () => bootloader(main)); document.addEventListener('DOMContentLoaded', main);
} }

View File

@@ -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));
});
});

View File

@@ -1,7 +1,8 @@
import { HttpClient, HttpClientModule } from '@angular/common/http'; import { HttpClient, HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core'; import { APP_INITIALIZER, NgModule } from '@angular/core';
import { BrowserModule, makeStateKey, TransferState } from '@angular/platform-browser'; import { BrowserModule, makeStateKey, TransferState } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { RouterModule, NoPreloading } from '@angular/router';
import { REQUEST } from '@nguniversal/express-engine/tokens'; import { REQUEST } from '@nguniversal/express-engine/tokens';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
@@ -30,9 +31,13 @@ import {
} from '../../app/core/services/browser-hard-redirect.service'; } from '../../app/core/services/browser-hard-redirect.service';
import { LocaleService } from '../../app/core/locale/locale.service'; import { LocaleService } from '../../app/core/locale/locale.service';
import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service'; import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service';
import { RouterModule, NoPreloading } from '@angular/router';
import { AuthRequestService } from '../../app/core/auth/auth-request.service'; import { AuthRequestService } from '../../app/core/auth/auth-request.service';
import { BrowserAuthRequestService } from '../../app/core/auth/browser-auth-request.service'; import { BrowserAuthRequestService } from '../../app/core/auth/browser-auth-request.service';
import { AppConfig, APP_CONFIG_STATE } from '../../config/app-config.interface';
import { DefaultAppConfig } from '../../config/default-app-config';
import { extendEnvironmentWithAppConfig } from '../../config/config.util';
import { environment } from '../../environments/environment';
export const REQ_KEY = makeStateKey<string>('req'); export const REQ_KEY = makeStateKey<string>('req');
@@ -59,7 +64,7 @@ export function getRequest(transferState: TransferState): any {
scrollPositionRestoration: 'enabled', scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled', anchorScrolling: 'enabled',
preloadingStrategy: NoPreloading preloadingStrategy: NoPreloading
}), }),
StatisticsModule.forRoot(), StatisticsModule.forRoot(),
Angulartics2RouterlessModule.forRoot(), Angulartics2RouterlessModule.forRoot(),
BrowserAnimationsModule, BrowserAnimationsModule,
@@ -74,6 +79,20 @@ export function getRequest(transferState: TransferState): any {
AppModule AppModule
], ],
providers: [ providers: [
{
provide: APP_INITIALIZER,
useFactory: (transferState: TransferState) => {
if (transferState.hasKey<AppConfig>(APP_CONFIG_STATE)) {
const appConfig = transferState.get<AppConfig>(APP_CONFIG_STATE, new DefaultAppConfig());
// extend environment with app config for browser
extendEnvironmentWithAppConfig(environment, appConfig);
}
return () => true;
},
deps: [TransferState],
multi: true
},
{ {
provide: REQUEST, provide: REQUEST,
useFactory: getRequest, useFactory: getRequest,

View File

@@ -1,38 +1,40 @@
import { NgModule } from '@angular/core'; import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { BrowserModule } from '@angular/platform-browser'; import { APP_INITIALIZER, NgModule } from '@angular/core';
import { BrowserModule, TransferState } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ServerModule } from '@angular/platform-server'; import { ServerModule } from '@angular/platform-server';
import { RouterModule } from '@angular/router';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { Angulartics2 } from 'angulartics2';
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga';
import { AppComponent } from '../../app/app.component'; import { AppComponent } from '../../app/app.component';
import { AppModule } from '../../app/app.module'; import { AppModule } from '../../app/app.module';
import { DSpaceServerTransferStateModule } from '../transfer-state/dspace-server-transfer-state.module'; import { DSpaceServerTransferStateModule } from '../transfer-state/dspace-server-transfer-state.module';
import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.service'; import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.service';
import { TranslateJson5UniversalLoader } from '../../ngx-translate-loaders/translate-json5-universal.loader'; import { TranslateJson5UniversalLoader } from '../../ngx-translate-loaders/translate-json5-universal.loader';
import { CookieService } from '../../app/core/services/cookie.service'; import { CookieService } from '../../app/core/services/cookie.service';
import { ServerCookieService } from '../../app/core/services/server-cookie.service'; import { ServerCookieService } from '../../app/core/services/server-cookie.service';
import { AuthService } from '../../app/core/auth/auth.service'; import { AuthService } from '../../app/core/auth/auth.service';
import { ServerAuthService } from '../../app/core/auth/server-auth.service'; import { ServerAuthService } from '../../app/core/auth/server-auth.service';
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga';
import { AngularticsProviderMock } from '../../app/shared/mocks/angulartics-provider.service.mock'; import { AngularticsProviderMock } from '../../app/shared/mocks/angulartics-provider.service.mock';
import { SubmissionService } from '../../app/submission/submission.service'; import { SubmissionService } from '../../app/submission/submission.service';
import { ServerSubmissionService } from '../../app/submission/server-submission.service'; import { ServerSubmissionService } from '../../app/submission/server-submission.service';
import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider';
import { ServerLocaleService } from '../../app/core/locale/server-locale.service'; import { ServerLocaleService } from '../../app/core/locale/server-locale.service';
import { LocaleService } from '../../app/core/locale/locale.service'; import { LocaleService } from '../../app/core/locale/locale.service';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { ForwardClientIpInterceptor } from '../../app/core/forward-client-ip/forward-client-ip.interceptor'; import { ForwardClientIpInterceptor } from '../../app/core/forward-client-ip/forward-client-ip.interceptor';
import { HardRedirectService } from '../../app/core/services/hard-redirect.service'; import { HardRedirectService } from '../../app/core/services/hard-redirect.service';
import { ServerHardRedirectService } from '../../app/core/services/server-hard-redirect.service'; import { ServerHardRedirectService } from '../../app/core/services/server-hard-redirect.service';
import { Angulartics2 } from 'angulartics2';
import { Angulartics2Mock } from '../../app/shared/mocks/angulartics2.service.mock'; import { Angulartics2Mock } from '../../app/shared/mocks/angulartics2.service.mock';
import { RouterModule } from '@angular/router';
import { AuthRequestService } from '../../app/core/auth/auth-request.service'; import { AuthRequestService } from '../../app/core/auth/auth-request.service';
import { ServerAuthRequestService } from '../../app/core/auth/server-auth-request.service'; import { ServerAuthRequestService } from '../../app/core/auth/server-auth-request.service';
import { AppConfig, APP_CONFIG_STATE } from '../../config/app-config.interface';
import { environment } from '../../environments/environment';
export function createTranslateLoader() { export function createTranslateLoader() {
return new TranslateJson5UniversalLoader('dist/server/assets/i18n/', '.json5'); return new TranslateJson5UniversalLoader('dist/server/assets/i18n/', '.json5');
@@ -60,6 +62,16 @@ export function createTranslateLoader() {
AppModule AppModule
], ],
providers: [ providers: [
// Initialize app config and extend environment
{
provide: APP_INITIALIZER,
useFactory: (transferState: TransferState) => {
transferState.set<AppConfig>(APP_CONFIG_STATE, environment as AppConfig);
return () => true;
},
deps: [TransferState],
multi: true
},
{ {
provide: Angulartics2, provide: Angulartics2,
useClass: Angulartics2Mock useClass: Angulartics2Mock

View File

@@ -170,9 +170,5 @@
"use-life-cycle-interface": false, "use-life-cycle-interface": false,
"no-outputs-metadata-property": true, "no-outputs-metadata-property": true,
"use-pipe-transform-interface": true "use-pipe-transform-interface": true
// "rxjs-collapse-imports": true,
// "rxjs-pipeable-operators-only": true,
// "rxjs-no-static-observable-methods": true,
// "rxjs-proper-imports": true
} }
} }

View File

@@ -1,8 +1,16 @@
import { join } from 'path';
import { buildAppConfig } from '../src/config/config.server';
import { commonExports } from './webpack.common'; import { commonExports } from './webpack.common';
module.exports = Object.assign({}, commonExports, { module.exports = Object.assign({}, commonExports, {
target: 'web', target: 'web',
node: { node: {
module: 'empty' module: 'empty'
},
devServer: {
before(app, server) {
buildAppConfig(join(process.cwd(), 'src/assets/config.json'));
}
} }
}); });

564
yarn.lock

File diff suppressed because it is too large Load Diff