Imagine a scenario where your app needs to show different versions of a component or services to different groups of users. If it sounds a bit farfetched, take the following scenario that was proposed to our development team in some real project:
The naive approach of having ngIf
directives all over the place can work for the simpler cases
but it would be hard to maintain and would also clutter our beautiful templates.
Also, components using different services when switching from one version to another would be very difficult to maintain.
So we came up to the following approach, to cope with all those situations:
ngIf
with our country
and version
parameters,
and would give an answer to simple problems like hiding a whole feature in some countries,
or hiding a particular field for some countries/versions.country
and version
parameters.
This directive would be used for more general cases,
where a component version can provide very different functionality from another version.To illustrate this post, we created an app. Our app proudly shows some country data, like a country flag, area and population. We made contact with governments all over the world and kindly asked them to join our non-profit revolutionary app, providing some basic data:
Some of these countries have really severe regulations and wanted to join the app, but without revealing their area and population initially, until their lawyers would determine if this data could be displayed. So we designed the first version of the app with a single screen, where the user would select the country from a select combo box, and a component with two subcomponents displaying the country data:
The content component should be optional, and will display depending on the country’s regulations.
These are some screenshots of the app at this point:
After the kick off, some users were so excited that started to ask for some new features:
The development team agreed that, given that some countries still had not provided their area and population, it was reasonable to think that some of them would not provide instantly their flag, capital and anthem. So we decided to version the header and content components, so we could give them a smooth path to upgrade their data, while still looking good in the app, showing the old components instead of new components with empty fields.
These are some screenshots of the final version of the app:
Our app builds on two directives, as we said.
The first one will show/hide an element depending on the feature availability for a certain country,
where the features will be COUNTRY_HEADER
and COUNTRY_CONTENT
.
This directive will get the feature availability from a service, the so called CountryConfigService
.
import {
CountryConfigDictionary,
DEFAULT_COUNTRY_CONFIG,
FeatureVersionDictionary,
} from './country-config.model';
@Injectable()
export class CountryConfigService {
private config: CountryConfigDictionary = DEFAULT_COUNTRY_CONFIG;
getCountryFeatures(countryCode: string): FeatureVersionDictionary {
return this.config[countryCode].features;
}
isFeatureEnabled(feature: string, countryCode: string): boolean {
const countryFeatures = this.getCountryFeatures(countryCode);
return countryFeatures.hasOwnProperty(feature);
}
getFeatureVersion(feature: string, countryCode: string): number {
const countryFeatures = this.getCountryFeatures(countryCode);
return countryFeatures[feature];
}
}
The config
is an object following a CountryConfigDictionary
model
that lets us define which version of the COUNTRY_HEADER
and COUNTRY_CONTENT
components,
if any, is using each country:
export const DEFAULT_COUNTRY_CONFIG: CountryConfigDictionary = {
es: {
features: {
COUNTRY_HEADER: 2,
COUNTRY_CONTENT: 2,
},
},
fr: {
features: {
COUNTRY_HEADER: 2,
COUNTRY_CONTENT: 2,
},
},
it: {
features: {
COUNTRY_HEADER: 2,
COUNTRY_CONTENT: 1,
},
},
pt: {
features: {
COUNTRY_HEADER: 1,
COUNTRY_CONTENT: 1,
},
},
uk: {
features: {
COUNTRY_HEADER: 1,
},
},
};
With that in mind, let’s see the FeatureIf
directive.
It will display an element if the feature is enabled for the country.
Optionally, we can define the minimum version implemented by the country,
meaning that if the country uses a lower version, the element will be hidden.
I will skip the standard imports to save space in the listing:
import { CountryConfigService } from '../../services/country-config/country-config.service';
@Directive({
selector: '[appFeatureIf]',
})
export class FeatureIfDirective implements OnChanges {
private _featureName: string;
private _countryCode: string;
private _minVersion = 0;
private _else = false;
private _hasView: boolean;
@Input() set appFeatureIf(featureName: string) {
this._featureName = featureName;
}
@Input() set appFeatureIfCountryCode(value: string) {
this._countryCode = value;
}
@Input() set appFeatureIfVersion(value: number) {
this._minVersion = value;
}
@Input() set appFeatureIfElse(value: boolean) {
this._else = value;
}
constructor(
private templateRef: TemplateRef<any>,
private viewContainerRef: ViewContainerRef,
private countryConfigService: CountryConfigService
) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes) {
this.applyChanges();
}
}
private applyChanges(): void {
const featureEnabled = this.countryConfigService.isFeatureEnabled(
this._featureName,
this._countryCode
);
const featureVersion =
this.countryConfigService.getFeatureVersion(
this._featureName,
this._countryCode
) || 0;
const enabled: boolean =
featureEnabled && featureVersion >= this._minVersion;
const displayed: boolean =
(enabled && !this._else) || (!enabled && this._else);
this.embedTemplate(displayed);
}
private embedTemplate(enabled): void {
if (enabled && !this._hasView) {
this.viewContainerRef.createEmbeddedView(this.templateRef);
this._hasView = true;
} else if (!enabled && this._hasView) {
this.viewContainerRef.clear();
this._hasView = false;
}
}
}
This structural directive makes use of 4 parameters: featureName
, countryCode
, featureVersion
and else
.
Pay attention on how we define input properties in a structural directive:
appFeatureIf
.
We use a setter to internally save it as _featureName
.appFeatureIfCountryCode
references the directive parameter countryCode
.
We also use here a setter to map the input to the private variable _countryCode
.Please remark below how the directive is used in a template.
The first parameter doesn’t need a key, while the rest is passed with "key: value"
tuples, separated by a semicolon (;
).
<div *appFeatureIf="'COUNTRY_HEADER';countryCode:code;version:2">
Show only for countries implementing the COUNTRY_HEADER feature with version
>= 2
</div>
<div *appFeatureIf="'COUNTRY_HEADER';countryCode:code;version:2; else:'true'">
Show otherwise
</div>
What the directive basically does is:
applyChanges()
.featureName
, countryCode
and minVersion
.else
parameter is defined and true
, then it will be displayed if the feature is disabled.embedTemplate()
, which creates the embedded view into the view container if the element should be displayed,
or clears the view container otherwise.We use this directive in two cases in our app.
In the header, we use it to hide the flag for countries implementing COUNTRY_HEADER
version 1.
<div
*appFeatureIf="'COUNTRY_HEADER';countryCode:country.code;
version:2"
class="flag {{country.code}}"
></div>
In the parent component, we use the directive with the else
parameter set to true
,
to display an informative text when the content component is not available.
<div
class="no-feature"
*appFeatureIf="'COUNTRY_CONTENT';
countryCode:country.code;else:'true'"
>
This feature is not yet available in {{country.name}}.
</div>
Our second directive will allow us to dynamically inject a component or another, depending on some parameters.
import { CountryConfigService } from '../../services/country-config/country-config.service';
import { DynamicComponentService } from '../../services/dynamic-component/dynamic-component.service';
import { DynamicComponent } from '../../services/dynamic-component/dynamic-component.model';
@Directive({
selector: '[appFeatureVersion]',
})
export class FeatureVersionDirective implements OnChanges {
private _featureName: string;
private _countryCode: string;
private _data: any;
private componentRef: ComponentRef<DynamicComponent>;
@Input() set appFeatureVersion(featureName: string) {
this._featureName = featureName;
}
@Input()
set appFeatureVersionCountryCode(value: string) {
this._countryCode = value;
}
@Input()
set appFeatureVersionData(value: any) {
this._data = value;
}
constructor(
private viewContainerRef: ViewContainerRef,
private countryConfigService: CountryConfigService,
private dynamicComponentService: DynamicComponentService,
private componentFactoryResolver: ComponentFactoryResolver
) {}
ngOnChanges(changes: SimpleChanges): void {
if (changes) {
this.applyChanges();
}
}
private applyChanges(): void {
const featureEnabled = this.countryConfigService.isFeatureEnabled(
this._featureName,
this._countryCode
);
const featureVersion =
this.countryConfigService.getFeatureVersion(
this._featureName,
this._countryCode
) || 0;
const dynamicComponent = this.dynamicComponentService.getComponent(
this._featureName,
featureVersion
);
this.clearViewContainer();
if (featureEnabled && dynamicComponent) {
this.embedComponent(dynamicComponent);
this.injectComponentData();
}
}
private clearViewContainer(): void {
this.viewContainerRef.clear();
}
private embedComponent(component: Type<DynamicComponent>): void {
const componentFactory =
this.componentFactoryResolver.resolveComponentFactory(component);
this.componentRef = this.viewContainerRef.createComponent(componentFactory);
}
private injectComponentData(): void {
this.componentRef.instance.data = this._data;
}
}
This time, the directive takes three parameters: featureName
, countryCode
and data
.
The data
parameter will be used to pass data to our dynamic component.
Since different components may have different inputs,
we took the approach of receiving any external data through this data
object.
Dynamic components may also receive external data through services, as we will see later.
So basically, what this directive does is:
applyChanges()
.DynamicComponentService
.embedTemplate()
,
which resolves a factory for this type of component and creates the embedded view into the view container.Let’s see the code for those DynamicComponent
and DynamicComponentService
classes.
The DynamicComponent
is just a class with a public data
property.
We will also create a dictionary interface and a constant with the current dynamic component classes
(country content version 1 and 2) that will be used by the service.
import { CountryContentV1Component } from '../../../country/components/country-content/v1/country-content.v1.component';
import { CountryContentV2Component } from '../../../country/components/country-content/v2/country-content.v2.component';
export class DynamicComponent {
data: any;
}
export interface DynamicComponentDictionary {
[key: string]: {
[key: number]: Type<DynamicComponent>;
};
}
export const DEFAULT_DYNAMIC_COMPONENT_DICTIONARY: DynamicComponentDictionary =
{
COUNTRY_CONTENT: {
1: CountryContentV1Component,
2: CountryContentV2Component,
},
};
The DynamicComponentService
simply returns the appropriate component class,
depending on the featureName
and version
parameters.
import {
DEFAULT_DYNAMIC_COMPONENT_DICTIONARY,
DynamicComponent,
DynamicComponentDictionary,
} from './dynamic-component.model';
@Injectable()
export class DynamicComponentService {
private componentDictionary: DynamicComponentDictionary =
DEFAULT_DYNAMIC_COMPONENT_DICTIONARY;
getComponent(featureName: string, version: number): Type<DynamicComponent> {
const selectedComponent = this.componentDictionary[featureName]
? this.componentDictionary[featureName][version]
: undefined;
return selectedComponent;
}
}
Let’s see how this directive is used in the parent component.
<ng-template
*appFeatureVersion="'COUNTRY_CONTENT';
countryCode:country.code;data:{country: country}"
>
</ng-template>
This is the code for the CountryContentV1Component
class.
import { DynamicComponent } from '../../../../shared/services/dynamic-component/dynamic-component.model';
import { Country } from '../../../services/country.model';
@Component({
selector: 'app-country-content-v1',
templateUrl: './country-content.v1.component.html',
styleUrls: ['./country-content.v1.component.scss'],
})
export class CountryContentV1Component implements DynamicComponent {
data: { country: Country };
}
And this is how the template uses the data
property to display the country data.
<div class="country-content">
<div class="data-row">
<span class="data-label"> Area: </span>
<span class="data-value"> {{data.country.area | number}} </span>
</div>
<div class="data-row">
<span class="data-label"> Population: </span>
<span class="data-value"> {{data.country.population | number}} </span>
</div>
</div>
You can see a demo of the application here:
https://stackblitz.com/edit/component-version-demo
The situation can get a bit more complicated if services are also versioned. Let’s imagine that the first version of the service providing country data just included the country name, area and population, and that a new version should be created to include the new data, while still providing the old version for backward compatibility.
In this case we can inject the corresponding service version in the versioned components.
We won’t be using the data
property from DynamicComponent
, but will get the data from the service instead.
We could also use injection tokens to dynamically inject the versioned service depending on certain conditions.
The following demo is a simple approach using versioned services:
https://stackblitz.com/edit/component-version-demo-services
The demo app is probably too simple for that kind of solution.
We could still smartly use some ngIf
and ngTemplate
stuff to get to the same solution.
But think of a case where the user doesn’t select the country from a combo box,
but the country gets auto detected from your device settings,
and think of a more complicated UI with a dashboard with several widgets that should be displayed or hidden,
or have different content according to the country, and then this approach will make much more sense.
This post is long enough to get into more details. If you have suggestions to enhance it, please include them in your comments :)