diff --git a/downstream_projects.json b/downstream_projects.json index 9cbc3ee4c..3f8d8d668 100644 --- a/downstream_projects.json +++ b/downstream_projects.json @@ -1,8 +1,9 @@ { "packageDir": "./dist", "projects": { - "sample-app-angular": "https://github.com/ui-router/sample-app-angular.git", + "sample-app-angular": "https://github.com/lindolo25/sample-app-angular.git", "angular18": "./test-angular-versions/v18", + "angular18standalone": "./test-angular-versions/v18-standalone", "typescript54": "./test-typescript-versions/typescript5.4" } } diff --git a/package.json b/package.json index 304e7ddae..90b773081 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@uirouter/angular", "description": "State-based routing for Angular", - "version": "14.0.0", + "version": "14.1.0", "scripts": { "clean": "shx rm -rf lib lib-esm _bundles _doc dist", "compile": "npm run clean && ngc", diff --git a/src/directives/uiSref.ts b/src/directives/uiSref.ts index 3b9055f90..558b37350 100644 --- a/src/directives/uiSref.ts +++ b/src/directives/uiSref.ts @@ -17,7 +17,10 @@ import { ReplaySubject, Subscription } from 'rxjs'; * @internal * # blah blah blah */ -@Directive({ selector: 'a[uiSref]' }) +@Directive({ + selector: 'a[uiSref]', + standalone: true +}) export class AnchorUISref { constructor(public _el: ElementRef, public _renderer: Renderer2) {} @@ -78,6 +81,7 @@ export class AnchorUISref { @Directive({ selector: '[uiSref]', exportAs: 'uiSref', + standalone: true }) export class UISref implements OnChanges { /** diff --git a/src/directives/uiSrefActive.ts b/src/directives/uiSrefActive.ts index 2884d40b4..478dea2c7 100644 --- a/src/directives/uiSrefActive.ts +++ b/src/directives/uiSrefActive.ts @@ -82,6 +82,11 @@ import { Subscription } from 'rxjs'; */ @Directive({ selector: '[uiSrefActive],[uiSrefActiveEq]', + hostDirectives: [{ + directive: UISrefStatus, + outputs: ['uiSrefStatus'] + }], + standalone: true }) export class UISrefActive { private _classes: string[] = []; diff --git a/src/directives/uiSrefStatus.ts b/src/directives/uiSrefStatus.ts index 66cc8a713..c86f52a92 100644 --- a/src/directives/uiSrefStatus.ts +++ b/src/directives/uiSrefStatus.ts @@ -180,8 +180,9 @@ function mergeSrefStatus(left: SrefStatus, right: SrefStatus): SrefStatus { * This API is subject to change. */ @Directive({ - selector: '[uiSrefStatus],[uiSrefActive],[uiSrefActiveEq]', + selector: '[uiSrefStatus]', exportAs: 'uiSrefStatus', + standalone: true }) export class UISrefStatus { /** current statuses of the state/params the uiSref directive is linking to */ diff --git a/src/directives/uiView.ts b/src/directives/uiView.ts index 724978c70..e181d62a6 100755 --- a/src/directives/uiView.ts +++ b/src/directives/uiView.ts @@ -1,13 +1,14 @@ import { Component, - ComponentFactory, - ComponentFactoryResolver, + ComponentMirror, ComponentRef, Inject, Injector, Input, OnDestroy, OnInit, + reflectComponentType, + Type, ViewChild, ViewContainerRef, } from '@angular/core'; @@ -33,6 +34,7 @@ import { } from '@uirouter/core'; import { Ng2ViewConfig } from '../statebuilders/views'; import { MergeInjector } from '../mergeInjector'; +import { CommonModule } from '@angular/common'; /** @hidden */ let id = 0; @@ -57,8 +59,8 @@ interface InputMapping { * * @internal */ -const ng2ComponentInputs = (factory: ComponentFactory): InputMapping[] => { - return factory.inputs.map((input) => ({ prop: input.propName, token: input.templateName })); +function ng2ComponentInputs(mirror: ComponentMirror): InputMapping[] { + return mirror.inputs.map((input) => ({ prop: input.templateName, token: input.templateName })); }; /** @@ -110,6 +112,8 @@ const ng2ComponentInputs = (factory: ComponentFactory): InputMapping[] => { @Component({ selector: 'ui-view, [ui-view]', exportAs: 'uiView', + standalone: true, + imports: [CommonModule], template: ` @@ -290,12 +294,9 @@ export class UIView implements OnInit, OnDestroy { const componentClass = config.viewDecl.component; // Create the component - const compFactoryResolver = componentInjector.get(ComponentFactoryResolver); - const compFactory = compFactoryResolver.resolveComponentFactory(componentClass); - this._componentRef = this._componentTarget.createComponent(compFactory, undefined, componentInjector); - + this._componentRef = this._componentTarget.createComponent(componentClass, { injector: componentInjector }); // Wire resolves to @Input()s - this._applyInputBindings(compFactory, this._componentRef.instance, context, componentClass); + this._applyInputBindings(componentClass, this._componentRef, context); } /** @@ -324,7 +325,7 @@ export class UIView implements OnInit, OnDestroy { const moduleInjector = context.getResolvable(NATIVE_INJECTOR_TOKEN).data; const mergedParentInjector = new MergeInjector(moduleInjector, parentComponentInjector); - return Injector.create(newProviders, mergedParentInjector); + return Injector.create({ providers: newProviders, parent: mergedParentInjector }); } /** @@ -333,25 +334,19 @@ export class UIView implements OnInit, OnDestroy { * Finds component inputs which match resolves (by name) and sets the input value * to the resolve data. */ - private _applyInputBindings(factory: ComponentFactory, component: any, context: ResolveContext, componentClass) { + private _applyInputBindings(component: Type, componentRef: ComponentRef, context: ResolveContext): void { const bindings = this._uiViewData.config.viewDecl['bindings'] || {}; const explicitBoundProps = Object.keys(bindings); - - // Returns the actual component property for a renamed an input renamed using `@Input('foo') _foo`. - // return the `_foo` property - const renamedInputProp = (prop: string) => { - const input = factory.inputs.find((i) => i.templateName === prop); - return (input && input.propName) || prop; - }; + const mirror = reflectComponentType(component); // Supply resolve data to component as specified in the state's `bindings: {}` const explicitInputTuples = explicitBoundProps.reduce( - (acc, key) => acc.concat([{ prop: renamedInputProp(key), token: bindings[key] }]), + (acc, key) => acc.concat([{ prop: key, token: bindings[key] }]), [] ); // Supply resolve data to matching @Input('prop') or inputs: ['prop'] - const implicitInputTuples = ng2ComponentInputs(factory).filter((tuple) => !inArray(explicitBoundProps, tuple.prop)); + const implicitInputTuples = ng2ComponentInputs(mirror).filter((tuple) => !inArray(explicitBoundProps, tuple.prop)); const addResolvable = (tuple: InputMapping) => ({ prop: tuple.prop, @@ -365,7 +360,7 @@ export class UIView implements OnInit, OnDestroy { .map(addResolvable) .filter((tuple) => tuple.resolvable && tuple.resolvable.resolved) .forEach((tuple) => { - component[tuple.prop] = injector.get(tuple.resolvable.token); + componentRef.setInput(tuple.prop, injector.get(tuple.resolvable.token)); }); } } diff --git a/src/index.ts b/src/index.ts index 111cd4936..b497146bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,5 +7,6 @@ export * from './statebuilders/lazyLoad'; export * from './statebuilders/views'; export * from './uiRouterConfig'; export * from './uiRouterNgModule'; +export * from './provideUiRouter'; export * from '@uirouter/core'; diff --git a/src/interface.ts b/src/interface.ts index 76e341dae..c9a0aee6e 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,6 +1,6 @@ import { StateDeclaration, _ViewDeclaration, Transition, HookResult } from '@uirouter/core'; import { Component, Type } from '@angular/core'; -import { ModuleTypeCallback } from './lazyLoad/lazyLoadNgModule'; +import { ComponentTypeCallback, ModuleTypeCallback } from './lazyLoad/lazyLoadNgModule'; /** * The StateDeclaration object is used to define a state or nested state. @@ -25,7 +25,7 @@ import { ModuleTypeCallback } from './lazyLoad/lazyLoadNgModule'; * } * ``` */ -export interface Ng2StateDeclaration extends StateDeclaration, Ng2ViewDeclaration { +export interface Ng2StateDeclaration extends StateDeclaration, Ng2ViewDeclaration { /** * An optional object used to define multiple named views. * @@ -152,10 +152,28 @@ export interface Ng2StateDeclaration extends StateDeclaration, Ng2ViewDeclaratio * } * ``` */ - loadChildren?: ModuleTypeCallback; + loadChildren?: ModuleTypeCallback; + + /** + * A function used to lazy load a `Component`. + * + * When the state is activate the `loadComponent` property should lazy load a standalone `Component` + * and use it to render the view of the state + * + * ### Example: + * ```ts + * var homeState = { + * name: 'home', + * url: '/home', + * loadComponent: () => import('./home/home.component') + * .then(result => result.HomeComponent) + * } + * ``` + */ + loadComponent?: ComponentTypeCallback; } -export interface Ng2ViewDeclaration extends _ViewDeclaration { +export interface Ng2ViewDeclaration extends _ViewDeclaration { /** * The `Component` class to use for this view. * @@ -238,7 +256,7 @@ export interface Ng2ViewDeclaration extends _ViewDeclaration { * } * ``` */ - component?: Type; + component?: Type; /** * An object which maps `resolve` keys to [[component]] `bindings`. diff --git a/src/lazyLoad/lazyLoadNgModule.ts b/src/lazyLoad/lazyLoadNgModule.ts index e4d362ac1..2508ccb31 100644 --- a/src/lazyLoad/lazyLoadNgModule.ts +++ b/src/lazyLoad/lazyLoadNgModule.ts @@ -1,11 +1,10 @@ -import { NgModuleRef, Injector, NgModuleFactory, Type, Compiler } from '@angular/core'; +import { NgModuleRef, Injector, Type, createNgModule, InjectionToken, isStandalone } from '@angular/core'; import { Transition, LazyLoadResult, UIRouter, Resolvable, NATIVE_INJECTOR_TOKEN, - isString, unnestR, inArray, StateObject, @@ -15,6 +14,7 @@ import { import { UIROUTER_MODULE_TOKEN, UIROUTER_ROOT_MODULE } from '../injectionTokens'; import { RootModule, StatesModule } from '../uiRouterNgModule'; import { applyModuleConfig } from '../uiRouterConfig'; +import { Ng2StateDeclaration } from '../interface'; /** * A function that returns an NgModule, or a promise for an NgModule @@ -26,7 +26,7 @@ import { applyModuleConfig } from '../uiRouterConfig'; * } * ``` */ -export type ModuleTypeCallback = () => Type | Promise>; +export type ModuleTypeCallback = () => Type | Promise>; /** * Returns a function which lazy loads a nested module @@ -67,17 +67,15 @@ export type ModuleTypeCallback = () => Type | Promise>; * - Finds the "replacement state" for the target state, and adds the new NgModule Injector to it (as a resolve) * - Returns the new states array */ -export function loadNgModule( - moduleToLoad: ModuleTypeCallback +export function loadNgModule( + moduleToLoad: ModuleTypeCallback ): (transition: Transition, stateObject: StateDeclaration) => Promise { return (transition: Transition, stateObject: StateDeclaration) => { - const ng2Injector = transition.injector().get(NATIVE_INJECTOR_TOKEN); - - const createModule = (factory: NgModuleFactory) => factory.create(ng2Injector); - const applyModule = (moduleRef: NgModuleRef) => applyNgModule(transition, moduleRef, ng2Injector, stateObject); + const ng2Injector = transition.injector().get(NATIVE_INJECTOR_TOKEN); - return loadModuleFactory(moduleToLoad, ng2Injector).then(createModule).then(applyModule); + return loadModuleFactory(moduleToLoad, ng2Injector) + .then(moduleRef => applyNgModule(moduleRef, ng2Injector, stateObject)); }; } @@ -90,22 +88,18 @@ export function loadNgModule( * * @internal */ -export function loadModuleFactory( - moduleToLoad: ModuleTypeCallback, +export function loadModuleFactory( + moduleToLoad: ModuleTypeCallback, ng2Injector: Injector -): Promise> { - const compiler: Compiler = ng2Injector.get(Compiler); - - const unwrapEsModuleDefault = (x) => (x && x.__esModule && x['default'] ? x['default'] : x); +): Promise> { return Promise.resolve(moduleToLoad()) - .then(unwrapEsModuleDefault) - .then((t: NgModuleFactory | Type) => { - if (t instanceof NgModuleFactory) { - return t; - } - return compiler.compileModuleAsync(t); - }); + .then(_unwrapEsModuleDefault) + .then((t: Type) => createNgModule(t, ng2Injector)); +} + +function _unwrapEsModuleDefault(x) { + return x && x.__esModule && x['default'] ? x['default'] : x; } /** @@ -122,9 +116,8 @@ export function loadModuleFactory( * * @internal */ -export function applyNgModule( - transition: Transition, - ng2Module: NgModuleRef, +export function applyNgModule( + ng2Module: NgModuleRef, parentInjector: Injector, lazyLoadState: StateDeclaration ): LazyLoadResult { @@ -192,8 +185,58 @@ export function applyNgModule( * * @internal */ -export function multiProviderParentChildDelta(parent: Injector, child: Injector, token: any) { - const childVals: RootModule[] = child.get(token, []); - const parentVals: RootModule[] = parent.get(token, []); +export function multiProviderParentChildDelta(parent: Injector, child: Injector, token: InjectionToken): RootModule[] { + const childVals: RootModule[] = child.get(token, []); + const parentVals: RootModule[] = parent.get(token, []); return childVals.filter((val) => parentVals.indexOf(val) === -1); } + +/** + * A function that returns a Component, or a promise for a Component + * + * #### Example: + * ```ts + * export function loadFooComponent() { + * return import('../foo/foo.component').then(result => result.FooComponent); + * } + * ``` + */ +export type ComponentTypeCallback = ModuleTypeCallback; + +export function loadComponent( + callback: ComponentTypeCallback +): (transition: Transition, stateObject: Ng2StateDeclaration) => Promise { + return (transition: Transition, stateObject: Ng2StateDeclaration) => { + + return Promise.resolve(callback()) + .then(_unwrapEsModuleDefault) + .then((component: Type) => applyComponent(component, transition, stateObject)) + } +} + +/** + * @internal + * @param component + * @param transition + * @param stateObject + */ +export function applyComponent( + component: Type, + transition: Transition, + stateObject: Ng2StateDeclaration +): LazyLoadResult { + + if (!isStandalone(component)) throw _notStandaloneError(); + + const registry = transition.router.stateRegistry; + const current = stateObject.component; + stateObject.component = component || current; + const removed = registry.deregister(stateObject).map(child => child.self); + const children = removed.filter(i => i.name != stateObject.name); + + return { states: [stateObject, ...children] } +} + +function _notStandaloneError(): Error { + return new Error("Is not standalone."); +} diff --git a/src/provideUiRouter.ts b/src/provideUiRouter.ts new file mode 100644 index 000000000..4cfe33908 --- /dev/null +++ b/src/provideUiRouter.ts @@ -0,0 +1,37 @@ +import { EnvironmentProviders, makeEnvironmentProviders } from "@angular/core"; +import { locationStrategy, makeRootProviders, RootModule } from "./uiRouterNgModule"; +import { _UIROUTER_INSTANCE_PROVIDERS, _UIROUTER_SERVICE_PROVIDERS } from "./providers"; + +/** + * Sets up providers necessary to enable UI-Router for the application. Intended as a replacement + * for [[UIRouterModule.forRoot]] in newer standalone based applications. + * + * Example: + * ```js + * const routerConfig = { + * otherwise: '/home', + * states: [homeState, aboutState] + * }; + * + * const appConfig: ApplicationConfig = { + * providers: [ + * provideZoneChangeDetection({ eventCoalescing: true }), + * provideUIRouter(routerConfig) + * ] + * }; + * + * bootstrapApplication(AppComponent, appConfig) + * .catch((err) => console.error(err)); + * ``` + * + * @param config declarative UI-Router configuration + * @returns an `EnvironmentProviders` which provides the [[UIRouter]] singleton instance + */ +export function provideUIRouter(config: RootModule = {}): EnvironmentProviders { + return makeEnvironmentProviders([ + _UIROUTER_INSTANCE_PROVIDERS, + _UIROUTER_SERVICE_PROVIDERS, + locationStrategy(config.useHash), + ...makeRootProviders(config), + ]); +} diff --git a/src/statebuilders/lazyLoad.ts b/src/statebuilders/lazyLoad.ts index 92e02fd06..2798282e7 100644 --- a/src/statebuilders/lazyLoad.ts +++ b/src/statebuilders/lazyLoad.ts @@ -1,6 +1,6 @@ import { LazyLoadResult, Transition, StateDeclaration } from '@uirouter/core'; // has or is using import { BuilderFunction, StateObject } from '@uirouter/core'; -import { loadNgModule } from '../lazyLoad/lazyLoadNgModule'; +import { loadComponent, loadNgModule } from '../lazyLoad/lazyLoadNgModule'; /** * This is a [[StateBuilder.builder]] function for ngModule lazy loading in Angular. @@ -46,6 +46,7 @@ import { loadNgModule } from '../lazyLoad/lazyLoadNgModule'; * */ export function ng2LazyLoadBuilder(state: StateObject, parent: BuilderFunction) { + const loadComponentFn = state['loadComponent']; const loadNgModuleFn = state['loadChildren']; - return loadNgModuleFn ? loadNgModule(loadNgModuleFn) : state.lazyLoad; + return loadComponentFn ? loadComponent(loadComponentFn) : loadNgModuleFn ? loadNgModule(loadNgModuleFn) : state.lazyLoad; } diff --git a/src/uiRouterNgModule.ts b/src/uiRouterNgModule.ts index 033ec735b..0275636e5 100644 --- a/src/uiRouterNgModule.ts +++ b/src/uiRouterNgModule.ts @@ -7,9 +7,8 @@ import { Injector, APP_INITIALIZER, } from '@angular/core'; -import { CommonModule, LocationStrategy, HashLocationStrategy, PathLocationStrategy } from '@angular/common'; +import { LocationStrategy, HashLocationStrategy, PathLocationStrategy } from '@angular/common'; import { _UIROUTER_DIRECTIVES } from './directives/directives'; -import { UIView } from './directives/uiView'; import { UrlRuleHandlerFn, TargetState, TargetStateDef, UIRouter, TransitionService } from '@uirouter/core'; import { _UIROUTER_INSTANCE_PROVIDERS, _UIROUTER_SERVICE_PROVIDERS } from './providers'; @@ -71,8 +70,9 @@ export function locationStrategy(useHash) { * This enables UI-Router to automatically register the states with the [[StateRegistry]] at bootstrap (and during lazy load). */ @NgModule({ - imports: [CommonModule], - declarations: [_UIROUTER_DIRECTIVES], + imports: [ + _UIROUTER_DIRECTIVES + ], exports: [_UIROUTER_DIRECTIVES], }) export class UIRouterModule { diff --git a/test-angular-versions/v18-standalone/README.md b/test-angular-versions/v18-standalone/README.md new file mode 100644 index 000000000..b3fa584f5 --- /dev/null +++ b/test-angular-versions/v18-standalone/README.md @@ -0,0 +1,27 @@ +# V18 + +This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.7. + +## Development server + +Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files. + +## Code scaffolding + +Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. + +## Build + +Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. + +## Running unit tests + +Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). + +## Running end-to-end tests + +Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities. + +## Further help + +To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/test-angular-versions/v18-standalone/angular.json b/test-angular-versions/v18-standalone/angular.json new file mode 100644 index 000000000..2e2421af2 --- /dev/null +++ b/test-angular-versions/v18-standalone/angular.json @@ -0,0 +1,124 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "packageManager": "yarn" + }, + "newProjectRoot": "projects", + "projects": { + "v18": { + "projectType": "application", + "schematics": { + "@schematics/angular:class": { + "skipTests": true + }, + "@schematics/angular:component": { + "skipTests": true + }, + "@schematics/angular:directive": { + "skipTests": true + }, + "@schematics/angular:guard": { + "skipTests": true + }, + "@schematics/angular:interceptor": { + "skipTests": true + }, + "@schematics/angular:pipe": { + "skipTests": true + }, + "@schematics/angular:resolver": { + "skipTests": true + }, + "@schematics/angular:service": { + "skipTests": true + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/v18", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kB", + "maximumError": "4kB" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "v18:build:production" + }, + "development": { + "buildTarget": "v18:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "assets": [ + { + "glob": "**/*", + "input": "public" + } + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + } + } + } + } + } +} diff --git a/test-angular-versions/v18-standalone/cypress.config.ts b/test-angular-versions/v18-standalone/cypress.config.ts new file mode 100644 index 000000000..d764a065c --- /dev/null +++ b/test-angular-versions/v18-standalone/cypress.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'cypress'; + +export default defineConfig({ + video: false, + e2e: { + setupNodeEvents(on, config) {}, + baseUrl: 'http://localhost:4000', + supportFile: false + }, +}) diff --git a/test-angular-versions/v18-standalone/cypress/e2e/sample_app.cy.js b/test-angular-versions/v18-standalone/cypress/e2e/sample_app.cy.js new file mode 100644 index 000000000..f01159c7f --- /dev/null +++ b/test-angular-versions/v18-standalone/cypress/e2e/sample_app.cy.js @@ -0,0 +1,69 @@ +describe('Angular app', () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + it('loads', () => { + cy.visit(''); + }); + + it('loads home state by default', () => { + cy.visit(''); + cy.url().should('include', '/home'); + }); + + it('renders uisref as links', () => { + cy.visit(''); + cy.get('a').contains('home'); + cy.get('a').contains('about'); + cy.get('a').contains('lazy'); + cy.get('a').contains('lazy.child'); + cy.get('a').contains('lazy.child.viewtarget'); + }); + + it('renders home', () => { + cy.visit('/home'); + cy.get('a').contains('home').should('have.class', 'active'); + cy.get('a').contains('about').should('not.have.class', 'active'); + cy.get('#default').contains('home works'); + }); + + it('renders about', () => { + cy.visit('/home'); + cy.visit('/about'); + cy.get('a').contains('home').should('not.have.class', 'active'); + cy.get('a').contains('about').should('have.class', 'active'); + cy.get('#default').contains('about works'); + }); + + it('loads lazy routes', () => { + cy.visit('/home'); + cy.visit('/lazy'); + cy.get('a').contains('home').should('not.have.class', 'active'); + cy.get('a').contains('lazy').should('have.class', 'active'); + cy.get('#default').contains('lazy works'); + }); + + it('routes to lazy routes', () => { + cy.visit('/lazy'); + cy.get('a').contains('home').should('not.have.class', 'active'); + cy.get('a').contains('lazy').should('have.class', 'active'); + cy.get('#default').contains('lazy works'); + }); + + it('routes to lazy child routes', () => { + cy.visit('/lazy/child'); + cy.get('a').contains('home').should('not.have.class', 'active'); + cy.get('a').contains('lazy.child').should('have.class', 'active'); + cy.get('#default').contains('lazy.child works'); + }); + + it('targets named views', () => { + cy.visit('/lazy/child/viewtarget'); + cy.get('a').contains('home').should('not.have.class', 'active'); + cy.get('a').contains('lazy.child').should('have.class', 'active'); + cy.get('#default').contains('lazy.child works'); + cy.get('#header').contains('lazy.child.viewtarget works'); + cy.get('#footer').contains('lazy.child.viewtarget works'); + }); +}); diff --git a/test-angular-versions/v18-standalone/package.json b/test-angular-versions/v18-standalone/package.json new file mode 100644 index 000000000..26365b60c --- /dev/null +++ b/test-angular-versions/v18-standalone/package.json @@ -0,0 +1,50 @@ +{ + "name": "v18", + "version": "0.0.0", + "scripts": { + "ng": "ng", + "start": "ng serve", + "build": "ng build", + "watch": "ng build --watch --configuration development", + "test": "npm run test:dev && npm run test:prod", + "test:dev": "ng build --configuration development && cypress-runner run --path dist/v18/browser", + "test:prod": "ng build --configuration production && cypress-runner run --path dist/v18/browser" + }, + "private": true, + "dependencies": { + "@angular/animations": "^18.0.0", + "@angular/common": "^18.0.0", + "@angular/compiler": "^18.0.0", + "@angular/core": "^18.0.0", + "@angular/forms": "^18.0.0", + "@angular/platform-browser": "^18.0.0", + "@angular/platform-browser-dynamic": "^18.0.0", + "@angular/router": "^18.0.0", + "rxjs": "~7.8.0", + "tslib": "^2.3.0", + "zone.js": "~0.14.3", + "@uirouter/angular": "*", + "@uirouter/cypress-runner": "*", + "@uirouter/core": "*", + "@uirouter/rx": "*" + }, + "devDependencies": { + "@angular-devkit/build-angular": "^18.0.7", + "@angular/cli": "^18.0.7", + "@angular/compiler-cli": "^18.0.0", + "@types/jasmine": "~5.1.0", + "jasmine-core": "~5.1.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.1.0", + "typescript": "~5.4.5" + }, + "checkPeerDependencies": { + "ignore": [ + "ajv", + "terser" + ] + } +} diff --git a/test-angular-versions/v18-standalone/public/favicon.ico b/test-angular-versions/v18-standalone/public/favicon.ico new file mode 100644 index 000000000..57614f9c9 Binary files /dev/null and b/test-angular-versions/v18-standalone/public/favicon.ico differ diff --git a/test-angular-versions/v18-standalone/src/app/about.component.ts b/test-angular-versions/v18-standalone/src/app/about.component.ts new file mode 100644 index 000000000..92e22051f --- /dev/null +++ b/test-angular-versions/v18-standalone/src/app/about.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-about', + template: `

