Merge remote-tracking branch '4scienceGithub/DURACOM-235' into DURACOM-235

# Conflicts:
#	src/app/collection-page/create-collection-page/create-collection-page.component.html
#	src/app/shared/comcol/comcol-forms/create-comcol-page/create-comcol-page.component.ts
This commit is contained in:
Giuseppe Digilio
2024-05-14 18:22:17 +02:00
3162 changed files with 115381 additions and 58370 deletions

View File

@@ -8,7 +8,16 @@
"eslint-plugin-deprecation",
"unused-imports",
"eslint-plugin-lodash",
"eslint-plugin-jsonc"
"eslint-plugin-jsonc",
"eslint-plugin-rxjs",
"eslint-plugin-simple-import-sort",
"eslint-plugin-import-newlines",
"eslint-plugin-jsonc",
"dspace-angular-ts",
"dspace-angular-html"
],
"ignorePatterns": [
"lint/test/fixture"
],
"overrides": [
{
@@ -18,7 +27,8 @@
"parserOptions": {
"project": [
"./tsconfig.json",
"./cypress/tsconfig.json"
"./cypress/tsconfig.json",
"./lint/tsconfig.json"
],
"createDefaultProgram": true
},
@@ -27,17 +37,32 @@
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"plugin:@angular-eslint/recommended",
"plugin:@angular-eslint/template/process-inline-templates"
"plugin:@angular-eslint/template/process-inline-templates",
"plugin:rxjs/recommended"
],
"rules": {
"indent": [
"error",
2,
{
"SwitchCase": 1,
"ignoredNodes": [
"ClassBody.body > PropertyDefinition[decorators.length > 0] > .key"
]
}
],
"max-classes-per-file": [
"error",
1
],
"comma-dangle": [
"off",
"error",
"always-multiline"
],
"object-curly-spacing": [
"error",
"always"
],
"eol-last": [
"error",
"always"
@@ -104,15 +129,13 @@
"allowTernary": true
}
],
"prefer-const": "off", // todo: re-enable & fix errors (more strict than it used to be in TSLint)
"prefer-const": "error",
"no-case-declarations": "error",
"no-extra-boolean-cast": "error",
"prefer-spread": "off",
"no-underscore-dangle": "off",
// todo: disabled rules from eslint:recommended, consider re-enabling & fixing
"no-prototype-builtins": "off",
"no-useless-escape": "off",
"no-case-declarations": "off",
"no-extra-boolean-cast": "off",
"@angular-eslint/directive-selector": [
"error",
@@ -139,7 +162,6 @@
}
],
"@angular-eslint/no-attribute-decorator": "error",
"@angular-eslint/no-forward-ref": "error",
"@angular-eslint/no-output-native": "warn",
"@angular-eslint/no-output-on-prefix": "warn",
"@angular-eslint/no-conflicting-lifecycle": "warn",
@@ -183,7 +205,7 @@
],
"@typescript-eslint/type-annotation-spacing": "error",
"@typescript-eslint/unified-signatures": "error",
"@typescript-eslint/ban-types": "warn", // todo: deal with {} type issues & re-enable
"@typescript-eslint/ban-types": "error",
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/no-misused-promises": "warn",
"@typescript-eslint/restrict-plus-operands": "warn",
@@ -200,17 +222,65 @@
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/require-await": "off",
"@typescript-eslint/no-base-to-string": [
"error",
{
"ignoredTypeNames": [
"ResourceType",
"Error"
]
}
],
"deprecation/deprecation": "warn",
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"import/order": "off",
"import/first": "error",
"import/newline-after-import": "error",
"import/no-duplicates": "error",
"import/no-deprecated": "warn",
"import/no-namespace": "error",
"import-newlines/enforce": [
"error",
{
"items": 1,
"semi": true,
"forceSingleLine": true
}
],
"unused-imports/no-unused-imports": "error",
"lodash/import-scope": [
"error",
"method"
]
],
"rxjs/no-nested-subscribe": "off", // todo: go over _all_ cases
// Custom DSpace Angular rules
"dspace-angular-ts/themed-component-classes": "error",
"dspace-angular-ts/themed-component-selectors": "error",
"dspace-angular-ts/themed-component-usages": "error"
}
},
{
"files": [
"*.spec.ts"
],
"parserOptions": {
"project": [
"./tsconfig.json",
"./cypress/tsconfig.json"
],
"createDefaultProgram": true
},
"rules": {
"prefer-const": "off",
// Custom DSpace Angular rules
"dspace-angular-ts/themed-component-usages": "error"
}
},
{
@@ -221,9 +291,8 @@
"plugin:@angular-eslint/template/recommended"
],
"rules": {
// todo: re-enable & fix errors
"@angular-eslint/template/no-negated-async": "off",
"@angular-eslint/template/eqeqeq": "off"
// Custom DSpace Angular rules
"dspace-angular-html/themed-component-usages": "error"
}
},
{

3
.gitattributes vendored
View File

@@ -14,3 +14,6 @@
*.scss eol=lf
*.html eol=lf
*.svg eol=lf
# Generated documentation should have LF line endings to reduce git noise
docs/lint/**/*.md eol=lf

View File

@@ -33,12 +33,12 @@ jobs:
#CHROME_VERSION: "90.0.4430.212-1"
# Bump Node heap size (OOM in CI after upgrading to Angular 15)
NODE_OPTIONS: '--max-old-space-size=4096'
# Project name to use when running docker-compose prior to e2e tests
# Project name to use when running "docker compose" prior to e2e tests
COMPOSE_PROJECT_NAME: 'ci'
strategy:
# Create a matrix of Node versions to test against (in parallel)
matrix:
node-version: [16.x, 18.x]
node-version: [18.x, 20.x]
# Do NOT exit immediately if one matrix job fails
fail-fast: false
# These are the actual CI steps to perform per job
@@ -74,7 +74,7 @@ jobs:
id: yarn-cache-dir-path
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
- name: Cache Yarn dependencies
uses: actions/cache@v3
uses: actions/cache@v4
with:
# Cache entire Yarn cache directory (see previous step)
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@@ -85,8 +85,14 @@ jobs:
- name: Install Yarn dependencies
run: yarn install --frozen-lockfile
- name: Build lint plugins
run: yarn run build:lint
- name: Run lint plugin tests
run: yarn run test:lint:nobuild
- name: Run lint
run: yarn run lint --quiet
run: yarn run lint:nobuild --quiet
- name: Check for circular dependencies
run: yarn run check-circ-deps
@@ -101,19 +107,19 @@ jobs:
# so that it can be shared with the 'codecov' job (see below)
# NOTE: Angular CLI only supports code coverage for specs. See https://github.com/angular/angular-cli/issues/6286
- name: Upload code coverage report to Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: matrix.node-version == '18.x'
with:
name: dspace-angular coverage report
name: coverage-report-${{ matrix.node-version }}
path: 'coverage/dspace-angular/lcov.info'
retention-days: 14
# Using docker-compose start backend using CI configuration
# Using "docker compose" start backend using CI configuration
# and load assetstore from a cached copy
- name: Start DSpace REST Backend via Docker (for e2e tests)
run: |
docker-compose -f ./docker/docker-compose-ci.yml up -d
docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli
docker compose -f ./docker/docker-compose-ci.yml up -d
docker compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli
docker container ls
# Run integration tests via Cypress.io
@@ -135,19 +141,19 @@ jobs:
# Cypress always creates a video of all e2e tests (whether they succeeded or failed)
# Save those in an Artifact
- name: Upload e2e test videos to Artifacts
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-test-videos
name: e2e-test-videos-${{ matrix.node-version }}
path: cypress/videos
# If e2e tests fail, Cypress creates a screenshot of what happened
# Save those in an Artifact
- name: Upload e2e test failure screenshots to Artifacts
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: failure()
with:
name: e2e-test-screenshots
name: e2e-test-screenshots-${{ matrix.node-version }}
path: cypress/screenshots
- name: Stop app (in case it stays up after e2e tests)
@@ -182,7 +188,7 @@ jobs:
run: kill -9 $(lsof -t -i:4000)
- name: Shutdown Docker containers
run: docker-compose -f ./docker/docker-compose-ci.yml down
run: docker compose -f ./docker/docker-compose-ci.yml down
# Codecov upload is a separate job in order to allow us to restart this separate from the entire build/test
# job above. This is necessary because Codecov uploads seem to randomly fail at times.
@@ -197,7 +203,7 @@ jobs:
# Download artifacts from previous 'tests' job
- name: Download coverage artifacts
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
# Now attempt upload to Codecov using its action.
# NOTE: We use a retry action to retry the Codecov upload if it fails the first time.
@@ -207,11 +213,12 @@ jobs:
- name: Upload coverage to Codecov.io
uses: Wandalen/wretry.action@v1.3.0
with:
action: codecov/codecov-action@v3
action: codecov/codecov-action@v4
# Ensure codecov-action throws an error when it fails to upload
# This allows us to auto-restart the action if an error is thrown
with: |
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
# Try re-running action 5 times max
attempt_limit: 5
# Run again in 30 seconds

View File

@@ -28,7 +28,7 @@ jobs:
# Use the reusable-docker-build.yml script from DSpace/DSpace repo to build our Docker image
uses: DSpace/DSpace/.github/workflows/reusable-docker-build.yml@main
with:
build_id: dspace-angular
build_id: dspace-angular-dev
image_name: dspace/dspace-angular
dockerfile_path: ./Dockerfile
secrets:

View File

@@ -16,7 +16,7 @@ jobs:
# Only add to project board if issue is flagged as "needs triage" or has no labels
# NOTE: By default we flag new issues as "needs triage" in our issue template
if: (contains(github.event.issue.labels.*.name, 'needs triage') || join(github.event.issue.labels.*.name) == '')
uses: actions/add-to-project@v0.5.0
uses: actions/add-to-project@v1.0.0
# Note, the authentication token below is an ORG level Secret.
# It must be created/recreated manually via a personal access token with admin:org, project, public_repo permissions
# See: https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token#permissions-for-the-github_token

View File

@@ -21,4 +21,4 @@ jobs:
# Assign the PR to whomever created it. This is useful for visualizing assignments on project boards
# See https://github.com/toshimaru/auto-author-assign
- name: Assign PR to creator
uses: toshimaru/auto-author-assign@v2.0.1
uses: toshimaru/auto-author-assign@v2.1.0

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/.angular/cache
/.nx
/__build__
/__server_build__
/node_modules

View File

@@ -109,22 +109,22 @@
"serve": {
"builder": "@angular-builders/custom-webpack:dev-server",
"options": {
"browserTarget": "dspace-angular:build",
"buildTarget": "dspace-angular:build",
"port": 4000
},
"configurations": {
"development": {
"browserTarget": "dspace-angular:build:development"
"buildTarget": "dspace-angular:build:development"
},
"production": {
"browserTarget": "dspace-angular:build:production"
"buildTarget": "dspace-angular:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "dspace-angular:build"
"buildTarget": "dspace-angular:build"
}
},
"test": {
@@ -217,23 +217,23 @@
}
},
"serve-ssr": {
"builder": "@nguniversal/builders:ssr-dev-server",
"builder": "@angular-devkit/build-angular:ssr-dev-server",
"options": {
"browserTarget": "dspace-angular:build",
"buildTarget": "dspace-angular:build",
"serverTarget": "dspace-angular:server",
"port": 4000
},
"configurations": {
"production": {
"browserTarget": "dspace-angular:build:production",
"buildTarget": "dspace-angular:build:production",
"serverTarget": "dspace-angular:server:production"
}
}
},
"prerender": {
"builder": "@nguniversal/builders:prerender",
"builder": "@angular-devkit/build-angular:prerender",
"options": {
"browserTarget": "dspace-angular:build:production",
"buildTarget": "dspace-angular:build:production",
"serverTarget": "dspace-angular:server:production",
"routes": [
"/"
@@ -266,6 +266,8 @@
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"cypress/**/*.ts",
"lint/**/*.ts",
"src/**/*.html",
"src/**/*.json5"
]

View File

@@ -17,6 +17,13 @@ ui:
# Trust X-FORWARDED-* headers from proxies (default = true)
useProxies: true
universal:
# Whether to inline "critical" styles into the server-side rendered HTML.
# Determining which styles are critical is a relatively expensive operation;
# this option can be disabled to boost server performance at the expense of
# loading smoothness.
inlineCriticalCss: true
# The REST API server settings
# NOTE: these settings define which (publicly available) REST API to use. They are usually
# 'synced' with the 'dspace.server.url' setting in your backend's local.cfg.
@@ -131,12 +138,16 @@ submission:
# NOTE: after how many time (milliseconds) submission is saved automatically
# eg. timer: 5 * (1000 * 60); // 5 minutes
timer: 0
# Always show the duplicate detection section if enabled, even if there are no potential duplicates detected
# (a message will be displayed to indicate no matches were found)
duplicateDetection:
alwaysShowSection: false
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
# # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used
# style: fas fa-user
- name: dc.author
style: fas fa-user
@@ -147,18 +158,40 @@ submission:
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
# # NOTE: fontawesome (v6.x) icon classes and bootstrap utility classes can be used
# style: text-success
# icon: fa-circle-check
# # NOTE: the class configured in property style is used by default, the icon property could be used in component
# configured to use a 'icon mode' display (mainly in edit-item page)
- value: 600
style: text-success
icon: fa-circle-check
- value: 500
style: text-info
icon: fa-gear
- value: 400
style: text-warning
icon: fa-circle-question
- value: 300
style: text-muted
icon: fa-thumbs-down
- value: 200
style: text-muted
icon: fa-circle-exclamation
- value: 100
style: text-muted
icon: fa-circle-stop
- value: 0
style: text-muted
icon: fa-ban
- value: -1
style: text-muted
icon: fa-circle-xmark
# default configuration
- value: default
style: text-muted
icon: fa-circle-xmark
# Default Language in which the UI will be rendered if the user's browser language is not an active language
defaultLanguage: en
@@ -169,6 +202,9 @@ languages:
- code: en
label: English
active: true
- code: ar
label: العربية
active: true
- code: ca
label: Català
active: true
@@ -272,6 +308,8 @@ homePage:
# No. of communities to list per page on the home page
# This will always round to the nearest number from the list of page sizes. e.g. if you set it to 7 it'll use 10
pageSize: 5
# Enable or disable the Discover filters on the homepage
showDiscoverFilters: false
# Item Config
item:
@@ -285,8 +323,17 @@ item:
# settings menu. See pageSizeOptions in 'pagination-component-options.model.ts'.
pageSize: 5
# Community Page Config
community:
# Search tab config
searchSection:
showSidebar: true
# Collection Page Config
collection:
# Search tab config
searchSection:
showSidebar: true
edit:
undoTimeout: 10000 # 10 seconds
@@ -363,10 +410,11 @@ mediaViewer:
# Whether the end user agreement is required before users use the repository.
# If enabled, the user will be required to accept the agreement before they can use the repository.
# And whether the privacy statement should exist or not.
# And whether the privacy statement/COAR notify support page should exist or not.
info:
enableEndUserAgreement: true
enablePrivacyStatement: true
enableCOARNotifySupport: true
# Whether to enable Markdown (https://commonmark.org/) and MathJax (https://www.mathjax.org/)
# display in supported metadata fields. By default, only dc.description.abstract is supported.
@@ -392,3 +440,69 @@ comcolSelectionSort:
# - collectionId: 8f7df5ca-f9c2-47a4-81ec-8a6393d6e5af
# source: "openaire"
# Search settings
search:
# Settings to enable/disable or configure advanced search filters.
advancedFilters:
enabled: false
# List of filters to enable in "Advanced Search" dropdown
filter: [ 'title', 'author', 'subject', 'entityType' ]
# Notify metrics
# Configuration for Notify Admin Dashboard for metrics visualization
notifyMetrics:
# Configuration for received messages
- title: 'admin-notify-dashboard.received-ldn'
boxes:
- color: '#B8DAFF'
title: 'admin-notify-dashboard.NOTIFY.incoming.accepted'
config: 'NOTIFY.incoming.accepted'
description: 'admin-notify-dashboard.NOTIFY.incoming.accepted.description'
- color: '#D4EDDA'
title: 'admin-notify-dashboard.NOTIFY.incoming.processed'
config: 'NOTIFY.incoming.processed'
description: 'admin-notify-dashboard.NOTIFY.incoming.processed.description'
- color: '#FDBBC7'
title: 'admin-notify-dashboard.NOTIFY.incoming.failure'
config: 'NOTIFY.incoming.failure'
description: 'admin-notify-dashboard.NOTIFY.incoming.failure.description'
- color: '#FDBBC7'
title: 'admin-notify-dashboard.NOTIFY.incoming.untrusted'
config: 'NOTIFY.incoming.untrusted'
description: 'admin-notify-dashboard.NOTIFY.incoming.untrusted.description'
- color: '#43515F'
title: 'admin-notify-dashboard.NOTIFY.incoming.involvedItems'
textColor: '#fff'
config: 'NOTIFY.incoming.involvedItems'
description: 'admin-notify-dashboard.NOTIFY.incoming.involvedItems.description'
# Configuration for outgoing messages
- title: 'admin-notify-dashboard.generated-ldn'
boxes:
- color: '#B8DAFF'
title: 'admin-notify-dashboard.NOTIFY.outgoing.queued'
config: 'NOTIFY.outgoing.queued'
description: 'admin-notify-dashboard.NOTIFY.outgoing.queued.description'
- color: '#FDEEBB'
title: 'admin-notify-dashboard.NOTIFY.outgoing.queued_for_retry'
config: 'NOTIFY.outgoing.queued_for_retry'
description: 'admin-notify-dashboard.NOTIFY.outgoing.queued_for_retry.description'
- color: '#FDBBC7'
title: 'admin-notify-dashboard.NOTIFY.outgoing.failure'
config: 'NOTIFY.outgoing.failure'
description: 'admin-notify-dashboard.NOTIFY.outgoing.failure.description'
- color: '#43515F'
title: 'admin-notify-dashboard.NOTIFY.outgoing.involvedItems'
textColor: '#fff'
config: 'NOTIFY.outgoing.involvedItems'
description: 'admin-notify-dashboard.NOTIFY.outgoing.involvedItems.description'
- color: '#D4EDDA'
title: 'admin-notify-dashboard.NOTIFY.outgoing.delivered'
config: 'NOTIFY.outgoing.delivered'
description: 'admin-notify-dashboard.NOTIFY.outgoing.delivered.description'

View File

@@ -1,5 +1,5 @@
import { Options } from 'cypress-axe';
import { testA11y } from 'cypress/support/utils';
import { Options } from 'cypress-axe';
describe('Admin Sidebar', () => {
beforeEach(() => {
@@ -22,7 +22,7 @@ describe('Admin Sidebar', () => {
// Currently all expandable sections have nested interactive elements
// See https://github.com/DSpace/dspace-angular/issues/2178
'nested-interactive': { enabled: false },
}
},
} as Options);
});
});

View File

@@ -0,0 +1,13 @@
beforeEach(() => {
cy.visit('/collections/create?parent='.concat(Cypress.env('DSPACE_TEST_COMMUNITY')));
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should show loading component while saving', () => {
const title = 'Test Collection Title';
cy.get('#title').type(title);
cy.get('button[type="submit"]').click();
cy.get('ds-loading').should('be.visible');
});

View File

@@ -45,7 +45,7 @@ describe('Edit Collection > Content Source tab', () => {
cy.get('#externalSourceCheck').check();
// Wait for the source controls to appear
cy.get('ds-collection-source-controls').should('be.visible');
// cy.get('ds-collection-source-controls').should('be.visible');
// Analyze entire page for accessibility issues
testA11y('ds-collection-source');

View File

@@ -6,7 +6,7 @@ describe('Collection Statistics Page', () => {
it('should load if you click on "Statistics" from a Collection page', () => {
cy.visit('/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')));
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
cy.location('pathname').should('eq', COLLECTIONSTATISTICSPAGE);
});

View File

@@ -0,0 +1,13 @@
beforeEach(() => {
cy.visit('/communities/create');
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should show loading component while saving', () => {
const title = 'Test Community Title';
cy.get('#title').type(title);
cy.get('button[type="submit"]').click();
cy.get('ds-loading').should('be.visible');
});

View File

@@ -6,7 +6,7 @@ describe('Community Statistics Page', () => {
it('should load if you click on "Statistics" from a Community page', () => {
cy.visit('/communities/'.concat(Cypress.env('DSPACE_TEST_COMMUNITY')));
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
cy.location('pathname').should('eq', COMMUNITYSTATISTICSPAGE);
});

View File

@@ -1,11 +1,12 @@
import '../support/commands';
import { REGEX_MATCH_NON_EMPTY_TEXT } from 'cypress/support/e2e';
import { testA11y } from 'cypress/support/utils';
import '../support/commands';
describe('Site Statistics Page', () => {
it('should load if you click on "Statistics" from homepage', () => {
cy.visit('/');
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
cy.location('pathname').should('eq', '/statistics');
});

View File

@@ -1,5 +1,5 @@
import { Options } from 'cypress-axe';
import { testA11y } from 'cypress/support/utils';
import { Options } from 'cypress-axe';
const ITEM_EDIT_PAGE = '/items/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')).concat('/edit');
@@ -54,8 +54,8 @@ describe('Edit Item > Bitstreams tab', () => {
// Currently Bitstreams page loads a pagination component per Bundle
// and they all use the same 'id="p-dad"'.
'duplicate-id': { enabled: false },
}
} as Options
},
} as Options,
);
});
});

