diff --git a/.eslintrc.json b/.eslintrc.json index 9d0961d293..3f3dbf27c9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -295,6 +295,15 @@ "listableObjectComponent" ] } + ], + "dspace-angular-ts/sort-standalone-imports": [ + "error", + { + "locale": "en-US", + "maxItems": 0, + "indent": 2, + "trailingComma": true + } ] } }, diff --git a/docs/lint/ts/index.md b/docs/lint/ts/index.md index 432b435a05..da22439e1e 100644 --- a/docs/lint/ts/index.md +++ b/docs/lint/ts/index.md @@ -2,6 +2,7 @@ _______ - [`dspace-angular-ts/alias-imports`](./rules/alias-imports.md): Unclear imports should be aliased for clarity +- [`dspace-angular-ts/sort-standalone-imports`](./rules/sort-standalone-imports.md): Sorts the standalone `@Component` imports alphabetically - [`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 diff --git a/docs/lint/ts/rules/sort-standalone-imports.md b/docs/lint/ts/rules/sort-standalone-imports.md new file mode 100644 index 0000000000..d1d2c28266 --- /dev/null +++ b/docs/lint/ts/rules/sort-standalone-imports.md @@ -0,0 +1,214 @@ +[DSpace ESLint plugins](../../../../lint/README.md) > [TypeScript rules](../index.md) > `dspace-angular-ts/sort-standalone-imports` +_______ + +Sorts the standalone `@Component` imports alphabetically + +_______ + +[Source code](../../../../lint/src/rules/ts/sort-standalone-imports.ts) + + +### Options + +#### `locale` + +The locale used to sort the imports., +#### `maxItems` + +The maximum number of imports that should be displayed before each import is separated onto its own line., +#### `indent` + +The indent used for the project., +#### `trailingComma` + +Whether the last import should have a trailing comma (only applicable for multiline imports). + + +### Examples + + +#### Valid code + +##### should sort multiple imports on separate lines + +```typescript +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + AsyncPipe, + RootComponent, + ], +}) +export class AppComponent {} +``` + +##### should not inlines singular imports when maxItems is 0 + +```typescript +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + RootComponent, + ], +}) +export class AppComponent {} +``` + +##### should inline singular imports when maxItems is 1 + +```typescript +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [RootComponent], +}) +export class AppComponent {} +``` + + + + +#### Invalid code & automatic fixes + +##### should sort multiple imports alphabetically + +```typescript +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + RootComponent, + AsyncPipe, + ], +}) +export class AppComponent {} +``` +Will produce the following error(s): +``` +Standalone imports should be sorted alphabetically +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + AsyncPipe, + RootComponent, + ], +}) +export class AppComponent {} +``` + + +##### should not put singular imports on one line when maxItems is 0 + +```typescript +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [RootComponent], +}) +export class AppComponent {} +``` +Will produce the following error(s): +``` +Standalone imports should be sorted alphabetically +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + RootComponent, + ], +}) +export class AppComponent {} +``` + + +##### should not put singular imports on a separate line when maxItems is 1 + +```typescript +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + RootComponent, + ], +}) +export class AppComponent {} +``` +Will produce the following error(s): +``` +Standalone imports should be sorted alphabetically +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [RootComponent], +}) +export class AppComponent {} +``` + + +##### should not display multiple imports on the same line + +```typescript +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [AsyncPipe, RootComponent], +}) +export class AppComponent {} +``` +Will produce the following error(s): +``` +Standalone imports should be sorted alphabetically +``` + +Result of `yarn lint --fix`: +```typescript +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + AsyncPipe, + RootComponent, + ], +}) +export class AppComponent {} +``` + + + diff --git a/lint/src/rules/ts/index.ts b/lint/src/rules/ts/index.ts index 639afbb067..531f0b3b9f 100644 --- a/lint/src/rules/ts/index.ts +++ b/lint/src/rules/ts/index.ts @@ -11,6 +11,7 @@ import { } from '../../util/structure'; /* eslint-disable import/no-namespace */ import * as aliasImports from './alias-imports'; +import * as sortStandaloneImports from './sort-standalone-imports'; import * as themedComponentClasses from './themed-component-classes'; import * as themedComponentSelectors from './themed-component-selectors'; import * as themedComponentUsages from './themed-component-usages'; @@ -20,6 +21,7 @@ import * as uniqueDecorators from './unique-decorators'; const index = [ aliasImports, + sortStandaloneImports, themedComponentClasses, themedComponentSelectors, themedComponentUsages, diff --git a/lint/src/rules/ts/sort-standalone-imports.ts b/lint/src/rules/ts/sort-standalone-imports.ts new file mode 100644 index 0000000000..30a2a5bca8 --- /dev/null +++ b/lint/src/rules/ts/sort-standalone-imports.ts @@ -0,0 +1,306 @@ +import { + ASTUtils as TSESLintASTUtils, + ESLintUtils, + TSESLint, + TSESTree, +} from '@typescript-eslint/utils'; + +import { + DSpaceESLintRuleInfo, + NamedTests, + OptionDoc, +} from '../../util/structure'; + +const DEFAULT_LOCALE = 'en-US'; +const DEFAULT_MAX_SIZE = 0; +const DEFAULT_SPACE_INDENT_AMOUNT = 2; +const DEFAULT_TRAILING_COMMA = true; + +export enum Message { + SORT_STANDALONE_IMPORTS_ARRAYS = 'sortStandaloneImportsArrays', +} + +export interface UniqueDecoratorsOptions { + locale: string; + maxItems: number; + indent: number; + trailingComma: boolean; +} + +export interface UniqueDecoratorsDocOptions { + locale: OptionDoc; + maxItems: OptionDoc; + indent: OptionDoc; + trailingComma: OptionDoc; +} + +export const info: DSpaceESLintRuleInfo<[UniqueDecoratorsOptions], [UniqueDecoratorsDocOptions]> = { + name: 'sort-standalone-imports', + meta: { + docs: { + description: 'Sorts the standalone `@Component` imports alphabetically', + }, + messages: { + [Message.SORT_STANDALONE_IMPORTS_ARRAYS]: 'Standalone imports should be sorted alphabetically', + }, + fixable: 'code', + type: 'problem', + schema: [ + { + type: 'object', + properties: { + locale: { + type: 'string', + }, + maxItems: { + type: 'number', + }, + indent: { + type: 'number', + }, + trailingComma: { + type: 'boolean', + }, + }, + additionalProperties: false, + }, + ], + }, + optionDocs: [ + { + locale: { + title: '`locale`', + description: 'The locale used to sort the imports.', + }, + maxItems: { + title: '`maxItems`', + description: 'The maximum number of imports that should be displayed before each import is separated onto its own line.', + }, + indent: { + title: '`indent`', + description: 'The indent used for the project.', + }, + trailingComma: { + title: '`trailingComma`', + description: 'Whether the last import should have a trailing comma (only applicable for multiline imports).', + }, + }, + ], + defaultOptions: [ + { + locale: DEFAULT_LOCALE, + maxItems: DEFAULT_MAX_SIZE, + indent: DEFAULT_SPACE_INDENT_AMOUNT, + trailingComma: DEFAULT_TRAILING_COMMA, + }, + ], +}; + +export const rule = ESLintUtils.RuleCreator.withoutDocs({ + ...info, + create(context: TSESLint.RuleContext, [{ locale, maxItems, indent, trailingComma }]: any) { + return { + ['ClassDeclaration > Decorator > CallExpression[callee.name="Component"] > ObjectExpression > Property[key.name="imports"] > ArrayExpression']: (node: TSESTree.ArrayExpression) => { + const identifiers = node.elements.filter(TSESLintASTUtils.isIdentifier); + const sortedNames: string[] = identifiers + .map((identifier) => identifier.name) + .sort((a: string, b: string) => a.localeCompare(b, locale)); + + const isSorted: boolean = identifiers.every((identifier, index) => identifier.name === sortedNames[index]); + + const requiresMultiline: boolean = maxItems < node.elements.length; + const isMultiline: boolean = /\n/.test(context.sourceCode.getText(node)); + + const incorrectFormat: boolean = requiresMultiline !== isMultiline; + + if (isSorted && !incorrectFormat) { + return; + } + + context.report({ + node: node.parent, + messageId: Message.SORT_STANDALONE_IMPORTS_ARRAYS, + fix: (fixer: TSESLint.RuleFixer) => { + if (requiresMultiline) { + const multilineImports: string = sortedNames + .map((name: string) => `${' '.repeat(2 * indent)}${name}${trailingComma ? ',' : ''}`) + .join(trailingComma ? '\n' : ',\n'); + + return fixer.replaceText(node, `[\n${multilineImports}\n${' '.repeat(indent)}]`); + } else { + return fixer.replaceText(node, `[${sortedNames.join(', ')}]`); + } + }, + }); + }, + }; + }, +}); + +export const tests: NamedTests = { + plugin: info.name, + valid: [ + { + name: 'should sort multiple imports on separate lines', + code: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + AsyncPipe, + RootComponent, + ], +}) +export class AppComponent {}`, + }, + { + name: 'should not inlines singular imports when maxItems is 0', + code: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + RootComponent, + ], +}) +export class AppComponent {}`, + }, + { + name: 'should inline singular imports when maxItems is 1', + options: [{ maxItems: 1 }], + code: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [RootComponent], +}) +export class AppComponent {}`, + }, + ], + invalid: [ + { + name: 'should sort multiple imports alphabetically', + code: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + RootComponent, + AsyncPipe, + ], +}) +export class AppComponent {}`, + errors: [ + { + messageId: Message.SORT_STANDALONE_IMPORTS_ARRAYS, + }, + ], + output: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + AsyncPipe, + RootComponent, + ], +}) +export class AppComponent {}`, + }, + { + name: 'should not put singular imports on one line when maxItems is 0', + code: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [RootComponent], +}) +export class AppComponent {}`, + errors: [ + { + messageId: Message.SORT_STANDALONE_IMPORTS_ARRAYS, + }, + ], + output: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + RootComponent, + ], +}) +export class AppComponent {}`, + }, + { + name: 'should not put singular imports on a separate line when maxItems is 1', + options: [{ maxItems: 1 }], + code: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + RootComponent, + ], +}) +export class AppComponent {}`, + errors: [ + { + messageId: Message.SORT_STANDALONE_IMPORTS_ARRAYS, + }, + ], + output: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [RootComponent], +}) +export class AppComponent {}`, + }, + { + name: 'should not display multiple imports on the same line', + code: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [AsyncPipe, RootComponent], +}) +export class AppComponent {}`, + errors: [ + { + messageId: Message.SORT_STANDALONE_IMPORTS_ARRAYS, + }, + ], + output: ` +@Component({ + selector: 'ds-app', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: true, + imports: [ + AsyncPipe, + RootComponent, + ], +}) +export class AppComponent {}`, + }, + ], +};