├── .prettierignore ├── .eslintignore ├── jest.setup.ts ├── src ├── types │ ├── value-type.ts │ └── contructor.ts ├── utilities │ ├── capitalize.ts │ ├── array-values.ts │ ├── object-properties.ts │ ├── metadata.ts │ ├── add-target.ts │ ├── add-value.ts │ ├── add-class.ts │ ├── array-values.test.ts │ ├── capitalize.test.ts │ ├── object-properties.test.ts │ ├── inheritable-statics.test.ts │ └── inheritable-statics.ts ├── index.ts ├── constants │ └── property-suffixes.ts └── decorators │ ├── class.ts │ ├── target.ts │ ├── classes.ts │ ├── targets.ts │ ├── value.ts │ ├── target.test.ts │ ├── class.test.ts │ ├── typed-controller.ts │ ├── targets.test.ts │ ├── classes.test.ts │ ├── value.test.ts │ └── typed-controller.test.ts ├── tsconfig.test.json ├── .release-it.json ├── .prettierrc ├── jest.config.js ├── tsconfig.json ├── .gitignore ├── .npmignore ├── .eslintrc ├── jest.utils.ts ├── LICENSE ├── package.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | -------------------------------------------------------------------------------- /src/types/value-type.ts: -------------------------------------------------------------------------------- 1 | export type ValueType = typeof Array | typeof Boolean | typeof Number | typeof Object | typeof String; 2 | -------------------------------------------------------------------------------- /src/utilities/capitalize.ts: -------------------------------------------------------------------------------- 1 | export function capitalize(value: string) { 2 | return value.charAt(0).toUpperCase() + value.slice(1); 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "emitDecoratorMetadata": true 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/types/contructor.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 2 | export type Constructor = new (...args: any[]) => T; 3 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "before:init": ["yarn lint", "yarn test"], 4 | "after:bump": "yarn build" 5 | }, 6 | "github": { 7 | "release": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utilities/array-values.ts: -------------------------------------------------------------------------------- 1 | export function getUniqueArrayValues(array: T[]): T[] { 2 | return array.reduce((unique, item) => (unique.includes(item) ? unique : [...unique, item]), [] as T[]); 3 | } 4 | -------------------------------------------------------------------------------- /src/utilities/object-properties.ts: -------------------------------------------------------------------------------- 1 | export function deleteOwnProperty(target: T, propertyKey: string) { 2 | if (Object.prototype.hasOwnProperty.call(target, propertyKey)) { 3 | delete target[propertyKey as keyof T]; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './decorators/typed-controller'; 2 | export * from './decorators/class'; 3 | export * from './decorators/classes'; 4 | export * from './decorators/target'; 5 | export * from './decorators/targets'; 6 | export * from './decorators/value'; 7 | -------------------------------------------------------------------------------- /src/constants/property-suffixes.ts: -------------------------------------------------------------------------------- 1 | export const CLASS_PROPERTY_SUFFIX = 'Class'; 2 | export const CLASSES_PROPERTY_SUFFIX = 'Classes'; 3 | export const TARGET_PROPERTY_SUFFIX = 'Target'; 4 | export const TARGETS_PROPERTY_SUFFIX = 'Targets'; 5 | export const VALUE_PROPERTY_SUFFIX = 'Value'; 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "all", 8 | "bracketSpacing": true, 9 | "bracketSameLine": false, 10 | "arrowParens": "avoid", 11 | "requirePragma": false, 12 | "insertPragma": false, 13 | "proseWrap": "preserve" 14 | } 15 | -------------------------------------------------------------------------------- /src/utilities/metadata.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | export const isReflectMetadataSupported = typeof Reflect !== 'undefined' && typeof Reflect.getMetadata !== 'undefined'; 4 | 5 | export function getReflectedMetadataType(target: object, propertyKey: string) { 6 | return Reflect.getMetadata('design:type', target, propertyKey); 7 | } 8 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | export default { 3 | preset: 'ts-jest', 4 | testEnvironment: 'jsdom', 5 | setupFilesAfterEnv: ['/jest.setup.ts'], 6 | collectCoverageFrom: ['/src/**', '!/src/index.ts'], 7 | globals: { 8 | 'ts-jest': { 9 | tsconfig: 'tsconfig.test.json', 10 | }, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "importHelpers": true, 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "experimentalDecorators": true, 10 | "lib": ["esnext", "dom", "dom.iterable", "scripthost"], 11 | "typeRoots": ["./node_modules/@types"] 12 | }, 13 | "exclude": ["dist"] 14 | } 15 | -------------------------------------------------------------------------------- /src/utilities/add-target.ts: -------------------------------------------------------------------------------- 1 | import { Controller as StimulusController } from '@hotwired/stimulus'; 2 | 3 | export function addTarget(controller: T, targetKey: string) { 4 | const constructor = controller.constructor as typeof StimulusController; 5 | 6 | if (!Object.prototype.hasOwnProperty.call(constructor, 'targets')) { 7 | constructor.targets = []; 8 | } 9 | 10 | if (!constructor.targets.includes(targetKey)) { 11 | constructor.targets.push(targetKey); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | *.log 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # Compiled output 8 | dist/ 9 | 10 | # Dependency directories 11 | node_modules/ 12 | 13 | # Tests 14 | coverage/ 15 | 16 | # TypeScript cache 17 | *.tsbuildinfo 18 | 19 | # Optional npm cache directory 20 | .npm 21 | 22 | # Optional eslint cache 23 | .eslintcache 24 | 25 | # Microbundle cache 26 | .rpt2_cache/ 27 | .rts2_cache_cjs/ 28 | .rts2_cache_es/ 29 | .rts2_cache_umd/ 30 | 31 | # Yarn Integrity file 32 | .yarn-integrity 33 | -------------------------------------------------------------------------------- /src/utilities/add-value.ts: -------------------------------------------------------------------------------- 1 | import { Controller as StimulusController } from '@hotwired/stimulus'; 2 | import { ValueType } from '../types/value-type'; 3 | 4 | export function addValue(controller: T, valueKey: string, valueType: ValueType) { 5 | const constructor = controller.constructor as typeof StimulusController; 6 | 7 | if (!Object.prototype.hasOwnProperty.call(constructor, 'values')) { 8 | constructor.values = {}; 9 | } 10 | 11 | constructor.values[valueKey] = valueType; 12 | } 13 | -------------------------------------------------------------------------------- /src/utilities/add-class.ts: -------------------------------------------------------------------------------- 1 | import { Controller as StimulusController } from '@hotwired/stimulus'; 2 | 3 | export function addClass(controller: T, targetKey: string) { 4 | const constructor = controller.constructor as typeof StimulusController & { classes: string[] }; 5 | 6 | if (!Object.prototype.hasOwnProperty.call(constructor, 'classes')) { 7 | constructor.classes = []; 8 | } 9 | 10 | if (!constructor.classes.includes(targetKey)) { 11 | constructor.classes.push(targetKey); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/decorators/class.ts: -------------------------------------------------------------------------------- 1 | import { Controller as StimulusController } from '@hotwired/stimulus'; 2 | import { CLASS_PROPERTY_SUFFIX } from '../constants/property-suffixes'; 3 | import { addClass } from '../utilities/add-class'; 4 | 5 | export function Class(controller: T, propertyKey: string) { 6 | if (!propertyKey.endsWith(CLASS_PROPERTY_SUFFIX)) { 7 | throw new Error(`"${propertyKey}" must end with "${CLASS_PROPERTY_SUFFIX}"`); 8 | } 9 | 10 | addClass(controller, propertyKey.slice(0, -CLASS_PROPERTY_SUFFIX.length)); 11 | } 12 | -------------------------------------------------------------------------------- /src/decorators/target.ts: -------------------------------------------------------------------------------- 1 | import { Controller as StimulusController } from '@hotwired/stimulus'; 2 | import { TARGET_PROPERTY_SUFFIX } from '../constants/property-suffixes'; 3 | import { addTarget } from '../utilities/add-target'; 4 | 5 | export function Target(controller: T, propertyKey: string) { 6 | if (!propertyKey.endsWith(TARGET_PROPERTY_SUFFIX)) { 7 | throw new Error(`"${propertyKey}" must end with "${TARGET_PROPERTY_SUFFIX}"`); 8 | } 9 | 10 | addTarget(controller, propertyKey.slice(0, -TARGET_PROPERTY_SUFFIX.length)); 11 | } 12 | -------------------------------------------------------------------------------- /src/decorators/classes.ts: -------------------------------------------------------------------------------- 1 | import { Controller as StimulusController } from '@hotwired/stimulus'; 2 | import { CLASSES_PROPERTY_SUFFIX } from '../constants/property-suffixes'; 3 | import { addClass } from '../utilities/add-class'; 4 | 5 | export function Classes(controller: T, propertyKey: string) { 6 | if (!propertyKey.endsWith(CLASSES_PROPERTY_SUFFIX)) { 7 | throw new Error(`"${propertyKey}" must end with "${CLASSES_PROPERTY_SUFFIX}"`); 8 | } 9 | 10 | addClass(controller, propertyKey.slice(0, -CLASSES_PROPERTY_SUFFIX.length)); 11 | } 12 | -------------------------------------------------------------------------------- /src/decorators/targets.ts: -------------------------------------------------------------------------------- 1 | import { Controller as StimulusController } from '@hotwired/stimulus'; 2 | import { TARGETS_PROPERTY_SUFFIX } from '../constants/property-suffixes'; 3 | import { addTarget } from '../utilities/add-target'; 4 | 5 | export function Targets(controller: T, propertyKey: string) { 6 | if (!propertyKey.endsWith(TARGETS_PROPERTY_SUFFIX)) { 7 | throw new Error(`"${propertyKey}" must end with "${TARGETS_PROPERTY_SUFFIX}"`); 8 | } 9 | 10 | addTarget(controller, propertyKey.slice(0, -TARGETS_PROPERTY_SUFFIX.length)); 11 | } 12 | -------------------------------------------------------------------------------- /src/utilities/array-values.test.ts: -------------------------------------------------------------------------------- 1 | import { getUniqueArrayValues } from './array-values'; 2 | 3 | describe('getUniqueArrayValues', () => { 4 | it('should return an array with unique values when array with duplicated values is provided', () => { 5 | expect( 6 | getUniqueArrayValues([undefined, 'a', 'a', null, 0, true, 'a', null, 'b', 0, false, true, 'c', 1]), 7 | ).toStrictEqual([undefined, 'a', null, 0, true, 'b', false, 'c', 1]); 8 | }); 9 | 10 | it('should return an empty array when an empty array is provided', () => { 11 | expect(getUniqueArrayValues([])).toStrictEqual([]); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/utilities/capitalize.test.ts: -------------------------------------------------------------------------------- 1 | import { capitalize } from './capitalize'; 2 | 3 | describe('capitalize', () => { 4 | it('should return a string with a first symbol in uppercase when a string with a first symbol in lowercase is provided', () => { 5 | expect(capitalize('test')).toEqual('Test'); 6 | }); 7 | 8 | it('should return a string with a first symbol in uppercase when a string with a first symbol in uppercase is provided', () => { 9 | expect(capitalize('Test')).toEqual('Test'); 10 | }); 11 | 12 | it('should return an empty string when an empty string is provided', () => { 13 | expect(capitalize('')).toEqual(''); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # IntelliJ project files 2 | .idea 3 | *.iml 4 | out 5 | gen 6 | 7 | # Logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | # Source 14 | src/ 15 | 16 | # Tests 17 | coverage/ 18 | jest.config.js 19 | jest.setup.ts 20 | jest.utils.ts 21 | 22 | # TypeScript 23 | tsconfig.json 24 | tsconfig.test.json 25 | *.tsbuildinfo 26 | 27 | # Optional npm cache directory 28 | .npm 29 | 30 | # eslint 31 | .eslintrc 32 | .eslintignore 33 | .eslintcache 34 | 35 | # prettier 36 | .prettierrc 37 | .prettierignore 38 | 39 | # Microbundle cache 40 | .rpt2_cache/ 41 | .rts2_cache_cjs/ 42 | .rts2_cache_es/ 43 | .rts2_cache_umd/ 44 | 45 | # Yarn 46 | .yarn.lock 47 | .yarn-integrity 48 | 49 | # release-it 50 | .release-it.json 51 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:prettier/recommended" 10 | ], 11 | "overrides": [ 12 | { 13 | "files": ["*.ts"], 14 | "rules": { 15 | "@typescript-eslint/no-non-null-assertion": 0 16 | } 17 | }, 18 | { 19 | "files": ["*.test.ts", "*.spec.ts"], 20 | "rules": { 21 | "@typescript-eslint/no-inferrable-types": [ 22 | 2, 23 | { 24 | "ignoreProperties": true 25 | } 26 | ] 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/utilities/object-properties.test.ts: -------------------------------------------------------------------------------- 1 | import { deleteOwnProperty } from './object-properties'; 2 | 3 | describe('deleteOwnProperties', () => { 4 | it('should delete object property when an owned property key is provided', () => { 5 | const testObject = { 6 | prop1: 'value1', 7 | prop2: 'value2', 8 | }; 9 | 10 | deleteOwnProperty(testObject, 'prop1'); 11 | 12 | expect(testObject).toEqual({ 13 | prop2: 'value2', 14 | }); 15 | }); 16 | 17 | it('should not delete object property when not owned property key is provided', () => { 18 | const testObject = { 19 | prop1: 'value1', 20 | }; 21 | 22 | deleteOwnProperty(testObject, 'prop2'); 23 | 24 | expect(testObject).toEqual({ 25 | prop1: 'value1', 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /jest.utils.ts: -------------------------------------------------------------------------------- 1 | import { Application, Controller } from '@hotwired/stimulus'; 2 | 3 | export async function startApplication< 4 | T extends Controller, 5 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 6 | M extends { [identifier: string]: new (...args: any[]) => T }, 7 | >(controllers: M, template: string) { 8 | document.body.innerHTML = template; 9 | 10 | const application = Application.start(); 11 | 12 | Object.entries(controllers).forEach(([identifier, controller]) => { 13 | application.register(identifier, controller); 14 | }); 15 | 16 | await new Promise(resolve => requestAnimationFrame(resolve)); 17 | 18 | return Object.keys(controllers).reduce( 19 | (instances, identifier) => ({ 20 | ...instances, 21 | [identifier]: application.getControllerForElementAndIdentifier( 22 | document.querySelector(`[data-controller="${identifier}"]`)!, 23 | identifier, 24 | ), 25 | }), 26 | {}, 27 | ) as { [K in keyof M]: InstanceType }; 28 | } 29 | -------------------------------------------------------------------------------- /src/utilities/inheritable-statics.test.ts: -------------------------------------------------------------------------------- 1 | import { readInheritableStaticArrayValues, readInheritableStaticObjectKeys } from './inheritable-statics'; 2 | 3 | describe('readInheritableStaticArrayValues', () => { 4 | it('should return an array with unique array values of inherited static property', () => { 5 | class Parent { 6 | static array = [1, 2, 3]; 7 | } 8 | 9 | class Child extends Parent { 10 | static array = [2, 3, 4]; 11 | } 12 | 13 | expect(readInheritableStaticArrayValues(Child, 'array')).toStrictEqual([1, 2, 3, 4]); 14 | }); 15 | }); 16 | 17 | describe('readInheritableStaticObjectKeys', () => { 18 | it('should return an array with unique property keys of inherited static property', () => { 19 | class Parent { 20 | static object: Record = { prop1: 1, prop2: 2, prop3: 3 }; 21 | } 22 | 23 | class Child extends Parent { 24 | static object: Record = { prop2: 2, prop3: 3, prop4: 4 }; 25 | } 26 | 27 | expect(readInheritableStaticObjectKeys(Child, 'object')).toStrictEqual(['prop1', 'prop2', 'prop3', 'prop4']); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Vytautas Antanavičius 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/decorators/value.ts: -------------------------------------------------------------------------------- 1 | import { Controller as StimulusController } from '@hotwired/stimulus'; 2 | import { VALUE_PROPERTY_SUFFIX } from '../constants/property-suffixes'; 3 | import { ValueType } from '../types/value-type'; 4 | import { addValue } from '../utilities/add-value'; 5 | import { getReflectedMetadataType, isReflectMetadataSupported } from '../utilities/metadata'; 6 | 7 | export function Value(type: ValueType): (controller: T, propertyKey: string) => void; 8 | export function Value(controller: T, propertyKey: string): void; 9 | export function Value(...args: unknown[]): unknown { 10 | if (args[1] === undefined) { 11 | return (controller: T, propertyKey: string) => { 12 | if (!propertyKey.endsWith(VALUE_PROPERTY_SUFFIX)) { 13 | throw new Error(`"${propertyKey}" must end with "${VALUE_PROPERTY_SUFFIX}"`); 14 | } 15 | 16 | const type = args[0] as ValueType; 17 | 18 | addValue(controller, propertyKey.slice(0, -VALUE_PROPERTY_SUFFIX.length), type); 19 | }; 20 | } 21 | 22 | const controller = args[0] as T; 23 | const propertyKey = args[1] as string; 24 | 25 | if (!isReflectMetadataSupported) { 26 | throw new Error(`Unknown "${propertyKey}" type, check if the "reflect-metadata" is configured correctly`); 27 | } 28 | 29 | return Value(getReflectedMetadataType(controller, propertyKey))(controller, propertyKey); 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@vytant/stimulus-decorators", 3 | "version": "1.1.0", 4 | "description": "TypeScript decorators for the Stimulus framework", 5 | "keywords": [ 6 | "stimulus", 7 | "typescript", 8 | "decorators" 9 | ], 10 | "repository": "git@github.com:vytant/stimulus-decorators.git", 11 | "author": "Vytautas Antanavičius", 12 | "license": "MIT", 13 | "private": false, 14 | "publishConfig": { 15 | "access": "public" 16 | }, 17 | "type": "module", 18 | "source": "src/index.ts", 19 | "exports": { 20 | "require": "./dist/index.cjs", 21 | "default": "./dist/index.modern.js" 22 | }, 23 | "main": "dist/index.cjs", 24 | "module": "dist/index.module.js", 25 | "unpkg": "dist/index.umd.js", 26 | "types": "dist/index.d.ts", 27 | "scripts": { 28 | "build": "microbundle", 29 | "dev": "microbundle watch", 30 | "lint": "eslint . --ext .ts", 31 | "test": "jest", 32 | "test:watch": "jest --watchAll", 33 | "test:coverage": "jest --coverage", 34 | "release": "release-it" 35 | }, 36 | "devDependencies": { 37 | "@types/jest": "^27.4.1", 38 | "@typescript-eslint/eslint-plugin": "^5.17.0", 39 | "@typescript-eslint/parser": "^5.17.0", 40 | "eslint": "^8.12.0", 41 | "eslint-config-prettier": "^8.5.0", 42 | "eslint-plugin-prettier": "^4.0.0", 43 | "jest": "^27.5.1", 44 | "microbundle": "^0.14.2", 45 | "prettier": "2.6.1", 46 | "release-it": "^14.13.1", 47 | "ts-jest": "^27.1.4" 48 | }, 49 | "dependencies": { 50 | "@hotwired/stimulus": "^3.0.0", 51 | "reflect-metadata": "^0.1.13" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/utilities/inheritable-statics.ts: -------------------------------------------------------------------------------- 1 | import { Constructor } from '../types/contructor'; 2 | import { getUniqueArrayValues } from './array-values'; 3 | 4 | export function readInheritableStaticArrayValues(constructor: Constructor, propertyKey: string) { 5 | const ancestors = getAncestorsForConstructor(constructor); 6 | 7 | return getUniqueArrayValues( 8 | ancestors.reduce((values, constructor) => { 9 | values.push(...getOwnStaticArrayValues(constructor, propertyKey)); 10 | 11 | return values; 12 | }, [] as string[]), 13 | ); 14 | } 15 | 16 | export function readInheritableStaticObjectKeys(constructor: Constructor, propertyKey: string) { 17 | const ancestors = getAncestorsForConstructor(constructor); 18 | 19 | return getUniqueArrayValues( 20 | ancestors.reduce((keys, constructor) => { 21 | keys.push(...getOwnStaticObjectKeys(constructor, propertyKey)); 22 | 23 | return keys; 24 | }, [] as string[]), 25 | ); 26 | } 27 | 28 | function getAncestorsForConstructor(constructor: Constructor) { 29 | const ancestors: Constructor[] = []; 30 | 31 | while (constructor) { 32 | ancestors.push(constructor); 33 | constructor = Object.getPrototypeOf(constructor); 34 | } 35 | 36 | return ancestors.reverse(); 37 | } 38 | 39 | function getOwnStaticArrayValues(constructor: Constructor, propertyKey: string) { 40 | const definition = constructor[propertyKey as keyof typeof constructor]; 41 | 42 | return Array.isArray(definition) ? definition : []; 43 | } 44 | 45 | function getOwnStaticObjectKeys(constructor: Constructor, propertyKey: string) { 46 | const definition = constructor[propertyKey as keyof typeof constructor]; 47 | 48 | return definition ? Object.keys(definition) : []; 49 | } 50 | -------------------------------------------------------------------------------- /src/decorators/target.test.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import { Target } from './target'; 3 | import { startApplication } from '../../jest.utils'; 4 | 5 | describe('@Target', () => { 6 | it('should add `@Target` decorated properties to `static targets` array of a controller', async () => { 7 | class TestController extends Controller { 8 | @Target firstTarget!: HTMLElement; 9 | @Target secondTarget!: HTMLElement; 10 | } 11 | 12 | const { test: testController } = await startApplication( 13 | { test: TestController }, 14 | '
', 15 | ); 16 | 17 | expect((testController.constructor as typeof TestController).targets).toStrictEqual(['first', 'second']); 18 | }); 19 | 20 | it('should add `@Target` decorated properties to `static targets` arrays of parent and child controllers separately', async () => { 21 | class ParentController extends Controller { 22 | @Target firstTarget!: HTMLElement; 23 | } 24 | 25 | class ChildController extends ParentController { 26 | @Target secondTarget!: HTMLElement; 27 | } 28 | 29 | const { parent: parentController, child: childController } = await startApplication( 30 | { parent: ParentController, child: ChildController }, 31 | ` 32 |
33 |
34 | `, 35 | ); 36 | 37 | expect((parentController.constructor as typeof ParentController).targets).toStrictEqual(['first']); 38 | expect((childController.constructor as typeof ChildController).targets).toStrictEqual(['second']); 39 | }); 40 | 41 | it("should throw an error when `@Target` decorated property doesn't end with `Target`", async () => { 42 | class TestController extends Controller {} 43 | 44 | const { test: testController } = await startApplication( 45 | { test: TestController }, 46 | '
', 47 | ); 48 | 49 | expect(() => Target(testController, 'firstTargettt')).toThrow('"firstTargettt" must end with "Target"'); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/decorators/class.test.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import { Class } from './class'; 3 | import { startApplication } from '../../jest.utils'; 4 | 5 | describe('@Class', () => { 6 | it('should add `@Class` decorated properties to `static classes` array of a controller', async () => { 7 | class TestController extends Controller { 8 | @Class firstClass!: string; 9 | @Class secondClass!: string; 10 | } 11 | 12 | const { test: testController } = await startApplication( 13 | { test: TestController }, 14 | '
', 15 | ); 16 | 17 | expect((testController.constructor as typeof TestController & { classes: string[] }).classes).toStrictEqual([ 18 | 'first', 19 | 'second', 20 | ]); 21 | }); 22 | 23 | it('should add `@Class` decorated properties to `static classes` arrays of parent and child controllers separately', async () => { 24 | class ParentController extends Controller { 25 | @Class firstClass!: string; 26 | } 27 | 28 | class ChildController extends ParentController { 29 | @Class secondClass!: string; 30 | } 31 | 32 | const { parent: parentController, child: childController } = await startApplication( 33 | { parent: ParentController, child: ChildController }, 34 | ` 35 |
36 |
37 | `, 38 | ); 39 | 40 | expect((parentController.constructor as typeof ParentController & { classes: string[] }).classes).toStrictEqual([ 41 | 'first', 42 | ]); 43 | expect((childController.constructor as typeof ChildController & { classes: string[] }).classes).toStrictEqual([ 44 | 'second', 45 | ]); 46 | }); 47 | 48 | it("should throw an error when `@Class` decorated property doesn't end with `Class`", async () => { 49 | class TestController extends Controller {} 50 | 51 | const { test: testController } = await startApplication( 52 | { test: TestController }, 53 | '
', 54 | ); 55 | 56 | expect(() => Class(testController, 'firstClassss')).toThrow('"firstClassss" must end with "Class"'); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/decorators/typed-controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller as StimulusController } from '@hotwired/stimulus'; 2 | import { Constructor } from '../types/contructor'; 3 | import { readInheritableStaticArrayValues, readInheritableStaticObjectKeys } from '../utilities/inheritable-statics'; 4 | import { deleteOwnProperty } from '../utilities/object-properties'; 5 | import { 6 | CLASS_PROPERTY_SUFFIX, 7 | CLASSES_PROPERTY_SUFFIX, 8 | TARGET_PROPERTY_SUFFIX, 9 | TARGETS_PROPERTY_SUFFIX, 10 | VALUE_PROPERTY_SUFFIX, 11 | } from '../constants/property-suffixes'; 12 | import { capitalize } from '../utilities/capitalize'; 13 | 14 | export function TypedController>(BaseController: T) { 15 | return class extends BaseController { 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | constructor(...args: any[]) { 18 | super(...args); 19 | 20 | const constructor = this.constructor as typeof StimulusController; 21 | 22 | // Deletes defined values to access getters defined by Stimulus blessings 23 | readInheritableStaticArrayValues(constructor, 'targets').forEach(name => { 24 | deleteOwnProperty(this, `${name}${TARGET_PROPERTY_SUFFIX}`); 25 | deleteOwnProperty(this, `${name}${TARGETS_PROPERTY_SUFFIX}`); 26 | }); 27 | 28 | readInheritableStaticArrayValues(constructor, 'classes').forEach(name => { 29 | deleteOwnProperty(this, `${name}${CLASS_PROPERTY_SUFFIX}`); 30 | deleteOwnProperty(this, `${name}${CLASSES_PROPERTY_SUFFIX}`); 31 | }); 32 | 33 | readInheritableStaticObjectKeys(constructor, 'values').forEach(key => { 34 | const valueKey = `${key}${VALUE_PROPERTY_SUFFIX}` as string & keyof this; 35 | 36 | if (Object.prototype.hasOwnProperty.call(this, valueKey)) { 37 | const defaultValue = this[valueKey]; 38 | 39 | delete this[valueKey]; 40 | 41 | // FIXME: this doesn't work when extending and overriding parent class value 42 | if (defaultValue !== undefined && !this[`has${capitalize(valueKey)}` as keyof this]) { 43 | // Sets default value only if there is no value defined in the template 44 | this[valueKey] = defaultValue; 45 | } 46 | } 47 | }); 48 | } 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/decorators/targets.test.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import { Targets } from './targets'; 3 | import { Target } from './target'; 4 | import { startApplication } from '../../jest.utils'; 5 | 6 | describe('@Targets', () => { 7 | it('should add `@Targets` decorated properties to `static targets` array of a controller', async () => { 8 | class TestController extends Controller { 9 | @Targets firstTargets!: HTMLElement[]; 10 | @Targets secondTargets!: HTMLElement[]; 11 | } 12 | 13 | const { test: testController } = await startApplication( 14 | { test: TestController }, 15 | '
', 16 | ); 17 | 18 | expect((testController.constructor as typeof TestController).targets).toStrictEqual(['first', 'second']); 19 | }); 20 | 21 | it('should add `@Targets` decorated properties to `static targets` arrays of parent and child controllers separately', async () => { 22 | class ParentController extends Controller { 23 | @Targets firstTargets!: HTMLElement; 24 | } 25 | 26 | class ChildController extends ParentController { 27 | @Targets secondTargets!: HTMLElement; 28 | } 29 | 30 | const { parent: parentController, child: childController } = await startApplication( 31 | { parent: ParentController, child: ChildController }, 32 | ` 33 |
34 |
35 | `, 36 | ); 37 | 38 | expect((parentController.constructor as typeof ParentController).targets).toStrictEqual(['first']); 39 | expect((childController.constructor as typeof ChildController).targets).toStrictEqual(['second']); 40 | }); 41 | 42 | it('should add `@Targets` and `@Target` decorated properties to `static targets` array of a controller without duplicating them', async () => { 43 | class TestController extends Controller { 44 | @Target firstTarget!: HTMLElement; 45 | @Target secondTarget!: HTMLElement; 46 | @Targets secondTargets!: HTMLElement[]; 47 | @Targets thirdTargets!: HTMLElement[]; 48 | } 49 | 50 | const { test: testController } = await startApplication( 51 | { test: TestController }, 52 | '
', 53 | ); 54 | 55 | expect((testController.constructor as typeof TestController).targets).toStrictEqual(['first', 'second', 'third']); 56 | }); 57 | 58 | it("should throw an error when `@Targets` decorated property doesn't end with `Targets`", async () => { 59 | class TestController extends Controller {} 60 | 61 | const { test: testController } = await startApplication( 62 | { test: TestController }, 63 | '
', 64 | ); 65 | 66 | expect(() => Targets(testController, 'firstTargetsss')).toThrow('"firstTargetsss" must end with "Targets"'); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/decorators/classes.test.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import { Classes } from './classes'; 3 | import { Class } from './class'; 4 | import { startApplication } from '../../jest.utils'; 5 | 6 | describe('@Classes', () => { 7 | it('should add `@Classes` decorated properties to `static classes` array of a controller', async () => { 8 | class TestController extends Controller { 9 | @Classes firstClasses!: string[]; 10 | @Classes secondClasses!: string[]; 11 | } 12 | 13 | const { test: testController } = await startApplication( 14 | { test: TestController }, 15 | '
', 16 | ); 17 | 18 | expect((testController.constructor as typeof TestController & { classes: string[] }).classes).toStrictEqual([ 19 | 'first', 20 | 'second', 21 | ]); 22 | }); 23 | 24 | it('should add `@Classes` decorated properties to `static classes` arrays of parent and child controllers separately', async () => { 25 | class ParentController extends Controller { 26 | @Classes firstClasses!: string; 27 | } 28 | 29 | class ChildController extends ParentController { 30 | @Classes secondClasses!: string; 31 | } 32 | 33 | const { parent: parentController, child: childController } = await startApplication( 34 | { parent: ParentController, child: ChildController }, 35 | ` 36 |
37 |
38 | `, 39 | ); 40 | 41 | expect((parentController.constructor as typeof ParentController & { classes: string[] }).classes).toStrictEqual([ 42 | 'first', 43 | ]); 44 | expect((childController.constructor as typeof ChildController & { classes: string[] }).classes).toStrictEqual([ 45 | 'second', 46 | ]); 47 | }); 48 | 49 | it('should add `@Classes` and `@Class` decorated properties to `static classes` array of a controller without duplicating them', async () => { 50 | class TestController extends Controller { 51 | @Class firstClass!: string; 52 | @Class secondClass!: string; 53 | @Classes secondClasses!: string[]; 54 | @Classes thirdClasses!: string[]; 55 | } 56 | 57 | const { test: testController } = await startApplication( 58 | { test: TestController }, 59 | '
', 60 | ); 61 | 62 | expect((testController.constructor as typeof TestController & { classes: string[] }).classes).toStrictEqual([ 63 | 'first', 64 | 'second', 65 | 'third', 66 | ]); 67 | }); 68 | 69 | it("should throw an error when `@Classes` decorated property doesn't end with `Classes`", async () => { 70 | class TestController extends Controller {} 71 | 72 | const { test: testController } = await startApplication( 73 | { test: TestController }, 74 | '
', 75 | ); 76 | 77 | expect(() => Classes(testController, 'firstClassesss')).toThrow('"firstClassesss" must end with "Classes"'); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/decorators/value.test.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import { Value } from './value'; 3 | import { startApplication } from '../../jest.utils'; 4 | 5 | jest.mock('../utilities/metadata', () => ({ 6 | __esModule: true, 7 | ...jest.requireActual('../utilities/metadata'), 8 | isReflectMetadataSupported: true, 9 | })); 10 | 11 | describe('@Value', () => { 12 | it('should add `@Value` decorated properties with types to `static values` object of a controller when types are provided', async () => { 13 | class TestController extends Controller { 14 | @Value(String) firstValue!: unknown; 15 | @Value(Number) secondValue!: unknown; 16 | } 17 | 18 | const { test: testController } = await startApplication( 19 | { test: TestController }, 20 | '
', 21 | ); 22 | 23 | expect((testController.constructor as typeof TestController).values).toStrictEqual({ 24 | first: String, 25 | second: Number, 26 | }); 27 | }); 28 | 29 | it('should add `@Value` decorated properties with types from `reflect-metadata` to `static values` object of a controller when types are not provided', async () => { 30 | jest.requireMock('../utilities/metadata').isReflectMetadataSupported = true; 31 | 32 | class TestController extends Controller { 33 | @Value firstValue!: string; 34 | @Value secondValue!: number; 35 | } 36 | 37 | const { test: testController } = await startApplication( 38 | { test: TestController }, 39 | '
', 40 | ); 41 | 42 | expect((testController.constructor as typeof TestController).values).toStrictEqual({ 43 | first: String, 44 | second: Number, 45 | }); 46 | }); 47 | 48 | it("should throw an error when `@Value` decorated property doesn't end with `Value`", async () => { 49 | class TestController extends Controller {} 50 | 51 | const { test: testController } = await startApplication( 52 | { test: TestController }, 53 | '
', 54 | ); 55 | 56 | expect(() => Value(testController, 'firstValueee')).toThrow('"firstValueee" must end with "Value"'); 57 | }); 58 | 59 | it("should throw an error when type is not passed to `@Value` decorated property and `reflect-metadata` isn't supported", async () => { 60 | jest.requireMock('../utilities/metadata').isReflectMetadataSupported = false; 61 | 62 | class TestController extends Controller {} 63 | 64 | const { test: testController } = await startApplication( 65 | { test: TestController }, 66 | '
', 67 | ); 68 | 69 | expect(() => Value(testController, 'firstValue')).toThrow( 70 | 'Unknown "firstValue" type, check if the "reflect-metadata" is configured correctly', 71 | ); 72 | }); 73 | 74 | it('should add `@Value` decorated properties to `static values` object of parent and child controllers separately', async () => { 75 | jest.requireMock('../utilities/metadata').isReflectMetadataSupported = true; 76 | 77 | class ParentController extends Controller { 78 | @Value(String) firstValue!: string; 79 | @Value secondValue!: number; 80 | } 81 | 82 | class ChildController extends ParentController { 83 | @Value(Boolean) thirdValue!: boolean; 84 | @Value fourthValue!: string[]; 85 | } 86 | 87 | const { parent: parentController, child: childController } = await startApplication( 88 | { parent: ParentController, child: ChildController }, 89 | ` 90 |
91 |
92 | `, 93 | ); 94 | 95 | expect((parentController.constructor as typeof ParentController).values).toStrictEqual({ 96 | first: String, 97 | second: Number, 98 | }); 99 | 100 | expect((childController.constructor as typeof ChildController).values).toStrictEqual({ 101 | third: Boolean, 102 | fourth: Array, 103 | }); 104 | }); 105 | 106 | afterAll(() => { 107 | jest.unmock('../utilities/metadata'); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stimulus Decorators 2 | 3 | Stimulus Decorators is a TypeScript library that extends the [Stimulus](https://stimulus.hotwired.dev/) framework with TypeScript decorators to give you improved IntelliSense and type safety of automatically generated Stimulus controller properties. 4 | 5 | ## Prerequisites 6 | 7 | - Stimulus 3 8 | - TypeScript 9 | 10 | ## Installation 11 | 12 | If you use Yarn package manager. 13 | 14 | ```bash 15 | yarn add @vytant/stimulus-decorators 16 | ``` 17 | 18 | If you use npm package manager. 19 | 20 | ```bash 21 | npm install --save @vytant/stimulus-decorators 22 | ``` 23 | 24 | ## Usage 25 | 26 | There are several decorators: 27 | 28 | - [`@Target`](#target_decorator) 29 | - [`@Targets`](#targets_decorator) 30 | - [`@Value`](#value_decorator) 31 | - [`@Class`](#class_decorator) 32 | - [`@Classes`](#classes_decorator) 33 | - [`@TypedController`](#typed_controller_decorator) 34 | 35 | ### `@Target` decorator 36 | 37 | Explicitly define target properties with types using the `@Target` decorator, and it will automatically add them to the `static targets` array for your Stimulus controller. 38 | 39 | ```ts 40 | // hello_controller.ts 41 | import { Controller } from '@hotwired/stimulus'; 42 | import { Target, TypedController } from '@vytant/stimulus-decorators'; 43 | 44 | @TypedController 45 | export default class extends Controller { 46 | @Target outputTarget!: HTMLElement; 47 | @Target nameTarget!: HTMLInputElement; 48 | 49 | greet() { 50 | this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!`; 51 | } 52 | } 53 | ``` 54 | 55 | Equivalent to: 56 | 57 | ```js 58 | // hello_controller.js 59 | import { Controller } from '@hotwired/stimulus'; 60 | 61 | export default class extends Controller { 62 | static targets = ['name', 'output']; 63 | 64 | greet() { 65 | this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!`; 66 | } 67 | } 68 | ``` 69 | 70 | ### `@Targets` decorator 71 | 72 | To get an array of all matching targets in scope, use the `@Targets` decorator. 73 | 74 | ```ts 75 | // slider_controller.ts 76 | import { Controller } from '@hotwired/stimulus'; 77 | import { Targets, TypedController } from '@vytant/stimulus-decorators'; 78 | 79 | @TypedController 80 | export default class extends Controller { 81 | @Targets slideTargets!: HTMLElement[]; 82 | 83 | connect() { 84 | this.slideTargets.forEach((element, index) => { 85 | /* … */ 86 | }); 87 | } 88 | } 89 | ``` 90 | 91 | Equivalent to: 92 | 93 | ```js 94 | // slider_controller.js 95 | import { Controller } from '@hotwired/stimulus'; 96 | 97 | export default class extends Controller { 98 | static targets = ['slide']; 99 | 100 | connect() { 101 | this.slideTargets.forEach((element, index) => { 102 | /* … */ 103 | }); 104 | } 105 | } 106 | ``` 107 | 108 | ### `@Value` decorator 109 | 110 | Explicitly define value properties with types and default values using the `@Value` decorator, and it will automatically add them to the `static values` object for your Stimulus controller. 111 | 112 | ```ts 113 | // loader_controller.ts 114 | import { Controller } from '@hotwired/stimulus'; 115 | import { Value, TypedController } from '@vytant/stimulus-decorators'; 116 | 117 | @TypedController 118 | export default class extends Controller { 119 | @Value(String) urlValue!: string; 120 | @Value(String) methodValue: string = 'GET'; 121 | 122 | connect() { 123 | fetch(this.urlValue, { method: this.methodValue }).then(/* … */); 124 | } 125 | } 126 | ``` 127 | 128 | Equivalent to: 129 | 130 | ```js 131 | // loader_controller.js 132 | import { Controller } from '@hotwired/stimulus'; 133 | 134 | export default class extends Controller { 135 | static values = { 136 | url: String, 137 | method: { type: String, default: 'GET' }, 138 | }; 139 | 140 | connect() { 141 | fetch(this.urlValue, { method: this.methodValue }).then(/* … */); 142 | } 143 | } 144 | ``` 145 | 146 | #### If you'd like to set the `type` of each value from its type definition, you must use [reflect-metadata](https://github.com/rbuckton/reflect-metadata). 147 | 148 | 1. Set `"emitDecoratorMetadata": true` in your `tsconfig.json`. 149 | 2. Import `reflect-metadata` **before** importing `@vytant/stimulus-decorators` (importing `reflect-metadata` is needed just once). 150 | 151 | ```ts 152 | // loader_controller.ts 153 | import 'reflect-metadata'; 154 | import { Controller } from '@hotwired/stimulus'; 155 | import { Value, TypedController } from '@vytant/stimulus-decorators'; 156 | 157 | @TypedController 158 | export default class extends Controller { 159 | @Value urlValue!: string; 160 | @Value methodValue: string = 'GET'; 161 | 162 | connect() { 163 | fetch(this.urlValue, { method: this.methodValue }).then(/* … */); 164 | } 165 | } 166 | ``` 167 | 168 | ### `@Class` decorator 169 | 170 | Explicitly define CSS class properties with types using the `@Class` decorator, and it will automatically add them to the `static classes` array for your Stimulus controller. 171 | 172 | ```ts 173 | // search_controller.ts 174 | import { Controller } from '@hotwired/stimulus'; 175 | import { Class, TypedController } from '@vytant/stimulus-decorators'; 176 | 177 | @TypedController 178 | export default class extends Controller { 179 | @Class loadingClass!: string; 180 | 181 | loadResults() { 182 | this.element.classList.add(this.loadingClass); 183 | 184 | fetch(/* … */); 185 | } 186 | } 187 | ``` 188 | 189 | Equivalent to: 190 | 191 | ```js 192 | // search_controller.js 193 | import { Controller } from '@hotwired/stimulus'; 194 | 195 | export default class extends Controller { 196 | static classes = ['loading']; 197 | 198 | loadResults() { 199 | this.element.classList.add(this.loadingClass); 200 | 201 | fetch(/* … */); 202 | } 203 | } 204 | ``` 205 | 206 | ### `@Classes` decorator 207 | 208 | To get an array of classes in the corresponding CSS class attribute, use the `@Classes` decorator. 209 | 210 | ```ts 211 | // search_controller.ts 212 | import { Controller } from '@hotwired/stimulus'; 213 | import { Classes, TypedController } from '@vytant/stimulus-decorators'; 214 | 215 | @TypedController 216 | export default class extends Controller { 217 | @Classes loadingClasses!: string[]; 218 | 219 | loadResults() { 220 | this.element.classList.add(...this.loadingClasses); 221 | 222 | fetch(/* … */); 223 | } 224 | } 225 | ``` 226 | 227 | Equivalent to: 228 | 229 | ```js 230 | // search_controller.js 231 | import { Controller } from '@hotwired/stimulus'; 232 | 233 | export default class extends Controller { 234 | static classes = ['loading']; 235 | 236 | loadResults() { 237 | this.element.classList.add(...this.loadingClasses); 238 | 239 | fetch(/* … */); 240 | } 241 | } 242 | ``` 243 | 244 | ### `@TypedController` decorator 245 | 246 | It is required to use the `@TypedController` decorator on every Stimulus controller where you use `@Target`, `@Targets`, or `@Value` decorators. 247 | 248 | ```ts 249 | // controller.ts 250 | import { Controller } from '@hotwired/stimulus'; 251 | import { TypedController } from '@vytant/stimulus-decorators'; 252 | 253 | @TypedController 254 | export default class extends Controller { 255 | /* … */ 256 | } 257 | ``` 258 | 259 | ## License 260 | 261 | The project is [MIT](https://choosealicense.com/licenses/mit/) licensed. 262 | -------------------------------------------------------------------------------- /src/decorators/typed-controller.test.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import { TypedController } from './typed-controller'; 3 | import { Class } from './class'; 4 | import { Classes } from './classes'; 5 | import { Target } from './target'; 6 | import { Targets } from './targets'; 7 | import { Value } from './value'; 8 | import { startApplication } from '../../jest.utils'; 9 | 10 | describe('@TypedController', () => { 11 | describe('@Class', () => { 12 | it('should delete `@Class` decorated properties and access Stimulus getters', async () => { 13 | @TypedController 14 | class TestController extends Controller { 15 | @Class firstClass!: string; 16 | @Class secondClass!: string; 17 | } 18 | 19 | const { test: testController } = await startApplication( 20 | { test: TestController }, 21 | '
', 22 | ); 23 | 24 | expect(Object.prototype.hasOwnProperty.call(testController, 'firstClass')).toBe(false); 25 | expect(Object.prototype.hasOwnProperty.call(testController, 'secondClass')).toBe(false); 26 | expect(testController.firstClass).toBe('first'); 27 | expect(testController.secondClass).toBe('second'); 28 | }); 29 | 30 | it('should delete `@Class` decorated properties and access Stimulus getters of parent and child controllers separately', async () => { 31 | @TypedController 32 | class ParentController extends Controller { 33 | @Class firstClass!: string; 34 | } 35 | 36 | @TypedController 37 | class ChildController extends ParentController { 38 | @Class secondClass!: string; 39 | } 40 | 41 | const { parent: parentController, child: childController } = await startApplication( 42 | { parent: ParentController, child: ChildController }, 43 | ` 44 |
45 |
46 | `, 47 | ); 48 | 49 | expect(Object.prototype.hasOwnProperty.call(parentController, 'firstClass')).toBe(false); 50 | expect(Object.prototype.hasOwnProperty.call(parentController, 'secondClass')).toBe(false); 51 | expect(parentController.firstClass).toBe('first'); 52 | expect((parentController as ChildController).secondClass).toBeUndefined(); 53 | 54 | expect(Object.prototype.hasOwnProperty.call(childController, 'firstClass')).toBe(false); 55 | expect(Object.prototype.hasOwnProperty.call(childController, 'secondClass')).toBe(false); 56 | expect(childController.firstClass).toBe('first'); 57 | expect(childController.secondClass).toBe('second'); 58 | }); 59 | }); 60 | 61 | describe('@Classes', () => { 62 | it('should delete `@Classes` decorated properties and access Stimulus getters', async () => { 63 | @TypedController 64 | class TestController extends Controller { 65 | @Classes firstClasses!: string[]; 66 | @Classes secondClasses!: string[]; 67 | } 68 | 69 | const { test: testController } = await startApplication( 70 | { test: TestController }, 71 | '
', 72 | ); 73 | 74 | expect(Object.prototype.hasOwnProperty.call(testController, 'firstClasses')).toBe(false); 75 | expect(Object.prototype.hasOwnProperty.call(testController, 'secondClasses')).toBe(false); 76 | expect(testController.firstClasses).toBeInstanceOf(Array); 77 | expect(testController.secondClasses).toBeInstanceOf(Array); 78 | }); 79 | 80 | it('should delete `@Classes` decorated properties and access Stimulus getters of parent and child controllers separately', async () => { 81 | @TypedController 82 | class ParentController extends Controller { 83 | @Classes firstClasses!: string[]; 84 | } 85 | 86 | @TypedController 87 | class ChildController extends ParentController { 88 | @Classes secondClasses!: string[]; 89 | } 90 | 91 | const { parent: parentController, child: childController } = await startApplication( 92 | { parent: ParentController, child: ChildController }, 93 | ` 94 |
95 |
96 | `, 97 | ); 98 | 99 | expect(Object.prototype.hasOwnProperty.call(parentController, 'firstTarget')).toBe(false); 100 | expect(Object.prototype.hasOwnProperty.call(parentController, 'secondTarget')).toBe(false); 101 | expect(parentController.firstClasses).toStrictEqual(['parent-first']); 102 | expect((parentController as ChildController).secondClasses).toBeUndefined(); 103 | 104 | expect(Object.prototype.hasOwnProperty.call(childController, 'firstTarget')).toBe(false); 105 | expect(Object.prototype.hasOwnProperty.call(childController, 'secondTarget')).toBe(false); 106 | expect(childController.firstClasses).toStrictEqual(['child-first']); 107 | expect(childController.secondClasses).toStrictEqual(['child-second']); 108 | }); 109 | }); 110 | 111 | describe('@Target', () => { 112 | it('should delete `@Target` decorated properties and access Stimulus getters', async () => { 113 | @TypedController 114 | class TestController extends Controller { 115 | @Target firstTarget!: HTMLElement; 116 | @Target secondTarget!: HTMLElement; 117 | } 118 | 119 | const { test: testController } = await startApplication( 120 | { test: TestController }, 121 | ` 122 |
123 |
124 |
125 |
126 | `, 127 | ); 128 | 129 | expect(Object.prototype.hasOwnProperty.call(testController, 'firstTarget')).toBe(false); 130 | expect(Object.prototype.hasOwnProperty.call(testController, 'secondTarget')).toBe(false); 131 | expect(testController.firstTarget).toBeInstanceOf(Element); 132 | expect(testController.secondTarget).toBeInstanceOf(Element); 133 | }); 134 | 135 | it('should delete `@Target` decorated properties and access Stimulus getters of parent and child controllers separately', async () => { 136 | @TypedController 137 | class ParentController extends Controller { 138 | @Target firstTarget!: HTMLElement; 139 | } 140 | 141 | @TypedController 142 | class ChildController extends ParentController { 143 | @Target secondTarget!: HTMLElement; 144 | } 145 | 146 | const { parent: parentController, child: childController } = await startApplication( 147 | { parent: ParentController, child: ChildController }, 148 | ` 149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 | `, 158 | ); 159 | 160 | expect(Object.prototype.hasOwnProperty.call(parentController, 'firstTarget')).toBe(false); 161 | expect(Object.prototype.hasOwnProperty.call(parentController, 'secondTarget')).toBe(false); 162 | expect(parentController.firstTarget).toBeInstanceOf(Element); 163 | expect((parentController as ChildController).secondTarget).toBeUndefined(); 164 | 165 | expect(Object.prototype.hasOwnProperty.call(childController, 'firstTarget')).toBe(false); 166 | expect(Object.prototype.hasOwnProperty.call(childController, 'secondTarget')).toBe(false); 167 | expect(childController.firstTarget).toBeInstanceOf(Element); 168 | expect(childController.secondTarget).toBeInstanceOf(Element); 169 | }); 170 | }); 171 | 172 | describe('@Targets', () => { 173 | it('should delete `@Targets` decorated properties and access Stimulus getters', async () => { 174 | @TypedController 175 | class TestController extends Controller { 176 | @Targets firstTargets!: HTMLElement[]; 177 | @Targets secondTargets!: HTMLElement[]; 178 | } 179 | 180 | const { test: testController } = await startApplication( 181 | { test: TestController }, 182 | ` 183 |
184 |
185 |
186 |
187 | `, 188 | ); 189 | 190 | expect(Object.prototype.hasOwnProperty.call(testController, 'firstTargets')).toBe(false); 191 | expect(Object.prototype.hasOwnProperty.call(testController, 'secondTargets')).toBe(false); 192 | expect(testController.firstTargets).toBeInstanceOf(Array); 193 | expect(testController.secondTargets).toBeInstanceOf(Array); 194 | }); 195 | 196 | it('should delete `@Targets` decorated properties and access Stimulus getters of parent and child controllers separately', async () => { 197 | @TypedController 198 | class ParentController extends Controller { 199 | @Targets firstTargets!: HTMLElement[]; 200 | } 201 | 202 | @TypedController 203 | class ChildController extends ParentController { 204 | @Targets secondTargets!: HTMLElement[]; 205 | } 206 | 207 | const { parent: parentController, child: childController } = await startApplication( 208 | { parent: ParentController, child: ChildController }, 209 | ` 210 |
211 |
212 |
213 |
214 |
215 |
216 |
217 |
218 | `, 219 | ); 220 | 221 | expect(Object.prototype.hasOwnProperty.call(parentController, 'firstTargets')).toBe(false); 222 | expect(Object.prototype.hasOwnProperty.call(parentController, 'secondTargets')).toBe(false); 223 | expect(parentController.firstTargets).toBeInstanceOf(Array); 224 | expect((parentController as ChildController).secondTargets).toBeUndefined(); 225 | 226 | expect(Object.prototype.hasOwnProperty.call(childController, 'firstTargets')).toBe(false); 227 | expect(Object.prototype.hasOwnProperty.call(childController, 'secondTargets')).toBe(false); 228 | expect(childController.firstTargets).toBeInstanceOf(Array); 229 | expect(childController.secondTargets).toBeInstanceOf(Array); 230 | }); 231 | }); 232 | 233 | describe('@Value', () => { 234 | it('should delete `@Value` decorated properties and access Stimulus getters', async () => { 235 | @TypedController 236 | class TestController extends Controller { 237 | @Value firstValue!: string; 238 | @Value secondValue!: number; 239 | } 240 | 241 | const { test: testController } = await startApplication( 242 | { test: TestController }, 243 | '
', 244 | ); 245 | 246 | expect(Object.prototype.hasOwnProperty.call(testController, 'firstValue')).toBe(false); 247 | expect(Object.prototype.hasOwnProperty.call(testController, 'secondValue')).toBe(false); 248 | expect(testController.firstValue).toBeDefined(); 249 | expect(testController.secondValue).toBeDefined(); 250 | }); 251 | 252 | it('should delete `@Value` decorated properties, assign default values and access Stimulus getters when values are assigned', async () => { 253 | @TypedController 254 | class TestController extends Controller { 255 | @Value firstValue: string = 'value'; 256 | @Value secondValue: number = 5; 257 | } 258 | 259 | const { test: testController } = await startApplication( 260 | { test: TestController }, 261 | ` 262 |
263 | `, 264 | ); 265 | 266 | expect(Object.prototype.hasOwnProperty.call(testController, 'firstValue')).toBe(false); 267 | expect(Object.prototype.hasOwnProperty.call(testController, 'secondValue')).toBe(false); 268 | expect(testController.firstValue).toBe('value'); 269 | expect(testController.secondValue).toBe(5); 270 | }); 271 | 272 | it('should delete `@Value` decorated properties, assign default values and access Stimulus getters when values are assigned of parent and child controllers separately', async () => { 273 | @TypedController 274 | class ParentController extends Controller { 275 | @Value firstValue: string = 'value'; 276 | } 277 | 278 | @TypedController 279 | class ChildController extends ParentController { 280 | @Value secondValue: number = 5; 281 | } 282 | 283 | const { parent: parentController, child: childController } = await startApplication( 284 | { parent: ParentController, child: ChildController }, 285 | ` 286 |
287 |
288 | `, 289 | ); 290 | 291 | expect(Object.prototype.hasOwnProperty.call(parentController, 'firstValue')).toBe(false); 292 | expect(Object.prototype.hasOwnProperty.call(parentController, 'secondValue')).toBe(false); 293 | expect(parentController.firstValue).toBe('value'); 294 | expect((parentController as ChildController).secondValue).toBeUndefined(); 295 | 296 | expect(Object.prototype.hasOwnProperty.call(childController, 'firstValue')).toBe(false); 297 | expect(Object.prototype.hasOwnProperty.call(childController, 'secondValue')).toBe(false); 298 | expect(childController.firstValue).toBe('value'); 299 | expect(childController.secondValue).toBe(5); 300 | }); 301 | }); 302 | }); 303 | --------------------------------------------------------------------------------