View File

@@ -6,7 +6,7 @@ describe('Item Statistics Page', () => {
it('should load if you click on "Statistics" from an Item/Entity page', () => {
cy.visit('/entities/publication/'.concat(Cypress.env('DSPACE_TEST_ENTITY_PUBLICATION')));
cy.get('ds-navbar ds-link-menu-item a[title="Statistics"]').click();
cy.get('ds-navbar ds-link-menu-item a[data-test="link-menu-item.menu.section.statistics"]').click();
cy.location('pathname').should('eq', ITEMSTATISTICSPAGE);
});

View File

@@ -0,0 +1,15 @@
const ADD_TEMPLATE_ITEM_PAGE = '/collections/'.concat(Cypress.env('DSPACE_TEST_COLLECTION')).concat('/itemtemplate');
describe('Item Template', () => {
beforeEach(() => {
cy.visit(ADD_TEMPLATE_ITEM_PAGE);
cy.loginViaForm(Cypress.env('DSPACE_TEST_ADMIN_USER'), Cypress.env('DSPACE_TEST_ADMIN_PASSWORD'));
});
it('should load properly', () => {
cy.contains('.ds-header-row .lbl-cell', 'Field', { timeout: 10000 }).should('exist').should('be.visible');
cy.contains('.ds-header-row b', 'Value', { timeout: 10000 }).should('exist').should('be.visible');
cy.contains('.ds-header-row b', 'Lang', { timeout: 10000 }).should('exist').should('be.visible');
cy.contains('.ds-header-row b', 'Edit', { timeout: 10000 }).should('exist').should('be.visible');
});
});

View File

@@ -3,35 +3,35 @@ import { testA11y } from 'cypress/support/utils';
const page = {
openLoginMenu() {
// Click the "Log In" dropdown menu in header
cy.get('ds-themed-navbar [data-test="login-menu"]').click();
cy.get('ds-header [data-test="login-menu"]').click();
},
openUserMenu() {
// Once logged in, click the User menu in header
cy.get('ds-themed-navbar [data-test="user-menu"]').click();
cy.get('ds-header [data-test="user-menu"]').click();
},
submitLoginAndPasswordByPressingButton(email, password) {
// Enter email
cy.get('ds-themed-navbar [data-test="email"]').type(email);
cy.get('ds-header [data-test="email"]').type(email);
// Enter password
cy.get('ds-themed-navbar [data-test="password"]').type(password);
cy.get('ds-header [data-test="password"]').type(password);
// Click login button
cy.get('ds-themed-navbar [data-test="login-button"]').click();
cy.get('ds-header [data-test="login-button"]').click();
},
submitLoginAndPasswordByPressingEnter(email, password) {
// In opened Login modal, fill out email & password, then click Enter
cy.get('ds-themed-navbar [data-test="email"]').type(email);
cy.get('ds-themed-navbar [data-test="password"]').type(password);
cy.get('ds-themed-navbar [data-test="password"]').type('{enter}');
cy.get('ds-header [data-test="email"]').type(email);
cy.get('ds-header [data-test="password"]').type(password);
cy.get('ds-header [data-test="password"]').type('{enter}');
},
submitLogoutByPressingButton() {
// This is the POST command that will actually log us out
cy.intercept('POST', '/server/api/authn/logout').as('logout');
// Click logout button
cy.get('ds-themed-navbar [data-test="logout-button"]').click();
cy.get('ds-header [data-test="logout-button"]').click();
// Wait until above POST command responds before continuing
// (This ensures next action waits until logout completes)
cy.wait('@logout');
}
},
};
describe('Login Modal', () => {
@@ -102,10 +102,10 @@ describe('Login Modal', () => {
page.openLoginMenu();
// Registration link should be visible
cy.get('ds-themed-navbar [data-test="register"]').should('be.visible');
cy.get('ds-header [data-test="register"]').should('be.visible');
// Click registration link & you should go to registration page
cy.get('ds-themed-navbar [data-test="register"]').click();
cy.get('ds-header [data-test="register"]').click();
cy.location('pathname').should('eq', '/register');
cy.get('ds-register-email').should('exist');
@@ -119,10 +119,10 @@ describe('Login Modal', () => {
page.openLoginMenu();
// Forgot password link should be visible
cy.get('ds-themed-navbar [data-test="forgot"]').should('be.visible');
cy.get('ds-header [data-test="forgot"]').should('be.visible');
// Click link & you should go to Forgot Password page
cy.get('ds-themed-navbar [data-test="forgot"]').click();
cy.get('ds-header [data-test="forgot"]').click();
cy.location('pathname').should('eq', '/forgot');
cy.get('ds-forgot-email').should('exist');

View File

@@ -1,16 +1,16 @@
const page = {
fillOutQueryInNavBar(query) {
// Click the magnifying glass
cy.get('ds-themed-navbar [data-test="header-search-icon"]').click();
cy.get('ds-header [data-test="header-search-icon"]').click();
// Fill out a query in input that appears
cy.get('ds-themed-navbar [data-test="header-search-box"]').type(query);
cy.get('ds-header [data-test="header-search-box"]').type(query);
},
submitQueryByPressingEnter() {
cy.get('ds-themed-navbar [data-test="header-search-box"]').type('{enter}');
cy.get('ds-header [data-test="header-search-box"]').type('{enter}');
},
submitQueryByPressingIcon() {
cy.get('ds-themed-navbar [data-test="header-search-icon"]').click();
}
cy.get('ds-header [data-test="header-search-icon"]').click();
},
};
describe('Search from Navigation Bar', () => {

View File

@@ -1,5 +1,5 @@
import { Options } from 'cypress-axe';
import { testA11y } from 'cypress/support/utils';
import { Options } from 'cypress-axe';
describe('Search Page', () => {
// NOTE: these tests currently assume this query will return results!
@@ -49,9 +49,9 @@ describe('Search Page', () => {
{
rules: {
// Card titles fail this test currently
'heading-order': { enabled: false }
}
} as Options
'heading-order': { enabled: false },
},
} as Options,
);
});
});

View File

@@ -41,9 +41,9 @@ describe('New Submission page', () => {
// All select boxes fail to have a name / aria-label.
// This is a bug in ng-dynamic-forms and may require https://github.com/DSpace/dspace-angular/issues/2216
'select-name': { enabled: false },
}
},
} as Options
} as Options,
);
// Discard button should work
@@ -138,7 +138,7 @@ describe('New Submission page', () => {
// Upload our DSpace logo via drag & drop onto submission form
// cy.get('div#section_upload')
cy.get('div.ds-document-drop-zone').selectFile('src/assets/images/dspace-logo.png', {
action: 'drag-drop'
action: 'drag-drop',
});
// Wait for upload to complete before proceeding
@@ -196,9 +196,9 @@ describe('New Submission page', () => {
// Seem to require updating ng-bootstrap and https://github.com/DSpace/dspace-angular/issues/2216
'aria-required-children': { enabled: false },
'nested-interactive': { enabled: false },
}
},
} as Options
} as Options,
);
// Click the lookup button next to "Publication" field
@@ -212,7 +212,7 @@ describe('New Submission page', () => {
testA11y({
include: ['ds-dynamic-lookup-relation-modal'],
exclude: [
['ul.nav-tabs'] // Tabs at top of model have several issues which seem to be caused by ng-bootstrap
['ul.nav-tabs'], // Tabs at top of model have several issues which seem to be caused by ng-bootstrap
],
});

View File

@@ -54,6 +54,6 @@ module.exports = (on, config) => {
// Retrieve currently saved value of REST Domain
getRestBaseDomain() {
return REST_DOMAIN ;
}
},
});
};

View File

@@ -3,8 +3,14 @@
// See docs at https://docs.cypress.io/api/cypress-api/custom-commands
// ***********************************************
import { AuthTokenInfo, TOKENITEM } from 'src/app/core/auth/models/auth-token-info.model';
import { DSPACE_XSRF_COOKIE, XSRF_REQUEST_HEADER } from 'src/app/core/xsrf/xsrf.constants';
import {
AuthTokenInfo,
TOKENITEM,
} from 'src/app/core/auth/models/auth-token-info.model';
import {
DSPACE_XSRF_COOKIE,
XSRF_REQUEST_HEADER,
} from 'src/app/core/xsrf/xsrf.constants';
import { v4 as uuidv4 } from 'uuid';
// Declare Cypress namespace to help with Intellisense & code completion in IDEs
@@ -67,7 +73,7 @@ function login(email: string, password: string): void {
url: baseRestUrl + '/api/authn/login',
headers: { [XSRF_REQUEST_HEADER]: csrfToken },
form: true, // indicates the body should be form urlencoded
body: { user: email, password: password }
body: { user: email, password: password },
}).then((resp) => {
// We expect a successful login
expect(resp.status).to.eq(200);

View File

@@ -15,12 +15,11 @@
// Import all custom Commands (from commands.ts) for all tests
import './commands';
// Import Cypress Axe tools for all tests
// https://github.com/component-driven/cypress-axe
import 'cypress-axe';
import { DSPACE_XSRF_COOKIE } from 'src/app/core/xsrf/xsrf.constants';
import { DSPACE_XSRF_COOKIE } from 'src/app/core/xsrf/xsrf.constants';
// Runs once before all tests
before(() => {

View File

@@ -7,7 +7,7 @@ import { Options } from 'cypress-axe';
function terminalLog(violations: Result[]) {
cy.task(
'log',
`${violations.length} accessibility violation${violations.length === 1 ? '' : 's'} ${violations.length === 1 ? 'was' : 'were'} detected`
`${violations.length} accessibility violation${violations.length === 1 ? '' : 's'} ${violations.length === 1 ? 'was' : 'were'} detected`,
);
// pluck specific keys to keep the table readable
const violationData = violations.map(
@@ -17,8 +17,8 @@ function terminalLog(violations: Result[]) {
description,
helpUrl,
nodes: nodes.length,
html: nodes.map(node => node.html)
})
html: nodes.map(node => node.html),
}),
);
// Print violations as an array, since 'node.html' above often breaks table alignment
@@ -38,7 +38,7 @@ export const testA11y = (context?: any, options?: Options) => {
// Disable color contrast checks as they are inaccurate / result in a lot of false positives
// See also open issues in axe-core: https://github.com/dequelabs/axe-core/labels/color%20contrast
{ id: 'color-contrast', enabled: false },
]
],
});
cy.checkA11y(context, options, terminalLog);
};

View File