about works!

`, + standalone: true, +}) +export class AboutComponent {} diff --git a/test-angular-versions/v18-standalone/src/app/app.component.ts b/test-angular-versions/v18-standalone/src/app/app.component.ts new file mode 100644 index 000000000..ac0e6f535 --- /dev/null +++ b/test-angular-versions/v18-standalone/src/app/app.component.ts @@ -0,0 +1,35 @@ +import { Component } from '@angular/core'; +import { AnchorUISref, UISref, UISrefActive, UIView } from '@uirouter/angular'; + +@Component({ + selector: 'app-root', + template: ` + + + +`, + styles: [ + ` + .app { + text-align: center; + border: 1px solid; + } + .active { + font-weight: bold; + } + `, + ], + standalone: true, + imports: [UIView, AnchorUISref, UISref, UISrefActive] +}) +export class AppComponent {} diff --git a/test-angular-versions/v18-standalone/src/app/app.config.ts b/test-angular-versions/v18-standalone/src/app/app.config.ts new file mode 100644 index 000000000..e4f2f542b --- /dev/null +++ b/test-angular-versions/v18-standalone/src/app/app.config.ts @@ -0,0 +1,10 @@ +import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { provideUIRouter } from '@uirouter/angular'; +import { states, config } from "./app.routes"; + +export const appConfig: ApplicationConfig = { + providers: [ + provideZoneChangeDetection({ eventCoalescing: true }), + provideUIRouter({ states: states, config: config }) + ] +}; diff --git a/test-angular-versions/v18-standalone/src/app/app.routes.ts b/test-angular-versions/v18-standalone/src/app/app.routes.ts new file mode 100644 index 000000000..defc3a10d --- /dev/null +++ b/test-angular-versions/v18-standalone/src/app/app.routes.ts @@ -0,0 +1,13 @@ +import { HomeComponent } from './home.component'; +import { AboutComponent } from './about.component'; +import { UIRouter } from '@uirouter/angular'; + +export const states = [ + { name: 'home', url: '/home', component: HomeComponent }, + { name: 'about', url: '/about', component: AboutComponent }, + { name: 'lazy.**', url: '/lazy', loadChildren: () => import('./lazy/lazy.module').then((m) => m.LazyModule) }, +]; + +export function config(router: UIRouter) { + router.urlService.rules.initial({ state: 'home' }); +} diff --git a/test-angular-versions/v18-standalone/src/app/home.component.ts b/test-angular-versions/v18-standalone/src/app/home.component.ts new file mode 100644 index 000000000..6fea45e5c --- /dev/null +++ b/test-angular-versions/v18-standalone/src/app/home.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-home', + template: `

