Skip to content

Commit

Permalink
feat: signal based configuration (#1593)
Browse files Browse the repository at this point in the history
Signal-based configuration

- Reducing boilerplate code
- Use of new feature: Angular Signal
- Usage of rxjs hidden behind `configSignal(...)`
- Still works with rxjs `config$ = toObservable(this.configSignal)`;

## Proposed change
```diff
export class MyComponent implements
-    OnChanges, DynamicConfigurable<MyConfig> {
+    DynamicConfigurableWithSignal<MyConfig> {
+   private readonly configService = inject(ConfigurationBaseService, { optional: true });

-   @input()
-   public config: Partial<MyConfig> | undefined;
+   public config = input<Partial<MyConfig>>();

-   @ConfigObserver()
-   private dynamicConfig$: ConfigurationObserver<MyConfig>;
-   public config$: Observable<MyConfig>;
+   @ConfigSignal()
+   public readonly configSignal = configSignal(
+     this.config,
+     MY_CONFIG_CONFIG_ID,
+     MY_CONFIG_DEFAULT_CONFIG,
+     this.configService
+   );

-   constructor(@optional() configService: ConfigurationBaseService) {
-      this.dynamicConfig$ = new ConfigurationObserver<MyConfig>(
-         MY_CONFIG_CONFIG_ID,
-         MY_CONFIG_DEFAULT_CONFIG,
-         configService
-      );
-      this.config$ = this.dynamicConfig$.asObservable();
-   }

-   public ngOnChanges(changes: SimpleChanges) {
-     if (changes.config) {
-       this.dynamicConfig$.next(this.config);
-     }
-   }
}
```
  • Loading branch information
matthieu-crouzet authored Apr 9, 2024
2 parents 55d6c04 + e71f70f commit 56e22af
Show file tree
Hide file tree
Showing 38 changed files with 705 additions and 126 deletions.
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { AsyncPipe, formatDate } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input, OnChanges, OnDestroy, Optional, SimpleChanges, ViewEncapsulation } from '@angular/core';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { ConfigObserver, ConfigurationBaseService, ConfigurationObserver, DynamicConfigurable } from '@o3r/configuration';
import { ChangeDetectionStrategy, Component, computed, effect, inject, input, ViewEncapsulation } from '@angular/core';
import { FormBuilder, FormControl, ReactiveFormsModule } from '@angular/forms';
import { configSignal, ConfigurationBaseService, DynamicConfigurableWithSignal, O3rConfig } from '@o3r/configuration';
import { O3rComponent } from '@o3r/core';
import { distinctUntilChanged, map, Observable, Subscription } from 'rxjs';
import { DatePickerInputPresComponent } from '../../utilities';
import { CONFIGURATION_PRES_CONFIG_ID, CONFIGURATION_PRES_DEFAULT_CONFIG, ConfigurationPresConfig } from './configuration-pres.config';