@@ -4,6 +4,7 @@
"**/*.ts"
],
"compilerOptions": {
"sourceMap": false,
"types": [
"cypress",
"cypress-axe",

View File

@@ -20,7 +20,7 @@ the Docker compose scripts in this 'docker' folder.
### Dockerfile
This Dockerfile is used to build a *development* DSpace 7 Angular UI image, published as 'dspace/dspace-angular'
This Dockerfile is used to build a *development* DSpace Angular UI image, published as 'dspace/dspace-angular'
```
docker build -t dspace/dspace-angular:latest .
@@ -46,11 +46,11 @@ A default/demo version of this image is built *automatically*.
## 'docker' directory
- docker-compose.yml
- Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker.
- Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace REST instance will also be started in Docker.
- docker-compose-rest.yml
- Runs a published instance of the DSpace 7 REST API - persists data in Docker volumes
- Runs a published instance of the DSpace REST API - persists data in Docker volumes
- docker-compose-ci.yml
- Runs a published instance of the DSpace 7 REST API for CI testing. The database is re-populated from a SQL dump on each startup.
- Runs a published instance of the DSpace REST API for CI testing. The database is re-populated from a SQL dump on each startup.
- cli.yml
- Docker compose file that provides a DSpace CLI container to work with a running DSpace REST container.
- cli.assetstore.yml
@@ -71,7 +71,7 @@ docker-compose -f docker/docker-compose.yml build
This command provides a quick way to start both the frontend & backend from this single codebase
```
docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d
docker-compose -p d8 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d
```
Keep in mind, you may also start the backend by cloning the 'DSpace/DSpace' GitHub repository separately. See the next section.
@@ -86,14 +86,14 @@ _The system will be started in 2 steps. Each step shares the same docker network
From 'DSpace/DSpace' clone (build first as needed):
```
docker-compose -p d7 up -d
docker-compose -p d8 up -d
```
NOTE: More detailed instructions on starting the backend via Docker can be found in the [Docker Compose instructions for the Backend](https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/README.md).
From 'DSpace/dspace-angular' clone (build first as needed)
```
docker-compose -p d7 -f docker/docker-compose.yml up -d
docker-compose -p d8 -f docker/docker-compose.yml up -d
```
At this point, you should be able to access the UI from http://localhost:4000,
@@ -107,19 +107,19 @@ This allows you to run the Angular UI in *production* mode, pointing it at the d
```
docker-compose -f docker/docker-compose-dist.yml pull
docker-compose -f docker/docker-compose-dist.yml build
docker-compose -p d7 -f docker/docker-compose-dist.yml up -d
docker-compose -p d8 -f docker/docker-compose-dist.yml up -d
```
## Ingest test data from AIPDIR
Create an administrator
```
docker-compose -p d7 -f docker/cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en
docker-compose -p d8 -f docker/cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en
```
Load content from AIP files
```
docker-compose -p d7 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli
docker-compose -p d8 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli
```
## Alternative Ingest - Use Entities dataset
@@ -127,12 +127,12 @@ _Delete your docker volumes or use a unique project (-p) name_
Start DSpace with Database Content from a database dump
```
docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml -f docker/db.entities.yml up -d
docker-compose -p d8 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml -f docker/db.entities.yml up -d
```
Load assetstore content and trigger a re-index of the repository
```
docker-compose -p d7 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli
docker-compose -p d8 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli
```
## End to end testing of the REST API (runs in GitHub Actions CI).
@@ -140,5 +140,5 @@ _In this instance, only the REST api runs in Docker using the Entities dataset.
This command is only really useful for testing our Continuous Integration process.
```
docker-compose -p d7ci -f docker/docker-compose-ci.yml up -d
docker-compose -p d8ci -f docker/docker-compose-ci.yml up -d
```

View File

@@ -12,8 +12,6 @@
# https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/cli.assetstore.yml
#
# Therefore, it should be kept in sync with that file
version: "3.7"
services:
dspace-cli:
environment:

View File

@@ -12,8 +12,6 @@
# https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/cli.ingest.yml
#
# Therefore, it should be kept in sync with that file
version: "3.7"
services:
dspace-cli:
environment:
@@ -34,5 +32,7 @@ services:
/dspace/bin/dspace packager -r -a -t AIP -e $${ADMIN_EMAIL} -f -u SITE*.zip
/dspace/bin/dspace database update-sequences
touch /dspace/solr/search/conf/reindex.flag
/dspace/bin/dspace index-discovery
/dspace/bin/dspace oai import
/dspace/bin/dspace oai clean-cache

View File

@@ -12,7 +12,6 @@
# https://github.com/DSpace/DSpace/blob/main/docker-compose-cli.yml
#
# Therefore, it should be kept in sync with that file
version: "3.7"
networks:
# Default to using network named 'dspacenet' from docker-compose-rest.yml.
# Its full name will be prepended with the project name (e.g. "-p d7" means it will be named "d7_dspacenet")

View File

@@ -12,11 +12,9 @@
# https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/db.entities.yml
#
# # Therefore, it should be kept in sync with that file
version: "3.7"
services:
dspacedb:
image: dspace/dspace-postgres-pgcrypto:loadsql
image: dspace/dspace-postgres-pgcrypto::${DSPACE_VER:-latest}-loadsql
environment:
# This LOADSQL should be kept in sync with the URL in DSpace/DSpace
# This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
@@ -29,23 +27,11 @@ services:
# 3. (Custom for Entities) enable Entity-specific collection submission mappings in item-submission.xml
# This 'sed' command inserts the sample configurations specific to the Entities data set, see:
# https://github.com/DSpace/DSpace/blob/main/dspace/config/item-submission.xml#L36-L49
# 4. Finally, start Tomcat
# 4. Finally, start DSpace
entrypoint:
- /bin/bash
- '-c'
- |
while (!</dev/tcp/dspacedb/5432) > /dev/null 2>&1; do sleep 1; done;
/dspace/bin/dspace database migrate ignored
sed -i '/name-map collection-handle="default".*/a \\n <name-map collection-handle="123456789/3" submission-name="Publication"/> \
<name-map collection-handle="123456789/4" submission-name="Publication"/> \
<name-map collection-handle="123456789/281" submission-name="Publication"/> \
<name-map collection-handle="123456789/5" submission-name="Publication"/> \
<name-map collection-handle="123456789/8" submission-name="OrgUnit"/> \
<name-map collection-handle="123456789/6" submission-name="Person"/> \
<name-map collection-handle="123456789/279" submission-name="Person"/> \
<name-map collection-handle="123456789/7" submission-name="Project"/> \
<name-map collection-handle="123456789/280" submission-name="Project"/> \
<name-map collection-handle="123456789/28" submission-name="Journal"/> \
<name-map collection-handle="123456789/29" submission-name="JournalVolume"/> \
<name-map collection-handle="123456789/30" submission-name="JournalIssue"/>' /dspace/config/item-submission.xml
catalina.sh run
java -jar /dspace/webapps/server-boot.jar --dspace.dir=/dspace

View File

@@ -10,7 +10,6 @@
# This is used by our GitHub CI at .github/workflows/build.yml
# It is based heavily on the Backend's Docker Compose:
# https://github.com/DSpace/DSpace/blob/main/docker-compose.yml
version: '3.7'
networks:
dspacenet:
services:
@@ -33,6 +32,7 @@ services:
# Tell Statistics to commit all views immediately instead of waiting on Solr's autocommit.
# This allows us to generate statistics in e2e tests so that statistics pages can be tested thoroughly.
solr__D__statistics__P__autoCommit: 'false'
LOGGING_CONFIG: /dspace/config/log4j2-container.xml
image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}"
depends_on:
- dspacedb
@@ -48,27 +48,31 @@ services:
# Ensure that the database is ready BEFORE starting tomcat
# 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep
# 2. Then, run database migration to init database tables (including any out-of-order ignored migrations, if any)
# 3. Finally, start Tomcat
# 3. Finally, start DSpace
entrypoint:
- /bin/bash
- '-c'
- |
while (!</dev/tcp/dspacedb/5432) > /dev/null 2>&1; do sleep 1; done;
/dspace/bin/dspace database migrate ignored
catalina.sh run
java -jar /dspace/webapps/server-boot.jar --dspace.dir=/dspace
# DSpace database container
# NOTE: This is customized to use our loadsql image, so that we are using a database with existing test data
dspacedb:
container_name: dspacedb
image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}-loadsql"
environment:
# This LOADSQL should be kept in sync with the LOADSQL in
# https://github.com/DSpace/DSpace/blob/main/dspace/src/main/docker-compose/db.entities.yml
# This SQL is available from https://github.com/DSpace-Labs/AIP-Files/releases/tag/demo-entities-data
LOADSQL: https://github.com/DSpace-Labs/AIP-Files/releases/download/demo-entities-data/dspace7-entities-data.sql
PGDATA: /pgdata
image: dspace/dspace-postgres-pgcrypto:loadsql
POSTGRES_PASSWORD: dspace
networks:
- dspacenet
ports:
- published: 5432
target: 5432
stdin_open: true
tty: true
volumes:
@@ -105,6 +109,8 @@ services:
cp -r /opt/solr/server/solr/configsets/statistics/* statistics
precreate-core qaevent /opt/solr/server/solr/configsets/qaevent
cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent
precreate-core suggestion /opt/solr/server/solr/configsets/suggestion
cp -r /opt/solr/server/solr/configsets/suggestion/* suggestion
exec solr -f
volumes:
assetstore:

View File

@@ -8,7 +8,6 @@
# Docker Compose for running the DSpace Angular UI dist build
# for previewing with the DSpace Demo site backend
version: '3.7'
networks:
dspacenet:
services:

View File

@@ -10,7 +10,6 @@
# This is based heavily on the docker-compose.yml that is available in the DSpace/DSpace
# (Backend) at:
# https://github.com/DSpace/DSpace/blob/main/docker-compose.yml
version: '3.7'
networks:
dspacenet:
ipam:
@@ -29,8 +28,9 @@ services:
# __D__ => "-" (e.g. google__D__metadata => google-metadata)
# dspace.dir, dspace.server.url, dspace.ui.url and dspace.name
dspace__P__dir: /dspace
dspace__P__server__P__url: http://localhost:8080/server
dspace__P__ui__P__url: http://localhost:4000
# Uncomment to set a non-default value for dspace.server.url or dspace.ui.url
# dspace__P__server__P__url: http://localhost:8080/server
# dspace__P__ui__P__url: http://localhost:4000
dspace__P__name: 'DSpace Started with Docker Compose'
# db.url: Ensure we are using the 'dspacedb' image for our database
db__P__url: 'jdbc:postgresql://dspacedb:5432/dspace'
@@ -39,6 +39,7 @@ services:
# proxies.trusted.ipranges: This setting is required for a REST API running in Docker to trust requests
# from the host machine. This IP range MUST correspond to the 'dspacenet' subnet defined above.
proxies__P__trusted__P__ipranges: '172.23.0'
LOGGING_CONFIG: /dspace/config/log4j2-container.xml
image: "${DOCKER_OWNER:-dspace}/dspace:${DSPACE_VER:-latest-test}"
depends_on:
- dspacedb
@@ -50,24 +51,27 @@ services:
stdin_open: true
tty: true
volumes:
# Keep DSpace assetstore directory between reboots
- assetstore:/dspace/assetstore
# Ensure that the database is ready BEFORE starting tomcat
# 1. While a TCP connection to dspacedb port 5432 is not available, continue to sleep
# 2. Then, run database migration to init database tables
# 3. Finally, start Tomcat
# 3. Finally, start DSpace
entrypoint:
- /bin/bash
- '-c'
- |
while (!</dev/tcp/dspacedb/5432) > /dev/null 2>&1; do sleep 1; done;
/dspace/bin/dspace database migrate
catalina.sh run
java -jar /dspace/webapps/server-boot.jar --dspace.dir=/dspace
# DSpace database container
dspacedb:
container_name: dspacedb
# Uses a custom Postgres image with pgcrypto installed
image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}"
environment:
PGDATA: /pgdata
image: "${DOCKER_OWNER:-dspace}/dspace-postgres-pgcrypto:${DSPACE_VER:-latest}"
POSTGRES_PASSWORD: dspace
networks:
- dspacenet
ports:
@@ -113,6 +117,8 @@ services:
cp -r /opt/solr/server/solr/configsets/statistics/* statistics
precreate-core qaevent /opt/solr/server/solr/configsets/qaevent
cp -r /opt/solr/server/solr/configsets/qaevent/* qaevent
precreate-core suggestion /opt/solr/server/solr/configsets/suggestion
cp -r /opt/solr/server/solr/configsets/suggestion/* suggestion
exec solr -f
volumes:
assetstore:

View File

@@ -9,7 +9,6 @@
# Docker Compose for running the DSpace Angular UI for testing/development
# Requires also running a REST API backend (either locally or remotely),
# for example via 'docker-compose-rest.yml'
version: '3.7'
networks:
dspacenet:
services:

4
docs/lint/html/index.md Normal file
View File

@@ -0,0 +1,4 @@
[DSpace ESLint plugins](../../../lint/README.md) > HTML rules
_______
- [`dspace-angular-html/themed-component-usages`](./rules/themed-component-usages.md): Themeable components should be used via the selector of their `ThemedComponent` wrapper class

View File

@@ -0,0 +1,110 @@
[DSpace ESLint plugins](../../../../lint/README.md) > [HTML rules](../index.md) > `dspace-angular-html/themed-component-usages`
_______
Themeable components should be used via the selector of their `ThemedComponent` wrapper class
This ensures that custom themes can correctly override _all_ instances of this component.
The only exception to this rule are unit tests, where we may want to use the base component in order to keep the test setup simple.
_______
[Source code](../../../../lint/src/rules/html/themed-component-usages.ts)
### Examples
#### Valid code
##### use no-prefix selectors in HTML templates
```html
<ds-test-themeable/>
<ds-test-themeable></ds-test-themeable>
<ds-test-themeable [test]="something"></ds-test-themeable>
```
##### use no-prefix selectors in TypeScript templates
```html
@Component({
template: '<ds-test-themeable></ds-test-themeable>'
})
class Test {
}
```
##### use no-prefix selectors in TypeScript test templates
Filename: `lint/test/fixture/src/test.spec.ts`
```html
@Component({
template: '<ds-test-themeable></ds-test-themeable>'
})
class Test {
}
```
##### base selectors are also allowed in TypeScript test templates
Filename: `lint/test/fixture/src/test.spec.ts`
```html
@Component({
template: '<ds-base-test-themeable></ds-base-test-themeable>'
})
class Test {
}
```
#### Invalid code &amp; automatic fixes
##### themed override selectors are not allowed in HTML templates
```html
<ds-themed-test-themeable/>
<ds-themed-test-themeable></ds-themed-test-themeable>
<ds-themed-test-themeable [test]="something"></ds-themed-test-themeable>
```
Will produce the following error(s):
```
Themeable components should be used via their ThemedComponent wrapper's selector
Themeable components should be used via their ThemedComponent wrapper's selector
Themeable components should be used via their ThemedComponent wrapper's selector
```
Result of `yarn lint --fix`:
```html
<ds-test-themeable/>
<ds-test-themeable></ds-test-themeable>
<ds-test-themeable [test]="something"></ds-test-themeable>
```
##### base selectors are not allowed in HTML templates
```html
<ds-base-test-themeable/>
<ds-base-test-themeable></ds-base-test-themeable>
<ds-base-test-themeable [test]="something"></ds-base-test-themeable>
```
Will produce the following error(s):
```
Themeable components should be used via their ThemedComponent wrapper's selector
Themeable components should be used via their ThemedComponent wrapper's selector
Themeable components should be used via their ThemedComponent wrapper's selector
```
Result of `yarn lint --fix`:
```html
<ds-test-themeable/>
<ds-test-themeable></ds-test-themeable>
<ds-test-themeable [test]="something"></ds-test-themeable>
```

6
docs/lint/ts/index.md Normal file
View File

@@ -0,0 +1,6 @@
[DSpace ESLint plugins](../../../lint/README.md) > TypeScript rules
_______
- [`dspace-angular-ts/themed-component-classes`](./rules/themed-component-classes.md): Formatting rules for themeable component classes
- [`dspace-angular-ts/themed-component-selectors`](./rules/themed-component-selectors.md): Themeable component selectors should follow the DSpace convention
- [`dspace-angular-ts/themed-component-usages`](./rules/themed-component-usages.md): Themeable components should be used via their `ThemedComponent` wrapper class

View File

@@ -0,0 +1,257 @@
[DSpace ESLint plugins](../../../../lint/README.md) > [TypeScript rules](../index.md) > `dspace-angular-ts/themed-component-classes`
_______
Formatting rules for themeable component classes
- All themeable components must be standalone.
- The base component must always be imported in the `ThemedComponent` wrapper. This ensures that it is always sufficient to import just the wrapper whenever we use the component.
_______
[Source code](../../../../lint/src/rules/ts/themed-component-classes.ts)
### Examples
#### Valid code
##### Regular non-themeable component
```typescript
@Component({
selector: 'ds-something',
standalone: true,
})
class Something {
}
```
##### Base component
```typescript
@Component({
selector: 'ds-base-test-themable',
standalone: true,
})
class TestThemeableTomponent {
}
```
##### Wrapper component
Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts`
```typescript
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [
TestThemeableComponent,
],
})
class ThemedTestThemeableTomponent extends ThemedComponent<TestThemeableComponent> {
}
```
##### Override component
Filename: `lint/test/fixture/src/themes/test/app/test/test-themeable.component.ts`
```typescript
@Component({
selector: 'ds-themed-test-themable',
standalone: true,
})
class Override extends BaseComponent {
}
```
#### Invalid code &amp; automatic fixes
##### Base component must be standalone
```typescript
@Component({
selector: 'ds-base-test-themable',
})
class TestThemeableComponent {
}
```
Will produce the following error(s):
```
Themeable components must be standalone
```
Result of `yarn lint --fix`:
```typescript
@Component({
selector: 'ds-base-test-themable',
standalone: true,
})
class TestThemeableComponent {
}
```
##### Wrapper component must be standalone and import base component
Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts`
```typescript
@Component({
selector: 'ds-test-themable',
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
```
Will produce the following error(s):
```
Themeable component wrapper classes must be standalone and import the base class
```
Result of `yarn lint --fix`:
```typescript
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [TestThemeableComponent],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
```
##### Wrapper component must import base component (array present but empty)
Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts`
```typescript
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
```
Will produce the following error(s):
```
Themed component wrapper classes must only import the base class
```
Result of `yarn lint --fix`:
```typescript
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [TestThemeableComponent],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
```
##### Wrapper component must import base component (array is wrong)
Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts`
```typescript
import { SomethingElse } from './somewhere-else';
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [
SomethingElse,
],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
```
Will produce the following error(s):
```
Themed component wrapper classes must only import the base class
```
Result of `yarn lint --fix`:
```typescript
import { SomethingElse } from './somewhere-else';
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [TestThemeableComponent],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
```
##### Wrapper component must import base component (array is wrong)
Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts`
```typescript
import { Something, SomethingElse } from './somewhere-else';
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [
SomethingElse,
],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
```
Will produce the following error(s):
```
Themed component wrapper classes must only import the base class
```
Result of `yarn lint --fix`:
```typescript
import { Something, SomethingElse } from './somewhere-else';
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [TestThemeableComponent],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
```
##### Override component must be standalone
Filename: `lint/test/fixture/src/themes/test/app/test/test-themeable.component.ts`
```typescript
@Component({
selector: 'ds-themed-test-themable',
})
class Override extends BaseComponent {
}
```
Will produce the following error(s):
```
Themeable components must be standalone
```
Result of `yarn lint --fix`:
```typescript
@Component({
selector: 'ds-themed-test-themable',
standalone: true,
})
class Override extends BaseComponent {
}
```

View File

@@ -0,0 +1,156 @@
[DSpace ESLint plugins](../../../../lint/README.md) > [TypeScript rules](../index.md) > `dspace-angular-ts/themed-component-selectors`
_______
Themeable component selectors should follow the DSpace convention
Each themeable component is comprised of a base component, a wrapper component and any number of themed components
- Base components should have a selector starting with `ds-base-`
- Themed components should have a selector starting with `ds-themed-`
- Wrapper components should have a selector starting with `ds-`, but not `ds-base-` or `ds-themed-`
- This is the regular DSpace selector prefix
- **When making a regular component themeable, its selector prefix should be changed to `ds-base-`, and the new wrapper's component should reuse the previous selector**
Unit tests are exempt from this rule, because they may redefine components using the same class name as other themeable components elsewhere in the source.
_______
[Source code](../../../../lint/src/rules/ts/themed-component-selectors.ts)
### Examples
#### Valid code
##### Regular non-themeable component selector
```typescript
@Component({
selector: 'ds-something',
})
class Something {
}
```
##### Themeable component selector should replace the original version, unthemed version should be changed to ds-base-
```typescript
@Component({
selector: 'ds-base-something',
})
class Something {
}
@Component({
selector: 'ds-something',
})
class ThemedSomething extends ThemedComponent<Something> {
}
@Component({
selector: 'ds-themed-something',
})
class OverrideSomething extends Something {
}
```
##### Other themed component wrappers should not interfere
```typescript
@Component({
selector: 'ds-something',
})
class Something {
}
@Component({
selector: 'ds-something-else',
})
class ThemedSomethingElse extends ThemedComponent<SomethingElse> {
}
```
#### Invalid code &amp; automatic fixes
##### Wrong selector for base component
Filename: `lint/test/fixture/src/app/test/test-themeable.component.ts`
```typescript
@Component({
selector: 'ds-something',
})
class TestThemeableComponent {
}
```
Will produce the following error(s):
```
Unthemed version of themeable component should have a selector starting with 'ds-base-'
```
Result of `yarn lint --fix`:
```typescript
@Component({
selector: 'ds-base-something',
})
class TestThemeableComponent {
}
```
##### Wrong selector for wrapper component
Filename: `lint/test/fixture/src/app/test/themed-test-themeable.component.ts`
```typescript
@Component({
selector: 'ds-themed-something',
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
```
Will produce the following error(s):
```
Themed component wrapper of themeable component shouldn't have a selector starting with 'ds-themed-'
```
Result of `yarn lint --fix`:
```typescript
@Component({
selector: 'ds-something',
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
```
##### Wrong selector for theme override
Filename: `lint/test/fixture/src/themes/test/app/test/test-themeable.component.ts`
```typescript
@Component({
selector: 'ds-something',
})
class TestThememeableComponent extends BaseComponent {
}
```
Will produce the following error(s):
```
Theme override of themeable component should have a selector starting with 'ds-themed-'
```
Result of `yarn lint --fix`:
```typescript
@Component({
selector: 'ds-themed-something',
})
class TestThememeableComponent extends BaseComponent {
}
```

View File

@@ -0,0 +1,332 @@
[DSpace ESLint plugins](../../../../lint/README.md) > [TypeScript rules](../index.md) > `dspace-angular-ts/themed-component-usages`
_______
Themeable components should be used via their `ThemedComponent` wrapper class
This ensures that custom themes can correctly override _all_ instances of this component.
There are a few exceptions where the base class can still be used:
- Class declaration expressions (otherwise we can't declare, extend or override the class in the first place)
- Angular modules (except for routing modules)
- Angular `@ViewChild` decorators
- Type annotations
_______
[Source code](../../../../lint/src/rules/ts/themed-component-usages.ts)
### Examples
#### Valid code
##### allow wrapper class usages
```typescript
import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component';
const config = {
a: ThemedTestThemeableComponent,
b: ChipsComponent,
}
```
##### allow base class in class declaration
```typescript
export class TestThemeableComponent {
}
```
##### allow inheriting from base class
```typescript
import { TestThemeableComponent } from './app/test/test-themeable.component';
export class ThemedAdminSidebarComponent extends ThemedComponent<TestThemeableComponent> {
}
```
##### allow base class in ViewChild
```typescript
import { TestThemeableComponent } from './app/test/test-themeable.component';
export class Something {
@ViewChild(TestThemeableComponent) test: TestThemeableComponent;
}
```
##### allow wrapper selectors in test queries
Filename: `lint/test/fixture/src/app/test/test.component.spec.ts`
```typescript
By.css('ds-themeable');
By.css('#test > ds-themeable > #nest');
```
##### allow wrapper selectors in cypress queries
Filename: `lint/test/fixture/src/app/test/test.component.cy.ts`
```typescript
By.css('ds-themeable');
By.css('#test > ds-themeable > #nest');
```
#### Invalid code &amp; automatic fixes
##### disallow direct usages of base class
```typescript
import { TestThemeableComponent } from './app/test/test-themeable.component';
import { TestComponent } from './app/test/test.component';
const config = {
a: TestThemeableComponent,
b: TestComponent,
}
```
Will produce the following error(s):
```
Themeable components should be used via their ThemedComponent wrapper
Themeable components should be used via their ThemedComponent wrapper
```
Result of `yarn lint --fix`:
```typescript
import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component';
import { TestComponent } from './app/test/test.component';
const config = {
a: ThemedTestThemeableComponent,
b: TestComponent,
}
```
##### disallow direct usages of base class, keep other imports
```typescript
import { Something, TestThemeableComponent } from './app/test/test-themeable.component';
import { TestComponent } from './app/test/test.component';
const config = {
a: TestThemeableComponent,
b: TestComponent,
c: Something,
}
```
Will produce the following error(s):
```
Themeable components should be used via their ThemedComponent wrapper
Themeable components should be used via their ThemedComponent wrapper
```
Result of `yarn lint --fix`:
```typescript
import { Something } from './app/test/test-themeable.component';
import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component';
import { TestComponent } from './app/test/test.component';
const config = {
a: ThemedTestThemeableComponent,
b: TestComponent,
c: Something,
}
```
##### handle array replacements correctly
```typescript
const DECLARATIONS = [
Something,
TestThemeableComponent,
Something,
ThemedTestThemeableComponent,
];
```
Will produce the following error(s):
```
Themeable components should be used via their ThemedComponent wrapper
```
Result of `yarn lint --fix`:
```typescript
const DECLARATIONS = [
Something,
Something,
ThemedTestThemeableComponent,
];
```
##### disallow override selector in test queries
Filename: `lint/test/fixture/src/app/test/test.component.spec.ts`
```typescript
By.css('ds-themed-themeable');
By.css('#test > ds-themed-themeable > #nest');
```
Will produce the following error(s):
```
Themeable components should be used via their ThemedComponent wrapper
Themeable components should be used via their ThemedComponent wrapper
```
Result of `yarn lint --fix`:
```typescript
By.css('ds-themeable');
By.css('#test > ds-themeable > #nest');
```
##### disallow base selector in test queries
Filename: `lint/test/fixture/src/app/test/test.component.spec.ts`
```typescript
By.css('ds-base-themeable');
By.css('#test > ds-base-themeable > #nest');
```
Will produce the following error(s):
```
Themeable components should be used via their ThemedComponent wrapper
Themeable components should be used via their ThemedComponent wrapper
```
Result of `yarn lint --fix`:
```typescript
By.css('ds-themeable');
By.css('#test > ds-themeable > #nest');
```
##### disallow override selector in cypress queries
Filename: `lint/test/fixture/src/app/test/test.component.cy.ts`
```typescript
cy.get('ds-themed-themeable');
cy.get('#test > ds-themed-themeable > #nest');
```
Will produce the following error(s):
```
Themeable components should be used via their ThemedComponent wrapper
Themeable components should be used via their ThemedComponent wrapper
```
Result of `yarn lint --fix`:
```typescript
cy.get('ds-themeable');
cy.get('#test > ds-themeable > #nest');
```
##### disallow base selector in cypress queries
Filename: `lint/test/fixture/src/app/test/test.component.cy.ts`
```typescript
cy.get('ds-base-themeable');
cy.get('#test > ds-base-themeable > #nest');
```
Will produce the following error(s):
```
Themeable components should be used via their ThemedComponent wrapper
Themeable components should be used via their ThemedComponent wrapper
```
Result of `yarn lint --fix`:
```typescript
cy.get('ds-themeable');
cy.get('#test > ds-themeable > #nest');
```
##### edge case: unable to find usage node through usage token, but import is still flagged and fixed
Filename: `lint/test/fixture/src/themes/test/app/test/other-themeable.component.ts`
```typescript
import { Component } from '@angular/core';
import { Context } from './app/core/shared/context.model';
import { TestThemeableComponent } from '../../../../app/test/test-themeable.component';
@Component({
standalone: true,
imports: [TestThemeableComponent],
})
export class UsageComponent {
}
```
Will produce the following error(s):
```
Themeable components should be used via their ThemedComponent wrapper
Themeable components should be used via their ThemedComponent wrapper
```
Result of `yarn lint --fix`:
```typescript
import { Component } from '@angular/core';
import { Context } from './app/core/shared/context.model';
import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component';
@Component({
standalone: true,
imports: [ThemedTestThemeableComponent],
})
export class UsageComponent {
}
```
##### edge case edge case: both are imported, only wrapper is retained
Filename: `lint/test/fixture/src/themes/test/app/test/other-themeable.component.ts`
```typescript
import { Component } from '@angular/core';
import { Context } from './app/core/shared/context.model';
import { TestThemeableComponent } from '../../../../app/test/test-themeable.component';
import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component';
@Component({
standalone: true,
imports: [TestThemeableComponent, ThemedTestThemeableComponent],
})
export class UsageComponent {
}
```
Will produce the following error(s):
```
Themeable components should be used via their ThemedComponent wrapper
Themeable components should be used via their ThemedComponent wrapper
```
Result of `yarn lint --fix`:
```typescript
import { Component } from '@angular/core';
import { Context } from './app/core/shared/context.model';
import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component';
@Component({
standalone: true,
imports: [ThemedTestThemeableComponent],
})
export class UsageComponent {
}
```

View File

@@ -15,7 +15,10 @@ module.exports = function (config) {
],
client: {
clearContext: false, // leave Jasmine Spec Runner output visible in browser
captureConsole: false
captureConsole: false,
jasmine: {
failSpecWithNoExpectations: true
}
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, './coverage/dspace-angular'),

3
lint/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/dist/
/coverage/
/node-modules/

50
lint/README.md Normal file
View File

@@ -0,0 +1,50 @@
# DSpace ESLint plugins
Custom ESLint rules for DSpace Angular peculiarities.
## Usage
These plugins are included with the rest of our ESLint configuration in [.eslintc.json](../.eslintrc.json). Individual rules can be configured or disabled there, like usual.
- In order for the new rules to be picked up by your IDE, you should first run `yarn build:lint` to build the plugins.
- This will also happen automatically each time `yarn lint` is run.
## Documentation
The rules are split up into plugins by language:
- [TypeScript rules](../docs/lint/ts/index.md)
- [HTML rules](../docs/lint/html/index.md)
> Run `yarn docs:lint` to generate this documentation!
## Developing
### Overview
- All rules are written in TypeScript and compiled into [`dist`](./dist)
- The plugins are linked into the main project dependencies from here
- These directories already contain the necessary `package.json` files to mark them as ESLint plugins
- Rule source files are structured, so they can be imported all in one go
- Each rule must export the following:
- `Messages`: an Enum of error message IDs
- `info`: metadata about this rule (name, description, messages, options, ...)
- `rule`: the implementation of the rule
- `tests`: the tests for this rule, as a set of valid/invalid code snippets. These snippets are used as example in the documentation.
- New rules should be added to their plugin's `index.ts`
- Some useful links
- [Developing ESLint plugins](https://eslint.org/docs/latest/extend/plugins)
- [Custom rules in typescript-eslint](https://typescript-eslint.io/developers/custom-rules)
- [Angular ESLint](https://github.com/angular-eslint/angular-eslint)
### Parsing project metadata in advance ~ TypeScript AST
While it is possible to retain persistent state between files during the linting process, it becomes quite complicated if the content of one file determines how we want to lint another file.
Because the two files may be linted out of order, we may not know whether the first file is wrong before we pass by the second. This means that we cannot report or fix the issue, because the first file is already detached from the linting context.
For example, we cannot consistently determine which components are themeable (i.e. have a `ThemedComponent` wrapper) while linting.
To work around this issue, we construct a registry of themeable components _before_ linting anything.
- We don't have a good way to hook into the ESLint parser at this time
- Instead, we leverage the actual TypeScript AST parser
- Retrieve all `ThemedComponent` wrapper files by the pattern of their path (`themed-*.component.ts`)
- Determine the themed component they're linked to (by the actual type annotation/import path, since filenames are prone to errors)
- Store metadata describing these component pairs in a global registry that can be shared between rules
- This only needs to happen once, and only takes a fraction of a second (for ~100 themeable components)

6
lint/dist/src/rules/html/package.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "eslint-plugin-dspace-angular-html",
"version": "0.0.0",
"main": "./index.js",
"private": true
}

6
lint/dist/src/rules/ts/package.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "eslint-plugin-dspace-angular-ts",
"version": "0.0.0",
"main": "./index.js",
"private": true
}

85
lint/generate-docs.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import {
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from 'fs';
import { join } from 'path';
import { default as htmlPlugin } from './src/rules/html';
import { default as tsPlugin } from './src/rules/ts';
const templates = new Map();
function lazyEJS(path: string, data: object): string {
if (!templates.has(path)) {
templates.set(path, require('ejs').compile(readFileSync(path).toString()));
}
return templates.get(path)(data).replace(/\r\n/g, '\n');
}
const docsDir = join('docs', 'lint');
const tsDir = join(docsDir, 'ts');
const htmlDir = join(docsDir, 'html');
if (existsSync(docsDir)) {
rmSync(docsDir, { recursive: true });
}
mkdirSync(join(tsDir, 'rules'), { recursive: true });
mkdirSync(join(htmlDir, 'rules'), { recursive: true });
function template(name: string): string {
return join('lint', 'src', 'util', 'templates', name);
}
// TypeScript docs
writeFileSync(
join(tsDir, 'index.md'),
lazyEJS(template('index.ejs'), {
plugin: tsPlugin,
rules: tsPlugin.index.map(rule => rule.info),
}),
);
for (const rule of tsPlugin.index) {
writeFileSync(
join(tsDir, 'rules', rule.info.name + '.md'),
lazyEJS(template('rule.ejs'), {
plugin: tsPlugin,
rule: rule.info,
tests: rule.tests,
}),
);
}
// HTML docs
writeFileSync(
join(htmlDir, 'index.md'),
lazyEJS(template('index.ejs'), {
plugin: htmlPlugin,
rules: htmlPlugin.index.map(rule => rule.info),
}),
);
for (const rule of htmlPlugin.index) {
writeFileSync(
join(htmlDir, 'rules', rule.info.name + '.md'),
lazyEJS(template('rule.ejs'), {
plugin: htmlPlugin,
rule: rule.info,
tests: rule.tests,
}),
);
}

7
lint/jasmine.json Normal file
View File

@@ -0,0 +1,7 @@
{
"spec_files": ["**/*.spec.js"],
"spec_dir": "lint/dist/test",
"helpers": [
"./test/helpers.js"
]
}

View File

@@ -0,0 +1,22 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
/* eslint-disable import/no-namespace */
import {
bundle,
RuleExports,
} from '../../util/structure';
import * as themedComponentUsages from './themed-component-usages';
const index = [
themedComponentUsages,
] as unknown as RuleExports[];
export = {
parser: require('@angular-eslint/template-parser'),
...bundle('dspace-angular-html', 'HTML', index),
};

View File

@@ -0,0 +1,191 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TmplAstElement } from '@angular-eslint/bundled-angular-compiler';
import { TemplateParserServices } from '@angular-eslint/utils';
import {
ESLintUtils,
TSESLint,
} from '@typescript-eslint/utils';
import { fixture } from '../../../test/fixture';
import {
DSpaceESLintRuleInfo,
NamedTests,
} from '../../util/structure';
import {
DISALLOWED_THEME_SELECTORS,
fixSelectors,
} from '../../util/theme-support';
import {
getFilename,
getSourceCode,
} from '../../util/typescript';
export enum Message {
WRONG_SELECTOR = 'mustUseThemedWrapperSelector',
}
export const info = {
name: 'themed-component-usages',
meta: {
docs: {
description: `Themeable components should be used via the selector of their \`ThemedComponent\` wrapper class
This ensures that custom themes can correctly override _all_ instances of this component.
The only exception to this rule are unit tests, where we may want to use the base component in order to keep the test setup simple.
`,
},
type: 'problem',
fixable: 'code',
schema: [],
messages: {
[Message.WRONG_SELECTOR]: 'Themeable components should be used via their ThemedComponent wrapper\'s selector',
},
},
defaultOptions: [],
} as DSpaceESLintRuleInfo;
export const rule = ESLintUtils.RuleCreator.withoutDocs({
...info,
create(context: TSESLint.RuleContext<Message, unknown[]>) {
if (getFilename(context).includes('.spec.ts')) {
// skip inline templates in unit tests
return {};
}
const parserServices = getSourceCode(context).parserServices as TemplateParserServices;
return {
[`Element$1[name = /^${DISALLOWED_THEME_SELECTORS}/]`](node: TmplAstElement) {
const { startSourceSpan, endSourceSpan } = node;
const openStart = startSourceSpan.start.offset as number;
context.report({
messageId: Message.WRONG_SELECTOR,
loc: parserServices.convertNodeSourceSpanToLoc(startSourceSpan),
fix(fixer) {
const oldSelector = node.name;
const newSelector = fixSelectors(oldSelector);
const ops = [
fixer.replaceTextRange([openStart + 1, openStart + 1 + oldSelector.length], newSelector),
];
// make sure we don't mangle self-closing tags
if (endSourceSpan !== null && startSourceSpan.end.offset !== endSourceSpan.end.offset) {
const closeStart = endSourceSpan.start.offset as number;
const closeEnd = endSourceSpan.end.offset as number;
ops.push(fixer.replaceTextRange([closeStart + 2, closeEnd - 1], newSelector));
}
return ops;
},
});
},
};
},
});
export const tests = {
plugin: info.name,
valid: [
{
name: 'use no-prefix selectors in HTML templates',
code: `
<ds-test-themeable/>
<ds-test-themeable></ds-test-themeable>
<ds-test-themeable [test]="something"></ds-test-themeable>
`,
},
{
name: 'use no-prefix selectors in TypeScript templates',
code: `
@Component({
template: '<ds-test-themeable></ds-test-themeable>'
})
class Test {
}
`,
},
{
name: 'use no-prefix selectors in TypeScript test templates',
filename: fixture('src/test.spec.ts'),
code: `
@Component({
template: '<ds-test-themeable></ds-test-themeable>'
})
class Test {
}
`,
},
{
name: 'base selectors are also allowed in TypeScript test templates',
filename: fixture('src/test.spec.ts'),
code: `
@Component({
template: '<ds-base-test-themeable></ds-base-test-themeable>'
})
class Test {
}
`,
},
],
invalid: [
{
name: 'themed override selectors are not allowed in HTML templates',
code: `
<ds-themed-test-themeable/>
<ds-themed-test-themeable></ds-themed-test-themeable>
<ds-themed-test-themeable [test]="something"></ds-themed-test-themeable>
`,
errors: [
{
messageId: Message.WRONG_SELECTOR,
},
{
messageId: Message.WRONG_SELECTOR,
},
{
messageId: Message.WRONG_SELECTOR,
},
],
output: `
<ds-test-themeable/>
<ds-test-themeable></ds-test-themeable>
<ds-test-themeable [test]="something"></ds-test-themeable>
`,
},
{
name: 'base selectors are not allowed in HTML templates',
code: `
<ds-base-test-themeable/>
<ds-base-test-themeable></ds-base-test-themeable>
<ds-base-test-themeable [test]="something"></ds-base-test-themeable>
`,
errors: [
{
messageId: Message.WRONG_SELECTOR,
},
{
messageId: Message.WRONG_SELECTOR,
},
{
messageId: Message.WRONG_SELECTOR,
},
],
output: `
<ds-test-themeable/>
<ds-test-themeable></ds-test-themeable>
<ds-test-themeable [test]="something"></ds-test-themeable>
`,
},
],
} as NamedTests;
export default rule;

View File

@@ -0,0 +1,25 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import {
bundle,
RuleExports,
} from '../../util/structure';
/* eslint-disable import/no-namespace */
import * as themedComponentClasses from './themed-component-classes';
import * as themedComponentSelectors from './themed-component-selectors';
import * as themedComponentUsages from './themed-component-usages';
const index = [
themedComponentClasses,
themedComponentSelectors,
themedComponentUsages,
] as unknown as RuleExports[];
export = {
...bundle('dspace-angular-ts', 'TypeScript', index),
};

View File

@@ -0,0 +1,382 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import {
ESLintUtils,
TSESLint,
TSESTree,
} from '@typescript-eslint/utils';
import { fixture } from '../../../test/fixture';
import {
getComponentImportNode,
getComponentInitializer,
getComponentStandaloneNode,
} from '../../util/angular';
import { appendObjectProperties } from '../../util/fix';
import { DSpaceESLintRuleInfo } from '../../util/structure';
import {
getBaseComponentClassName,
inThemedComponentOverrideFile,
isThemeableComponent,
isThemedComponentWrapper,
} from '../../util/theme-support';
import { getFilename } from '../../util/typescript';
export enum Message {
NOT_STANDALONE = 'mustBeStandalone',
NOT_STANDALONE_IMPORTS_BASE = 'mustBeStandaloneAndImportBase',
WRAPPER_IMPORTS_BASE = 'wrapperShouldImportBase',
}
export const info = {
name: 'themed-component-classes',
meta: {
docs: {
description: `Formatting rules for themeable component classes
- All themeable components must be standalone.
- The base component must always be imported in the \`ThemedComponent\` wrapper. This ensures that it is always sufficient to import just the wrapper whenever we use the component.
`,
},
type: 'problem',
fixable: 'code',
schema: [],
messages: {
[Message.NOT_STANDALONE]: 'Themeable components must be standalone',
[Message.NOT_STANDALONE_IMPORTS_BASE]: 'Themeable component wrapper classes must be standalone and import the base class',
[Message.WRAPPER_IMPORTS_BASE]: 'Themed component wrapper classes must only import the base class',
},
},
defaultOptions: [],
} as DSpaceESLintRuleInfo;
export const rule = ESLintUtils.RuleCreator.withoutDocs({
...info,
create(context: TSESLint.RuleContext<Message, unknown[]>) {
const filename = getFilename(context);
if (filename.endsWith('.spec.ts')) {
return {};
}
function enforceStandalone(decoratorNode: TSESTree.Decorator, withBaseImport = false) {
const standaloneNode = getComponentStandaloneNode(decoratorNode);
if (standaloneNode === undefined) {
// We may need to add these properties in one go
if (!withBaseImport) {
context.report({
messageId: Message.NOT_STANDALONE,
node: decoratorNode,
fix(fixer) {
const initializer = getComponentInitializer(decoratorNode);
return appendObjectProperties(context, fixer, initializer, ['standalone: true']);
},
});
}
} else if (!standaloneNode.value) {
context.report({
messageId: Message.NOT_STANDALONE,
node: standaloneNode,
fix(fixer) {
return fixer.replaceText(standaloneNode, 'true');
},
});
}
if (withBaseImport) {
const baseClass = getBaseComponentClassName(decoratorNode);
if (baseClass === undefined) {
return;
}
const importsNode = getComponentImportNode(decoratorNode);
if (importsNode === undefined) {
if (standaloneNode === undefined) {
context.report({
messageId: Message.NOT_STANDALONE_IMPORTS_BASE,
node: decoratorNode,
fix(fixer) {
const initializer = getComponentInitializer(decoratorNode);
return appendObjectProperties(context, fixer, initializer, ['standalone: true', `imports: [${baseClass}]`]);
},
});
} else {
context.report({
messageId: Message.WRAPPER_IMPORTS_BASE,
node: decoratorNode,
fix(fixer) {
const initializer = getComponentInitializer(decoratorNode);
return appendObjectProperties(context, fixer, initializer, [`imports: [${baseClass}]`]);
},
});
}
} else {
// If we have an imports node, standalone: true will be enforced by another rule
const imports = importsNode.elements.map(e => (e as TSESTree.Identifier).name);
if (!imports.includes(baseClass) || imports.length > 1) {
// The wrapper should _only_ import the base component
context.report({
messageId: Message.WRAPPER_IMPORTS_BASE,
node: importsNode,
fix(fixer) {
// todo: this may leave unused imports, but that's better than mangling things
return fixer.replaceText(importsNode, `[${baseClass}]`);
},
});
}
}
}
}
return {
'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: TSESTree.Decorator) {
const classNode = node.parent as TSESTree.ClassDeclaration;
const className = classNode.id?.name;
if (className === undefined) {
return;
}
if (isThemedComponentWrapper(node)) {
enforceStandalone(node, true);
} else if (inThemedComponentOverrideFile(filename)) {
enforceStandalone(node);
} else if (isThemeableComponent(className)) {
enforceStandalone(node);
}
},
};
},
});
export const tests = {
plugin: info.name,
valid: [
{
name: 'Regular non-themeable component',
code: `
@Component({
selector: 'ds-something',
standalone: true,
})
class Something {
}
`,
},
{
name: 'Base component',
code: `
@Component({
selector: 'ds-base-test-themable',
standalone: true,
})
class TestThemeableTomponent {
}
`,
},
{
name: 'Wrapper component',
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
code: `
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [
TestThemeableComponent,
],
})
class ThemedTestThemeableTomponent extends ThemedComponent<TestThemeableComponent> {
}
`,
},
{
name: 'Override component',
filename: fixture('src/themes/test/app/test/test-themeable.component.ts'),
code: `
@Component({
selector: 'ds-themed-test-themable',
standalone: true,
})
class Override extends BaseComponent {
}
`,
},
],
invalid: [
{
name: 'Base component must be standalone',
code: `
@Component({
selector: 'ds-base-test-themable',
})
class TestThemeableComponent {
}
`,
errors:[
{
messageId: Message.NOT_STANDALONE,
},
],
output: `
@Component({
selector: 'ds-base-test-themable',
standalone: true,
})
class TestThemeableComponent {
}
`,
},
{
name: 'Wrapper component must be standalone and import base component',
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
code: `
@Component({
selector: 'ds-test-themable',
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
errors:[
{
messageId: Message.NOT_STANDALONE_IMPORTS_BASE,
},
],
output: `
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [TestThemeableComponent],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
},
{
name: 'Wrapper component must import base component (array present but empty)',
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
code: `
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
errors:[
{
messageId: Message.WRAPPER_IMPORTS_BASE,
},
],
output: `
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [TestThemeableComponent],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
},
{
name: 'Wrapper component must import base component (array is wrong)',
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
code: `
import { SomethingElse } from './somewhere-else';
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [
SomethingElse,
],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
errors:[
{
messageId: Message.WRAPPER_IMPORTS_BASE,
},
],
output: `
import { SomethingElse } from './somewhere-else';
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [TestThemeableComponent],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
}, {
name: 'Wrapper component must import base component (array is wrong)',
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
code: `
import { Something, SomethingElse } from './somewhere-else';
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [
SomethingElse,
],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
errors:[
{
messageId: Message.WRAPPER_IMPORTS_BASE,
},
],
output: `
import { Something, SomethingElse } from './somewhere-else';
@Component({
selector: 'ds-test-themable',
standalone: true,
imports: [TestThemeableComponent],
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
},
{
name: 'Override component must be standalone',
filename: fixture('src/themes/test/app/test/test-themeable.component.ts'),
code: `
@Component({
selector: 'ds-themed-test-themable',
})
class Override extends BaseComponent {
}
`,
errors:[
{
messageId: Message.NOT_STANDALONE,
},
],
output: `
@Component({
selector: 'ds-themed-test-themable',
standalone: true,
})
class Override extends BaseComponent {
}
`,
},
],
};

View File

@@ -0,0 +1,257 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import {
ESLintUtils,
TSESLint,
TSESTree,
} from '@typescript-eslint/utils';
import { fixture } from '../../../test/fixture';
import { getComponentSelectorNode } from '../../util/angular';
import { stringLiteral } from '../../util/misc';
import { DSpaceESLintRuleInfo } from '../../util/structure';
import {
inThemedComponentOverrideFile,
isThemeableComponent,
isThemedComponentWrapper,
} from '../../util/theme-support';
import { getFilename } from '../../util/typescript';
export enum Message {
BASE = 'wrongSelectorUnthemedComponent',
WRAPPER = 'wrongSelectorThemedComponentWrapper',
THEMED = 'wrongSelectorThemedComponentOverride',
}
export const info = {
name: 'themed-component-selectors',
meta: {
docs: {
description: `Themeable component selectors should follow the DSpace convention
Each themeable component is comprised of a base component, a wrapper component and any number of themed components
- Base components should have a selector starting with \`ds-base-\`
- Themed components should have a selector starting with \`ds-themed-\`
- Wrapper components should have a selector starting with \`ds-\`, but not \`ds-base-\` or \`ds-themed-\`
- This is the regular DSpace selector prefix
- **When making a regular component themeable, its selector prefix should be changed to \`ds-base-\`, and the new wrapper's component should reuse the previous selector**
Unit tests are exempt from this rule, because they may redefine components using the same class name as other themeable components elsewhere in the source.
`,
},
type: 'problem',
schema: [],
fixable: 'code',
messages: {
[Message.BASE]: 'Unthemed version of themeable component should have a selector starting with \'ds-base-\'',
[Message.WRAPPER]: 'Themed component wrapper of themeable component shouldn\'t have a selector starting with \'ds-themed-\'',
[Message.THEMED]: 'Theme override of themeable component should have a selector starting with \'ds-themed-\'',
},
},
defaultOptions: [],
} as DSpaceESLintRuleInfo;
export const rule = ESLintUtils.RuleCreator.withoutDocs({
...info,
create(context: TSESLint.RuleContext<Message, unknown[]>) {
const filename = getFilename(context);
if (filename.endsWith('.spec.ts')) {
return {};
}
function enforceWrapperSelector(selectorNode: TSESTree.StringLiteral) {
if (selectorNode?.value.startsWith('ds-themed-')) {
context.report({
messageId: Message.WRAPPER,
node: selectorNode,
fix(fixer) {
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-themed-', 'ds-')));
},
});
}
}
function enforceBaseSelector(selectorNode: TSESTree.StringLiteral) {
if (!selectorNode?.value.startsWith('ds-base-')) {
context.report({
messageId: Message.BASE,
node: selectorNode,
fix(fixer) {
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-base-')));
},
});
}
}
function enforceThemedSelector(selectorNode: TSESTree.StringLiteral) {
if (!selectorNode?.value.startsWith('ds-themed-')) {
context.report({
messageId: Message.THEMED,
node: selectorNode,
fix(fixer) {
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-themed-')));
},
});
}
}
return {
'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: TSESTree.Decorator) {
const selectorNode = getComponentSelectorNode(node);
if (selectorNode === undefined) {
return;
}
const selector = selectorNode?.value;
const classNode = node.parent as TSESTree.ClassDeclaration;
const className = classNode.id?.name;
if (selector === undefined || className === undefined) {
return;
}
if (isThemedComponentWrapper(node)) {
enforceWrapperSelector(selectorNode);
} else if (inThemedComponentOverrideFile(filename)) {
enforceThemedSelector(selectorNode);
} else if (isThemeableComponent(className)) {
enforceBaseSelector(selectorNode);
}
},
};
},
});
export const tests = {
plugin: info.name,
valid: [
{
name: 'Regular non-themeable component selector',
code: `
@Component({
selector: 'ds-something',
})
class Something {
}
`,
},
{
name: 'Themeable component selector should replace the original version, unthemed version should be changed to ds-base-',
code: `
@Component({
selector: 'ds-base-something',
})
class Something {
}
@Component({
selector: 'ds-something',
})
class ThemedSomething extends ThemedComponent<Something> {
}
@Component({
selector: 'ds-themed-something',
})
class OverrideSomething extends Something {
}
`,
},
{
name: 'Other themed component wrappers should not interfere',
code: `
@Component({
selector: 'ds-something',
})
class Something {
}
@Component({
selector: 'ds-something-else',
})
class ThemedSomethingElse extends ThemedComponent<SomethingElse> {
}
`,
},
],
invalid: [
{
name: 'Wrong selector for base component',
filename: fixture('src/app/test/test-themeable.component.ts'),
code: `
@Component({
selector: 'ds-something',
})
class TestThemeableComponent {
}
`,
errors: [
{
messageId: Message.BASE,
},
],
output: `
@Component({
selector: 'ds-base-something',
})
class TestThemeableComponent {
}
`,
},
{
name: 'Wrong selector for wrapper component',
filename: fixture('src/app/test/themed-test-themeable.component.ts'),
code: `
@Component({
selector: 'ds-themed-something',
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
errors: [
{
messageId: Message.WRAPPER,
},
],
output: `
@Component({
selector: 'ds-something',
})
class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
},
{
name: 'Wrong selector for theme override',
filename: fixture('src/themes/test/app/test/test-themeable.component.ts'),
code: `
@Component({
selector: 'ds-something',
})
class TestThememeableComponent extends BaseComponent {
}
`,
errors: [
{
messageId: Message.THEMED,
},
],
output: `
@Component({
selector: 'ds-themed-something',
})
class TestThememeableComponent extends BaseComponent {
}
`,
},
],
};
export default rule;

View File

@@ -0,0 +1,502 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import {
ESLintUtils,
TSESLint,
TSESTree,
} from '@typescript-eslint/utils';
import { fixture } from '../../../test/fixture';
import {
removeWithCommas,
replaceOrRemoveArrayIdentifier,
} from '../../util/fix';
import { DSpaceESLintRuleInfo } from '../../util/structure';
import {
allThemeableComponents,
DISALLOWED_THEME_SELECTORS,
fixSelectors,
getThemeableComponentByBaseClass,
isAllowedUnthemedUsage,
} from '../../util/theme-support';
import {
findImportSpecifier,
findUsages,
findUsagesByName,
getFilename,
relativePath,
} from '../../util/typescript';
export enum Message {
WRONG_CLASS = 'mustUseThemedWrapperClass',
WRONG_IMPORT = 'mustImportThemedWrapper',
WRONG_SELECTOR = 'mustUseThemedWrapperSelector',
BASE_IN_MODULE = 'baseComponentNotNeededInModule',
}
export const info = {
name: 'themed-component-usages',
meta: {
docs: {
description: `Themeable components should be used via their \`ThemedComponent\` wrapper class
This ensures that custom themes can correctly override _all_ instances of this component.
There are a few exceptions where the base class can still be used:
- Class declaration expressions (otherwise we can't declare, extend or override the class in the first place)
- Angular modules (except for routing modules)
- Angular \`@ViewChild\` decorators
- Type annotations
`,
},
type: 'problem',
schema: [],
fixable: 'code',
messages: {
[Message.WRONG_CLASS]: 'Themeable components should be used via their ThemedComponent wrapper',
[Message.WRONG_IMPORT]: 'Themeable components should be used via their ThemedComponent wrapper',
[Message.WRONG_SELECTOR]: 'Themeable components should be used via their ThemedComponent wrapper',
[Message.BASE_IN_MODULE]: 'Base themeable components shouldn\'t be declared in modules',
},
},
defaultOptions: [],
} as DSpaceESLintRuleInfo;
export const rule = ESLintUtils.RuleCreator.withoutDocs({
...info,
create(context: TSESLint.RuleContext<Message, unknown[]>) {
const filename = getFilename(context);
function handleUnthemedUsagesInTypescript(node: TSESTree.Identifier) {
if (isAllowedUnthemedUsage(node)) {
return;
}
const entry = getThemeableComponentByBaseClass(node.name);
if (entry === undefined) {
// this should never happen
throw new Error(`No such themeable component in registry: '${node.name}'`);
}
context.report({
messageId: Message.WRONG_CLASS,
node: node,
fix(fixer) {
if (node.parent.type === TSESTree.AST_NODE_TYPES.ArrayExpression) {
return replaceOrRemoveArrayIdentifier(context, fixer, node, entry.wrapperClass);
} else {
return fixer.replaceText(node, entry.wrapperClass);
}
},
});
}
function handleThemedSelectorQueriesInTests(node: TSESTree.Literal) {
context.report({
node,
messageId: Message.WRONG_SELECTOR,
fix(fixer){
const newSelector = fixSelectors(node.raw);
return fixer.replaceText(node, newSelector);
},
});
}
function handleUnthemedImportsInTypescript(specifierNode: TSESTree.ImportSpecifier) {
const allUsages = findUsages(context, specifierNode.local);
const badUsages = allUsages.filter(usage => !isAllowedUnthemedUsage(usage));
if (badUsages.length === 0) {
return;
}
const importedNode = specifierNode.imported;
const declarationNode = specifierNode.parent as TSESTree.ImportDeclaration;
const entry = getThemeableComponentByBaseClass(importedNode.name);
if (entry === undefined) {
// this should never happen
throw new Error(`No such themeable component in registry: '${importedNode.name}'`);
}
context.report({
messageId: Message.WRONG_IMPORT,
node: importedNode,
fix(fixer) {
const ops = [];
const wrapperImport = findImportSpecifier(context, entry.wrapperClass);
if (findUsagesByName(context, entry.wrapperClass).length === 0) {
// Wrapper is not present in this file, safe to add import
const newImportLine = `import { ${entry.wrapperClass} } from '${relativePath(filename, entry.wrapperPath)}';`;
if (declarationNode.specifiers.length === 1) {
if (allUsages.length === badUsages.length) {
ops.push(fixer.replaceText(declarationNode, newImportLine));
} else if (wrapperImport === undefined) {
ops.push(fixer.insertTextAfter(declarationNode, '\n' + newImportLine));
}
} else {
ops.push(...removeWithCommas(context, fixer, specifierNode));
if (wrapperImport === undefined) {
ops.push(fixer.insertTextAfter(declarationNode, '\n' + newImportLine));
}
}
} else {
// Wrapper already present in the file, remove import instead
if (allUsages.length === badUsages.length) {
if (declarationNode.specifiers.length === 1) {
// Make sure we remove the newline as well
ops.push(fixer.removeRange([declarationNode.range[0], declarationNode.range[1] + 1]));
} else {
ops.push(...removeWithCommas(context, fixer, specifierNode));
}
}
}
return ops;
},
});
}
// ignore tests and non-routing modules
if (filename.endsWith('.spec.ts')) {
return {
[`CallExpression[callee.object.name = "By"][callee.property.name = "css"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests,
};
} else if (filename.endsWith('.cy.ts')) {
return {
[`CallExpression[callee.object.name = "cy"][callee.property.name = "get"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests,
};
} else if (
filename.match(/(?!src\/themes\/).*(?!routing).module.ts$/)
|| filename.match(/themed-.+\.component\.ts$/)
) {
// do nothing
return {};
} else {
return allThemeableComponents().reduce(
(rules, entry) => {
return {
...rules,
[`:not(:matches(ClassDeclaration, ImportSpecifier)) > Identifier[name = "${entry.baseClass}"]`]: handleUnthemedUsagesInTypescript,
[`ImportSpecifier[imported.name = "${entry.baseClass}"]`]: handleUnthemedImportsInTypescript,
};
}, {},
);
}
},
});
export const tests = {
plugin: info.name,
valid: [
{
name: 'allow wrapper class usages',
code: `
import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component';
const config = {
a: ThemedTestThemeableComponent,
b: ChipsComponent,
}
`,
},
{
name: 'allow base class in class declaration',
code: `
export class TestThemeableComponent {
}
`,
},
{
name: 'allow inheriting from base class',
code: `
import { TestThemeableComponent } from './app/test/test-themeable.component';
export class ThemedAdminSidebarComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
},
{
name: 'allow base class in ViewChild',
code: `
import { TestThemeableComponent } from './app/test/test-themeable.component';
export class Something {
@ViewChild(TestThemeableComponent) test: TestThemeableComponent;
}
`,
},
{
name: 'allow wrapper selectors in test queries',
filename: fixture('src/app/test/test.component.spec.ts'),
code: `
By.css('ds-themeable');
By.css('#test > ds-themeable > #nest');
`,
},
{
name: 'allow wrapper selectors in cypress queries',
filename: fixture('src/app/test/test.component.cy.ts'),
code: `
By.css('ds-themeable');
By.css('#test > ds-themeable > #nest');
`,
},
],
invalid: [
{
name: 'disallow direct usages of base class',
code: `
import { TestThemeableComponent } from './app/test/test-themeable.component';
import { TestComponent } from './app/test/test.component';
const config = {
a: TestThemeableComponent,
b: TestComponent,
}
`,
errors: [
{
messageId: Message.WRONG_IMPORT,
},
{
messageId: Message.WRONG_CLASS,
},
],
output: `
import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component';
import { TestComponent } from './app/test/test.component';
const config = {
a: ThemedTestThemeableComponent,
b: TestComponent,
}
`,
},
{
name: 'disallow direct usages of base class, keep other imports',
code: `
import { Something, TestThemeableComponent } from './app/test/test-themeable.component';
import { TestComponent } from './app/test/test.component';
const config = {
a: TestThemeableComponent,
b: TestComponent,
c: Something,
}
`,
errors: [
{
messageId: Message.WRONG_IMPORT,
},
{
messageId: Message.WRONG_CLASS,
},
],
output: `
import { Something } from './app/test/test-themeable.component';
import { ThemedTestThemeableComponent } from './app/test/themed-test-themeable.component';
import { TestComponent } from './app/test/test.component';
const config = {
a: ThemedTestThemeableComponent,
b: TestComponent,
c: Something,
}
`,
},
{
name: 'handle array replacements correctly',
code: `
const DECLARATIONS = [
Something,
TestThemeableComponent,
Something,
ThemedTestThemeableComponent,
];
`,
errors: [
{
messageId: Message.WRONG_CLASS,
},
],
output: `
const DECLARATIONS = [
Something,
Something,
ThemedTestThemeableComponent,
];
`,
},
{
name: 'disallow override selector in test queries',
filename: fixture('src/app/test/test.component.spec.ts'),
code: `
By.css('ds-themed-themeable');
By.css('#test > ds-themed-themeable > #nest');
`,
errors: [
{
messageId: Message.WRONG_SELECTOR,
},
{
messageId: Message.WRONG_SELECTOR,
},
],
output: `
By.css('ds-themeable');
By.css('#test > ds-themeable > #nest');
`,
},
{
name: 'disallow base selector in test queries',
filename: fixture('src/app/test/test.component.spec.ts'),
code: `
By.css('ds-base-themeable');
By.css('#test > ds-base-themeable > #nest');
`,
errors: [
{
messageId: Message.WRONG_SELECTOR,
},
{
messageId: Message.WRONG_SELECTOR,
},
],
output: `
By.css('ds-themeable');
By.css('#test > ds-themeable > #nest');
`,
},
{
name: 'disallow override selector in cypress queries',
filename: fixture('src/app/test/test.component.cy.ts'),
code: `
cy.get('ds-themed-themeable');
cy.get('#test > ds-themed-themeable > #nest');
`,
errors: [
{
messageId: Message.WRONG_SELECTOR,
},
{
messageId: Message.WRONG_SELECTOR,
},
],
output: `
cy.get('ds-themeable');
cy.get('#test > ds-themeable > #nest');
`,
},
{
name: 'disallow base selector in cypress queries',
filename: fixture('src/app/test/test.component.cy.ts'),
code: `
cy.get('ds-base-themeable');
cy.get('#test > ds-base-themeable > #nest');
`,
errors: [
{
messageId: Message.WRONG_SELECTOR,
},
{
messageId: Message.WRONG_SELECTOR,
},
],
output: `
cy.get('ds-themeable');
cy.get('#test > ds-themeable > #nest');
`,
},
{
name: 'edge case: unable to find usage node through usage token, but import is still flagged and fixed',
filename: fixture('src/themes/test/app/test/other-themeable.component.ts'),
code: `
import { Component } from '@angular/core';
import { Context } from './app/core/shared/context.model';
import { TestThemeableComponent } from '../../../../app/test/test-themeable.component';
@Component({
standalone: true,
imports: [TestThemeableComponent],
})
export class UsageComponent {
}
`,
errors: [
{
messageId: Message.WRONG_IMPORT,
},
{
messageId: Message.WRONG_CLASS,
},
],
output: `
import { Component } from '@angular/core';
import { Context } from './app/core/shared/context.model';
import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component';
@Component({
standalone: true,
imports: [ThemedTestThemeableComponent],
})
export class UsageComponent {
}
`,
},
{
name: 'edge case edge case: both are imported, only wrapper is retained',
filename: fixture('src/themes/test/app/test/other-themeable.component.ts'),
code: `
import { Component } from '@angular/core';
import { Context } from './app/core/shared/context.model';
import { TestThemeableComponent } from '../../../../app/test/test-themeable.component';
import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component';
@Component({
standalone: true,
imports: [TestThemeableComponent, ThemedTestThemeableComponent],
})
export class UsageComponent {
}
`,
errors: [
{
messageId: Message.WRONG_IMPORT,
},
{
messageId: Message.WRONG_CLASS,
},
],
output: `
import { Component } from '@angular/core';
import { Context } from './app/core/shared/context.model';
import { ThemedTestThemeableComponent } from '../../../../app/test/themed-test-themeable.component';
@Component({
standalone: true,
imports: [ThemedTestThemeableComponent],
})
export class UsageComponent {
}
`,
},
],
};
export default rule;

83
lint/src/util/angular.ts Normal file
View File

@@ -0,0 +1,83 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TSESTree } from '@typescript-eslint/utils';
import { getObjectPropertyNodeByName } from './typescript';
export function getComponentSelectorNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.StringLiteral | undefined {
const property = getComponentInitializerNodeByName(componentDecoratorNode, 'selector');
if (property !== undefined) {
// todo: support template literals as well
if (property.type === TSESTree.AST_NODE_TYPES.Literal && typeof property.value === 'string') {
return property as TSESTree.StringLiteral;
}
}
return undefined;
}
export function getComponentStandaloneNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.BooleanLiteral | undefined {
const property = getComponentInitializerNodeByName(componentDecoratorNode, 'standalone');
if (property !== undefined) {
if (property.type === TSESTree.AST_NODE_TYPES.Literal && typeof property.value === 'boolean') {
return property as TSESTree.BooleanLiteral;
}
}
return undefined;
}
export function getComponentImportNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.ArrayExpression | undefined {
const property = getComponentInitializerNodeByName(componentDecoratorNode, 'imports');
if (property !== undefined) {
if (property.type === TSESTree.AST_NODE_TYPES.ArrayExpression) {
return property as TSESTree.ArrayExpression;
}
}
return undefined;
}
export function getComponentClassName(decoratorNode: TSESTree.Decorator): string | undefined {
if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) {
return undefined;
}
if (decoratorNode.parent.id?.type !== TSESTree.AST_NODE_TYPES.Identifier) {
return undefined;
}
return decoratorNode.parent.id.name;
}
export function getComponentSuperClassName(decoratorNode: TSESTree.Decorator): string | undefined {
if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) {
return undefined;
}
if (decoratorNode.parent.superClass?.type !== TSESTree.AST_NODE_TYPES.Identifier) {
return undefined;
}
return decoratorNode.parent.superClass.name;
}
export function getComponentInitializer(componentDecoratorNode: TSESTree.Decorator): TSESTree.ObjectExpression {
return (componentDecoratorNode.expression as TSESTree.CallExpression).arguments[0] as TSESTree.ObjectExpression;
}
export function getComponentInitializerNodeByName(componentDecoratorNode: TSESTree.Decorator, name: string): TSESTree.Node | undefined {
const initializer = getComponentInitializer(componentDecoratorNode);
return getObjectPropertyNodeByName(initializer, name);
}
export function isPartOfViewChild(node: TSESTree.Identifier): boolean {
return (node.parent as any)?.callee?.name === 'ViewChild';
}

125
lint/src/util/fix.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TSESTree } from '@typescript-eslint/utils';
import {
RuleContext,
RuleFix,
RuleFixer,
} from '@typescript-eslint/utils/ts-eslint';
import { getSourceCode } from './typescript';
export function appendObjectProperties(context: RuleContext<any, any>, fixer: RuleFixer, objectNode: TSESTree.ObjectExpression, properties: string[]): RuleFix {
// todo: may not handle empty objects too well
const lastProperty = objectNode.properties[objectNode.properties.length - 1];
const source = getSourceCode(context);
const nextToken = source.getTokenAfter(lastProperty);
// todo: newline & indentation are hardcoded for @Component({})
// todo: we're assuming that we need trailing commas, what if we don't?
const newPart = '\n' + properties.map(p => ` ${p},`).join('\n');
if (nextToken !== null && nextToken.value === ',') {
return fixer.insertTextAfter(nextToken, newPart);
} else {
return fixer.insertTextAfter(lastProperty, ',' + newPart);
}
}
export function appendArrayElement(context: RuleContext<any, any>, fixer: RuleFixer, arrayNode: TSESTree.ArrayExpression, value: string): RuleFix {
const source = getSourceCode(context);
if (arrayNode.elements.length === 0) {
// This is the first element
const openArray = source.getTokenByRangeStart(arrayNode.range[0]);
if (openArray == null) {
throw new Error('Unexpected null token for opening square bracket');
}
// safe to assume the list is single-line
return fixer.insertTextAfter(openArray, `${value}`);
} else {
const lastElement = arrayNode.elements[arrayNode.elements.length - 1];
if (lastElement == null) {
throw new Error('Unexpected null node in array');
}
const nextToken = source.getTokenAfter(lastElement);
// todo: we don't know if the list is chopped or not, so we can't make any assumptions -- may produce output that will be flagged by other rules on the next run!
// todo: we're assuming that we need trailing commas, what if we don't?
if (nextToken !== null && nextToken.value === ',') {
return fixer.insertTextAfter(nextToken, ` ${value},`);
} else {
return fixer.insertTextAfter(lastElement, `, ${value},`);
}
}
}
export function isLast(elementNode: TSESTree.Node): boolean {
if (!elementNode.parent) {
return false;
}
let siblingNodes: (TSESTree.Node | null)[] = [null];
if (elementNode.parent.type === TSESTree.AST_NODE_TYPES.ArrayExpression) {
siblingNodes = elementNode.parent.elements;
} else if (elementNode.parent.type === TSESTree.AST_NODE_TYPES.ImportDeclaration) {
siblingNodes = elementNode.parent.specifiers;
}
return elementNode === siblingNodes[siblingNodes.length - 1];
}
export function removeWithCommas(context: RuleContext<any, any>, fixer: RuleFixer, elementNode: TSESTree.Node): RuleFix[] {
const ops = [];
const source = getSourceCode(context);
let nextToken = source.getTokenAfter(elementNode);
let prevToken = source.getTokenBefore(elementNode);
if (nextToken !== null && prevToken !== null) {
if (nextToken.value === ',') {
nextToken = source.getTokenAfter(nextToken);
if (nextToken !== null) {
ops.push(fixer.removeRange([elementNode.range[0], nextToken.range[0]]));
}
}
if (isLast(elementNode) && prevToken.value === ',') {
prevToken = source.getTokenBefore(prevToken);
if (prevToken !== null) {
ops.push(fixer.removeRange([prevToken.range[1], elementNode.range[1]]));
}
}
} else if (nextToken !== null) {
ops.push(fixer.removeRange([elementNode.range[0], nextToken.range[0]]));
}
return ops;
}
export function replaceOrRemoveArrayIdentifier(context: RuleContext<any, any>, fixer: RuleFixer, identifierNode: TSESTree.Identifier, newValue: string): RuleFix[] {
if (identifierNode.parent.type !== TSESTree.AST_NODE_TYPES.ArrayExpression) {
throw new Error('Parent node is not an array expression!');
}
const array = identifierNode.parent as TSESTree.ArrayExpression;
for (const element of array.elements) {
if (element !== null && element.type === TSESTree.AST_NODE_TYPES.Identifier && element.name === newValue) {
return removeWithCommas(context, fixer, identifierNode);
}
}
return [fixer.replaceText(identifierNode, newValue)];
}

28
lint/src/util/misc.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
export function match(rangeA: number[], rangeB: number[]) {
return rangeA[0] === rangeB[0] && rangeA[1] === rangeB[1];
}
export function stringLiteral(value: string): string {
return `'${value}'`;
}
/**
* Transform Windows-style paths into Unix-style paths
*/
export function toUnixStylePath(path: string): string {
// note: we're assuming that none of the directory/file names contain '\' or '/' characters.
// using these characters in paths is very bad practice in general, so this should be a safe assumption.
if (path.includes('\\')) {
return path.replace(/^[A-Z]:\\/, '/').replaceAll('\\', '/');
}
return path;
}

View File

@@ -0,0 +1,57 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TSESLint } from '@typescript-eslint/utils';
import { RuleTester } from 'eslint';
import { EnumType } from 'typescript';
export type Meta = TSESLint.RuleMetaData<string>;
export type Valid = TSESLint.ValidTestCase<unknown[]> | RuleTester.ValidTestCase;
export type Invalid = TSESLint.InvalidTestCase<string, unknown[]> | RuleTester.InvalidTestCase;
export interface DSpaceESLintRuleInfo {
name: string;
meta: Meta,
defaultOptions: unknown[],
}
export interface NamedTests {
plugin: string;
valid: Valid[];
invalid: Invalid[];
}
export interface RuleExports {
Message: EnumType,
info: DSpaceESLintRuleInfo,
rule: TSESLint.RuleModule<string>,
tests: NamedTests,
default: unknown,
}
export interface PluginExports {
name: string,
language: string,
rules: Record<string, unknown>,
index: RuleExports[],
}
export function bundle(
name: string,
language: string,
index: RuleExports[],
): PluginExports {
return index.reduce((o: PluginExports, i: RuleExports) => {
o.rules[i.info.name] = i.rule;
return o;
}, {
name,
language,
rules: {},
index,
});
}

View File

@@ -0,0 +1,5 @@
[DSpace ESLint plugins](../../../lint/README.md) > <%= plugin.language %> rules
_______
<% rules.forEach(rule => { %>
- [`<%= plugin.name %>/<%= rule.name %>`](./rules/<%= rule.name %>.md)<% if (rule.meta?.docs?.description) {%>: <%= rule.meta.docs.description.split('\n')[0].trim() -%><% }-%>
<% }) %>

View File

@@ -0,0 +1,48 @@
[DSpace ESLint plugins](../../../../lint/README.md) > [<%= plugin.language %> rules](../index.md) > `<%= plugin.name %>/<%= rule.name %>`
_______
<%- rule.meta.docs?.description %>
_______
[Source code](../../../../lint/src/rules/<%- plugin.name.replace('dspace-angular-', '') %>/<%- rule.name %>.ts)
### Examples
<% if (tests.valid) {%>
#### Valid code
<% tests.valid.forEach(test => { %>
##### <%= test.name !== undefined ? test.name : 'UNNAMED' %>
<% if (test.filename) { %>
Filename: `<%- test.filename %>`
<% } %>
```<%- plugin.language.toLowerCase() %>
<%- test.code.trim() %>
```
<% }) %>
<% } %>
<% if (tests.invalid) {%>
#### Invalid code <%= rule.meta.fixable ? ' & automatic fixes' : '' %>
<% tests.invalid.forEach(test => { %>
##### <%= test.name !== undefined ? test.name : 'UNNAMED' %>
<% if (test.filename) { %>
Filename: `<%- test.filename %>`
<% } %>
```<%- plugin.language.toLowerCase() %>
<%- test.code.trim() %>
```
Will produce the following error(s):
```
<% for (const error of test.errors) { -%>
<%- rule.meta.messages[error.messageId] %>
<% } -%>
```
<% if (test.output) { %>
Result of `yarn lint --fix`:
```<%- plugin.language.toLowerCase() %>
<%- test.output.trim() %>
```
<% } %>
<% }) %>
<% } %>

View File

@@ -0,0 +1,265 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { TSESTree } from '@typescript-eslint/utils';
import { readFileSync } from 'fs';
import { basename } from 'path';
import ts, { Identifier } from 'typescript';
import {
getComponentClassName,
isPartOfViewChild,
} from './angular';
import {
isPartOfClassDeclaration,
isPartOfTypeExpression,
} from './typescript';
/**
* Couples a themeable Component to its ThemedComponent wrapper
*/
export interface ThemeableComponentRegistryEntry {
basePath: string;
baseFileName: string,
baseClass: string;
wrapperPath: string;
wrapperFileName: string,
wrapperClass: string;
}
function isAngularComponentDecorator(node: ts.Node) {
if (node.kind === ts.SyntaxKind.Decorator && node.parent.kind === ts.SyntaxKind.ClassDeclaration) {
const decorator = node as ts.Decorator;
if (decorator.expression.kind === ts.SyntaxKind.CallExpression) {
const method = decorator.expression as ts.CallExpression;
if (method.expression.kind === ts.SyntaxKind.Identifier) {
return (method.expression as Identifier).text === 'Component';
}
}
}
return false;
}
function findImportDeclaration(source: ts.SourceFile, identifierName: string): ts.ImportDeclaration | undefined {
return ts.forEachChild(source, (topNode: ts.Node) => {
if (topNode.kind === ts.SyntaxKind.ImportDeclaration) {
const importDeclaration = topNode as ts.ImportDeclaration;
if (importDeclaration.importClause?.namedBindings?.kind === ts.SyntaxKind.NamedImports) {
const namedImports = importDeclaration.importClause?.namedBindings as ts.NamedImports;
for (const element of namedImports.elements) {
if (element.name.text === identifierName) {
return importDeclaration;
}
}
}
}
return undefined;
});
}
/**
* Listing of all themeable Components
*/
class ThemeableComponentRegistry {
public readonly entries: Set<ThemeableComponentRegistryEntry>;
public readonly byBaseClass: Map<string, ThemeableComponentRegistryEntry>;
public readonly byWrapperClass: Map<string, ThemeableComponentRegistryEntry>;
public readonly byBasePath: Map<string, ThemeableComponentRegistryEntry>;
public readonly byWrapperPath: Map<string, ThemeableComponentRegistryEntry>;
constructor() {
this.entries = new Set();
this.byBaseClass = new Map();
this.byWrapperClass = new Map();
this.byBasePath = new Map();
this.byWrapperPath = new Map();
}
public initialize(prefix = '') {
if (this.entries.size > 0) {
return;
}
function registerWrapper(path: string) {
const source = getSource(path);
function traverse(node: ts.Node) {
if (node.parent !== undefined && isAngularComponentDecorator(node)) {
const classNode = node.parent as ts.ClassDeclaration;
if (classNode.name === undefined || classNode.heritageClauses === undefined) {
return;
}
const wrapperClass = classNode.name?.escapedText as string;
for (const heritageClause of classNode.heritageClauses) {
for (const type of heritageClause.types) {
if ((type as any).expression.escapedText === 'ThemedComponent') {
if (type.kind !== ts.SyntaxKind.ExpressionWithTypeArguments || type.typeArguments === undefined) {
continue;
}
const firstTypeArg = type.typeArguments[0] as ts.TypeReferenceNode;
const baseClass = (firstTypeArg.typeName as ts.Identifier)?.escapedText;
if (baseClass === undefined) {
continue;
}
const importDeclaration = findImportDeclaration(source, baseClass);
if (importDeclaration === undefined) {
continue;
}
const basePath = resolveLocalPath((importDeclaration.moduleSpecifier as ts.StringLiteral).text, path);
themeableComponents.add({
baseClass,
basePath: basePath.replace(new RegExp(`^${prefix}`), ''),
baseFileName: basename(basePath).replace(/\.ts$/, ''),
wrapperClass,
wrapperPath: path.replace(new RegExp(`^${prefix}`), ''),
wrapperFileName: basename(path).replace(/\.ts$/, ''),
});
}
}
}
return;
} else {
ts.forEachChild(node, traverse);
}
}
traverse(source);
}
const glob = require('glob');
// note: this outputs Unix-style paths on Windows
const wrappers: string[] = glob.GlobSync(prefix + 'src/app/**/themed-*.component.ts', { ignore: 'node_modules/**' }).found;
for (const wrapper of wrappers) {
registerWrapper(wrapper);
}
}
private add(entry: ThemeableComponentRegistryEntry) {
this.entries.add(entry);
this.byBaseClass.set(entry.baseClass, entry);
this.byWrapperClass.set(entry.wrapperClass, entry);
this.byBasePath.set(entry.basePath, entry);
this.byWrapperPath.set(entry.wrapperPath, entry);
}
}
export const themeableComponents = new ThemeableComponentRegistry();
/**
* Construct the AST of a TypeScript source file
* @param file
*/
function getSource(file: string): ts.SourceFile {
return ts.createSourceFile(
file,
readFileSync(file).toString(),
ts.ScriptTarget.ES2020, // todo: actually use tsconfig.json?
/*setParentNodes */ true,
);
}
/**
* Resolve a possibly relative local path into an absolute path starting from the root directory of the project
*/
function resolveLocalPath(path: string, relativeTo: string) {
if (path.startsWith('src/')) {
return path;
} else if (path.startsWith('./')) {
const parts = relativeTo.split('/');
return [
...parts.slice(0, parts.length - 1),
path.replace(/^.\//, ''),
].join('/') + '.ts';
} else {
throw new Error(`Unsupported local path: ${path}`);
}
}
export function isThemedComponentWrapper(decoratorNode: TSESTree.Decorator): boolean {
if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) {
return false;
}
if (decoratorNode.parent.superClass?.type !== TSESTree.AST_NODE_TYPES.Identifier) {
return false;
}
return (decoratorNode.parent.superClass as any)?.name === 'ThemedComponent';
}
export function getBaseComponentClassName(decoratorNode: TSESTree.Decorator): string | undefined {
const wrapperClass = getComponentClassName(decoratorNode);
if (wrapperClass === undefined) {
return;
}
themeableComponents.initialize();
const entry = themeableComponents.byWrapperClass.get(wrapperClass);
if (entry === undefined) {
return undefined;
}
return entry.baseClass;
}
export function isThemeableComponent(className: string): boolean {
themeableComponents.initialize();
return themeableComponents.byBaseClass.has(className);
}
export function inThemedComponentOverrideFile(filename: string): boolean {
const match = filename.match(/src\/themes\/[^\/]+\/(app\/.*)/);
if (!match) {
return false;
}
themeableComponents.initialize();
// todo: this is fragile!
return themeableComponents.byBasePath.has(`src/${match[1]}`);
}
export function allThemeableComponents(): ThemeableComponentRegistryEntry[] {
themeableComponents.initialize();
return [...themeableComponents.entries];
}
export function getThemeableComponentByBaseClass(baseClass: string): ThemeableComponentRegistryEntry | undefined {
themeableComponents.initialize();
return themeableComponents.byBaseClass.get(baseClass);
}
export function isAllowedUnthemedUsage(usageNode: TSESTree.Identifier) {
return isPartOfClassDeclaration(usageNode) || isPartOfTypeExpression(usageNode) || isPartOfViewChild(usageNode);
}
export const DISALLOWED_THEME_SELECTORS = 'ds-(base|themed)-';
export function fixSelectors(text: string): string {
return text.replaceAll(/ds-(base|themed)-/g, 'ds-');
}

154
lint/src/util/typescript.ts Normal file
View File

@@ -0,0 +1,154 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import {
TSESLint,
TSESTree,
} from '@typescript-eslint/utils';
import {
match,
toUnixStylePath,
} from './misc';
export type AnyRuleContext = TSESLint.RuleContext<string, unknown[]>;
/**
* Return the current filename based on the ESLint rule context as a Unix-style path.
* This is easier for regex and comparisons to glob paths.
*/
export function getFilename(context: AnyRuleContext): string {
// TSESLint claims this is deprecated, but the suggested alternative is undefined (could be a version mismatch between ESLint and TSESlint?)
// eslint-disable-next-line deprecation/deprecation
return toUnixStylePath(context.getFilename());
}
export function getSourceCode(context: AnyRuleContext): TSESLint.SourceCode {
// TSESLint claims this is deprecated, but the suggested alternative is undefined (could be a version mismatch between ESLint and TSESlint?)
// eslint-disable-next-line deprecation/deprecation
return context.getSourceCode();
}
export function getObjectPropertyNodeByName(objectNode: TSESTree.ObjectExpression, propertyName: string): TSESTree.Node | undefined {
for (const propertyNode of objectNode.properties) {
if (
propertyNode.type === TSESTree.AST_NODE_TYPES.Property
&& (
(
propertyNode.key?.type === TSESTree.AST_NODE_TYPES.Identifier
&& propertyNode.key?.name === propertyName
) || (
propertyNode.key?.type === TSESTree.AST_NODE_TYPES.Literal
&& propertyNode.key?.value === propertyName
)
)
) {
return propertyNode.value;
}
}
return undefined;
}
export function findUsages(context: AnyRuleContext, localNode: TSESTree.Identifier): TSESTree.Identifier[] {
const source = getSourceCode(context);
const usages: TSESTree.Identifier[] = [];
for (const token of source.ast.tokens) {
if (token.type === TSESTree.AST_TOKEN_TYPES.Identifier && token.value === localNode.name && !match(token.range, localNode.range)) {
const node = source.getNodeByRangeIndex(token.range[0]);
// todo: in some cases, the resulting node can actually be the whole program (!)
if (node !== null) {
usages.push(node as TSESTree.Identifier);
}
}
}
return usages;
}
export function findUsagesByName(context: AnyRuleContext, identifier: string): TSESTree.Identifier[] {
const source = getSourceCode(context);
const usages: TSESTree.Identifier[] = [];
for (const token of source.ast.tokens) {
if (token.type === TSESTree.AST_TOKEN_TYPES.Identifier && token.value === identifier) {
const node = source.getNodeByRangeIndex(token.range[0]);
// todo: in some cases, the resulting node can actually be the whole program (!)
if (node !== null) {
usages.push(node as TSESTree.Identifier);
}
}
}
return usages;
}
export function isPartOfTypeExpression(node: TSESTree.Identifier): boolean {
return node.parent?.type?.valueOf().startsWith('TSType');
}
export function isPartOfClassDeclaration(node: TSESTree.Identifier): boolean {
return node.parent?.type === TSESTree.AST_NODE_TYPES.ClassDeclaration;
}
function fromSrc(path: string): string {
const m = path.match(/^.*(src\/.+)(\.(ts|json|js)?)$/);
if (m) {
return m[1];
} else {
throw new Error(`Can't infer project-absolute TS/resource path from: ${path}`);
}
}
export function relativePath(thisFile: string, importFile: string): string {
const fromParts = fromSrc(thisFile).split('/');
const toParts = fromSrc(importFile).split('/');
let lastCommon = 0;
for (let i = 0; i < fromParts.length - 1; i++) {
if (fromParts[i] === toParts[i]) {
lastCommon++;
} else {
break;
}
}
const path = toParts.slice(lastCommon, toParts.length).join('/');
const backtrack = fromParts.length - lastCommon - 1;
let prefix: string;
if (backtrack > 0) {
prefix = '../'.repeat(backtrack);
} else {
prefix = './';
}
return prefix + path;
}
export function findImportSpecifier(context: AnyRuleContext, identifier: string): TSESTree.ImportSpecifier | undefined {
const source = getSourceCode(context);
const usages: TSESTree.Identifier[] = [];
for (const token of source.ast.tokens) {
if (token.type === TSESTree.AST_TOKEN_TYPES.Identifier && token.value === identifier) {
const node = source.getNodeByRangeIndex(token.range[0]);
// todo: in some cases, the resulting node can actually be the whole program (!)
if (node && node.parent && node.parent.type === TSESTree.AST_NODE_TYPES.ImportSpecifier) {
return node.parent;
}
}
}
return undefined;
}

View File

@@ -0,0 +1,9 @@
# ESLint testing fixtures
The files in this directory are used for the ESLint testing environment
- Some rules rely on registries that must be built up _before_ the rule is run
- In order to test these registries, the fixture sources contain a few dummy components
- The TypeScript ESLint test runner requires at least one dummy file to exist to run any tests
- By default, [`test.ts`](./src/test.ts) is used. Note that this file is empty; it's only there for the TypeScript configuration, the actual content is injected from the `code` property in the tests.
- To test rules that make assertions based on the path of the file, you'll need to include the `filename` property in the test configuration. Note that it must point to an existing file too!
- The `filename` must be provided as `fixture('src/something.ts')`

View File

@@ -0,0 +1,13 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
export const FIXTURE = 'lint/test/fixture/';
export function fixture(path: string): string {
return FIXTURE + path;
}

View File

@@ -0,0 +1,14 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { ThemedTestThemeableComponent } from './themed-test-themeable.component';
export const ROUTES = [
{
component: ThemedTestThemeableComponent,
},
];

View File

@@ -0,0 +1,16 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Component } from '@angular/core';
@Component({
selector: 'ds-base-test-themeable',
template: '',
standalone: true,
})
export class TestThemeableComponent {
}

View File

@@ -0,0 +1,8 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/

View File

@@ -0,0 +1,8 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/

View File

@@ -0,0 +1,15 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Component } from '@angular/core';
@Component({
selector: 'ds-test',
template: '',
})
export class TestComponent {
}

View File

@@ -0,0 +1,24 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
// @ts-ignore
import { NgModule } from '@angular/core';
import { TestComponent } from './test.component';
import { TestThemeableComponent } from './test-themeable.component';
import { ThemedTestThemeableComponent } from './themed-test-themeable.component';
@NgModule({
declarations: [
TestComponent,
TestThemeableComponent,
ThemedTestThemeableComponent,
],
})
export class TestModule {
}

View File

@@ -0,0 +1,31 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Component } from '@angular/core';
import { ThemedComponent } from '../../../../../../src/app/shared/theme-support/themed.component';
import { TestThemeableComponent } from './test-themeable.component';
@Component({
selector: 'ds-test-themeable',
template: '',
standalone: true,
imports: [TestThemeableComponent],
})
export class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
protected getComponentName(): string {
return '';
}
protected importThemedComponent(themeName: string): Promise<any> {
return Promise.resolve(undefined);
}
protected importUnthemedComponent(): Promise<any> {
return Promise.resolve(undefined);
}
}

View File

@@ -0,0 +1,16 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Component } from '@angular/core';
@Component({
selector: 'ds-themed-test-themeable',
template: '',
})
export class OtherThemeableComponent {
}

View File

@@ -0,0 +1,18 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Component } from '@angular/core';
import { TestThemeableComponent as BaseComponent } from '../../../../app/test/test-themeable.component';
@Component({
selector: 'ds-themed-test-themeable',
template: '',
})
export class TestThemeableComponent extends BaseComponent {
}

View File

@@ -0,0 +1,22 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
// @ts-ignore
import { NgModule } from '@angular/core';
import { OtherThemeableComponent } from './app/test/other-themeable.component';
import { TestThemeableComponent } from './app/test/test-themeable.component';
@NgModule({
declarations: [
TestThemeableComponent,
OtherThemeableComponent,
],
})
export class TestModule {
}

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"include": [
"src/**/*.ts"
],
"exclude": []
}

13
lint/test/helpers.js Normal file
View File

@@ -0,0 +1,13 @@
const SpecReporter = require('jasmine-spec-reporter').SpecReporter;
const StacktraceOption = require('jasmine-spec-reporter').StacktraceOption;
jasmine.getEnv().clearReporters(); // Clear default console reporter for those instead
jasmine.getEnv().addReporter(new SpecReporter({
spec: {
displayErrorMessages: false,
},
summary: {
displayFailed: true,
displayStacktrace: StacktraceOption.PRETTY,
},
}));

26
lint/test/rules.spec.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { default as htmlPlugin } from '../src/rules/html';
import { default as tsPlugin } from '../src/rules/ts';
import {
htmlRuleTester,
tsRuleTester,
} from './testing';
describe('TypeScript rules', () => {
for (const { info, rule, tests } of tsPlugin.index) {
tsRuleTester.run(info.name, rule, tests as any);
}
});
describe('HTML rules', () => {
for (const { info, rule, tests } of htmlPlugin.index) {
htmlRuleTester.run(info.name, rule, tests);
}
});

View File

@@ -0,0 +1,76 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { default as html } from '../src/rules/html';
import { default as ts } from '../src/rules/ts';
describe('plugin structure', () => {
for (const pluginExports of [ts, html]) {
const pluginName = pluginExports.name ?? 'UNNAMED PLUGIN';
describe(pluginName, () => {
it('should have a name', () => {
expect(pluginExports.name).toBeTruthy();
});
it('should have rules', () => {
expect(pluginExports.index).toBeTruthy();
expect(pluginExports.rules).toBeTruthy();
expect(pluginExports.index.length).toBeGreaterThan(0);
});
for (const ruleExports of pluginExports.index) {
const ruleName = ruleExports.info.name ?? 'UNNAMED RULE';
describe(ruleName, () => {
it('should have a name', () => {
expect(ruleExports.info.name).toBeTruthy();
});
it('should be included under the right name in the plugin', () => {
expect(pluginExports.rules[ruleExports.info.name]).toBe(ruleExports.rule);
});
it('should contain metadata', () => {
expect(ruleExports.info).toBeTruthy();
expect(ruleExports.info.name).toBeTruthy();
expect(ruleExports.info.meta).toBeTruthy();
expect(ruleExports.info.defaultOptions).toBeTruthy();
});
it('should contain messages', () => {
expect(ruleExports.Message).toBeTruthy();
expect(ruleExports.info.meta.messages).toBeTruthy();
});
describe('messages', () => {
for (const member of Object.keys(ruleExports.Message)) {
describe(member, () => {
const id = (ruleExports.Message as any)[member];
it('should have a valid ID', () => {
expect(id).toBeTruthy();
});
it('should have valid metadata', () => {
expect(ruleExports.info.meta.messages[id]).toBeTruthy();
});
});
}
});
it('should contain tests', () => {
expect(ruleExports.tests).toBeTruthy();
expect(ruleExports.tests.valid.length).toBeGreaterThan(0);
expect(ruleExports.tests.invalid.length).toBeGreaterThan(0);
});
});
}
});
}
});

53
lint/test/testing.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { RuleTester as TypeScriptRuleTester } from '@typescript-eslint/rule-tester';
import { RuleTester } from 'eslint';
import { themeableComponents } from '../src/util/theme-support';
import {
FIXTURE,
fixture,
} from './fixture';
// Register themed components from test fixture
themeableComponents.initialize(FIXTURE);
TypeScriptRuleTester.itOnly = fit;
TypeScriptRuleTester.itSkip = xit;
export const tsRuleTester = new TypeScriptRuleTester({
parser: '@typescript-eslint/parser',
defaultFilenames: {
ts: fixture('src/test.ts'),
tsx: 'n/a',
},
parserOptions: {
project: fixture('tsconfig.json'),
},
});
class HtmlRuleTester extends RuleTester {
run(name: string, rule: any, tests: { valid: any[], invalid: any[] }) {
super.run(name, rule, {
valid: tests.valid.map((test) => ({
filename: fixture('test.html'),
...test,
})),
invalid: tests.invalid.map((test) => ({
filename: fixture('test.html'),
...test,
})),
});
}
}
export const htmlRuleTester = new HtmlRuleTester({
parser: require.resolve('@angular-eslint/template-parser'),
});

View File

@@ -0,0 +1,24 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { themeableComponents } from '../src/util/theme-support';
describe('theme-support', () => {
describe('themeable component registry', () => {
it('should contain all themeable components from the fixture', () => {
expect(themeableComponents.entries.size).toBe(1);
expect(themeableComponents.byBasePath.size).toBe(1);
expect(themeableComponents.byWrapperPath.size).toBe(1);
expect(themeableComponents.byBaseClass.size).toBe(1);
expect(themeableComponents.byBaseClass.get('TestThemeableComponent')).toBeTruthy();
expect(themeableComponents.byBasePath.get('src/app/test/test-themeable.component.ts')).toBeTruthy();
expect(themeableComponents.byWrapperPath.get('src/app/test/themed-test-themeable.component.ts')).toBeTruthy();
});
});
});

27
lint/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2021",
"lib": [
"es2021"
],
"module": "nodenext",
"moduleResolution": "nodenext",
"noImplicitReturns": true,
"skipLibCheck": true,
"strict": true,
"outDir": "./dist",
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"types": [
"jasmine",
"node"
]
},
"include": [
"**/*.ts",
],
"exclude": [
"dist",
"test/fixture"
]
}