home works!

`, + standalone: true, +}) +export class HomeComponent {} diff --git a/test-angular-versions/v18-standalone/src/app/lazy/lazy.component.ts b/test-angular-versions/v18-standalone/src/app/lazy/lazy.component.ts new file mode 100644 index 000000000..60b4fbf27 --- /dev/null +++ b/test-angular-versions/v18-standalone/src/app/lazy/lazy.component.ts @@ -0,0 +1,12 @@ +import { Component, Input } from '@angular/core'; + +@Component({ + selector: 'app-lazy', + template: ` +

{{ state.name }} works!

+ + `, +}) +export class LazyComponent { + @Input('$state$') state: any; +} diff --git a/test-angular-versions/v18-standalone/src/app/lazy/lazy.module.ts b/test-angular-versions/v18-standalone/src/app/lazy/lazy.module.ts new file mode 100644 index 000000000..74836c415 --- /dev/null +++ b/test-angular-versions/v18-standalone/src/app/lazy/lazy.module.ts @@ -0,0 +1,35 @@ +import { InjectionToken, NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { UIRouterModule } from '@uirouter/angular'; +import { LazyComponent } from './lazy.component'; + +export const states = [ + { name: 'lazy', url: '/lazy', component: LazyComponent }, + { + name: 'lazy.child', + url: '/child', + loadComponent: () => import("./lazy2.component").then(m => m.Lazy2Component) + }, + { + name: 'lazy.child.viewtarget', + url: '/viewtarget', + views: { + '!header': { component: LazyComponent }, + 'footer@': { component: LazyComponent }, + }, + }, +]; + +export const LAZY_PROVIDER_TOKE = new InjectionToken("lazyProvider"); + +@NgModule({ + imports: [CommonModule, UIRouterModule.forChild({ states: states })], + providers: [ + { + provide: LAZY_PROVIDER_TOKE, + useValue: "provided value" + } + ], + declarations: [LazyComponent], +}) +export class LazyModule {} diff --git a/test-angular-versions/v18-standalone/src/app/lazy/lazy2.component.ts b/test-angular-versions/v18-standalone/src/app/lazy/lazy2.component.ts new file mode 100644 index 000000000..aac212fdb --- /dev/null +++ b/test-angular-versions/v18-standalone/src/app/lazy/lazy2.component.ts @@ -0,0 +1,18 @@ +import { Component, inject, input } from '@angular/core'; +import { Ng2StateDeclaration, UIRouterModule } from '@uirouter/angular'; +import { LAZY_PROVIDER_TOKE } from './lazy.module'; + +@Component({ + selector: 'app-lazy', + standalone: true, + imports: [UIRouterModule], + template: ` +

