Custom ESLint rules to enforce new ThemedComponent selector convention

The following cases are covered:
- ThemedComponent wrapper selectors must not start with ds-themed-
- Base component selectors must start with ds-base-
- Themed component selectors must start with ds-themed-
- The ThemedComponent wrapper must always be used in HTML
- The ThemedComponent wrapper must be used in TypeScript _where appropriate_:
  - Required
    - Explicit usages (e.g. modal instantiation, routing modules, ...)
    - By.css selector queries (in order to align with the HTML rule)
  - Unchecked
    - Non-routing modules (to ensure the components can be declared)
    - ViewChild hooks (since they need to attach to the underlying component)

All rules work with --fix to automatically migrate to the new convention
This covers most of the codebase, but minor manual adjustment are needed afterwards
This commit is contained in:
Yury Bondarenko
2024-03-14 10:00:10 +01:00
parent 41eccbbfe1
commit 3937be13f2
35 changed files with 1352 additions and 34 deletions

View File

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

View File

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

View File

@@ -0,0 +1,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-base-test-themeable',
template: '',
})
export class TestThemeableComponent {
}

View File

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

View File

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

View File

@@ -0,0 +1,23 @@
/**
* 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 { TestThemeableComponent } from './test-themeable.component';
import { TestComponent } from './test.component';
import { ThemedTestThemeableComponent } from './themed-test-themeable.component';
@NgModule({
declarations: [
TestComponent,
TestThemeableComponent,
ThemedTestThemeableComponent,
]
})
export class TestModule {
}

View File

@@ -0,0 +1,28 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
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: '',
})
export class ThemedTestThemeableComponent extends ThemedComponent<TestThemeableComponent> {
protected getComponentName(): string {
return '';
}
protected importThemedComponent(themeName: string): Promise<any> {
return Promise.resolve(undefined);
}
protected importUnthemedComponent(): Promise<any> {
return Promise.resolve(undefined);
}
}

View File

View File

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

View File

@@ -0,0 +1,19 @@
/**
* 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 { TestThemeableComponent } from './app/test/test-themeable.component';
@NgModule({
declarations: [
TestThemeableComponent,
]
})
export class TestModule {
}

View File

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

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

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

View File

@@ -0,0 +1,140 @@
/**
* 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 {
fixture,
tsRuleTester,
} from '../testing';
import rule from '../../src/rules/ts/themed-component-selectors';
describe('themed-component-selectors', () => {
tsRuleTester.run('themed-component-selectors', rule as any, {
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: 'wrongSelectorUnthemedComponent',
},
],
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: 'wrongSelectorThemedComponentWrapper',
},
],
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: 'wrongSelectorThemedComponentOverride',
},
],
output: `
@Component({
selector: 'ds-themed-something',
})
class TestThememeableComponent extends BaseComponent {
}
`,
},
],
} as any);
});

View File

@@ -0,0 +1,190 @@
/**
* 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 {
fixture,
htmlRuleTester,
tsRuleTester,
} from '../testing';
import tsRule from '../../src/rules/ts/themed-component-usages';
import htmlRule from '../../src/rules/html/themed-component-usages';
describe('themed-component-usages (TypeScript)', () => {
tsRuleTester.run('themed-component-usages', tsRule as any, {
valid: [
{
code: `
const config = {
a: ThemedTestThemeableComponent,
b: ChipsComponent,
}
`,
},
{
code: `
export class TestThemeableComponent {
}
`,
},
{
code: `
import { TestThemeableComponent } from '../test/test-themeable.component.ts';
export class ThemedAdminSidebarComponent extends ThemedComponent<TestThemeableComponent> {
}
`,
},
{
code: `
import { TestThemeableComponent } from '../test/test-themeable.component.ts';
export class Something {
@ViewChild(TestThemeableComponent) test: TestThemeableComponent;
}
`,
},
{
name: fixture('src/app/test/test.component.spec.ts'),
code: `
By.css('ds-themeable');
By.Css('#test > ds-themeable > #nest');
`,
},
],
invalid: [
{
code: `
import { TestThemeableComponent } from '../test/test-themeable.component.ts';
import { TestComponent } from '../test/test.component.ts';
const config = {
a: TestThemeableComponent,
b: TestComponent,
}
`,
errors: [
{
messageId: 'mustImportThemedWrapper',
},
{
messageId: 'mustUseThemedWrapper',
},
],
output: `
import { ThemedTestThemeableComponent } from '../test/themed-test-themeable.component.ts';
import { TestComponent } from '../test/test.component.ts';
const config = {
a: ThemedTestThemeableComponent,
b: TestComponent,
}
`
},
{
filename: fixture('src/app/test/test.component.spec.ts'),
code: `
By.css('ds-themed-themeable');
By.css('#test > ds-themed-themeable > #nest');
`,
errors: [
{
messageId: 'mustUseThemedWrapper',
},
{
messageId: 'mustUseThemedWrapper',
},
],
output: `
By.css('ds-themeable');
By.css('#test > ds-themeable > #nest');
`,
},
{
filename: fixture('src/app/test/test.component.spec.ts'),
code: `
By.css('ds-base-themeable');
By.css('#test > ds-base-themeable > #nest');
`,
errors: [
{
messageId: 'mustUseThemedWrapper',
},
{
messageId: 'mustUseThemedWrapper',
},
],
output: `
By.css('ds-themeable');
By.css('#test > ds-themeable > #nest');
`,
},
],
} as any);
});
describe('themed-component-usages (HTML)', () => {
htmlRuleTester.run('themed-component-usages', htmlRule, {
valid: [
{
code: `
<ds-test-themeable/>
<ds-test-themeable></ds-test-themeable>
<ds-test-themeable [test]="something"></ds-test-themeable>
`,
},
],
invalid: [
{
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: 'mustUseThemedWrapperSelector',
},
{
messageId: 'mustUseThemedWrapperSelector',
},
{
messageId: 'mustUseThemedWrapperSelector',
},
],
output: `
<ds-test-themeable/>
<ds-test-themeable></ds-test-themeable>
<ds-test-themeable [test]="something"></ds-test-themeable>
`,
},
{
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: 'mustUseThemedWrapperSelector',
},
{
messageId: 'mustUseThemedWrapperSelector',
},
{
messageId: 'mustUseThemedWrapperSelector',
},
],
output: `
<ds-test-themeable/>
<ds-test-themeable></ds-test-themeable>
<ds-test-themeable [test]="something"></ds-test-themeable>
`,
},
]
});
});

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

@@ -0,0 +1,52 @@
/**
* 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 } from 'eslint';
import { RuleTester as TypeScriptRuleTester } from '@typescript-eslint/rule-tester';
import { themeableComponents } from '../src/util/theme-support';
const FIXTURE = 'lint/test/fixture/';
// Register themed components from test fixture
themeableComponents.initialize(FIXTURE);
TypeScriptRuleTester.itOnly = fit;
export function fixture(path: string): string {
return FIXTURE + path;
}
export const tsRuleTester = new TypeScriptRuleTester({
parser: '@typescript-eslint/parser',
defaultFilenames: {
ts: fixture('src/test.ts'),
tsx: 'n/a',
},
parserOptions: {
project: fixture('tsconfig.json'),
}
});
class HtmlRuleTester extends RuleTester {
run(name: string, rule: any, tests: { valid: any[], invalid: any[] }) {
super.run(name, rule, {
valid: tests.valid.map((test) => ({
filename: fixture('test.html'),
...test,
})),
invalid: tests.invalid.map((test) => ({
filename: fixture('test.html'),
...test,
})),
});
}
}
export const htmlRuleTester = new HtmlRuleTester({
parser: require.resolve('@angular-eslint/template-parser'),
});

View File

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