View File

@@ -17,11 +17,16 @@
"build:stats": "ng build --stats-json",
"build:prod": "cross-env NODE_ENV=production yarn run build:ssr",
"build:ssr": "ng build --configuration production && ng run dspace-angular:server:production",
"build:lint": "rimraf 'lint/dist/**/*.js' 'lint/dist/**/*.js.map' && tsc -b lint/tsconfig.json",
"test": "ng test --source-map=true --watch=false --configuration test",
"test:watch": "nodemon --exec \"ng test --source-map=true --watch=true --configuration test\"",
"test:headless": "ng test --source-map=true --watch=false --configuration test --browsers=ChromeHeadless --code-coverage",
"lint": "ng lint",
"lint-fix": "ng lint --fix=true",
"test:lint": "yarn build:lint && yarn test:lint:nobuild",
"test:lint:nobuild": "jasmine --config=lint/jasmine.json",
"lint": "yarn build:lint && yarn lint:nobuild",
"lint:nobuild": "ng lint",
"lint-fix": "yarn build:lint && ng lint --fix=true",
"docs:lint": "ts-node --project ./lint/tsconfig.json ./lint/generate-docs.ts",
"e2e": "cross-env NODE_ENV=production ng e2e",
"clean:dev:config": "rimraf src/assets/config.json",
"clean:coverage": "rimraf coverage",
@@ -40,7 +45,8 @@
"cypress:run": "cypress run",
"env:yaml": "ts-node --project ./tsconfig.ts-node.json scripts/env-to-yaml.ts",
"base-href": "ts-node --project ./tsconfig.ts-node.json scripts/base-href.ts",
"check-circ-deps": "npx madge --exclude '(bitstream|bundle|collection|config-submission-form|eperson|item|version)\\.model\\.ts$' --circular --extensions ts ./"
"check-circ-deps": "npx madge --exclude '(bitstream|bundle|collection|config-submission-form|eperson|item|version)\\.model\\.ts$' --circular --extensions ts ./",
"postinstall": "yarn build:lint || echo 'Skipped DSpace ESLint plugins.'"
},
"browser": {
"fs": false,
@@ -55,28 +61,28 @@
"ts-node": "10.2.1"
},
"dependencies": {
"@angular/animations": "^15.2.8",
"@angular/cdk": "^15.2.8",
"@angular/common": "^15.2.8",
"@angular/compiler": "^15.2.8",
"@angular/core": "^15.2.8",
"@angular/forms": "^15.2.8",
"@angular/localize": "15.2.8",
"@angular/platform-browser": "^15.2.8",
"@angular/platform-browser-dynamic": "^15.2.8",
"@angular/platform-server": "^15.2.8",
"@angular/router": "^15.2.8",
"@angular/animations": "^17.3.4",
"@angular/cdk": "^17.3.4",
"@angular/common": "^17.3.4",
"@angular/compiler": "^17.3.4",
"@angular/core": "^17.3.4",
"@angular/forms": "^17.3.4",
"@angular/localize": "17.3.4",
"@angular/platform-browser": "^17.3.4",
"@angular/platform-browser-dynamic": "^17.3.4",
"@angular/platform-server": "^17.3.4",
"@angular/router": "^17.3.4",
"@angular/ssr": "^17.3.0",
"@babel/runtime": "7.21.0",
"@kolkov/ngx-gallery": "^2.0.1",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.11.3",
"@ng-bootstrap/ng-bootstrap": "^11.0.0",
"@ng-dynamic-forms/core": "^15.0.0",
"@ng-dynamic-forms/ui-ng-bootstrap": "^15.0.0",
"@ngrx/effects": "^15.4.0",
"@ngrx/router-store": "^15.4.0",
"@ngrx/store": "^15.4.0",
"@nguniversal/express-engine": "^15.2.1",
"@ng-dynamic-forms/core": "^16.0.0",
"@ng-dynamic-forms/ui-ng-bootstrap": "^16.0.0",
"@ngrx/effects": "^17.1.1",
"@ngrx/router-store": "^17.1.1",
"@ngrx/store": "^17.1.1",
"@ngx-translate/core": "^14.0.0",
"@nicky-lenaers/ngx-scroll-to": "^14.0.0",
"@types/grecaptcha": "^3.0.4",
@@ -93,8 +99,8 @@
"date-fns": "^2.29.3",
"date-fns-tz": "^1.3.7",
"deepmerge": "^4.3.1",
"ejs": "^3.1.9",
"express": "^4.18.2",
"ejs": "^3.1.10",
"express": "^4.19.2",
"express-rate-limit": "^5.1.3",
"fast-json-patch": "^3.1.1",
"filesize": "^6.1.0",
@@ -110,17 +116,15 @@
"lodash": "^4.17.21",
"lru-cache": "^7.14.1",
"markdown-it": "^13.0.1",
"markdown-it-mathjax3": "^4.3.2",
"mirador": "^3.3.0",
"mirador-dl-plugin": "^0.13.0",
"mirador-share-plugin": "^0.11.0",
"morgan": "^1.10.0",
"ng-mocks": "^14.10.0",
"ng2-file-upload": "1.4.0",
"ng2-file-upload": "5.0.0",
"ng2-nouislider": "^2.0.0",
"ngx-infinite-scroll": "^15.0.0",
"ngx-infinite-scroll": "^16.0.0",
"ngx-pagination": "6.0.3",
"ngx-sortablejs": "^11.1.0",
"ngx-ui-switch": "^14.1.0",
"nouislider": "^15.7.1",
"pem": "1.14.7",
@@ -128,28 +132,28 @@
"react-copy-to-clipboard": "^5.1.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0",
"sanitize-html": "^2.10.0",
"sanitize-html": "^2.12.1",
"sortablejs": "1.15.0",
"uuid": "^8.3.2",
"webfontloader": "1.6.28",
"zone.js": "~0.11.5"
"zone.js": "~0.14.4"
},
"devDependencies": {
"@angular-builders/custom-webpack": "~15.0.0",
"@angular-devkit/build-angular": "^15.2.6",
"@angular-eslint/builder": "15.2.1",
"@angular-eslint/eslint-plugin": "15.2.1",
"@angular-eslint/eslint-plugin-template": "15.2.1",
"@angular-eslint/schematics": "15.2.1",
"@angular-eslint/template-parser": "15.2.1",
"@angular/cli": "^15.2.6",
"@angular/compiler-cli": "^15.2.8",
"@angular/language-service": "^15.2.8",
"@angular-builders/custom-webpack": "~17.0.1",
"@angular-devkit/build-angular": "^17.3.0",
"@angular-eslint/builder": "17.2.1",
"@angular-eslint/bundled-angular-compiler": "17.2.1",
"@angular-eslint/eslint-plugin": "17.2.1",
"@angular-eslint/eslint-plugin-template": "17.2.1",
"@angular-eslint/schematics": "17.2.1",
"@angular-eslint/template-parser": "17.2.1",
"@angular/cli": "^17.3.0",
"@angular/compiler-cli": "^17.3.4",
"@angular/language-service": "^17.3.4",
"@cypress/schematic": "^1.5.0",
"@fortawesome/fontawesome-free": "^6.4.0",
"@ngrx/store-devtools": "^15.4.0",
"@ngtools/webpack": "^15.2.6",
"@nguniversal/builders": "^15.2.1",
"@ngrx/store-devtools": "^17.1.1",
"@ngtools/webpack": "^16.2.12",
"@types/deep-freeze": "0.1.2",
"@types/ejs": "^3.1.2",
"@types/express": "^4.17.17",
@@ -158,9 +162,12 @@
"@types/lodash": "^4.14.194",
"@types/node": "^14.14.9",
"@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.59.1",
"@typescript-eslint/parser": "^5.59.1",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@typescript-eslint/rule-tester": "^7.2.0",
"@typescript-eslint/utils": "^7.2.0",
"axe-core": "^4.7.2",
"browser-sync": "^3.0.0",
"compression-webpack-plugin": "^9.2.0",
"copy-webpack-plugin": "^6.4.1",
"cross-env": "^7.0.3",
@@ -169,12 +176,18 @@
"deep-freeze": "0.0.1",
"eslint": "^8.39.0",
"eslint-plugin-deprecation": "^1.4.1",
"eslint-plugin-dspace-angular-html": "link:./lint/dist/src/rules/html",
"eslint-plugin-dspace-angular-ts": "link:./lint/dist/src/rules/ts",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsdoc": "^39.6.4",
"eslint-plugin-import-newlines": "^1.3.1",
"eslint-plugin-jsdoc": "^45.0.0",
"eslint-plugin-jsonc": "^2.6.0",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-unused-imports": "^2.0.0",
"express-static-gzip": "^2.1.7",
"jasmine": "^3.8.0",
"jasmine-core": "^3.8.0",
"jasmine-marbles": "0.9.2",
"karma": "^6.4.2",
@@ -183,7 +196,7 @@
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"karma-mocha-reporter": "2.2.5",
"ngx-mask": "^13.1.7",
"ngx-mask": "14.2.4",
"nodemon": "^2.0.22",
"postcss": "^8.4",
"postcss-apply": "0.12.0",
@@ -199,7 +212,7 @@
"sass-loader": "^12.6.0",
"sass-resources-loader": "^2.2.5",
"ts-node": "^8.10.2",
"typescript": "~4.8.4",
"typescript": "~5.3.3",
"webpack": "5.76.1",
"webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^4.2.0",

View File

@@ -275,9 +275,11 @@ function readFileIfExists(pathToFile) {
try {
return fs.readFileSync(pathToFile, 'utf8');
} catch (e) {
if (e instanceof Error) {
console.error('Error:', e.stack);
}
}
}
return null;
}