Expand All @@ -19,64 +18,55 @@ const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ConfigurationPresComponent implements OnChanges, OnDestroy, DynamicConfigurable<ConfigurationPresConfig> {
/** Configuration stream based on the input and the stored configuration*/
public config$: Observable<ConfigurationPresConfig>;
export class ConfigurationPresComponent implements DynamicConfigurableWithSignal<ConfigurationPresConfig> {
private readonly configurationService = inject(ConfigurationBaseService, { optional: true });
private readonly fb = inject(FormBuilder);

@ConfigObserver()
private readonly dynamicConfig$: ConfigurationObserver<ConfigurationPresConfig>;
/** Input configuration to override the default configuration of the component */
public config = input<Partial<ConfigurationPresConfig>>();
/** Configuration signal based on the input and the stored configuration */
@O3rConfig()
public configSignal = configSignal(this.config, CONFIGURATION_PRES_CONFIG_ID, CONFIGURATION_PRES_DEFAULT_CONFIG, this.configurationService);

/** Input configuration to override the default configuration of the component*/
@Input()
public config: Partial<ConfigurationPresConfig> | undefined;
public destinations = computed(() => this.configSignal().destinations);
public shouldProposeRoundTrip = computed(() => this.configSignal().shouldProposeRoundTrip);

/**
* Form group
*/
public form: FormGroup<{ destination: FormControl<string | null>; outboundDate: FormControl<string | null>; inboundDate: FormControl<string | null> }>;
public form = this.fb.group({
destination: new FormControl<string | null>(null),
outboundDate: new FormControl<string | null>(this.formatDate(Date.now() + 7 * ONE_DAY_IN_MS)),
inboundDate: new FormControl<string | null>(this.formatDate(Date.now() + 14 * ONE_DAY_IN_MS))
});

private readonly subscription = new Subscription();

constructor(@Optional() configurationService: ConfigurationBaseService, fb: FormBuilder) {
this.dynamicConfig$ = new ConfigurationObserver<ConfigurationPresConfig>(CONFIGURATION_PRES_CONFIG_ID, CONFIGURATION_PRES_DEFAULT_CONFIG, configurationService);
this.config$ = this.dynamicConfig$.asObservable();
this.form = fb.group({
destination: new FormControl<string | null>(null),
outboundDate: new FormControl<string | null>(this.formatDate(Date.now() + 7 * ONE_DAY_IN_MS)),
inboundDate: new FormControl<string | null>(this.formatDate(Date.now() + 14 * ONE_DAY_IN_MS))
});
this.subscription.add(
this.config$.pipe(map(({ inXDays }) => inXDays), distinctUntilChanged()).subscribe((inXDays) => {
this.form.controls.outboundDate.setValue(this.formatDate(Date.now() + inXDays * ONE_DAY_IN_MS));
constructor() {
const inXDays = computed(() => this.configSignal().inXDays);
effect(
() => {
this.form.controls.outboundDate.setValue(this.formatDate(Date.now() + inXDays() * ONE_DAY_IN_MS));
if (this.form.value.inboundDate && this.form.value.outboundDate && this.form.value.inboundDate <= this.form.value.outboundDate) {
this.form.controls.inboundDate.setValue(
this.formatDate((this.form.value.outboundDate ? (new Date(this.form.value.outboundDate)).getTime() : Date.now()) + 7 * ONE_DAY_IN_MS)
);
}
})
},
// Needed because inboundDate input is handled by signal
{ allowSignalWrites: true }
);
this.subscription.add(
this.config$.pipe(map(({ destinations }) => destinations)).subscribe((destinations) => {
const selectedDestination = destinations.find((d) => d.cityName === this.form.value.destination);
effect(
() => {
const selectedDestination = this.destinations().find((d) => d.cityName === this.form.value.destination);
if (selectedDestination && !selectedDestination.available) {
this.form.controls.destination.reset();
}
})
},
// Needed because destination input is handled by signal
{ allowSignalWrites: true }
);
}

private formatDate(dateTime: number) {
return formatDate(dateTime, 'yyyy-MM-dd', 'en-GB');
}

public ngOnChanges(changes: SimpleChanges) {
if (changes.config) {
this.dynamicConfig$.next(this.config);
}
}

public ngOnDestroy() {
this.subscription.unsubscribe();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ <h2 class="bg-body-tertiary text-secondary border border-light-subtle border-3 b
</div>
<h3 class="row card-text mt-auto ms-1 h5">Where do you want to go?</h3>
<form class="row g-2" [formGroup]="form">
<div class="col-12 col-xl-6" [class.col-xl-12]="(config$ | async)?.shouldProposeRoundTrip">
<div class="col-12 col-xl-6" [class.col-xl-12]="shouldProposeRoundTrip()">
<div class="input-group">
<label class="input-group-text w-50" for="destination">Destination</label>
<select class="form-select" id="destination" formControlName="destination">
<option disabled selected value> -- select a destination -- </option>
@for (destination of (config$ | async)?.destinations; track destination.cityCode) {
@for (destination of destinations(); track destination.cityCode) {
<option [disabled]="!destination.available" [value]="destination.cityName">{{destination.cityName}}</option>
}
</select>
Expand All @@ -30,7 +30,7 @@ <h3 class="row card-text mt-auto ms-1 h5">Where do you want to go?</h3>
<o3r-date-picker-input-pres [id]="'date-outbound'" class="w-50" formControlName="outboundDate"></o3r-date-picker-input-pres>
</div>
</div>
@if ((config$ | async)?.shouldProposeRoundTrip) {
@if (shouldProposeRoundTrip()) {
<div class="col-12 col-xl-6">
<div class="input-group">
<label for="date-inbound" class="input-group-text w-50">Return</label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ export interface ComponentInformation {
export class ComponentClassExtractor {

/** List of interfaces that a configurable component can implement */
public readonly CONFIGURABLE_INTERFACES: string[] = ['DynamicConfigurable', 'Configurable'];
public readonly CONFIGURABLE_INTERFACES: string[] = ['DynamicConfigurable', 'DynamicConfigurableWithSignal', 'Configurable'];

/**
* @param source Typescript SourceFile node of the file
* @param logger Logger
* @param filePath Path to the file to extract the data from
*/
constructor(public source: ts.SourceFile, private logger: logging.LoggerApi, public filePath: string) {
constructor(public source: ts.SourceFile, private readonly logger: logging.LoggerApi, public filePath: string) {
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ export const getOtterLikeComponentInfo = (componentClassInstance: any, host: Ele
const translations = getTranslations(host);
const analytics = getAnalyticEvents(host);
return {
componentName: componentClassInstance.constructor.name,
// Cannot use anymore `constructor.name` else all components are named `_a`
componentName: componentClassInstance.constructor.ɵcmp?.debugInfo?.className || componentClassInstance.constructor.name,
configId,
translations,
analytics
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,7 @@ export class OtterInspectorService {
this.inspectorDiv.style.height = `${rect.height}px`;
this.inspectorDiv.style.top = `${rect.top}px`;
this.inspectorDiv.style.left = `${rect.left}px`;
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
this.inspectorDiv.firstChild!.textContent = `<${this.selectedComponent.component.constructor.name}>`;
this.inspectorDiv.firstChild!.textContent = `<${this.selectedComponent.componentName}>`;
}
}

Expand Down
6 changes: 6 additions & 0 deletions packages/@o3r/configuration/collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
"factory": "./schematics/configuration-to-component/index#ngAddConfig",
"schema": "./schematics/configuration-to-component/schema.json",
"aliases": ["add-config"]
},
"use-config-signal": {
"description": "Migrate from configuration observable to signal",
"factory": "./schematics/use-config-signal/index#ngUseConfigSignal",
"schema": "./schematics/use-config-signal/schema.json",
"aliases": ["migrate-to-signal-config"]
}
}
}
10 changes: 10 additions & 0 deletions packages/@o3r/configuration/migration.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"$schema": "https://raw.githubusercontent.com/angular/angular-cli/master/packages/angular_devkit/schematics/collection-schema.json",
"schematics": {
"migration-v10_3": {
"version": "10.3.0-alpha.0",
"description": "Updates of @o3r/configuration to v10.3.*",
"factory": "./schematics/ng-update/v10-3/index#updateV10_3"
}
}
}
2 changes: 1 addition & 1 deletion packages/@o3r/configuration/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"build": "yarn nx build configuration",
"build:fixtures:jest": "tsc -b tsconfig.fixture.jest.json --pretty",
"build:fixtures:jasmine": "tsc -b tsconfig.fixture.jasmine.json --pretty",
"prepare:build:builders": "yarn cpy 'schematics/**/*.json' 'schematics/**/templates/**' dist/schematics && yarn cpy 'collection.json' dist && yarn cpy 'schemas/*.json' 'dist/schemas'",
"prepare:build:builders": "yarn cpy 'schematics/**/*.json' 'schematics/**/templates/**' dist/schematics && yarn cpy '{collection,migration}.json' dist && yarn cpy 'schemas/*.json' 'dist/schemas'",
"prepare:publish": "prepare-publish ./dist"
},
"peerDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,21 @@ export class NgComponent {}
expect(componentFileContent).toContain('public config$: Observable<TestConfig>');
});

it('should create the config file and update the component with signal based configuration', async () => {
const runner = new SchematicTestRunner('schematics', collectionPath);
const tree = await runner.runSchematic('configuration-to-component', {
projectName: 'test-project',
path: o3rComponentPath,
useSignal: true
}, initialTree);

expect(tree.exists(o3rComponentPath.replace(/component\.ts$/, 'config.ts'))).toBeTruthy();
const componentFileContent = tree.readText(o3rComponentPath);
expect(componentFileContent).toContain('DynamicConfigurableWithSignal<TestConfig>');
expect(componentFileContent).toContain('public config = input<Partial<TestConfig>>()');
expect(componentFileContent).toContain('public readonly configSignal = configSignal(this.config, TEST_CONFIG_ID, TEST_DEFAULT_CONFIG, this.configurationService)');
});

it('should not expose the component', async () => {
const runner = new SchematicTestRunner('schematics', collectionPath);
const tree = await runner.runSchematic('configuration-to-component', {
Expand Down Expand Up @@ -121,7 +136,8 @@ export class NgComponent {}
path: ngComponentPath,
skipLinter: false,
projectName: 'test-project',
exposeComponent: true
exposeComponent: true,
useSignal: false
}), initialTree, { interactive: false }))).rejects.toThrow();
});

Expand Down
Loading

0 comments on commit 56e22af

Please sign in to comment.