Skip to content

Commit

Permalink
Merge branch 'master' into mdc-migration
Browse files Browse the repository at this point in the history
  • Loading branch information
PowerKiKi committed Mar 19, 2023
2 parents 5a11d0c + 46da887 commit bdd6a21
Show file tree
Hide file tree
Showing 15 changed files with 494 additions and 105 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe('NaturalAbstractEditableList', () => {
expect(list.formArray.length).toBe(1);
expect(list.dataSource.data.length).toBe(1);
expect(list.getItems()).toEqual([
{id: '1', name: 'name-1', description: 'description-1', children: [], parent: null},
{id: '1', name: 'name-1', description: 'description-1', children: [], parent: null} as unknown as Item,
]);

list.setItems([service.getItem(), {} as Item]);
Expand Down
2 changes: 2 additions & 0 deletions projects/natural/src/lib/classes/abstract-editable-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ export class NaturalAbstractEditableList<
* - AbstractModelService.getPartialVariablesForCreation()
* - AbstractModelService.getPartialVariablesForUpdate()
* - some other required treatment.
*
* TODO return type is incorrect and should be closer to `Partial<T>[]` or an even looser type, because we don't really know what fields exists in the form. When we fix this, we should also remove type coercing in unit tests.
*/
public getItems(): T[] {
return this.formArray.getRawValue();
Expand Down
131 changes: 113 additions & 18 deletions projects/natural/src/lib/modules/common/services/seo.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import {TestBed} from '@angular/core/testing';
import {NATURAL_SEO_CONFIG, NaturalSeo, NaturalSeoConfig, NaturalSeoService} from '@ecodev/natural';
import {
NATURAL_SEO_CONFIG,
NaturalDialogTriggerComponent,
NaturalDialogTriggerRoutingData,
NaturalSeo,
NaturalSeoConfig,
NaturalSeoService,
} from '@ecodev/natural';
import {stripTags} from './seo.service';
import {RouterTestingModule} from '@angular/router/testing';
import {Component, NgZone} from '@angular/core';
import {Component} from '@angular/core';
import {Router, Routes} from '@angular/router';
import {Meta, Title} from '@angular/platform-browser';

@Component({
template: ` <div>Test component</div>`,
template: ` <div>Test simple component</div>`,
})
class TestSimpleComponent {}

Expand All @@ -32,6 +39,12 @@ describe('stripTags', () => {
expect(stripTags('<strong>one</strong> > two > three')).toBe('one > two > three');
});
});

const dialogTrigger: NaturalDialogTriggerRoutingData<TestSimpleComponent, never> = {
component: TestSimpleComponent,
dialogConfig: {},
};

const routes: Routes = [
{
path: 'no-seo',
Expand Down Expand Up @@ -94,33 +107,74 @@ const routes: Routes = [
}) satisfies NaturalSeo,
},
},
{
path: 'basic-dialog',
component: NaturalDialogTriggerComponent,
outlet: 'secondary',
data: {
trigger: dialogTrigger,
seo: {title: 'basic dialog title'} as NaturalSeo,
},
},
{
path: 'resolve-dialog',
component: NaturalDialogTriggerComponent,
outlet: 'secondary',
data: {
trigger: dialogTrigger,
// Here we simulate the data structure after the resolve,
// but in a real app it would be resolved by a proper Resolver
user: {
model: {
name: 'dialog user name',
description: 'dialog user description',
},
},
seo: {
resolveKey: 'user',
robots: 'dialog resolve robots',
} as NaturalSeo,
},
},
{
path: 'primary-dialog',
component: NaturalDialogTriggerComponent,
data: {
trigger: dialogTrigger,
seo: {title: 'primary dialog title'} as NaturalSeo,
},
},
];

