URL Tracker DevLog #3: A durable frontend
Dennis
Posted on June 23, 2023
It's been a while since the last URL Tracker devlog. The frontend of the URL Tracker has been somewhat challenging and I've laid off the work on the URL Tracker for a while because I wasn't happy with my initial set-up for the frontend, but I didn't know how to make it better. Let's have a look at what's been going on.
Moving with Umbraco
Umbraco is currently working on a fresh new backoffice. AngularJS is going to be replaced by Lit, which means that all AngularJS logic has to be replaced. For plugins, it's important to abstract away as much of AngularJS as possible to minimize the impact of the new backoffice.
If, like me, you are more comfortable with C# and you have limited knowledge of Javascript and Typescript, this may pose a challenge. AngularJS is a framework that attempts to "be everywhere". The whole thing with AngularJS is that you go AngularJS all the way and the framework pretty much controls your whole application.
Attempt #1
In order to make the transition, we need to use as little AngularJS as we can and move as much as possible to Lit. However, we need to make use of several Umbraco services that are only available through AngularJS, so somehow we need to bridge the gap between AngularJS and Lit.
Lit binds objects through attributes on the html tag. This would allow us to pass data between AngularJS and Lit, simply by serializing the data and writing it into an attribute. Services can not be serialized like that though, so we'd have to keep business logic in AngularJS and only use Lit for display logic.
I've attempted to build my logic this way, but it did not work the way I wanted it to. What ends up happening is that you build your DOM tree twice. Once in AngularJS and once in Lit. This poses additional challenges as each lit component has to have slots in order to inject AngularJS directives into them. This method is messy and complicated. I didn't see an alternative at that time though, so I shelved the URL Tracker for a while until I got a better plan.
Attempt #2
And a better plan materialized after Code Garden 2023. The idea is as follows:
- Create a main directive and inject all required services into that.
- Use the
link
option on the directive to instantiate a lit element for the extension. - Assign the services to this instance using instance properties and methods on the element
- Bind the element as child of the current AngularJS directive
- Do everything else in Lit
At Code Garden, I got a small demonstration of the new backoffice and in particular how they connect Lit components to the new Umbraco frontend API. They use so called 'contexts' with 'providers' and 'consumers'. A provider is an element that creates and shares an instance of a context and a consumer is an element that receives an instance of a context from an ancestor element in the DOM tree.
Lit happens to offer a similar product, called 'lit-labs/context'. I hope that if I use this product, it will be easy to switch to Umbraco's version later. Let's have a look at how this works in practice:
Passing services from AngularJS to Lit
My application will use Typescript, Lit and Vite. I know I will be needing this logic more often, so I abstracted the logic for passing services from AngularJS to Lit into a typescript mixin.
maincontext.mixin.ts
type LitElementConstructor<T = LitElement> = new (...args: any[]) => T;
export function UrlTrackerMainContext<TBase extends LitElementConstructor>(Base: TBase){
return class MainContext extends Base {
// π This record keeps track of all registered services so we won't accidentally register the same thing twice.
_contextCollection: Record<string, unknown> = {};
// π SetContext is called by the AngularJS directive to assign services to this lit component
public SetContext<T>(service: T, context: ReturnType<typeof createContext<T>>, key: string){
if (this._contextCollection[key]){
(this._contextCollection[key] as ContextProvider<ReturnType<typeof createContext<T>>>).setValue(service);
}
else{
// π Create a ContextProvider so that the assigned service becomes available to all descendants.
this._contextCollection[key] = new ContextProvider(this, {context: context, initialValue: service});
}
}
}
}
This mixin is applied to a host component that acts as the root of our extension. Custom services can be assigned directly to this component, as the following snippet shows:
main.lit.ts
@customElement('urltracker-dashboard')
export class UrlTrackerDashboard extends UrlTrackerMainContext(LitElement) {
@provide({context: tabServiceContext})
tabService: TabService = tabsService;
@provide({context: notificationServiceContext})
notificationService: INotificationService = notificationService;
@provide({context: recommendationServiceContext})
recommendationService: IRecommendationsService = recommendationService;
@provide({context: versionProviderContext})
versionProvider: IVersionProvider = versionProvider;
protected render(): unknown {
return html`<urltracker-dashboard-content></urltracker-dashboard-content>`;
}
}
Finally, we can create an AngularJS directive to instantiate the component and assign the required services to it:
directive.ts
ngUrltrackerDashboard.alias = "ngUrltrackerDashboard";
ngUrltrackerDashboard.$inject = ["localizationService"]
export function ngUrltrackerDashboard(localizationService: ILocalizationService): angular.IDirective {
return {
restrict: 'E',
link: function (_scope, element) {
// π Create the element
let dashboardElement = document.createElement('urltracker-dashboard') as UrlTrackerDashboard;
// π Assign the localization service to the element
dashboardElement.SetContext(localizationService, localizationServiceContext, localizationServiceKey);
// π Insert the element into the DOM
element[0].appendChild(dashboardElement);
}
};
}
At this point, all services that I need are available to the entire Lit app and the amount of AngularJS has been minimized.
Making the app extendable
In AngularJS it was easy to make an app extendable, simply by including html files with more AngularJS. A collection in the backend allowed us to define URLs to html files.
Lit doesn't let us just include html files from the server and Lit's security features prevent us from simply inserting arbitrary tags. In order to make the URL Tracker extendable, we'll use the unsafeHTML
feature of Lit and secure it ourselves. Here's an example of how this could look:
content.lit.ts
@customElement("urltracker-dashboard-content")
export class UrlTrackerDashboardContent extends LitElement{
// ... private fields and services have been hidden for brevity
// π On element loading, we request the extensions from the backend
async connectedCallback(): Promise<void> {
super.connectedCallback();
this.loading++;
try{
if (!this.tabService) throw Error("Tab service is not defined, but is required by this element.");
if (!this.localizationService) throw Error("localization service is not defined, but is required by this element");
let response = await this.tabService.GetTabs();
let titleAliases = response.results.map((item) => "urlTrackerDashboardTabs_" + item.alias);
let labelAliases = response.results.map((item) => "urlTrackerDashboardTabLabels_" + item.alias);
let titlePromise = this.localizationService.localizeMany(titleAliases);
let labels = await this.localizationService.localizeMany(labelAliases);
let titles = await titlePromise;
let result: Array<IDashboardTab> = response.results.map((item, index) => ({
name: titles[index],
label: labels[index] ? labels[index] : titles[index],
// π the backend might be passing us anything, so we have to sanitize the input.
// This line ensures that the template can only contain letters, numbers and dashes.
template: item.view.replace(/[^A-Za-z0-9\-]/g, '')
}));
this.tabs = result;
}
finally{
this.loading--;
}
}
render() {
let contentOrLoader;
if (this.loading) {
contentOrLoader = html`<uui-loader-bar animationDuration="1.5"></uui-loader-bar>`
}
else {
let tabsOrNothing;
if (this.tabs && this.tabs?.length > 1) {
tabsOrNothing = html`
<uui-tab-group>
${this.tabs?.map((item) => html`<uui-tab label="${item.label ? item.label : item.name}" ?active="${item === this.activeTab}" @click="${() => this.activeTab = item}">${item.name}</uui-tab>`)}
</uui-tab-group>`
}
else {
tabsOrNothing = nothing;
}
// π Use 'unsafeHTML' to insert the tag from the extension.
contentOrLoader = html`
${tabsOrNothing}
<uui-scroll-container class="dashboard-body">
<div class="dashboard-body-container">
${unsafeHTML(`<${this.activeTab?.template}></${this.activeTab?.template}>`)}
</div>
</uui-scroll-container>
<urltracker-dashboard-footer>
</urltracker-dashboard-footer>
`;
}
return html`
<div class="dashboard">
<div class="dashboard-content">
${contentOrLoader}
</div>
</div>
`;
}
}
Later on, I'll move the sanitization logic into the service itself so that all input is always sanitized, no matter where the service is used.
Victory?
This approach looks very promising thus far, but I only started very recently. I still need to try out opening panels, showing notifications and more. Hopefully, those things will work smoothly as well.
That's all I wanted to share. I'd love to hear your thoughts on this topic. How do you prepare your app for the new backoffice? What do you think of this approach? Which parts do you like and which parts would you do differently?
For now: Thank you very much for reading and I hope to see you again in the next blog! π
Posted on June 23, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024