137
server.ts
View File

@@ -17,7 +17,6 @@
import 'zone.js/node';
import 'reflect-metadata';
import 'rxjs';
/* eslint-disable import/no-namespace */
import * as morgan from 'morgan';
@@ -39,23 +38,26 @@ import { join } from 'path';
import { enableProdMode } from '@angular/core';
import { ngExpressEngine } from '@nguniversal/express-engine';
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import { environment } from './src/environments/environment';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { hasNoValue, hasValue } from './src/app/shared/empty.util';
import { hasValue } from './src/app/shared/empty.util';
import { UIServerConfig } from './src/config/ui-server-config.interface';
import { ServerAppModule } from './src/main.server';
import bootstrap from './src/main.server';
import { buildAppConfig } from './src/config/config.server';
import { APP_CONFIG, AppConfig } from './src/config/app-config.interface';
import {
APP_CONFIG,
AppConfig,
} from './src/config/app-config.interface';
import { extendEnvironmentWithAppConfig } from './src/config/config.util';
import { logStartupMessage } from './startup-message';
import { TOKENITEM } from './src/app/core/auth/models/auth-token-info.model';
import { CommonEngine } from '@angular/ssr';
import { APP_BASE_HREF } from '@angular/common';
import {
REQUEST,
RESPONSE,
} from './src/express.tokens';
/*
* Set path for the browser application's dist folder
@@ -127,27 +129,6 @@ export function app() {
*/
server.use(json());
// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
server.engine('html', (_, options, callback) =>
ngExpressEngine({
bootstrap: ServerAppModule,
providers: [
{
provide: REQUEST,
useValue: (options as any).req,
},
{
provide: RESPONSE,
useValue: (options as any).req.res,
},
{
provide: APP_CONFIG,
useValue: environment
}
]
})(_, (options as any), callback)
);
server.engine('ejs', ejs.renderFile);
/*
@@ -162,7 +143,7 @@ export function app() {
server.get('/robots.txt', (req, res) => {
res.setHeader('content-type', 'text/plain');
res.render('assets/robots.txt.ejs', {
'origin': req.protocol + '://' + req.headers.host
'origin': req.protocol + '://' + req.headers.host,
});
});
@@ -177,7 +158,7 @@ export function app() {
router.use('/sitemap**', createProxyMiddleware({
target: `${environment.rest.baseUrl}/sitemaps`,
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
changeOrigin: true
changeOrigin: true,
}));
/**
@@ -186,7 +167,7 @@ export function app() {
router.use('/signposting**', createProxyMiddleware({
target: `${environment.rest.baseUrl}`,
pathRewrite: path => path.replace(environment.ui.nameSpace, '/'),
changeOrigin: true
changeOrigin: true,
}));
/**
@@ -197,7 +178,7 @@ export function app() {
const RateLimit = require('express-rate-limit');
const limiter = new RateLimit({
windowMs: (environment.ui as UIServerConfig).rateLimiter.windowMs,
max: (environment.ui as UIServerConfig).rateLimiter.max
max: (environment.ui as UIServerConfig).rateLimiter.max,
});
server.use(limiter);
}
@@ -236,10 +217,10 @@ export function app() {
/*
* The callback function to serve server side angular
*/
function ngApp(req, res) {
if (environment.universal.preboot) {
function ngApp(req, res, next) {
if (environment.ssr.enabled) {
// Render the page to user via SSR (server side rendering)
serverSideRender(req, res);
serverSideRender(req, res, next);
} else {
// If preboot is disabled, just serve the client
console.log('Universal off, serving for direct client-side rendering (CSR)');
@@ -252,30 +233,50 @@ function ngApp(req, res) {
* returned to the user.
* @param req current request
* @param res current response
* @param next the next function
* @param sendToUser if true (default), send the rendered content to the user.
* If false, then only save this rendered content to the in-memory cache (to refresh cache).
*/
function serverSideRender(req, res, sendToUser: boolean = true) {
function serverSideRender(req, res, next, sendToUser: boolean = true) {
const { protocol, originalUrl, baseUrl, headers } = req;
const commonEngine = new CommonEngine({ enablePerformanceProfiler: environment.ssr.enablePerformanceProfiler });
// Render the page via SSR (server side rendering)
res.render(indexHtml, {
req,
res,
preboot: environment.universal.preboot,
async: environment.universal.async,
time: environment.universal.time,
baseUrl: environment.ui.nameSpace,
originUrl: environment.ui.baseUrl,
requestUrl: req.originalUrl,
}, (err, data) => {
if (hasNoValue(err) && hasValue(data)) {
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
inlineCriticalCss: environment.ssr.inlineCriticalCss,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: DIST_FOLDER,
providers: [
{ provide: APP_BASE_HREF, useValue: baseUrl },
{
provide: REQUEST,
useValue: req,
},
{
provide: RESPONSE,
useValue: res,
},
{
provide: APP_CONFIG,
useValue: environment,
},
],
})
.then((html) => {
if (hasValue(html)) {
// save server side rendered page to cache (if any are enabled)
saveToCache(req, data);
saveToCache(req, html);
if (sendToUser) {
res.locals.ssr = true; // mark response as SSR (enables text compression)
// send rendered page to user
res.send(data);
res.send(html);
}
} else if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
}
})
.catch((err) => {
if (hasValue(err) && err.code === 'ERR_HTTP_HEADERS_SENT') {
// When this error occurs we can't fall back to CSR because the response has already been
// sent. These errors occur for various reasons in universal, not all of which are in our
// control to solve.
@@ -290,6 +291,7 @@ function serverSideRender(req, res, sendToUser: boolean = true) {
clientSideRender(req, res);
}
}
next(err);
});
}
@@ -325,7 +327,7 @@ function initCache() {
botCache = new LRU( {
max: environment.cache.serverSide.botCache.max,
ttl: environment.cache.serverSide.botCache.timeToLive,
allowStale: environment.cache.serverSide.botCache.allowStale
allowStale: environment.cache.serverSide.botCache.allowStale,
});
}
@@ -337,7 +339,7 @@ function initCache() {
anonymousCache = new LRU( {
max: environment.cache.serverSide.anonymousCache.max,
ttl: environment.cache.serverSide.anonymousCache.timeToLive,
allowStale: environment.cache.serverSide.anonymousCache.allowStale
allowStale: environment.cache.serverSide.anonymousCache.allowStale,
});
}
}
@@ -348,7 +350,7 @@ function initCache() {
function botCacheEnabled(): boolean {
// Caching is only enabled if SSR is enabled AND
// "max" pages to cache is greater than zero
return environment.universal.preboot && environment.cache.serverSide.botCache.max && (environment.cache.serverSide.botCache.max > 0);
return environment.ssr.enabled && environment.cache.serverSide.botCache.max && (environment.cache.serverSide.botCache.max > 0);
}
/**
@@ -357,7 +359,7 @@ function botCacheEnabled(): boolean {
function anonymousCacheEnabled(): boolean {
// Caching is only enabled if SSR is enabled AND
// "max" pages to cache is greater than zero
return environment.universal.preboot && environment.cache.serverSide.anonymousCache.max && (environment.cache.serverSide.anonymousCache.max > 0);
return environment.ssr.enabled && environment.cache.serverSide.anonymousCache.max && (environment.cache.serverSide.anonymousCache.max > 0);
}
/**
@@ -370,9 +372,9 @@ function cacheCheck(req, res, next) {
// If the bot cache is enabled and this request looks like a bot, check the bot cache for a cached page.
if (botCacheEnabled() && isbot(req.get('user-agent'))) {
cachedCopy = checkCacheForRequest('bot', botCache, req, res);
cachedCopy = checkCacheForRequest('bot', botCache, req, res, next);
} else if (anonymousCacheEnabled() && !isUserAuthenticated(req)) {
cachedCopy = checkCacheForRequest('anonymous', anonymousCache, req, res);
cachedCopy = checkCacheForRequest('anonymous', anonymousCache, req, res, next);
}
// If cached copy exists, return it to the user.
@@ -408,14 +410,15 @@ function cacheCheck(req, res, next) {
* @param cache LRU cache to check
* @param req current request to look for in the cache
* @param res current response
* @param next the next function
* @returns cached copy (if found) or undefined (if not found)
*/
function checkCacheForRequest(cacheName: string, cache: LRU<string, any>, req, res): any {
function checkCacheForRequest(cacheName: string, cache: LRU<string, any>, req, res, next): any {
// Get the cache key for this request
const key = getCacheKey(req);
// Check if this page is in our cache
let cachedCopy = cache.get(key);
const cachedCopy = cache.get(key);
if (cachedCopy) {
if (environment.cache.serverSide.debug) { console.log(`CACHE HIT FOR ${key} in ${cacheName} cache`); }
@@ -426,7 +429,7 @@ function checkCacheForRequest(cacheName: string, cache: LRU<string, any>, req, r
// Update cached copy by rerendering server-side
// NOTE: In this scenario the currently cached copy will be returned to the current user.
// This re-render is peformed behind the scenes to update cached copy for next user.
serverSideRender(req, res, false);
serverSideRender(req, res, next, false);
}
} else {
if (environment.cache.serverSide.debug) { console.log(`CACHE MISS FOR ${key} in ${cacheName} cache.`); }
@@ -529,8 +532,8 @@ function serverStarted() {
function createHttpsServer(keys) {
const listener = createServer({
key: keys.serviceKey,
cert: keys.certificate
}, app).listen(environment.ui.port, environment.ui.host, () => {
cert: keys.certificate,
}, app()).listen(environment.ui.port, environment.ui.host, () => {
serverStarted();
});
@@ -597,7 +600,7 @@ function start() {
if (serviceKey && certificate) {
createHttpsServer({
serviceKey: serviceKey,
certificate: certificate
certificate: certificate,
});
} else {
console.warn('Disabling certificate validation and proceeding with a self-signed certificate. If this is a production server, it is recommended that you configure a valid certificate instead.');
@@ -606,7 +609,7 @@ function start() {
createCertificate({
days: 1,
selfSigned: true
selfSigned: true,
}, (error, keys) => {
createHttpsServer(keys);
});
@@ -627,7 +630,7 @@ function healthCheck(req, res) {
})
.catch((error) => {
res.status(error.response.status).send({
error: error.message
error: error.message,
});
});
}

View File

@@ -0,0 +1,117 @@
import { AbstractControl } from '@angular/forms';
import {
mapToCanActivate,
Route,
} from '@angular/router';
import {
DYNAMIC_ERROR_MESSAGES_MATCHER,
DynamicErrorMessagesMatcher,
} from '@ng-dynamic-forms/core';
import { i18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { GroupAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
import { SiteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import {
EPERSON_PATH,
GROUP_PATH,
} from './access-control-routing-paths';
import { BulkAccessComponent } from './bulk-access/bulk-access.component';
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component';
import { EPersonResolver } from './epeople-registry/eperson-resolver.service';
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
import { GroupPageGuard } from './group-registry/group-page.guard';
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
/**
* Condition for displaying error messages on email form field
*/
export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
(control: AbstractControl, model: any, hasFocus: boolean) => {
return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus);
};
const providers = [
{
provide: DYNAMIC_ERROR_MESSAGES_MATCHER,
useValue: ValidateEmailErrorStateMatcher,
},
];
export const ROUTES: Route[] = [
{
path: EPERSON_PATH,
component: EPeopleRegistryComponent,
resolve: {
breadcrumb: i18nBreadcrumbResolver,
},
providers,
data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' },
canActivate: mapToCanActivate([SiteAdministratorGuard]),
},
{
path: `${EPERSON_PATH}/create`,
component: EPersonFormComponent,
resolve: {
breadcrumb: i18nBreadcrumbResolver,
},
providers,
data: { title: 'admin.access-control.epeople.add.title', breadcrumbKey: 'admin.access-control.epeople.add' },
canActivate: mapToCanActivate([SiteAdministratorGuard]),
},
{
path: `${EPERSON_PATH}/:id/edit`,
component: EPersonFormComponent,
resolve: {
breadcrumb: i18nBreadcrumbResolver,
ePerson: EPersonResolver,
},
providers,
data: { title: 'admin.access-control.epeople.edit.title', breadcrumbKey: 'admin.access-control.epeople.edit' },
canActivate: mapToCanActivate([SiteAdministratorGuard]),
},
{
path: GROUP_PATH,
component: GroupsRegistryComponent,
resolve: {
breadcrumb: i18nBreadcrumbResolver,
},
providers,
data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' },
canActivate: mapToCanActivate([GroupAdministratorGuard]),
},
{
path: `${GROUP_PATH}/create`,
component: GroupFormComponent,
resolve: {
breadcrumb: i18nBreadcrumbResolver,
},
providers,
data: {
title: 'admin.access-control.groups.title.addGroup',
breadcrumbKey: 'admin.access-control.groups.addGroup',
},
canActivate: mapToCanActivate([GroupAdministratorGuard]),
},
{
path: `${GROUP_PATH}/:groupId/edit`,
component: GroupFormComponent,
resolve: {
breadcrumb: i18nBreadcrumbResolver,
},
providers,
data: {
title: 'admin.access-control.groups.title.singleGroup',
breadcrumbKey: 'admin.access-control.groups.singleGroup',
},
canActivate: mapToCanActivate([GroupPageGuard]),
},
{
path: 'bulk-access',
component: BulkAccessComponent,
resolve: {
breadcrumb: i18nBreadcrumbResolver,
},
data: { title: 'admin.access-control.bulk-access.title', breadcrumbKey: 'admin.access-control.bulk-access' },
canActivate: mapToCanActivate([SiteAdministratorGuard]),
},
];

View File

@@ -1,5 +1,5 @@
import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getAccessControlModuleRoute } from '../app-routing-paths';
import { URLCombiner } from '../core/url-combiner/url-combiner';
export const EPERSON_PATH = 'epeople';

View File

@@ -1,94 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
import { EPERSON_PATH, GROUP_PATH } from './access-control-routing-paths';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { GroupPageGuard } from './group-registry/group-page.guard';
import {
GroupAdministratorGuard
} from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
import {
SiteAdministratorGuard
} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { BulkAccessComponent } from './bulk-access/bulk-access.component';
import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component';
import { EPersonResolver } from './epeople-registry/eperson-resolver.service';
@NgModule({
imports: [
RouterModule.forChild([
{
path: EPERSON_PATH,
component: EPeopleRegistryComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver
},
data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' },
canActivate: [SiteAdministratorGuard]
},
{
path: `${EPERSON_PATH}/create`,
component: EPersonFormComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver,
},
data: { title: 'admin.access-control.epeople.add.title', breadcrumbKey: 'admin.access-control.epeople.add' },
canActivate: [SiteAdministratorGuard],
},
{
path: `${EPERSON_PATH}/:id/edit`,
component: EPersonFormComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver,
ePerson: EPersonResolver,
},
data: { title: 'admin.access-control.epeople.edit.title', breadcrumbKey: 'admin.access-control.epeople.edit' },
canActivate: [SiteAdministratorGuard],
},
{
path: GROUP_PATH,
component: GroupsRegistryComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver
},
data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' },
canActivate: [GroupAdministratorGuard]
},
{
path: `${GROUP_PATH}/create`,
component: GroupFormComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver
},
data: { title: 'admin.access-control.groups.title.addGroup', breadcrumbKey: 'admin.access-control.groups.addGroup' },
canActivate: [GroupAdministratorGuard]
},
{
path: `${GROUP_PATH}/:groupId/edit`,
component: GroupFormComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver
},
data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' },
canActivate: [GroupPageGuard]
},
{
path: 'bulk-access',
component: BulkAccessComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver
},
data: { title: 'admin.access-control.bulk-access.title', breadcrumbKey: 'admin.access-control.bulk-access' },
canActivate: [SiteAdministratorGuard]
},
])
]
})
/**
* Routing module for the AccessControl section of the admin sidebar
*/
export class AccessControlRoutingModule {
}

View File

@@ -1,67 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { SharedModule } from '../shared/shared.module';
import { AccessControlRoutingModule } from './access-control-routing.module';
import { EPeopleRegistryComponent } from './epeople-registry/epeople-registry.component';
import { EPersonFormComponent } from './epeople-registry/eperson-form/eperson-form.component';
import { GroupFormComponent } from './group-registry/group-form/group-form.component';
import { MembersListComponent } from './group-registry/group-form/members-list/members-list.component';
import { SubgroupsListComponent } from './group-registry/group-form/subgroup-list/subgroups-list.component';
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
import { FormModule } from '../shared/form/form.module';
import { DYNAMIC_ERROR_MESSAGES_MATCHER, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core';
import { AbstractControl } from '@angular/forms';
import { BulkAccessComponent } from './bulk-access/bulk-access.component';
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
import { BulkAccessBrowseComponent } from './bulk-access/browse/bulk-access-browse.component';
import { BulkAccessSettingsComponent } from './bulk-access/settings/bulk-access-settings.component';
import { SearchModule } from '../shared/search/search.module';
import { AccessControlFormModule } from '../shared/access-control-form-container/access-control-form.module';
/**
* Condition for displaying error messages on email form field
*/
export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
(control: AbstractControl, model: any, hasFocus: boolean) => {
return (control.touched && !hasFocus) || (control.errors?.emailTaken && hasFocus);
};
@NgModule({
imports: [
CommonModule,
SharedModule,
RouterModule,
AccessControlRoutingModule,
FormModule,
NgbAccordionModule,
SearchModule,
AccessControlFormModule,
],
exports: [
MembersListComponent,
],
declarations: [
EPeopleRegistryComponent,
EPersonFormComponent,
GroupsRegistryComponent,
GroupFormComponent,
SubgroupsListComponent,
MembersListComponent,
BulkAccessComponent,
BulkAccessBrowseComponent,
BulkAccessSettingsComponent,
],
providers: [
{
provide: DYNAMIC_ERROR_MESSAGES_MATCHER,
useValue: ValidateEmailErrorStateMatcher
},
]
})
/**
* This module handles all components related to the access control pages
*/
export class AccessControlModule {
}

View File

@@ -23,10 +23,10 @@
<a ngbNavLink>{{'admin.access-control.bulk-access-browse.search.header' | translate}}</a>
<ng-template ngbNavContent>
<div class="mx-n3">
<ds-themed-search [configuration]="'administrativeBulkAccess'"
<ds-search [configuration]="'administrativeBulkAccess'"
[selectable]="true"
[selectionConfig]="{ repeatable: true, listId: listId }"
[showThumbnails]="false"></ds-themed-search>
[showThumbnails]="false"></ds-search>
</div>
</ng-template>
</li>
@@ -37,7 +37,6 @@
<ng-template ngbNavContent>
<ds-pagination
[paginationOptions]="(paginationOptions$ | async)"
[pageInfoState]="(objectsSelected$|async)?.payload.pageInfo"
[collectionSize]="(objectsSelected$|async)?.payload?.totalElements"
[objects]="(objectsSelected$|async)"
[showPaginator]="false"

View File

@@ -1,16 +1,28 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { of } from 'rxjs';
import { NgbAccordionModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import {
ComponentFixture,
TestBed,
waitForAsync,
} from '@angular/core/testing';
import {
NgbAccordionModule,
NgbNavModule,
} from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { BulkAccessBrowseComponent } from './bulk-access-browse.component';
import { buildPaginatedList } from '../../../core/data/paginated-list.model';
import { PageInfo } from '../../../core/shared/page-info.model';
import { getMockThemeService } from '../../../shared/mocks/theme-service.mock';
import { ListableObjectComponentLoaderComponent } from '../../../shared/object-collection/shared/listable-object/listable-object-component-loader.component';
import { SelectableListItemControlComponent } from '../../../shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component';
import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service';
import { SelectableObject } from '../../../shared/object-list/selectable-list/selectable-list.service.spec';
import { PageInfo } from '../../../core/shared/page-info.model';
import { buildPaginatedList } from '../../../core/data/paginated-list.model';
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
import { ThemedSearchComponent } from '../../../shared/search/themed-search.component';
import { ThemeService } from '../../../shared/theme-support/theme.service';
import { BulkAccessBrowseComponent } from './bulk-access-browse.component';
describe('BulkAccessBrowseComponent', () => {
let component: BulkAccessBrowseComponent;
@@ -31,14 +43,28 @@ describe('BulkAccessBrowseComponent', () => {
imports: [
NgbAccordionModule,
NgbNavModule,
TranslateModule.forRoot()
TranslateModule.forRoot(),
BulkAccessBrowseComponent,
],
providers: [
{ provide: SelectableListService, useValue: selectableListService },
{ provide: ThemeService, useValue: getMockThemeService() },
],
declarations: [BulkAccessBrowseComponent],
providers: [ { provide: SelectableListService, useValue: selectableListService }, ],
schemas: [
NO_ERRORS_SCHEMA
]
}).compileComponents();
NO_ERRORS_SCHEMA,
],
})
.overrideComponent(BulkAccessBrowseComponent, {
remove: {
imports: [
PaginationComponent,
ThemedSearchComponent,
SelectableListItemControlComponent,
ListableObjectComponentLoaderComponent,
],
},
})
.compileComponents();
}));
beforeEach(() => {
@@ -72,7 +98,7 @@ describe('BulkAccessBrowseComponent', () => {
'elementsPerPage': 5,
'totalElements': 2,
'totalPages': 1,
'currentPage': 1
'currentPage': 1,
}), [selected1, selected2]);
const rd = createSuccessfulRemoteDataObject(list);

View File

@@ -1,19 +1,48 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import {
AsyncPipe,
NgForOf,
NgIf,
} from '@angular/common';
import {
Component,
Input,
OnDestroy,
OnInit,
} from '@angular/core';
import {
NgbAccordionModule,
NgbNavModule,
} from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { NgxPaginationModule } from 'ngx-pagination';
import {
BehaviorSubject,
Subscription,
} from 'rxjs';
import {
distinctUntilChanged,
map,
} from 'rxjs/operators';
import { BehaviorSubject, Subscription } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component';
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service';
import { SelectableListState } from '../../../shared/object-list/selectable-list/selectable-list.reducer';
import {
buildPaginatedList,
PaginatedList,
} from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data';
import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model';
import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model';
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
import { PageInfo } from '../../../core/shared/page-info.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-configuration.service';
import { hasValue } from '../../../shared/empty.util';
import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model';
import { ListableObjectComponentLoaderComponent } from '../../../shared/object-collection/shared/listable-object/listable-object-component-loader.component';
import { SelectableListItemControlComponent } from '../../../shared/object-collection/shared/selectable-list-item-control/selectable-list-item-control.component';
import { SelectableListState } from '../../../shared/object-list/selectable-list/selectable-list.reducer';
import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service';
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
import { ThemedSearchComponent } from '../../../shared/search/themed-search.component';
import { BrowserOnlyPipe } from '../../../shared/utils/browser-only.pipe';
@Component({
selector: 'ds-bulk-access-browse',
@@ -22,9 +51,24 @@ import { hasValue } from '../../../shared/empty.util';
providers: [
{
provide: SEARCH_CONFIG_SERVICE,
useClass: SearchConfigurationService
}
]
useClass: SearchConfigurationService,
},
],
imports: [
PaginationComponent,
AsyncPipe,
NgbAccordionModule,
TranslateModule,
NgIf,
NgbNavModule,
ThemedSearchComponent,
BrowserOnlyPipe,
NgForOf,
NgxPaginationModule,
SelectableListItemControlComponent,
ListableObjectComponentLoaderComponent,
],
standalone: true,
})
export class BulkAccessBrowseComponent implements OnInit, OnDestroy {
@@ -49,7 +93,7 @@ export class BulkAccessBrowseComponent implements OnInit, OnDestroy {
paginationOptions$: BehaviorSubject<PaginationComponentOptions> = new BehaviorSubject<PaginationComponentOptions>(Object.assign(new PaginationComponentOptions(), {
id: 'bas',
pageSize: 5,
currentPage: 1
currentPage: 1,
}));
/**
@@ -67,20 +111,20 @@ export class BulkAccessBrowseComponent implements OnInit, OnDestroy {
this.subs.push(
this.selectableListService.getSelectableList(this.listId).pipe(
distinctUntilChanged(),
map((list: SelectableListState) => this.generatePaginatedListBySelectedElements(list))
).subscribe(this.objectsSelected$)
map((list: SelectableListState) => this.generatePaginatedListBySelectedElements(list)),
).subscribe(this.objectsSelected$),
);
}
pageNext() {
this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, {
currentPage: this.paginationOptions$.value.currentPage + 1
currentPage: this.paginationOptions$.value.currentPage + 1,
}));
}
pagePrev() {
this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, {
currentPage: this.paginationOptions$.value.currentPage - 1
currentPage: this.paginationOptions$.value.currentPage - 1,
}));
}
@@ -99,12 +143,12 @@ export class BulkAccessBrowseComponent implements OnInit, OnDestroy {
elementsPerPage: this.paginationOptions$.value.pageSize,
totalElements: list?.selection.length,
totalPages: this.calculatePageCount(this.paginationOptions$.value.pageSize, list?.selection.length),
currentPage: this.paginationOptions$.value.currentPage
currentPage: this.paginationOptions$.value.currentPage,
});
if (pageInfo.currentPage > pageInfo.totalPages) {
pageInfo.currentPage = pageInfo.totalPages;
this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, {
currentPage: pageInfo.currentPage
currentPage: pageInfo.currentPage,
}));
}
return createSuccessfulRemoteDataObject(buildPaginatedList(pageInfo, list?.selection || []));

View File

@@ -1,18 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
TestBed,
} from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { of } from 'rxjs';
import { BulkAccessComponent } from './bulk-access.component';
import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service';
import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service';
import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { Process } from '../../process-page/processes/process.model';
import { RouterTestingModule } from '@angular/router/testing';
import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service';
import { getMockThemeService } from '../../shared/mocks/theme-service.mock';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer';
import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { ThemeService } from '../../shared/theme-support/theme.service';
import { BulkAccessComponent } from './bulk-access.component';
import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.component';
describe('BulkAccessComponent', () => {
let component: BulkAccessComponent;
@@ -31,35 +36,35 @@ describe('BulkAccessComponent', () => {
'startDate': {
'year': 2026,
'month': 5,
'day': 31
'day': 31,
},
'endDate': null,
},
'endDate': null
}
],
'state': {
'item': {
'toggleStatus': true,
'accessMode': 'replace'
'accessMode': 'replace',
},
'bitstream': {
'toggleStatus': false,
'accessMode': '',
'changesLimit': '',
'selectedBitstreams': []
}
}
'selectedBitstreams': [],
},
},
};
const mockFile = {
'uuids': [
'1234', '5678'
'1234', '5678',
],
'file': { }
'file': { },
};
const mockSettings: any = jasmine.createSpyObj('AccessControlFormContainerComponent', {
getValue: jasmine.createSpy('getValue'),
reset: jasmine.createSpy('reset')
reset: jasmine.createSpy('reset'),
});
const selection: any[] = [{ indexableObject: { uuid: '1234' } }, { indexableObject: { uuid: '5678' } }];
const selectableListState: SelectableListState = { id: 'test', selection };
@@ -71,15 +76,23 @@ describe('BulkAccessComponent', () => {
await TestBed.configureTestingModule({
imports: [
RouterTestingModule,
TranslateModule.forRoot()
TranslateModule.forRoot(),
BulkAccessComponent,
],
declarations: [ BulkAccessComponent ],
providers: [
{ provide: BulkAccessControlService, useValue: bulkAccessControlServiceMock },
{ provide: NotificationsService, useValue: NotificationsServiceStub },
{ provide: SelectableListService, useValue: selectableListServiceMock }
{ provide: SelectableListService, useValue: selectableListServiceMock },
{ provide: ThemeService, useValue: getMockThemeService() },
],
schemas: [NO_ERRORS_SCHEMA]
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(BulkAccessComponent, {
remove: {
imports: [
BulkAccessSettingsComponent,
],
},
})
.compileComponents();
});

View File

@@ -1,17 +1,34 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import {
Component,
OnInit,
ViewChild,
} from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import {
BehaviorSubject,
Subscription,
} from 'rxjs';
import {
distinctUntilChanged,
map,
} from 'rxjs/operators';
import { BehaviorSubject, Subscription } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.component';
import { BulkAccessControlService } from '../../shared/access-control-form-container/bulk-access-control.service';
import { SelectableListState } from '../../shared/object-list/selectable-list/selectable-list.reducer';
import { SelectableListService } from '../../shared/object-list/selectable-list/selectable-list.service';
import { BulkAccessBrowseComponent } from './browse/bulk-access-browse.component';
import { BulkAccessSettingsComponent } from './settings/bulk-access-settings.component';
@Component({
selector: 'ds-bulk-access',
templateUrl: './bulk-access.component.html',
styleUrls: ['./bulk-access.component.scss']
styleUrls: ['./bulk-access.component.scss'],
imports: [
TranslateModule,
BulkAccessSettingsComponent,
BulkAccessBrowseComponent,
],
standalone: true,
})
export class BulkAccessComponent implements OnInit {
@@ -37,7 +54,7 @@ export class BulkAccessComponent implements OnInit {
constructor(
private bulkAccessControlService: BulkAccessControlService,
private selectableListService: SelectableListService
private selectableListService: SelectableListService,
) {
}
@@ -45,8 +62,8 @@ export class BulkAccessComponent implements OnInit {
this.subs.push(
this.selectableListService.getSelectableList(this.listId).pipe(
distinctUntilChanged(),
map((list: SelectableListState) => this.generateIdListBySelectedElements(list))
).subscribe(this.objectsSelected$)
map((list: SelectableListState) => this.generateIdListBySelectedElements(list)),
).subscribe(this.objectsSelected$),
);
}
@@ -74,12 +91,12 @@ export class BulkAccessComponent implements OnInit {
const { file } = this.bulkAccessControlService.createPayloadFile({
bitstreamAccess,
itemAccess,
state: settings.state
state: settings.state,
});
this.bulkAccessControlService.executeScript(
this.objectsSelected$.value || [],
file
file,
).subscribe();
}

View File

@@ -1,8 +1,13 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
TestBed,
} from '@angular/core/testing';
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { AccessControlFormContainerComponent } from '../../../shared/access-control-form-container/access-control-form-container.component';
import { BulkAccessSettingsComponent } from './bulk-access-settings.component';
import { NO_ERRORS_SCHEMA } from '@angular/core';
describe('BulkAccessSettingsComponent', () => {
let component: BulkAccessSettingsComponent;
@@ -15,36 +20,39 @@ describe('BulkAccessSettingsComponent', () => {
'startDate': {
'year': 2026,
'month': 5,
'day': 31
'day': 31,
},
'endDate': null,
},
'endDate': null
}
],
'state': {
'item': {
'toggleStatus': true,
'accessMode': 'replace'
'accessMode': 'replace',
},
'bitstream': {
'toggleStatus': false,
'accessMode': '',
'changesLimit': '',
'selectedBitstreams': []
}
}
'selectedBitstreams': [],
},
},
};
const mockControl: any = jasmine.createSpyObj('AccessControlFormContainerComponent', {
getFormValue: jasmine.createSpy('getFormValue'),
reset: jasmine.createSpy('reset')
reset: jasmine.createSpy('reset'),
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NgbAccordionModule, TranslateModule.forRoot()],
declarations: [BulkAccessSettingsComponent],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
imports: [NgbAccordionModule, TranslateModule.forRoot(), BulkAccessSettingsComponent],
schemas: [NO_ERRORS_SCHEMA],
})
.overrideComponent(BulkAccessSettingsComponent, {
remove: { imports: [AccessControlFormContainerComponent] },
})
.compileComponents();
});
beforeEach(() => {

View File

@@ -1,13 +1,25 @@
import { Component, ViewChild } from '@angular/core';
import { NgIf } from '@angular/common';
import {
AccessControlFormContainerComponent
} from '../../../shared/access-control-form-container/access-control-form-container.component';
Component,
ViewChild,
} from '@angular/core';
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { AccessControlFormContainerComponent } from '../../../shared/access-control-form-container/access-control-form-container.component';
@Component({
selector: 'ds-bulk-access-settings',
templateUrl: 'bulk-access-settings.component.html',
styleUrls: ['./bulk-access-settings.component.scss'],
exportAs: 'dsBulkSettings'
exportAs: 'dsBulkSettings',
imports: [
NgbAccordionModule,
TranslateModule,
NgIf,
AccessControlFormContainerComponent,
],
standalone: true,
})
export class BulkAccessSettingsComponent {

View File

@@ -1,5 +1,6 @@
/* eslint-disable max-classes-per-file */
import { Action } from '@ngrx/store';
import { EPerson } from '../../core/eperson/models/eperson.model';
import { type } from '../../shared/ngrx/type';

View File

@@ -41,11 +41,10 @@
</div>
</form>
<ds-themed-loading *ngIf="searching$ | async"></ds-themed-loading>
<ds-loading *ngIf="searching$ | async"></ds-loading>
<ds-pagination
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(searching$ | async)"
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && (searching$ | async) !== true"
[paginationOptions]="config"
[pageInfoState]="pageInfoState$"
[collectionSize]="(pageInfoState$ | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true">
@@ -87,7 +86,7 @@
</ds-pagination>
<div *ngIf="(pageInfoState$ | async)?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
<div *ngIf="(pageInfoState$ | async)?.totalElements === 0" class="alert alert-info w-100 mb-2" role="alert">
{{labelPrefix + 'no-items' | translate}}
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More