{{ state().name }} works!

+

{{ _providedString }}

+ + `, +}) +export class Lazy2Component { + state = input.required({ alias: '$state$' }); + _providedString = inject(LAZY_PROVIDER_TOKE); +} diff --git a/test-angular-versions/v18-standalone/src/index.html b/test-angular-versions/v18-standalone/src/index.html new file mode 100644 index 000000000..9a21ef6c3 --- /dev/null +++ b/test-angular-versions/v18-standalone/src/index.html @@ -0,0 +1,13 @@ + + + + + V18 + + + + + + + + diff --git a/test-angular-versions/v18-standalone/src/main.ts b/test-angular-versions/v18-standalone/src/main.ts new file mode 100644 index 000000000..35b00f346 --- /dev/null +++ b/test-angular-versions/v18-standalone/src/main.ts @@ -0,0 +1,6 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, appConfig) + .catch((err) => console.error(err)); diff --git a/test-angular-versions/v18-standalone/src/styles.css b/test-angular-versions/v18-standalone/src/styles.css new file mode 100644 index 000000000..90d4ee007 --- /dev/null +++ b/test-angular-versions/v18-standalone/src/styles.css @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/test-angular-versions/v18-standalone/tsconfig.app.json b/test-angular-versions/v18-standalone/tsconfig.app.json new file mode 100644 index 000000000..3775b37e3 --- /dev/null +++ b/test-angular-versions/v18-standalone/tsconfig.app.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "types": [] + }, + "files": [ + "src/main.ts" + ], + "include": [ + "src/**/*.d.ts" + ] +} diff --git a/test-angular-versions/v18-standalone/tsconfig.json b/test-angular-versions/v18-standalone/tsconfig.json new file mode 100644 index 000000000..7f6dcedfe --- /dev/null +++ b/test-angular-versions/v18-standalone/tsconfig.json @@ -0,0 +1,33 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": false, + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "target": "ES2022", + "module": "ES2022", + "useDefineForClassFields": false, + "lib": [ + "ES2022", + "dom" + ] + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/test-angular-versions/v18-standalone/tsconfig.spec.json b/test-angular-versions/v18-standalone/tsconfig.spec.json new file mode 100644 index 000000000..5fb748d92 --- /dev/null +++ b/test-angular-versions/v18-standalone/tsconfig.spec.json @@ -0,0 +1,15 @@ +/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ +/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/test/uiView/resolveBinding.spec.ts b/test/uiView/resolveBinding.spec.ts index 35e95bf56..85aad77d5 100644 --- a/test/uiView/resolveBinding.spec.ts +++ b/test/uiView/resolveBinding.spec.ts @@ -1,4 +1,4 @@ -import { Component, Inject, Input } from '@angular/core'; +import { Component, Inject, input, Input } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Ng2StateDeclaration, UIRouterModule, UIView } from '../../src'; import { By } from '@angular/platform-browser'; @@ -18,6 +18,14 @@ describe('uiView', () => { @Input('resolve3') _resolve3; @Input('resolve4') _resolve4; @Input() resolve5; + @Input({ alias: 'resolve6' }) _resolve6; + @Input({ alias: 'resolve7' }) _resolve7; + @Input({ transform: (value: string) => `${value}1` }) resolve8; + resolve9 = input(""); + resolve10 = input(""); + _resolve11 = input("", { alias: 'resolve11' }); + _resolve12 = input("", { alias: 'resolve12' }); + resolve13 = input("", { transform: value => `${value}1` }); } let comp: ManyResolvesComponent; @@ -32,6 +40,9 @@ describe('uiView', () => { // component_input: 'resolve name' resolve2: 'Resolve2', resolve4: 'Resolve4', + resolve7: 'Resolve7', + resolve10: 'Resolve10', + resolve12: 'Resolve12' }, resolve: [ { token: 'resolve1', resolveFn: () => 'resolve1' }, @@ -39,6 +50,14 @@ describe('uiView', () => { { token: 'resolve3', resolveFn: () => 'resolve3' }, { token: 'Resolve4', resolveFn: () => 'resolve4' }, new Resolvable('resolve5', () => 'resolve5', [], { async: 'NOWAIT' }), + { token: 'resolve6', resolveFn: () => 'resolve6' }, + { token: 'Resolve7', resolveFn: () => 'resolve7' }, + { token: 'resolve8', resolveFn: () => 'resolve8' }, + { token: 'resolve9', resolveFn: () => 'resolve9' }, + { token: 'Resolve10', resolveFn: () => 'resolve10' }, + { token: 'resolve11', resolveFn: () => 'resolve11' }, + { token: 'Resolve12', resolveFn: () => 'resolve12' }, + { token: 'resolve13', resolveFn: () => 'resolve13' } ], }; @@ -80,6 +99,38 @@ describe('uiView', () => { expect(typeof comp.resolve5.then).toBe('function'); }); + it('should bind resolve by alias to component input templateName', () => { + expect(comp._resolve6).toBe('resolve6'); + }); + + it('should bind resolve by alias to the component input templateName specified in state `bindings`', () => { + expect(comp._resolve7).toBe('resolve7'); + }); + + it('should bind resolve to the component input name and transform its value', () => { + expect(comp.resolve8).toBe('resolve81'); + }); + + it('should bind resolve by name to component input signal name', () => { + expect(comp.resolve9()).toBe('resolve9'); + }); + + it('should bind resolve by name to the component input signal specified by `bindings`', () => { + expect(comp.resolve10()).toBe('resolve10'); + }); + + it('should bind resolve by name to component input signal templateName', () => { + expect(comp._resolve11()).toBe('resolve11'); + }); + + it('should bind resolve by name to the component input signal templateName specified in state `bindings`', () => { + expect(comp._resolve12()).toBe('resolve12'); + }); + + it('should bind resolve to the component input signal name and transform its value', () => { + expect(comp.resolve13()).toBe('resolve131'); + }); + ///////////////////////////////////////// it('should inject resolve by name to constructor', () => {