Skip to content

Commit 5ea25f2

Browse files
authored
Improve blog features (#242)
* Improve blog features * Fix cypress tests
1 parent 5af91f1 commit 5ea25f2

13 files changed

Lines changed: 1165 additions & 138 deletions

cypress/e2e/offering.cy.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -343,11 +343,10 @@ describe('/my-offerings',{
343343

344344
const interceptors = (productSpec:any, productOfferingPOST:any, newCatalog:any, defaultCatalog: any, defaultCategory:any, offPricePOST:any) => {
345345
const specResponse = [[productSpec], []]
346-
const response = [[],[], [productOfferingPOST], []]
347346
const sr = [[newCatalog],[],[newCatalog], []]
348-
let call = 0
349347
let specCall = 0
350348
let scall = 0
349+
let offeringCreated = false
351350

352351
cy.intercept({method: 'GET', url: 'http://proxy.docker:8004/catalog/catalog?*'}, (res)=>{
353352
res.reply({
@@ -358,7 +357,7 @@ const interceptors = (productSpec:any, productOfferingPOST:any, newCatalog:any,
358357
cy.intercept({method: 'GET', url: 'http://proxy.docker:8004/catalog/productOffering?*'}, (res)=>{
359358
res.reply({
360359
statusCode: 200,
361-
body: response[call++]
360+
body: offeringCreated ? [productOfferingPOST] : []
362361
})
363362
}).as('productOff')
364363
cy.intercept({method: 'GET', url: 'http://proxy.docker:8004/catalog/productSpecification?*'}, (res)=>{
@@ -384,7 +383,10 @@ const interceptors = (productSpec:any, productOfferingPOST:any, newCatalog:any,
384383
}).as('defaultCategory')
385384

386385

387-
cy.intercept({method: 'POST', url: `http://proxy.docker:8004/catalog/catalog/${newCatalog.id}/productOffering`}, {statusCode: 201, body: productOfferingPOST}).as('offPOST')
386+
cy.intercept({method: 'POST', url: `http://proxy.docker:8004/catalog/catalog/${newCatalog.id}/productOffering`}, (req)=>{
387+
offeringCreated = true
388+
req.reply({statusCode: 201, body: productOfferingPOST})
389+
}).as('offPOST')
388390
if (offPricePOST){
389391
cy.intercept({method: 'GET', url: 'http://proxy.docker:8004//usage/usageSpecification?*'}, (res)=>{
390392
res.reply({

src/app/app-routing.module.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ const routes: Routes = [
124124
component: DomeBlogComponent
125125
},
126126
{
127-
path: 'blog/:id',
127+
path: 'blog/:slugOrId',
128128
component: BlogEntryDetailComponent
129129
},
130130
{
@@ -158,4 +158,3 @@ const routes: Routes = [
158158
exports: [RouterModule]
159159
})
160160
export class AppRoutingModule { }
161-

src/app/pages/dome-blog/blog-entry-detail/blog-entry-detail.component.html

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,5 @@
11
<div class="bg-secondary-50 dark:bg-secondary-200 flex min-h-full flex-col">
22
<section class="w-full bg-[#DDE6F6]">
3-
<div
4-
class="mx-auto w-full max-w-[1440px] px-6 py-10 sm:px-8 md:px-10 md:py-12 lg:px-16 xl:px-[160px]">
5-
<div class="flex flex-row mx-auto max-w-screen-xl justify-between">
6-
<div class="flex flex-col">
7-
<h1
8-
class="text-[clamp(2.75rem,2.1rem+2.1vw,3.75rem)] font-extrabold leading-[0.95] tracking-[0.02em] text-[#0B1528]">
9-
{{entry.title}}
10-
</h1>
11-
<p class="mt-4 text-[clamp(1rem,0.92rem+0.35vw,1.25rem)] leading-[1.6] text-[#4C5A6B]">
12-
Created by
13-
<b class="text-[#2D58A7]">
14-
{{entry.author}}
15-
</b>
16-
on {{entry.date | date:'EEEE, dd/MM/yy, HH:mm'}}.
17-
</p>
18-
</div>
19-
</div>
20-
</div>
213
<div class="pb-4">
224
<nav class="flex px-5 py-3 shadow-lg text-gray-700 border border-gray-200 bg-secondary-50 dark:bg-secondary-100 dark:border-gray-800 dark:text-white">
235
<ol class="inline-flex items-center space-x-1 md:space-x-2 rtl:space-x-reverse">
@@ -46,9 +28,64 @@
4628
<div class="flex-1 mx-auto w-full max-w-[1440px] p-4 pb-16 sm:px-8 md:px-10 lg:px-16 xl:px-[160px]">
4729
<div
4830
class="mx-auto w-full max-w-screen-xl rounded-lg border border-gray-300 bg-white p-6 shadow-lg dark:border-secondary-300 dark:bg-secondary-100 md:p-8 lg:p-10">
31+
<div class="mb-4 flex items-start justify-between gap-4">
32+
<h1 class="text-3xl font-bold text-[#0B1528] dark:text-white break-words">{{entry.title}}</h1>
33+
@if(canManageEntry()){
34+
<div class="flex items-center gap-2">
35+
<button type="button" (click)="goToUpdate()"
36+
class="text-white bg-primary-100 hover:bg-primary-50 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-full text-sm p-2.5 text-center inline-flex items-center"
37+
title="Edit entry">
38+
<svg class="w-[18px] h-[18px] text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
39+
<path fill-rule="evenodd" d="M14 4.182A4.136 4.136 0 0 1 16.9 3c1.087 0 2.13.425 2.899 1.182A4.01 4.01 0 0 1 21 7.037c0 1.068-.43 2.092-1.194 2.849L18.5 11.214l-5.8-5.71 1.287-1.31.012-.012Zm-2.717 2.763L6.186 12.13l2.175 2.141 5.063-5.218-2.141-2.108Zm-6.25 6.886-1.98 5.849a.992.992 0 0 0 .245 1.026 1.03 1.03 0 0 0 1.043.242L10.282 19l-5.25-5.168Zm6.954 4.01 5.096-5.186-2.218-2.183-5.063 5.218 2.185 2.15Z" clip-rule="evenodd"/>
40+
</svg>
41+
</button>
42+
<button type="button" (click)="openDeleteDialog()" [disabled]="deleting"
43+
class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 font-medium rounded-full text-sm p-2.5 text-center inline-flex items-center disabled:opacity-60"
44+
title="Delete entry">
45+
<svg class="w-[18px] h-[18px] text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
46+
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 7h12m-1 0-.9 12.1A2 2 0 0 1 14.11 21H9.89a2 2 0 0 1-1.99-1.9L7 7m3 0V5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2"/>
47+
</svg>
48+
</button>
49+
</div>
50+
}
51+
</div>
52+
<p class="mb-6 text-[#4C5A6B] dark:text-secondary-50">
53+
Created by
54+
<b class="text-[#2D58A7] dark:text-primary-50">
55+
{{entry.author}}
56+
</b>
57+
on {{entry.date | date:'EEEE, dd/MM/yy, HH:mm'}}.
58+
</p>
59+
@if(getEntryTags(); as tags){
60+
@if(tags.length > 0){
61+
<div class="mb-6 flex flex-wrap gap-2">
62+
@for(tag of tags; track tag){
63+
<span class="inline-flex items-center rounded-md border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 dark:border-blue-500/40 dark:bg-blue-900/30 dark:text-blue-200">
64+
{{ tag }}
65+
</span>
66+
}
67+
</div>
68+
}
69+
}
70+
@if(getFeaturedImage(); as featuredImage){
71+
<div class="mb-6 flex justify-center">
72+
<img [src]="featuredImage" [alt]="entry.title + ' featured image'"
73+
class="max-h-[300px] w-full max-w-3xl rounded-lg border border-gray-300 object-contain bg-gray-50 p-2 dark:border-gray-700 dark:bg-secondary-300">
74+
</div>
75+
}
4976
<markdown class="text-gray-700 dark:text-secondary-50 whitespace-pre-line break-words"
5077
[data]="entry.content">
5178
</markdown>
5279
</div>
5380
</div>
5481
</div>
82+
83+
<app-confirm-dialog
84+
[isOpen]="showDeleteConfirm"
85+
[title]="deleteConfirmTitle"
86+
[message]="deleteConfirmMessage"
87+
[confirmText]="deleteConfirmButtonText"
88+
[confirmButtonClass]="deleteConfirmButtonClass"
89+
(confirm)="confirmDeleteEntry()"
90+
(cancel)="closeDeleteDialog()"
91+
></app-confirm-dialog>
Lines changed: 101 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,109 @@
1-
import { ComponentFixture, TestBed } from '@angular/core/testing';
2-
import { NO_ERRORS_SCHEMA } from '@angular/core';
3-
import { TranslateModule } from '@ngx-translate/core';
4-
import { RouterTestingModule } from '@angular/router/testing';
5-
import { HttpClientTestingModule } from '@angular/common/http/testing';
6-
import { MarkdownModule } from 'ngx-markdown';
7-
81
import { BlogEntryDetailComponent } from './blog-entry-detail.component';
2+
import { convertToParamMap } from '@angular/router';
93

104
describe('BlogEntryDetailComponent', () => {
11-
let component: BlogEntryDetailComponent;
12-
let fixture: ComponentFixture<BlogEntryDetailComponent>;
13-
14-
beforeEach(async () => {
15-
await TestBed.configureTestingModule({
16-
schemas: [NO_ERRORS_SCHEMA],
17-
imports: [BlogEntryDetailComponent, HttpClientTestingModule, RouterTestingModule, TranslateModule.forRoot(), MarkdownModule.forRoot()]
18-
})
19-
.compileComponents();
20-
21-
fixture = TestBed.createComponent(BlogEntryDetailComponent);
22-
component = fixture.componentInstance;
23-
});
5+
const buildComponent = (slugOrId = 'entry-slug', serviceOverrides?: Partial<any>) => {
6+
const route = {
7+
snapshot: {
8+
paramMap: convertToParamMap({ slugOrId })
9+
}
10+
} as any;
11+
const router = { navigate: jasmine.createSpy('navigate') } as any;
12+
const domeBlogService = {
13+
getBlogEntries: jasmine.createSpy('getBlogEntries').and.resolveTo([
14+
{ _id: 'entry-1', slug: 'entry-slug' }
15+
]),
16+
getBlogEntryById: jasmine.createSpy('getBlogEntryById').and.resolveTo({
17+
_id: 'entry-1',
18+
title: 'Entry title',
19+
metaDescription: 'Meta description',
20+
content: 'Body content'
21+
}),
22+
deleteBlogEntry: jasmine.createSpy('deleteBlogEntry').and.resolveTo({ ok: true }),
23+
...serviceOverrides
24+
} as any;
25+
26+
const localStorageService = {
27+
getObject: jasmine.createSpy('getObject').and.returnValue({})
28+
} as any;
29+
const titleService = { setTitle: jasmine.createSpy('setTitle') } as any;
30+
const metaService = { updateTag: jasmine.createSpy('updateTag') } as any;
31+
32+
const component = new BlogEntryDetailComponent(
33+
route,
34+
router,
35+
domeBlogService,
36+
localStorageService,
37+
titleService,
38+
metaService
39+
);
40+
41+
return { component, router, domeBlogService, titleService, metaService };
42+
};
2443

2544
it('should create', () => {
45+
const { component } = buildComponent();
2646
expect(component).toBeTruthy();
2747
});
48+
49+
it('should resolve slug to entry and apply seo metadata on init', async () => {
50+
const { component, domeBlogService, titleService, metaService } = buildComponent('entry-slug');
51+
52+
await component.ngOnInit();
53+
54+
expect(domeBlogService.getBlogEntries).toHaveBeenCalled();
55+
expect(domeBlogService.getBlogEntryById).toHaveBeenCalledWith('entry-1');
56+
expect(titleService.setTitle).toHaveBeenCalledWith('Entry title');
57+
expect(metaService.updateTag).toHaveBeenCalledWith({ name: 'description', content: 'Meta description' });
58+
});
59+
60+
it('should return featured image from string or object', () => {
61+
const { component } = buildComponent();
62+
component.entry = { featuredImage: ' https://cdn/test.png ' };
63+
expect(component.getFeaturedImage()).toBe('https://cdn/test.png');
64+
65+
component.entry = { featuredImage: { url: ' https://cdn/obj.png ' } };
66+
expect(component.getFeaturedImage()).toBe('https://cdn/obj.png');
67+
});
68+
69+
it('should normalize tags from array and csv', () => {
70+
const { component } = buildComponent();
71+
component.entry = { tags: [' ai ', '', 'news'] };
72+
expect(component.getEntryTags()).toEqual(['ai', 'news']);
73+
74+
component.entry = { tags: 'one, two, , three' };
75+
expect(component.getEntryTags()).toEqual(['one', 'two', 'three']);
76+
});
77+
78+
it('should open delete confirmation when entry id exists', () => {
79+
const { component } = buildComponent();
80+
component.entry = { _id: 'entry-2', title: 'Delete me' };
81+
82+
component.openDeleteDialog();
83+
84+
expect(component.showDeleteConfirm).toBeTrue();
85+
expect(component.deleteConfirmMessage).toContain('Delete me');
86+
});
87+
88+
it('should delete entry and navigate back', async () => {
89+
const { component, domeBlogService, router } = buildComponent();
90+
component.entry = { _id: 'entry-5', title: 'Post' };
91+
92+
await component.confirmDeleteEntry();
93+
94+
expect(domeBlogService.deleteBlogEntry).toHaveBeenCalledWith('entry-5');
95+
expect(router.navigate).toHaveBeenCalledWith(['/blog']);
96+
expect(component.deleting).toBeFalse();
97+
});
98+
99+
it('should fallback to id lookup for object ids', async () => {
100+
const objectId = '507f1f77bcf86cd799439011';
101+
const { component, domeBlogService } = buildComponent(objectId);
102+
domeBlogService.getBlogEntryById.and.resolveTo({ _id: objectId, title: 'By id' });
103+
104+
const result = await component.getEntryBySlugOrId(objectId);
105+
106+
expect(domeBlogService.getBlogEntryById).toHaveBeenCalledWith(objectId);
107+
expect(result._id).toBe(objectId);
108+
});
28109
});

0 commit comments

Comments
 (0)