mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
Merge remote-tracking branch 'upstream/main' into minor-themed-component-fixes_contribute-main
# Conflicts: # src/app/item-page/media-viewer/media-viewer.component.html # src/themes/custom/app/shared/comcol-page-handle/comcol-page-handle.component.ts
This commit is contained in:
@@ -11,7 +11,13 @@
|
||||
"eslint-plugin-jsonc",
|
||||
"eslint-plugin-rxjs",
|
||||
"eslint-plugin-simple-import-sort",
|
||||
"eslint-plugin-import-newlines"
|
||||
"eslint-plugin-import-newlines",
|
||||
"eslint-plugin-jsonc",
|
||||
"dspace-angular-ts",
|
||||
"dspace-angular-html"
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"lint/test/fixture"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
@@ -21,7 +27,8 @@
|
||||
"parserOptions": {
|
||||
"project": [
|
||||
"./tsconfig.json",
|
||||
"./cypress/tsconfig.json"
|
||||
"./cypress/tsconfig.json",
|
||||
"./lint/tsconfig.json"
|
||||
],
|
||||
"createDefaultProgram": true
|
||||
},
|
||||
@@ -38,7 +45,10 @@
|
||||
"error",
|
||||
2,
|
||||
{
|
||||
"SwitchCase": 1
|
||||
"SwitchCase": 1,
|
||||
"ignoredNodes": [
|
||||
"ClassBody.body > PropertyDefinition[decorators.length > 0] > .key"
|
||||
]
|
||||
}
|
||||
],
|
||||
"max-classes-per-file": [
|
||||
@@ -212,6 +222,15 @@
|
||||
"@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",
|
||||
|
||||
@@ -238,7 +257,12 @@
|
||||
"method"
|
||||
],
|
||||
|
||||
"rxjs/no-nested-subscribe": "off" // todo: go over _all_ cases
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -253,7 +277,10 @@
|
||||
"createDefaultProgram": true
|
||||
},
|
||||
"rules": {
|
||||
"prefer-const": "off"
|
||||
"prefer-const": "off",
|
||||
|
||||
// Custom DSpace Angular rules
|
||||
"dspace-angular-ts/themed-component-usages": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -262,7 +289,11 @@
|
||||
],
|
||||
"extends": [
|
||||
"plugin:@angular-eslint/template/recommended"
|
||||
]
|
||||
],
|
||||
"rules": {
|
||||
// Custom DSpace Angular rules
|
||||
"dspace-angular-html/themed-component-usages": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
|
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -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
|
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
@@ -266,6 +266,8 @@
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"src/**/*.ts",
|
||||
"cypress/**/*.ts",
|
||||
"lint/**/*.ts",
|
||||
"src/**/*.html",
|
||||
"src/**/*.json5"
|
||||
]
|
||||
|
@@ -3,31 +3,31 @@ import { testA11y } from 'cypress/support/utils';
|
||||
const page = {
|
||||
openLoginMenu() {
|
||||
// Click the "Log In" dropdown menu in header
|
||||
cy.get('ds-themed-header [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-header [data-test="user-menu"]').click();
|
||||
cy.get('ds-header [data-test="user-menu"]').click();
|
||||
},
|
||||
submitLoginAndPasswordByPressingButton(email, password) {
|
||||
// Enter email
|
||||
cy.get('ds-themed-header [data-test="email"]').type(email);
|
||||
cy.get('ds-header [data-test="email"]').type(email);
|
||||
// Enter password
|
||||
cy.get('ds-themed-header [data-test="password"]').type(password);
|
||||
cy.get('ds-header [data-test="password"]').type(password);
|
||||
// Click login button
|
||||
cy.get('ds-themed-header [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-header [data-test="email"]').type(email);
|
||||
cy.get('ds-themed-header [data-test="password"]').type(password);
|
||||
cy.get('ds-themed-header [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-header [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');
|
||||
@@ -102,10 +102,10 @@ describe('Login Modal', () => {
|
||||
page.openLoginMenu();
|
||||
|
||||
// Registration link should be visible
|
||||
cy.get('ds-themed-header [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-header [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-header [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-header [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');
|
||||
|
||||
|
@@ -1,15 +1,15 @@
|
||||
const page = {
|
||||
fillOutQueryInNavBar(query) {
|
||||
// Click the magnifying glass
|
||||
cy.get('ds-themed-header [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-header [data-test="header-search-box"]').type(query);
|
||||
cy.get('ds-header [data-test="header-search-box"]').type(query);
|
||||
},
|
||||
submitQueryByPressingEnter() {
|
||||
cy.get('ds-themed-header [data-test="header-search-box"]').type('{enter}');
|
||||
cy.get('ds-header [data-test="header-search-box"]').type('{enter}');
|
||||
},
|
||||
submitQueryByPressingIcon() {
|
||||
cy.get('ds-themed-header [data-test="header-search-icon"]').click();
|
||||
cy.get('ds-header [data-test="header-search-icon"]').click();
|
||||
},
|
||||
};
|
||||
|
||||
|
4
docs/lint/html/index.md
Normal file
4
docs/lint/html/index.md
Normal 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
|
110
docs/lint/html/rules/themed-component-usages.md
Normal file
110
docs/lint/html/rules/themed-component-usages.md
Normal 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 & 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
6
docs/lint/ts/index.md
Normal 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
|
257
docs/lint/ts/rules/themed-component-classes.md
Normal file
257
docs/lint/ts/rules/themed-component-classes.md
Normal 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 & 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 {
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
156
docs/lint/ts/rules/themed-component-selectors.md
Normal file
156
docs/lint/ts/rules/themed-component-selectors.md
Normal 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 & 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 {
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
332
docs/lint/ts/rules/themed-component-usages.md
Normal file
332
docs/lint/ts/rules/themed-component-usages.md
Normal 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 & 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 {
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
3
lint/.gitignore
vendored
Normal file
3
lint/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/dist/
|
||||
/coverage/
|
||||
/node-modules/
|
50
lint/README.md
Normal file
50
lint/README.md
Normal 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
6
lint/dist/src/rules/html/package.json
vendored
Normal 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
6
lint/dist/src/rules/ts/package.json
vendored
Normal 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
85
lint/generate-docs.ts
Normal 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
7
lint/jasmine.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"spec_files": ["**/*.spec.js"],
|
||||
"spec_dir": "lint/dist/test",
|
||||
"helpers": [
|
||||
"./test/helpers.js"
|
||||
]
|
||||
}
|
22
lint/src/rules/html/index.ts
Normal file
22
lint/src/rules/html/index.ts
Normal 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),
|
||||
};
|
191
lint/src/rules/html/themed-component-usages.ts
Normal file
191
lint/src/rules/html/themed-component-usages.ts
Normal 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;
|
25
lint/src/rules/ts/index.ts
Normal file
25
lint/src/rules/ts/index.ts
Normal 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),
|
||||
};
|
382
lint/src/rules/ts/themed-component-classes.ts
Normal file
382
lint/src/rules/ts/themed-component-classes.ts
Normal 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 {
|
||||
}
|
||||
`,
|
||||
},
|
||||
],
|
||||
};
|
257
lint/src/rules/ts/themed-component-selectors.ts
Normal file
257
lint/src/rules/ts/themed-component-selectors.ts
Normal 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;
|
502
lint/src/rules/ts/themed-component-usages.ts
Normal file
502
lint/src/rules/ts/themed-component-usages.ts
Normal 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
83
lint/src/util/angular.ts
Normal 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
125
lint/src/util/fix.ts
Normal 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
28
lint/src/util/misc.ts
Normal 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;
|
||||
}
|
57
lint/src/util/structure.ts
Normal file
57
lint/src/util/structure.ts
Normal 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,
|
||||
});
|
||||
}
|
5
lint/src/util/templates/index.ejs
Normal file
5
lint/src/util/templates/index.ejs
Normal 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() -%><% }-%>
|
||||
<% }) %>
|
48
lint/src/util/templates/rule.ejs
Normal file
48
lint/src/util/templates/rule.ejs
Normal 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() %>
|
||||
```
|
||||
<% } %>
|
||||
<% }) %>
|
||||
<% } %>
|
265
lint/src/util/theme-support.ts
Normal file
265
lint/src/util/theme-support.ts
Normal 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
154
lint/src/util/typescript.ts
Normal 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;
|
||||
}
|
9
lint/test/fixture/README.md
Normal file
9
lint/test/fixture/README.md
Normal 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')`
|
13
lint/test/fixture/index.ts
Normal file
13
lint/test/fixture/index.ts
Normal 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;
|
||||
}
|
14
lint/test/fixture/src/app/test/test-routing.module.ts
Normal file
14
lint/test/fixture/src/app/test/test-routing.module.ts
Normal 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,
|
||||
},
|
||||
];
|
16
lint/test/fixture/src/app/test/test-themeable.component.ts
Normal file
16
lint/test/fixture/src/app/test/test-themeable.component.ts
Normal 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 {
|
||||
}
|
8
lint/test/fixture/src/app/test/test.component.cy.ts
Normal file
8
lint/test/fixture/src/app/test/test.component.cy.ts
Normal 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/
|
||||
*/
|
||||
|
8
lint/test/fixture/src/app/test/test.component.spec.ts
Normal file
8
lint/test/fixture/src/app/test/test.component.spec.ts
Normal 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/
|
||||
*/
|
||||
|
15
lint/test/fixture/src/app/test/test.component.ts
Normal file
15
lint/test/fixture/src/app/test/test.component.ts
Normal 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 {
|
||||
}
|
24
lint/test/fixture/src/app/test/test.module.ts
Normal file
24
lint/test/fixture/src/app/test/test.module.ts
Normal 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 {
|
||||
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
0
lint/test/fixture/src/test.ts
Normal file
0
lint/test/fixture/src/test.ts
Normal 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 {
|
||||
|
||||
}
|
@@ -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 {
|
||||
|
||||
}
|
22
lint/test/fixture/src/themes/test/test.module.ts
Normal file
22
lint/test/fixture/src/themes/test/test.module.ts
Normal 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 {
|
||||
|
||||
}
|
7
lint/test/fixture/tsconfig.json
Normal file
7
lint/test/fixture/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": []
|
||||
}
|
13
lint/test/helpers.js
Normal file
13
lint/test/helpers.js
Normal 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
26
lint/test/rules.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
76
lint/test/structure.spec.ts
Normal file
76
lint/test/structure.spec.ts
Normal 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
53
lint/test/testing.ts
Normal 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'),
|
||||
});
|
24
lint/test/theme-support.spec.ts
Normal file
24
lint/test/theme-support.spec.ts
Normal 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
27
lint/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
22
package.json
22
package.json
@@ -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,
|
||||
@@ -136,6 +142,7 @@
|
||||
"@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",
|
||||
@@ -155,8 +162,10 @@
|
||||
"@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",
|
||||
@@ -167,6 +176,8 @@
|
||||
"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-import-newlines": "^1.3.1",
|
||||
"eslint-plugin-jsdoc": "^45.0.0",
|
||||
@@ -176,6 +187,7 @@
|
||||
"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",
|
||||
|
@@ -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>
|
||||
|
@@ -41,7 +41,7 @@
|
||||
</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) !== true"
|
||||
[paginationOptions]="config"
|
||||
|
@@ -42,12 +42,12 @@
|
||||
</button>
|
||||
</ds-form>
|
||||
|
||||
<ds-themed-loading [showMessage]="false" *ngIf="!formGroup"></ds-themed-loading>
|
||||
<ds-loading [showMessage]="false" *ngIf="!formGroup"></ds-loading>
|
||||
|
||||
<div *ngIf="epersonService.getActiveEPerson() | async">
|
||||
<h2>{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}</h2>
|
||||
|
||||
<ds-themed-loading [showMessage]="false" *ngIf="groups$ | async | dsHasNoValue"></ds-themed-loading>
|
||||
<ds-loading [showMessage]="false" *ngIf="groups$ | async | dsHasNoValue"></ds-loading>
|
||||
|
||||
<ds-pagination
|
||||
*ngIf="(groups$ | async)?.payload?.totalElements > 0"
|
||||
|
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ds-themed-loading *ngIf="loading$ | async"></ds-themed-loading>
|
||||
<ds-loading *ngIf="loading$ | async"></ds-loading>
|
||||
<ds-pagination
|
||||
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && (loading$ | async) !== true"
|
||||
[paginationOptions]="config"
|
||||
|
@@ -21,7 +21,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
|
||||
import { FileDropzoneNoUploaderComponent } from '../../shared/upload/file-dropzone-no-uploader/file-dropzone-no-uploader.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-metadata-import-page',
|
||||
selector: 'ds-base-metadata-import-page',
|
||||
templateUrl: './metadata-import-page.component.html',
|
||||
imports: [
|
||||
TranslateModule,
|
||||
|
@@ -7,9 +7,10 @@ import { MetadataImportPageComponent } from './metadata-import-page.component';
|
||||
* Themed wrapper for {@link MetadataImportPageComponent}.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-metadata-import-page',
|
||||
selector: 'ds-metadata-import-page',
|
||||
templateUrl: '../../shared/theme-support/themed.component.html',
|
||||
standalone: true,
|
||||
imports: [MetadataImportPageComponent],
|
||||
})
|
||||
export class ThemedMetadataImportPageComponent extends ThemedComponent<MetadataImportPageComponent> {
|
||||
protected getComponentName(): string {
|
||||
|
@@ -15,11 +15,11 @@
|
||||
|
||||
|
||||
<div>
|
||||
<ds-themed-search
|
||||
<ds-search
|
||||
[configuration]="selectedSearchConfig$ | async"
|
||||
[showViewModes]="false"
|
||||
[searchEnabled]="false"
|
||||
[context]="context"
|
||||
></ds-themed-search>
|
||||
></ds-search>
|
||||
</div>
|
||||
|
||||
|
@@ -1 +1 @@
|
||||
<ds-themed-configuration-search-page configuration="administrativeView" [context]="context"></ds-themed-configuration-search-page>
|
||||
<ds-configuration-search-page configuration="administrativeView" [context]="context"></ds-configuration-search-page>
|
||||
|
@@ -138,3 +138,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
.browser-firefox-windows {
|
||||
--ds-dark-scrollbar-width: 20px;
|
||||
}
|
||||
}
|
||||
|
@@ -41,7 +41,7 @@ import { ThemeService } from '../../shared/theme-support/theme.service';
|
||||
* Component representing the admin sidebar
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-admin-sidebar',
|
||||
selector: 'ds-base-admin-sidebar',
|
||||
templateUrl: './admin-sidebar.component.html',
|
||||
styleUrls: ['./admin-sidebar.component.scss'],
|
||||
animations: [slideSidebar],
|
||||
|
@@ -11,10 +11,11 @@ import { AdminSidebarComponent } from './admin-sidebar.component';
|
||||
* Themed wrapper for AdminSidebarComponent
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-admin-sidebar',
|
||||
selector: 'ds-admin-sidebar',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../shared/theme-support/themed.component.html',
|
||||
standalone: true,
|
||||
imports: [AdminSidebarComponent],
|
||||
})
|
||||
export class ThemedAdminSidebarComponent extends ThemedComponent<AdminSidebarComponent> {
|
||||
|
||||
|
@@ -1 +1 @@
|
||||
<ds-themed-configuration-search-page configuration="supervision" [context]="context"></ds-themed-configuration-search-page>
|
||||
<ds-configuration-search-page configuration="supervision" [context]="context"></ds-configuration-search-page>
|
||||
|
@@ -15,6 +15,7 @@ import { NotificationsService } from 'src/app/shared/notifications/notifications
|
||||
|
||||
import { DSONameService } from '../../../../../../core/breadcrumbs/dso-name.service';
|
||||
import { RemoteData } from '../../../../../../core/data/remote-data';
|
||||
import { RequestEntryState } from '../../../../../../core/data/request-entry-state.model';
|
||||
import { Group } from '../../../../../../core/eperson/models/group.model';
|
||||
import { SupervisionOrder } from '../../../../../../core/supervision-order/models/supervision-order.model';
|
||||
import { SupervisionOrderDataService } from '../../../../../../core/supervision-order/supervision-order-data.service';
|
||||
@@ -95,7 +96,7 @@ export class SupervisionOrderGroupSelectorComponent {
|
||||
this.supervisionOrderDataService.create(supervisionDataObject, this.itemUUID, this.selectedGroup.uuid, this.selectedOrderType).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
).subscribe((rd: RemoteData<SupervisionOrder>) => {
|
||||
if (rd.state === 'Success') {
|
||||
if (rd.state === RequestEntryState.Success) {
|
||||
this.notificationsService.success(this.translateService.get('supervision-group-selector.notification.create.success.title', { name: this.dsoNameService.getName(this.selectedGroup) }));
|
||||
this.create.emit(rd.payload);
|
||||
this.close();
|
||||
|
@@ -1,3 +1,3 @@
|
||||
<ds-themed-root
|
||||
<ds-root
|
||||
[shouldShowFullscreenLoader]="(isAuthBlocking$ | async) || (isThemeLoading$ | async)"
|
||||
[shouldShowRouteLoader]="isRouteLoading$ | async"></ds-themed-root>
|
||||
[shouldShowRouteLoader]="isRouteLoading$ | async"></ds-root>
|
||||
|
@@ -2,7 +2,7 @@
|
||||
<div class="container" *ngVar="(bitstreamFormatsRD$ | async) as formatsRD">
|
||||
<div class="row" *ngIf="bitstreamRD?.hasSucceeded && formatsRD?.hasSucceeded">
|
||||
<div class="col-md-2">
|
||||
<ds-themed-thumbnail [thumbnail]="bitstreamRD?.payload"></ds-themed-thumbnail>
|
||||
<ds-thumbnail [thumbnail]="bitstreamRD?.payload"></ds-thumbnail>
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
<div class="container">
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<ds-error *ngIf="bitstreamRD?.hasFailed" message="{{'error.bitstream' | translate}}"></ds-error>
|
||||
<ds-themed-loading *ngIf="!bitstreamRD || !formatsRD || bitstreamRD?.isLoading || formatsRD?.isLoading"
|
||||
message="{{'loading.bitstream' | translate}}"></ds-themed-loading>
|
||||
<ds-loading *ngIf="!bitstreamRD || !formatsRD || bitstreamRD?.isLoading || formatsRD?.isLoading"
|
||||
message="{{'loading.bitstream' | translate}}"></ds-loading>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
@@ -80,7 +80,7 @@ import { VarDirective } from '../../shared/utils/var.directive';
|
||||
import { ThemedThumbnailComponent } from '../../thumbnail/themed-thumbnail.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-edit-bitstream-page',
|
||||
selector: 'ds-base-edit-bitstream-page',
|
||||
styleUrls: ['./edit-bitstream-page.component.scss'],
|
||||
templateUrl: './edit-bitstream-page.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
@@ -4,10 +4,11 @@ import { ThemedComponent } from '../../shared/theme-support/themed.component';
|
||||
import { EditBitstreamPageComponent } from './edit-bitstream-page.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-themed-edit-bitstream-page',
|
||||
selector: 'ds-edit-bitstream-page',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../shared/theme-support/themed.component.html',
|
||||
standalone: true,
|
||||
imports: [EditBitstreamPageComponent],
|
||||
})
|
||||
export class ThemedEditBitstreamPageComponent extends ThemedComponent<EditBitstreamPageComponent> {
|
||||
protected getComponentName(): string {
|
||||
|
@@ -18,7 +18,7 @@ import { BreadcrumbsService } from './breadcrumbs.service';
|
||||
* Component representing the breadcrumbs of a page
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-breadcrumbs',
|
||||
selector: 'ds-base-breadcrumbs',
|
||||
templateUrl: './breadcrumbs.component.html',
|
||||
styleUrls: ['./breadcrumbs.component.scss'],
|
||||
standalone: true,
|
||||
|
@@ -7,10 +7,11 @@ import { BreadcrumbsComponent } from './breadcrumbs.component';
|
||||
* Themed wrapper for BreadcrumbsComponent
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-breadcrumbs',
|
||||
selector: 'ds-breadcrumbs',
|
||||
styleUrls: [],
|
||||
templateUrl: '../shared/theme-support/themed.component.html',
|
||||
standalone: true,
|
||||
imports: [BreadcrumbsComponent],
|
||||
})
|
||||
export class ThemedBreadcrumbsComponent extends ThemedComponent<BreadcrumbsComponent> {
|
||||
protected getComponentName(): string {
|
||||
|
@@ -28,7 +28,6 @@ import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.serv
|
||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { BrowseByComponent } from '../../shared/browse-by/browse-by.component';
|
||||
import { ThemedBrowseByComponent } from '../../shared/browse-by/themed-browse-by.component';
|
||||
import { ThemedComcolPageBrowseByComponent } from '../../shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component';
|
||||
import { ComcolPageContentComponent } from '../../shared/comcol/comcol-page-content/comcol-page-content.component';
|
||||
@@ -135,7 +134,6 @@ describe('BrowseByDateComponent', () => {
|
||||
ThemedComcolPageHandleComponent,
|
||||
ComcolPageContentComponent,
|
||||
ThemedComcolPageBrowseByComponent,
|
||||
BrowseByComponent,
|
||||
ThemedLoadingComponent,
|
||||
ThemedBrowseByComponent,
|
||||
],
|
||||
|
@@ -35,7 +35,6 @@ import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.serv
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { BrowseByComponent } from '../../shared/browse-by/browse-by.component';
|
||||
import { ThemedComcolPageBrowseByComponent } from '../../shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component';
|
||||
import { ThemedComcolPageContentComponent } from '../../shared/comcol/comcol-page-content/themed-comcol-page-content.component';
|
||||
import { ThemedComcolPageHandleComponent } from '../../shared/comcol/comcol-page-handle/themed-comcol-page-handle.component';
|
||||
@@ -72,7 +71,6 @@ import {
|
||||
ThemedComcolPageContentComponent,
|
||||
DsoEditMenuComponent,
|
||||
ThemedComcolPageBrowseByComponent,
|
||||
BrowseByComponent,
|
||||
TranslateModule,
|
||||
ThemedLoadingComponent,
|
||||
ThemedBrowseByComponent,
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<section class="comcol-page-browse-section">
|
||||
<div class="browse-by-metadata w-100">
|
||||
<ds-themed-browse-by *ngIf="(loading$ | async) !== true" class="col-xs-12 w-100"
|
||||
<ds-browse-by *ngIf="(loading$ | async) !== true" class="col-xs-12 w-100"
|
||||
title="{{'browse.title' | translate:{
|
||||
field: 'browse.metadata.' + browseId | translate,
|
||||
startsWith: (startsWith)? ('browse.startsWith' | translate: { startsWith: '"' + startsWith + '"' }) : '',
|
||||
@@ -14,8 +14,8 @@
|
||||
[startsWithOptions]="startsWithOptions"
|
||||
(prev)="goPrev()"
|
||||
(next)="goNext()">
|
||||
</ds-themed-browse-by>
|
||||
<ds-themed-loading *ngIf="loading$ | async"
|
||||
message="{{'loading.browse-by-page' | translate}}"></ds-themed-loading>
|
||||
</ds-browse-by>
|
||||
<ds-loading *ngIf="loading$ | async"
|
||||
message="{{'loading.browse-by-page' | translate}}"></ds-loading>
|
||||
</div>
|
||||
</section>
|
||||
|
@@ -45,7 +45,6 @@ import { BrowseEntry } from '../../core/shared/browse-entry.model';
|
||||
import { Context } from '../../core/shared/context.model';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { getFirstSucceededRemoteData } from '../../core/shared/operators';
|
||||
import { BrowseByComponent } from '../../shared/browse-by/browse-by.component';
|
||||
import { ThemedComcolPageBrowseByComponent } from '../../shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component';
|
||||
import { ThemedComcolPageContentComponent } from '../../shared/comcol/comcol-page-content/themed-comcol-page-content.component';
|
||||
import { ThemedComcolPageHandleComponent } from '../../shared/comcol/comcol-page-handle/themed-comcol-page-handle.component';
|
||||
@@ -78,7 +77,6 @@ export const BBM_PAGINATION_ID = 'bbm';
|
||||
ThemedComcolPageContentComponent,
|
||||
DsoEditMenuComponent,
|
||||
ThemedComcolPageBrowseByComponent,
|
||||
BrowseByComponent,
|
||||
TranslateModule,
|
||||
ThemedLoadingComponent,
|
||||
ThemedBrowseByComponent,
|
||||
|
@@ -27,7 +27,6 @@ import { Context } from '../../core/shared/context.model';
|
||||
import { HierarchicalBrowseDefinition } from '../../core/shared/hierarchical-browse-definition.model';
|
||||
import { VocabularyEntryDetail } from '../../core/submission/vocabularies/models/vocabulary-entry-detail.model';
|
||||
import { VocabularyOptions } from '../../core/submission/vocabularies/models/vocabulary-options.model';
|
||||
import { BrowseByComponent } from '../../shared/browse-by/browse-by.component';
|
||||
import { ThemedBrowseByComponent } from '../../shared/browse-by/themed-browse-by.component';
|
||||
import { ThemedComcolPageBrowseByComponent } from '../../shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component';
|
||||
import { ThemedComcolPageContentComponent } from '../../shared/comcol/comcol-page-content/themed-comcol-page-content.component';
|
||||
@@ -55,7 +54,6 @@ import { BrowseByDataType } from '../browse-by-switcher/browse-by-data-type';
|
||||
ThemedComcolPageContentComponent,
|
||||
DsoEditMenuComponent,
|
||||
ThemedComcolPageBrowseByComponent,
|
||||
BrowseByComponent,
|
||||
TranslateModule,
|
||||
ThemedLoadingComponent,
|
||||
ThemedBrowseByComponent,
|
||||
|
@@ -25,7 +25,6 @@ import { ItemDataService } from '../../core/data/item-data.service';
|
||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { BrowseByComponent } from '../../shared/browse-by/browse-by.component';
|
||||
import { ThemedBrowseByComponent } from '../../shared/browse-by/themed-browse-by.component';
|
||||
import { ThemedComcolPageBrowseByComponent } from '../../shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component';
|
||||
import { ComcolPageContentComponent } from '../../shared/comcol/comcol-page-content/comcol-page-content.component';
|
||||
@@ -108,7 +107,6 @@ describe('BrowseByTitleComponent', () => {
|
||||
ComcolPageContentComponent,
|
||||
DsoEditMenuComponent,
|
||||
ThemedComcolPageBrowseByComponent,
|
||||
BrowseByComponent,
|
||||
ThemedLoadingComponent,
|
||||
ThemedBrowseByComponent,
|
||||
] },
|
||||
|
@@ -15,7 +15,6 @@ import {
|
||||
SortDirection,
|
||||
SortOptions,
|
||||
} from '../../core/cache/models/sort-options.model';
|
||||
import { BrowseByComponent } from '../../shared/browse-by/browse-by.component';
|
||||
import { ThemedBrowseByComponent } from '../../shared/browse-by/themed-browse-by.component';
|
||||
import { ThemedComcolPageBrowseByComponent } from '../../shared/comcol/comcol-page-browse-by/themed-comcol-page-browse-by.component';
|
||||
import { ThemedComcolPageContentComponent } from '../../shared/comcol/comcol-page-content/themed-comcol-page-content.component';
|
||||
@@ -47,7 +46,6 @@ import {
|
||||
ThemedComcolPageContentComponent,
|
||||
DsoEditMenuComponent,
|
||||
ThemedComcolPageBrowseByComponent,
|
||||
BrowseByComponent,
|
||||
TranslateModule,
|
||||
ThemedLoadingComponent,
|
||||
ThemedBrowseByComponent,
|
||||
|
@@ -28,14 +28,14 @@
|
||||
<ng-template ngbNavContent>
|
||||
<div class="row mt-2">
|
||||
<div class="col-12 col-lg-6">
|
||||
<ds-themed-search-form id="search-form"
|
||||
<ds-search-form id="search-form"
|
||||
[query]="(searchOptions$ | async)?.query"
|
||||
[scope]="(searchOptions$ | async)?.scope"
|
||||
[currentUrl]="'./'"
|
||||
[inPlaceSearch]="true"
|
||||
[searchPlaceholder]="'collection.edit.item-mapper.search-form.placeholder' | translate"
|
||||
(submitSearch)="performedSearch = true">
|
||||
</ds-themed-search-form>
|
||||
</ds-search-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@@ -17,45 +17,45 @@
|
||||
</ds-comcol-page-logo>
|
||||
|
||||
<!-- Handle -->
|
||||
<ds-themed-comcol-page-handle
|
||||
<ds-comcol-page-handle
|
||||
[content]="collection.handle"
|
||||
[title]="'collection.page.handle'">
|
||||
</ds-themed-comcol-page-handle>
|
||||
</ds-comcol-page-handle>
|
||||
<!-- Introductory text -->
|
||||
<ds-themed-comcol-page-content
|
||||
<ds-comcol-page-content
|
||||
[content]="collection.introductoryText"
|
||||
[hasInnerHtml]="true">
|
||||
</ds-themed-comcol-page-content>
|
||||
</ds-comcol-page-content>
|
||||
<!-- News -->
|
||||
<ds-themed-comcol-page-content
|
||||
<ds-comcol-page-content
|
||||
[content]="collection.sidebarText"
|
||||
[hasInnerHtml]="true"
|
||||
[title]="'collection.page.news'">
|
||||
</ds-themed-comcol-page-content>
|
||||
</ds-comcol-page-content>
|
||||
</header>
|
||||
<ds-dso-edit-menu></ds-dso-edit-menu>
|
||||
</div>
|
||||
<section class="comcol-page-browse-section">
|
||||
<!-- Browse-By Links -->
|
||||
<ds-themed-comcol-page-browse-by
|
||||
<ds-comcol-page-browse-by
|
||||
[id]="collection.id"
|
||||
[contentType]="collection.type">
|
||||
</ds-themed-comcol-page-browse-by>
|
||||
</ds-comcol-page-browse-by>
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
</section>
|
||||
<footer *ngIf="collection.copyrightText" class="border-top my-5 pt-4">
|
||||
<!-- Copyright -->
|
||||
<ds-themed-comcol-page-content
|
||||
<ds-comcol-page-content
|
||||
[content]="collection.copyrightText"
|
||||
[hasInnerHtml]="true">
|
||||
</ds-themed-comcol-page-content>
|
||||
</ds-comcol-page-content>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<ds-error *ngIf="collectionRD?.hasFailed"
|
||||
message="{{'error.collection' | translate}}"></ds-error>
|
||||
<ds-themed-loading *ngIf="collectionRD?.isLoading"
|
||||
message="{{'loading.collection' | translate}}"></ds-themed-loading>
|
||||
<ds-loading *ngIf="collectionRD?.isLoading"
|
||||
message="{{'loading.collection' | translate}}"></ds-loading>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -54,7 +54,7 @@ import { ViewTrackerComponent } from '../statistics/angulartics/dspace/view-trac
|
||||
import { getCollectionPageRoute } from './collection-page-routing-paths';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-collection-page',
|
||||
selector: 'ds-base-collection-page',
|
||||
styleUrls: ['./collection-page.component.scss'],
|
||||
templateUrl: './collection-page.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
@@ -25,7 +25,7 @@
|
||||
<label class="form-check-label"
|
||||
for="externalSourceCheck">{{ 'collection.edit.tabs.source.external' | translate }}</label>
|
||||
</div>
|
||||
<ds-themed-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-themed-loading>
|
||||
<ds-loading *ngIf="!contentSource" [message]="'loading.content-source' | translate"></ds-loading>
|
||||
<h3 *ngIf="contentSource && (contentSource?.harvestType !== harvestTypeNone)">{{ 'collection.edit.tabs.source.form.head' | translate }}</h3>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
@@ -3,10 +3,10 @@
|
||||
<div class="col-12" *ngVar="(itemRD$ | async) as itemRD">
|
||||
<ng-container *ngIf="itemRD?.hasSucceeded">
|
||||
<h1 class="border-bottom">{{ 'collection.edit.template.head' | translate:{ collection: dsoNameService.getName(collection) } }}</h1>
|
||||
<ds-themed-dso-edit-metadata [updateDataService]="itemTemplateService" [dso]="itemRD?.payload"></ds-themed-dso-edit-metadata>
|
||||
<ds-dso-edit-metadata [updateDataService]="itemTemplateService" [dso]="itemRD?.payload"></ds-dso-edit-metadata>
|
||||
<button [routerLink]="getCollectionEditUrl(collection)" class="btn btn-outline-secondary">{{ 'collection.edit.template.cancel' | translate }}</button>
|
||||
</ng-container>
|
||||
<ds-themed-loading *ngIf="itemRD?.isLoading" [message]="'collection.edit.template.loading' | translate"></ds-themed-loading>
|
||||
<ds-loading *ngIf="itemRD?.isLoading" [message]="'collection.edit.template.loading' | translate"></ds-loading>
|
||||
<ds-alert *ngIf="itemRD?.hasFailed" [type]="AlertTypeEnum.Error" [content]="'collection.edit.template.error' | translate"></ds-alert>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -12,7 +12,6 @@ import { of as observableOf } from 'rxjs';
|
||||
|
||||
import { ItemTemplateDataService } from '../../core/data/item-template-data.service';
|
||||
import { Collection } from '../../core/shared/collection.model';
|
||||
import { DsoEditMetadataComponent } from '../../dso-shared/dso-edit-metadata/dso-edit-metadata.component';
|
||||
import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component';
|
||||
import { getMockThemeService } from '../../shared/mocks/theme-service.mock';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
@@ -51,7 +50,7 @@ describe('EditItemTemplatePageComponent', () => {
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).overrideComponent(EditItemTemplatePageComponent, {
|
||||
remove: {
|
||||
imports: [ThemedDsoEditMetadataComponent, DsoEditMetadataComponent],
|
||||
imports: [ThemedDsoEditMetadataComponent],
|
||||
},
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
@@ -24,7 +24,6 @@ import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Collection } from '../../core/shared/collection.model';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators';
|
||||
import { DsoEditMetadataComponent } from '../../dso-shared/dso-edit-metadata/dso-edit-metadata.component';
|
||||
import { ThemedDsoEditMetadataComponent } from '../../dso-shared/dso-edit-metadata/themed-dso-edit-metadata.component';
|
||||
import { AlertComponent } from '../../shared/alert/alert.component';
|
||||
import { AlertType } from '../../shared/alert/alert-type';
|
||||
@@ -33,11 +32,10 @@ import { VarDirective } from '../../shared/utils/var.directive';
|
||||
import { getCollectionEditRoute } from '../collection-page-routing-paths';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-edit-item-template-page',
|
||||
selector: 'ds-base-edit-item-template-page',
|
||||
templateUrl: './edit-item-template-page.component.html',
|
||||
imports: [
|
||||
ThemedDsoEditMetadataComponent,
|
||||
DsoEditMetadataComponent,
|
||||
RouterLink,
|
||||
AsyncPipe,
|
||||
VarDirective,
|
||||
|
@@ -4,10 +4,11 @@ import { ThemedComponent } from '../../shared/theme-support/themed.component';
|
||||
import { EditItemTemplatePageComponent } from './edit-item-template-page.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-themed-edit-item-template-page',
|
||||
selector: 'ds-edit-item-template-page',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../shared/theme-support/themed.component.html',
|
||||
standalone: true,
|
||||
imports: [EditItemTemplatePageComponent],
|
||||
})
|
||||
/**
|
||||
* Component for editing the item template of a collection
|
||||
|
@@ -7,10 +7,11 @@ import { CollectionPageComponent } from './collection-page.component';
|
||||
* Themed wrapper for CollectionPageComponent
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-collection-page',
|
||||
selector: 'ds-collection-page',
|
||||
styleUrls: [],
|
||||
templateUrl: '../shared/theme-support/themed.component.html',
|
||||
standalone: true,
|
||||
imports: [CollectionPageComponent],
|
||||
})
|
||||
export class ThemedCollectionPageComponent extends ThemedComponent<CollectionPageComponent> {
|
||||
protected getComponentName(): string {
|
||||
|
@@ -1,4 +1,4 @@
|
||||
<div class="container">
|
||||
<h1>{{ 'communityList.title' | translate }}</h1>
|
||||
<ds-themed-community-list></ds-themed-community-list>
|
||||
<ds-community-list></ds-community-list>
|
||||
</div>
|
||||
|
@@ -8,7 +8,7 @@ import { ThemedCommunityListComponent } from './community-list/themed-community-
|
||||
* navigated to with community-list.page.routing.module
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-community-list-page',
|
||||
selector: 'ds-base-community-list-page',
|
||||
templateUrl: './community-list-page.component.html',
|
||||
standalone: true,
|
||||
imports: [ThemedCommunityListComponent, TranslateModule],
|
||||
|
@@ -1,4 +1,4 @@
|
||||
<ds-themed-loading *ngIf="(dataSource.loading$ | async) && !loadingNode" class="ds-themed-loading"></ds-themed-loading>
|
||||
<ds-loading *ngIf="(dataSource.loading$ | async) && !loadingNode" class="ds-themed-loading"></ds-loading>
|
||||
<cdk-tree [dataSource]="dataSource" [treeControl]="treeControl" [trackBy]="trackBy">
|
||||
<!-- This is the tree node template for show more node -->
|
||||
<cdk-tree-node *cdkTreeNodeDef="let node; when: isShowMore" cdkTreeNodePadding
|
||||
@@ -12,7 +12,7 @@
|
||||
class="btn btn-outline-primary btn-sm" role="button">
|
||||
<i class="fas fa-angle-down"></i> {{ 'communityList.showMore' | translate }}
|
||||
</button>
|
||||
<ds-themed-loading *ngIf="node===loadingNode && dataSource.loading$ | async" class="ds-themed-loading"></ds-themed-loading>
|
||||
<ds-loading *ngIf="node===loadingNode && dataSource.loading$ | async" class="ds-themed-loading"></ds-loading>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted" cdkTreeNodePadding>
|
||||
@@ -61,7 +61,7 @@
|
||||
<span aria-hidden="true" class="btn btn-default invisible">
|
||||
<span class="fa fa-chevron-right"></span>
|
||||
</span>
|
||||
<ds-themed-loading class="ds-themed-loading"></ds-themed-loading>
|
||||
<ds-loading class="ds-themed-loading"></ds-loading>
|
||||
</div>
|
||||
</cdk-tree-node>
|
||||
<!-- This is the tree node template for leaf nodes (collections and (sub)coms without children) -->
|
||||
|
@@ -38,7 +38,7 @@ import { FlatNode } from '../flat-node.model';
|
||||
* Which nodes were expanded is kept in the store, so this persists across pages.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-community-list',
|
||||
selector: 'ds-base-community-list',
|
||||
templateUrl: './community-list.component.html',
|
||||
styleUrls: ['./community-list.component.scss'],
|
||||
standalone: true,
|
||||
|
@@ -5,10 +5,11 @@ import { CommunityListComponent } from './community-list.component';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'ds-themed-community-list',
|
||||
selector: 'ds-community-list',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../shared/theme-support/themed.component.html',
|
||||
standalone: true,
|
||||
imports: [CommunityListComponent],
|
||||
})
|
||||
export class ThemedCommunityListComponent extends ThemedComponent<CommunityListComponent> {
|
||||
protected getComponentName(): string {
|
||||
|
@@ -7,10 +7,11 @@ import { CommunityListPageComponent } from './community-list-page.component';
|
||||
* Themed wrapper for CommunityListPageComponent
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-themed-community-list-page',
|
||||
selector: 'ds-community-list-page',
|
||||
styleUrls: [],
|
||||
templateUrl: '../shared/theme-support/themed.component.html',
|
||||
standalone: true,
|
||||
imports: [CommunityListPageComponent],
|
||||
})
|
||||
export class ThemedCommunityListPageComponent extends ThemedComponent<CommunityListPageComponent> {
|
||||
protected getComponentName(): string {
|
||||
|
@@ -10,34 +10,34 @@
|
||||
<ds-comcol-page-logo *ngIf="logoRD$" [logo]="(logoRD$ | async)?.payload" [alternateText]="'community.logo' | translate">
|
||||
</ds-comcol-page-logo>
|
||||
<!-- Handle -->
|
||||
<ds-themed-comcol-page-handle [content]="communityPayload.handle" [title]="'community.page.handle'">
|
||||
</ds-themed-comcol-page-handle>
|
||||
<ds-comcol-page-handle [content]="communityPayload.handle" [title]="'community.page.handle'">
|
||||
</ds-comcol-page-handle>
|
||||
<!-- Introductory text -->
|
||||
<ds-themed-comcol-page-content [content]="communityPayload.introductoryText" [hasInnerHtml]="true">
|
||||
</ds-themed-comcol-page-content>
|
||||
<ds-comcol-page-content [content]="communityPayload.introductoryText" [hasInnerHtml]="true">
|
||||
</ds-comcol-page-content>
|
||||
<!-- News -->
|
||||
<ds-themed-comcol-page-content [content]="communityPayload.sidebarText" [hasInnerHtml]="true"
|
||||
<ds-comcol-page-content [content]="communityPayload.sidebarText" [hasInnerHtml]="true"
|
||||
[title]="'community.page.news'">
|
||||
</ds-themed-comcol-page-content>
|
||||
</ds-comcol-page-content>
|
||||
</header>
|
||||
<ds-dso-edit-menu></ds-dso-edit-menu>
|
||||
</div>
|
||||
<section class="comcol-page-browse-section">
|
||||
|
||||
<!-- Browse-By Links -->
|
||||
<ds-themed-comcol-page-browse-by [id]="communityPayload.id" [contentType]="communityPayload.type">
|
||||
</ds-themed-comcol-page-browse-by>
|
||||
<ds-comcol-page-browse-by [id]="communityPayload.id" [contentType]="communityPayload.type">
|
||||
</ds-comcol-page-browse-by>
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
</section>
|
||||
<footer *ngIf="communityPayload.copyrightText" class="border-top my-5 pt-4">
|
||||
<!-- Copyright -->
|
||||
<ds-themed-comcol-page-content [content]="communityPayload.copyrightText" [hasInnerHtml]="true">
|
||||
</ds-themed-comcol-page-content>
|
||||
<ds-comcol-page-content [content]="communityPayload.copyrightText" [hasInnerHtml]="true">
|
||||
</ds-comcol-page-content>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ds-error *ngIf="communityRD?.hasFailed" message="{{'error.community' | translate}}"></ds-error>
|
||||
<ds-themed-loading *ngIf="communityRD?.isLoading" message="{{'loading.community' | translate}}"></ds-themed-loading>
|
||||
<ds-loading *ngIf="communityRD?.isLoading" message="{{'loading.community' | translate}}"></ds-loading>
|
||||
</div>
|
||||
|
@@ -47,7 +47,7 @@ import { ThemedCollectionPageSubCollectionListComponent } from './sections/sub-c
|
||||
import { ThemedCommunityPageSubCommunityListComponent } from './sections/sub-com-col-section/sub-community-list/themed-community-page-sub-community-list.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-community-page',
|
||||
selector: 'ds-base-community-page',
|
||||
styleUrls: ['./community-page.component.scss'],
|
||||
templateUrl: './community-page.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
@@ -9,5 +9,5 @@
|
||||
</ds-viewable-collection>
|
||||
</div>
|
||||
<ds-error *ngIf="subCollectionsRD?.hasFailed" message="{{'error.sub-collections' | translate}}"></ds-error>
|
||||
<ds-themed-loading *ngIf="subCollectionsRD?.isLoading" message="{{'loading.sub-collections' | translate}}"></ds-themed-loading>
|
||||
<ds-loading *ngIf="subCollectionsRD?.isLoading" message="{{'loading.sub-collections' | translate}}"></ds-loading>
|
||||
</ng-container>
|
||||
|
@@ -36,7 +36,7 @@ import { PaginationComponentOptions } from '../../../../shared/pagination/pagina
|
||||
import { VarDirective } from '../../../../shared/utils/var.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-community-page-sub-collection-list',
|
||||
selector: 'ds-base-community-page-sub-collection-list',
|
||||
styleUrls: ['./community-page-sub-collection-list.component.scss'],
|
||||
templateUrl: './community-page-sub-collection-list.component.html',
|
||||
animations: [fadeIn],
|
||||
|
@@ -8,10 +8,11 @@ import { ThemedComponent } from '../../../../shared/theme-support/themed.compone
|
||||
import { CommunityPageSubCollectionListComponent } from './community-page-sub-collection-list.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-themed-community-page-sub-collection-list',
|
||||
selector: 'ds-community-page-sub-collection-list',
|
||||
styleUrls: [],
|
||||
templateUrl: '../../../../shared/theme-support/themed.component.html',
|
||||
standalone: true,
|
||||
imports: [CommunityPageSubCollectionListComponent],
|
||||
})
|
||||
export class ThemedCollectionPageSubCollectionListComponent extends ThemedComponent<CommunityPageSubCollectionListComponent> {
|
||||
@Input() community: Community;
|
||||
|
@@ -1,8 +1,8 @@
|
||||
<ng-container *ngIf="(community$ | async) as community">
|
||||
<ds-themed-community-page-sub-community-list
|
||||
<ds-community-page-sub-community-list
|
||||
[community]="community">
|
||||
</ds-themed-community-page-sub-community-list>
|
||||
<ds-themed-community-page-sub-collection-list
|
||||
</ds-community-page-sub-community-list>
|
||||
<ds-community-page-sub-collection-list
|
||||
[community]="community">
|
||||
</ds-themed-community-page-sub-collection-list>
|
||||
</ds-community-page-sub-collection-list>
|
||||
</ng-container>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user