describe('NaturalSeoService', () => {
let service: NaturalSeoService;
let router: Router;
let title: Title;
let meta: Meta;
let ngZone: NgZone;

function assertSeo(
url: string,
secondary: string | null,
expectedTitle: string,
expectedDescription: string | undefined,
expectedRobots: string | undefined,
): Promise<void> {
return ngZone.run(() =>
router.navigateByUrl(url).then(() => {
return router
.navigate([url])
.then(() => {
if (secondary) return router.navigate([{outlets: {secondary: [secondary]}}]);
else return Promise.resolve(true);
})
.then(() => {
expect(title.getTitle()).toBe(expectedTitle);
expect(meta.getTag('name="description"')?.getAttribute('value')).toBe(expectedDescription);
expect(meta.getTag('name="robots"')?.getAttribute('value')).toBe(expectedRobots);
}),
);
});
}

async function configure(config: NaturalSeoConfig): Promise<void> {
await TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes(routes)],
imports: [RouterTestingModule.withRoutes(routes, {enableTracing: true})],
providers: [
{
provide: NATURAL_SEO_CONFIG,
Expand All @@ -133,7 +187,6 @@ describe('NaturalSeoService', () => {
router = TestBed.inject(Router);
title = TestBed.inject(Title);
meta = TestBed.inject(Meta);
ngZone = TestBed.inject(NgZone);
}

describe('with simplest config', () => {
Expand All @@ -148,23 +201,51 @@ describe('NaturalSeoService', () => {
});

it('should update SEO automatically from default values', async () => {
await assertSeo('no-seo', 'my app', undefined, undefined);
await assertSeo('no-seo', null, 'my app', undefined, undefined);
});

it('should update SEO automatically from basic routing', async () => {
await assertSeo('basic-seo', 'basic title - my app', 'basic description', 'basic robots');
await assertSeo('basic-seo', null, 'basic title - my app', 'basic description', 'basic robots');
});

it('should update SEO automatically from resolve routing', async () => {
await assertSeo('resolve-seo', 'user name - my app', 'user description', 'resolve robots');
await assertSeo('resolve-seo', null, 'user name - my app', 'user description', 'resolve robots');
});

it('should update SEO automatically from resolve routing even with null resolved', async () => {
await assertSeo('resolve-null-seo', 'my app', undefined, 'resolve null robots');
await assertSeo('resolve-null-seo', null, 'my app', undefined, 'resolve null robots');
});

it('should update SEO automatically from callback routing', async () => {
await assertSeo('callback-seo', 'callback title - my app', 'callback description', 'callback robots');
await assertSeo('callback-seo', null, 'callback title - my app', 'callback description', 'callback robots');
});

it('should update SEO automatically with NaturalDialogTriggerComponent with basic SEO', async () => {
await assertSeo('no-seo', 'basic-dialog', 'basic dialog title - my app', undefined, undefined);
});

it('should update SEO automatically and combine SEO from page and NaturalDialogTriggerComponent', async () => {
await assertSeo(
'basic-seo',
'basic-dialog',
'basic dialog title - basic title - my app',
undefined,
undefined,
);
});

it('should update SEO automatically and resolve from NaturalDialogTriggerComponent data', async () => {
await assertSeo(
'basic-seo',
'resolve-dialog',
'dialog user name - basic title - my app',
'dialog user description',
'dialog resolve robots',
);
});

it('should duplicate title part of a NaturalDialogTriggerComponent which is on primary outlet', async () => {
await assertSeo('primary-dialog', null, 'primary dialog title - my app', undefined, undefined);
});
});

Expand All @@ -183,20 +264,33 @@ describe('NaturalSeoService', () => {
});

it('should update SEO automatically from default values', async () => {
await assertSeo('no-seo', 'my extra part - my app', 'my default description', 'my default robots');
await assertSeo('no-seo', null, 'my extra part - my app', 'my default description', 'my default robots');
});

it('should update SEO automatically from basic routing', async () => {
await assertSeo('basic-seo', 'basic title - my extra part - my app', 'basic description', 'basic robots');
await assertSeo(
'basic-seo',
null,
'basic title - my extra part - my app',
'basic description',
'basic robots',
);
});

it('should update SEO automatically from resolve routing', async () => {
await assertSeo('resolve-seo', 'user name - my extra part - my app', 'user description', 'resolve robots');
await assertSeo(
'resolve-seo',
null,
'user name - my extra part - my app',
'user description',
'resolve robots',
);
});

it('should update SEO automatically from resolve routing even with null resolved', async () => {
await assertSeo(
'resolve-null-seo',
null,
'my extra part - my app',
'my default description',
'resolve null robots',
Expand All @@ -206,6 +300,7 @@ describe('NaturalSeoService', () => {
it('should update SEO automatically from callback routing', async () => {
await assertSeo(
'callback-seo',
null,
'callback title - my extra part - my app',
'callback description',
'callback robots',
Expand Down
58 changes: 46 additions & 12 deletions projects/natural/src/lib/modules/common/services/seo.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {Inject, Injectable, InjectionToken} from '@angular/core';
import {Meta, Title} from '@angular/platform-browser';
import {ActivatedRouteSnapshot, Data, NavigationEnd, Router} from '@angular/router';
import {ActivatedRouteSnapshot, Data, NavigationEnd, PRIMARY_OUTLET, Router} from '@angular/router';
import {filter} from 'rxjs/operators';
import {NaturalDialogTriggerComponent} from '../../dialog-trigger/dialog-trigger.component';

export type NaturalSeo = NaturalSeoBasic | NaturalSeoCallback | NaturalSeoResolve;

Expand Down Expand Up @@ -97,7 +98,10 @@ type ResolvedData = {
*
* The full title has the following structure:
*
* page title - extra part - app name
* dialog title - page title - extra part - app name
*
* `dialog title` only exists if a `NaturalDialogTriggerComponent` is currently open, and that some SEO is
* configured for it in the routing.
*/
@Injectable({
providedIn: 'root',
Expand All @@ -112,9 +116,21 @@ export class NaturalSeoService {
private readonly metaTagService: Meta,
) {
this.router.events.pipe(filter(event => event instanceof NavigationEnd)).subscribe(() => {
this.routeData = this.getRouteData(this.router.routerState.root.snapshot);
const root = this.router.routerState.root.snapshot;
this.routeData = this.getRouteData(root);
const seo: NaturalSeo = this.routeData.seo ?? {title: ''};
const basic = this.toBasic(seo);

const dialogRouteData = this.getDialogRouteData(root);
const dialogSeo: NaturalSeo = dialogRouteData?.seo;

let basic = this.toBasic(seo, this.routeData);
if (dialogRouteData && dialogSeo) {
const dialogBasic = this.toBasic(dialogSeo, dialogRouteData);
basic = {
...dialogBasic,
title: this.join([dialogBasic.title, basic.title]),
};
}

this.update(basic);
});
Expand All @@ -133,11 +149,11 @@ export class NaturalSeoService {
// Title
const parts = [
seo.title,
this.config.extraPart && this.routeData ? this.config.extraPart(this.routeData) : null,
this.config.extraPart && this.routeData ? this.config.extraPart(this.routeData) : '',
this.config.applicationName,
];

const title = parts.filter(s => !!s).join(' - ');
const title = this.join(parts);
this.titleService.setTitle(title);

// Description
Expand All @@ -149,6 +165,10 @@ export class NaturalSeoService {
this.updateTag('robots', robots);
}

private join(parts: string[]): string {
return parts.filter(s => !!s).join(' - ');
}

private updateTag(name: string, value?: string): void {
if (value) {
this.metaTagService.updateTag({
Expand All @@ -167,19 +187,33 @@ export class NaturalSeoService {
if (route.firstChild) {
return this.getRouteData(route.firstChild);
} else {
return route.data ?? null;
return route.data;
}
}

private toBasic(seo: NaturalSeo): NaturalSeoBasic {
if (!this.routeData) {
throw new Error('Must have some route data to get basic SEO');
/**
* Returns the data from the `NaturalDialogTriggerComponent` if one is open
*/
private getDialogRouteData(route: ActivatedRouteSnapshot): Data | null {
if (route.component === NaturalDialogTriggerComponent && route.outlet !== PRIMARY_OUTLET) {
return route.data;
}

for (const child of route.children) {
const data = this.getDialogRouteData(child);
if (data) {
return data;
}
}

return null;
}

private toBasic(seo: NaturalSeo, routeData: Data): NaturalSeoBasic {
if (typeof seo === 'function') {
return seo(this.routeData);
return seo(routeData);
} else if ('resolveKey' in seo) {
const data: ResolvedData | undefined = this.routeData[seo.resolveKey];
const data: ResolvedData | undefined = routeData[seo.resolveKey];
if (!data) {
throw new Error('Could not find resolved data for SEO service with key: ' + seo.resolveKey);
}
Expand Down
36 changes: 24 additions & 12 deletions projects/natural/src/lib/modules/file/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,30 @@ function createFileInput(document: Document): HTMLInputElement {
}

export function isDirectory(file: File): Promise<boolean> {
return file
.slice(0, 1)
.text()
.then(
text => {
// Firefox will return empty string for a folder, so we must check that special case.
// That means that any empty file will incorrectly be interpreted as a folder on all
// browsers, but that's tolerable because there is no real use-case to upload an empty file.
return text !== '';
},
() => false,
);
return blobText(file.slice(0, 1)).then(
text => {
// Firefox will return empty string for a folder, so we must check that special case.
// That means that any empty file will incorrectly be interpreted as a folder on all
// browsers, but that's tolerable because there is no real use-case to upload an empty file.
return text !== '';
},
() => false,
);
}

/**
* This is a ponyfill for `Blob.text()`, because Safari 13 and 14 do not support it, https://caniuse.com/?search=blob.text,
* and we try our best not to break iPhone users too much.
*/
function blobText(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = reject;
reader.readAsText(blob);
});
}

export function stopEvent(event: Event): void {
Expand Down
Loading

0 comments on commit bdd6a21

Please sign in to comment.