Blog URL preview: /blog/{{ entryForm.controls['slug'].value || 'my-blog-entry' }}
+ @if(entryForm.controls['slug'].touched && entryForm.controls['slug'].invalid){
+ Use only lowercase letters, numbers and hyphens.
+ }
+ @if(isSlugTaken()){
+ This slug is already in use. Pick a different one.
+ }
+
+ Meta description cannot exceed 160 characters.
+ }
+
+ Excerpt cannot exceed 300 characters.
+ }
+
+ Separate tags with commas.
+
+
diff --git a/src/app/pages/dome-blog/entry-form/entry-form.component.spec.ts b/src/app/pages/dome-blog/entry-form/entry-form.component.spec.ts
index 51cdd889..957e5759 100644
--- a/src/app/pages/dome-blog/entry-form/entry-form.component.spec.ts
+++ b/src/app/pages/dome-blog/entry-form/entry-form.component.spec.ts
@@ -1,28 +1,166 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { NO_ERRORS_SCHEMA } from '@angular/core';
-import { TranslateModule } from '@ngx-translate/core';
-import { RouterTestingModule } from '@angular/router/testing';
-import { HttpClientTestingModule } from '@angular/common/http/testing';
-
import { EntryFormComponent } from './entry-form.component';
+import { of } from 'rxjs';
+import { convertToParamMap } from '@angular/router';
describe('EntryFormComponent', () => {
- let component: EntryFormComponent;
- let fixture: ComponentFixture;
+ const buildComponent = (options?: {
+ routeId?: string | null;
+ entryById?: any;
+ entries?: any[];
+ }) => {
+ const routeId = options?.routeId ?? null;
+ const route = {
+ snapshot: {
+ paramMap: convertToParamMap(routeId ? { id: routeId } : {})
+ }
+ } as any;
- beforeEach(async () => {
- await TestBed.configureTestingModule({
- schemas: [NO_ERRORS_SCHEMA],
- imports: [EntryFormComponent, HttpClientTestingModule, RouterTestingModule, TranslateModule.forRoot()]
- })
- .compileComponents();
-
- fixture = TestBed.createComponent(EntryFormComponent);
- component = fixture.componentInstance;
- fixture.detectChanges();
- });
+ const router = {
+ navigate: jasmine.createSpy('navigate')
+ } as any;
+
+ const localStorageService = {
+ getObject: jasmine.createSpy('getObject').and.returnValue({})
+ } as any;
+
+ const domeBlogService = {
+ getBlogEntries: jasmine.createSpy('getBlogEntries').and.resolveTo(options?.entries ?? []),
+ getBlogEntryById: jasmine.createSpy('getBlogEntryById').and.resolveTo(options?.entryById ?? {}),
+ createBlogEntry: jasmine.createSpy('createBlogEntry').and.returnValue(of({ ok: true })),
+ updateBlogEntry: jasmine.createSpy('updateBlogEntry').and.resolveTo({ ok: true }),
+ deleteBlogEntry: jasmine.createSpy('deleteBlogEntry').and.resolveTo({ ok: true })
+ } as any;
+
+ const attachmentService = {
+ uploadFile: jasmine.createSpy('uploadFile').and.returnValue(of({ content: 'https://cdn/image.png' }))
+ } as any;
+
+ const component = new EntryFormComponent(
+ route,
+ router,
+ localStorageService,
+ domeBlogService,
+ attachmentService
+ );
+
+ return { component, route, router, domeBlogService };
+ };
it('should create', () => {
+ const { component } = buildComponent();
expect(component).toBeTruthy();
});
+
+ it('should normalize slug values', () => {
+ const { component } = buildComponent();
+ expect(component.slugify(' Hello World!!! ')).toBe('hello-world');
+ expect(component.slugify('Test---Slug')).toBe('test-slug');
+ });
+
+ it('should parse tags as deduped normalized array', () => {
+ const { component } = buildComponent();
+ component.entryForm.controls['tags'].setValue('AI, growth marketing, ai, , Product Updates ');
+
+ expect(component.parseTagsFromForm()).toEqual(['AI', 'growth marketing', 'Product Updates']);
+ });
+
+ it('should populate edit form values including tags and featured image on init', async () => {
+ const { component, domeBlogService } = buildComponent({
+ routeId: 'entry-1',
+ entries: [],
+ entryById: {
+ _id: 'entry-1',
+ title: 'Post Title',
+ slug: 'post-title',
+ featuredImage: { url: 'https://cdn/cover.png' },
+ metaDescription: 'Meta text',
+ excerpt: 'Excerpt text',
+ tags: ['one', 'two'],
+ content: 'Body'
+ }
+ });
+
+ await component.ngOnInit();
+
+ expect(domeBlogService.getBlogEntryById).toHaveBeenCalledWith('entry-1');
+ expect(component.entryForm.controls['slug'].value).toBe('post-title');
+ expect(component.entryForm.controls['featuredImage'].value).toBe('https://cdn/cover.png');
+ expect(component.entryForm.controls['tags'].value).toBe('one, two');
+ });
+
+ it('should include tags and seo fields in create payload', async () => {
+ const { component, domeBlogService, router } = buildComponent();
+ component.partyId = 'party-1';
+ component.name = 'Author Name';
+ component.entryForm.setValue({
+ title: 'My Entry',
+ slug: 'my-entry',
+ featuredImage: 'https://cdn/cover.png',
+ metaDescription: 'Meta',
+ excerpt: 'Excerpt',
+ tags: 'AI, ai, Launches',
+ content: 'Body'
+ });
+
+ await component.create();
+
+ expect(domeBlogService.createBlogEntry).toHaveBeenCalledWith({
+ title: 'My Entry',
+ slug: 'my-entry',
+ featuredImage: 'https://cdn/cover.png',
+ metaDescription: 'Meta',
+ excerpt: 'Excerpt',
+ tags: ['AI', 'Launches'],
+ partyId: 'party-1',
+ author: 'Author Name',
+ content: 'Body'
+ });
+ expect(router.navigate).toHaveBeenCalledWith(['/blog']);
+ });
+
+ it('should extract featured image url from object value', () => {
+ const { component } = buildComponent();
+ component.entryForm.controls['featuredImage'].setValue({ url: 'https://cdn/object.png' } as any);
+
+ expect(component.getFeaturedImageUrl()).toBe('https://cdn/object.png');
+ });
+
+ it('should detect duplicate slug from existing entries except same id', () => {
+ const { component } = buildComponent();
+ component.blogId = 'entry-2';
+ component.existingEntries = [
+ { _id: 'entry-2', slug: 'same-slug' },
+ { _id: 'entry-3', slug: 'same-slug' }
+ ];
+ component.entryForm.controls['slug'].setValue('same-slug');
+
+ expect(component.isSlugTaken()).toBeTrue();
+ });
+
+ it('should block submit while featured image is uploading', () => {
+ const { component } = buildComponent();
+ component.entryForm.setValue({
+ title: 'My Entry',
+ slug: 'my-entry',
+ featuredImage: '',
+ metaDescription: '',
+ excerpt: '',
+ tags: '',
+ content: 'Body'
+ });
+ component.uploadingFeaturedImage = true;
+
+ expect(component.canSubmit()).toBeFalse();
+ });
+
+ it('should delete entry and navigate back when confirmed', async () => {
+ const { component, domeBlogService, router } = buildComponent({ routeId: 'entry-9' });
+ component.blogId = 'entry-9';
+
+ await component.confirmDelete();
+
+ expect(domeBlogService.deleteBlogEntry).toHaveBeenCalledWith('entry-9');
+ expect(router.navigate).toHaveBeenCalledWith(['/blog']);
+ expect(component.loading).toBeFalse();
+ });
});
diff --git a/src/app/pages/dome-blog/entry-form/entry-form.component.ts b/src/app/pages/dome-blog/entry-form/entry-form.component.ts
index 8fdc1c8e..c022d8dd 100644
--- a/src/app/pages/dome-blog/entry-form/entry-form.component.ts
+++ b/src/app/pages/dome-blog/entry-form/entry-form.component.ts
@@ -2,31 +2,43 @@ import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import {MarkdownTextareaComponent} from "src/app/shared/forms/markdown-textarea/markdown-textarea.component";
import { DomeBlogServiceService } from "src/app/services/dome-blog-service.service"
+import { AttachmentServiceService } from "src/app/services/attachment-service.service";
import {LocalStorageService} from "src/app/services/local-storage.service";
import { ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
-import { lastValueFrom } from 'rxjs';
import { LoginInfo } from 'src/app/models/interfaces';
import * as moment from 'moment';
+import { ConfirmDialogComponent } from "src/app/shared/confirm-dialog/confirm-dialog.component";
+import { environment } from "src/environments/environment";
@Component({
selector: 'app-entry-form',
standalone: true,
- imports: [MarkdownTextareaComponent, ReactiveFormsModule],
+ imports: [MarkdownTextareaComponent, ReactiveFormsModule, ConfirmDialogComponent],
templateUrl: './entry-form.component.html',
styleUrl: './entry-form.component.css'
})
export class EntryFormComponent implements OnInit {
+ private readonly slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+ private readonly filenameRegex = /^[A-Za-z0-9_.-]+$/;
+ readonly maxFileSize: number = environment.MAX_FILE_SIZE;
+
constructor(
private route: ActivatedRoute,
private router: Router,
private localStorage: LocalStorageService,
- private domeBlogService: DomeBlogServiceService
+ private domeBlogService: DomeBlogServiceService,
+ private attachmentService: AttachmentServiceService
) {
}
entryForm = new FormGroup({
title: new FormControl('', [Validators.required]),
+ slug: new FormControl('', [Validators.required, Validators.pattern(this.slugRegex)]),
+ featuredImage: new FormControl(''),
+ metaDescription: new FormControl('', [Validators.maxLength(160)]),
+ excerpt: new FormControl('', [Validators.maxLength(300)]),
+ tags: new FormControl(''),
content: new FormControl('', [Validators.required]),
});
@@ -36,13 +48,32 @@ export class EntryFormComponent implements OnInit {
errorMessage:any='';
name:any='';
blogId:any=undefined;
+ existingEntries: any[] = [];
+ slugManuallyEdited = false;
+ showDeleteConfirm = false;
+ deleteConfirmTitle = 'Delete entry';
+ deleteConfirmMessage = '';
+ deleteConfirmButtonText = 'Delete';
+ deleteConfirmButtonClass = 'px-4 py-2 text-sm font-medium text-white bg-red-700 border border-transparent rounded-md hover:bg-red-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500';
+ uploadingFeaturedImage = false;
async ngOnInit(): Promise {
+ this.setupSlugHandlers();
this.blogId = this.route.snapshot.paramMap.get('id')!;
+ await this.loadExistingEntries();
if(this.blogId){
let blogInfo = await this.domeBlogService.getBlogEntryById(this.blogId);
this.entryForm.controls['title'].setValue(blogInfo.title);
+ const slugValue = this.slugify(blogInfo.slug || blogInfo.title || '');
+ this.entryForm.controls['slug'].setValue(slugValue, { emitEvent: false });
+ this.entryForm.controls['featuredImage'].setValue(this.extractFeaturedImageUrl(blogInfo.featuredImage));
+ this.entryForm.controls['metaDescription'].setValue(blogInfo.metaDescription || '');
+ this.entryForm.controls['excerpt'].setValue(blogInfo.excerpt || '');
+ this.entryForm.controls['tags'].setValue(this.formatTagsForInput(blogInfo.tags));
+ this.slugManuallyEdited = slugValue.length > 0 && slugValue !== this.slugify(blogInfo.title || '');
this.entryForm.controls['content'].setValue(blogInfo.content);
+ } else {
+ this.entryForm.controls['slug'].setValue(this.slugify(this.entryForm.value.title || ''), { emitEvent: false });
}
this.initPartyInfo();
}
@@ -52,8 +83,18 @@ export class EntryFormComponent implements OnInit {
}
async create(){
+ if (this.entryForm.invalid || this.isSlugTaken()) {
+ this.entryForm.markAllAsTouched();
+ return;
+ }
+
let body={
title: this.entryForm.value.title,
+ slug: this.entryForm.value.slug,
+ featuredImage: this.getFeaturedImageUrl(),
+ metaDescription: this.entryForm.value.metaDescription,
+ excerpt: this.entryForm.value.excerpt,
+ tags: this.parseTagsFromForm(),
partyId: this.partyId,
author: this.name,
content: this.entryForm.value.content,
@@ -82,8 +123,18 @@ export class EntryFormComponent implements OnInit {
}
async update(){
+ if (this.entryForm.invalid || this.isSlugTaken()) {
+ this.entryForm.markAllAsTouched();
+ return;
+ }
+
let body={
title: this.entryForm.value.title,
+ slug: this.entryForm.value.slug,
+ featuredImage: this.getFeaturedImageUrl(),
+ metaDescription: this.entryForm.value.metaDescription,
+ excerpt: this.entryForm.value.excerpt,
+ tags: this.parseTagsFromForm(),
content: this.entryForm.value.content
}
//await lastValueFrom(this.domeBlogService.createBlogEntry(body))
@@ -100,6 +151,41 @@ export class EntryFormComponent implements OnInit {
}
}
+ openDeleteDialog() {
+ if (!this.blogId || this.loading) {
+ return;
+ }
+
+ this.deleteConfirmMessage = `Are you sure you want to delete "${this.entryForm.controls['title'].value || 'this post'}"? This action cannot be undone.`;
+ this.showDeleteConfirm = true;
+ }
+
+ closeDeleteDialog() {
+ this.showDeleteConfirm = false;
+ }
+
+ async confirmDelete(){
+ if (!this.blogId) {
+ this.closeDeleteDialog();
+ return;
+ }
+
+ this.closeDeleteDialog();
+ this.loading = true;
+ try {
+ await this.domeBlogService.deleteBlogEntry(this.blogId);
+ this.loading=false;
+ this.goBack();
+ } catch (error) {
+ this.loading=false;
+ this.errorMessage='There was an error while deleting the entry!'
+ this.showError=true;
+ setTimeout(() => {
+ this.showError = false;
+ }, 3000);
+ }
+ }
+
initPartyInfo(){
let aux = this.localStorage.getObject('login_items') as LoginInfo;
if(JSON.stringify(aux) != '{}' && (((aux.expire - moment().unix())-4) > 0)) {
@@ -114,5 +200,217 @@ export class EntryFormComponent implements OnInit {
}
}
+ async loadExistingEntries() {
+ try {
+ const entries = await this.domeBlogService.getBlogEntries();
+ this.existingEntries = Array.isArray(entries) ? entries : [];
+ } catch (error) {
+ this.existingEntries = [];
+ }
+ }
+
+ setupSlugHandlers() {
+ this.entryForm.controls['title'].valueChanges.subscribe((value) => {
+ if (this.slugManuallyEdited) {
+ return;
+ }
+
+ this.entryForm.controls['slug'].setValue(this.slugify(value || ''), { emitEvent: false });
+ });
+
+ this.entryForm.controls['slug'].valueChanges.subscribe((value) => {
+ const normalized = this.slugify(value || '');
+ if (value !== normalized) {
+ this.entryForm.controls['slug'].setValue(normalized, { emitEvent: false });
+ }
+
+ const generatedFromTitle = this.slugify(this.entryForm.controls['title'].value || '');
+ this.slugManuallyEdited = normalized.length > 0 && normalized !== generatedFromTitle;
+ });
+ }
+
+ slugify(value: string): string {
+ return value
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-')
+ .replace(/^-+|-+$/g, '');
+ }
+
+ isSlugTaken(): boolean {
+ const slug = this.slugify(this.entryForm.controls['slug'].value || '');
+ if (!slug) {
+ return false;
+ }
+
+ return this.existingEntries.some((entry) => {
+ const existingSlug = this.slugify((entry.slug || entry.title || '') as string);
+ return existingSlug === slug && entry._id !== this.blogId;
+ });
+ }
+
+ canSubmit(): boolean {
+ return !this.loading && !this.uploadingFeaturedImage && this.entryForm.valid && !this.isSlugTaken();
+ }
+
+ onFeaturedImageSelected(event: Event) {
+ const inputElement = event.target as HTMLInputElement;
+ const file = inputElement.files?.[0];
+ if (!file) {
+ return;
+ }
+
+ if (!file.type.startsWith('image/')) {
+ this.showTemporaryError('File must have a valid image format!');
+ inputElement.value = '';
+ return;
+ }
+
+ if (file.size > this.maxFileSize) {
+ this.showTemporaryError('File size must be under 3MB.');
+ inputElement.value = '';
+ return;
+ }
+
+ const sanitizedFileName = file.name.replace(/[^A-Za-z0-9_.-]/g, '_');
+ const uploadFileName = `blogcover_${Date.now()}_${sanitizedFileName}`;
+ if (!this.filenameRegex.test(uploadFileName)) {
+ this.showTemporaryError('File name contains unsupported characters.');
+ inputElement.value = '';
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.onload = (e: ProgressEvent) => {
+ const rawResult = e.target?.result;
+ if (typeof rawResult !== 'string' || !rawResult.includes(',')) {
+ this.showTemporaryError('There was an error while processing the image file.');
+ inputElement.value = '';
+ return;
+ }
+
+ const base64String = rawResult.split(',')[1];
+ const fileBody = {
+ content: {
+ name: uploadFileName,
+ data: base64String
+ },
+ contentType: file.type,
+ isPublic: true
+ };
+
+ this.uploadingFeaturedImage = true;
+ this.attachmentService.uploadFile(fileBody).subscribe({
+ next: (data) => {
+ const uploadedUrl = (data?.content || '').toString();
+ if (!uploadedUrl) {
+ this.showTemporaryError('There was an error while uploading the featured image.');
+ return;
+ }
+
+ this.entryForm.controls['featuredImage'].setValue(uploadedUrl);
+ },
+ error: (error) => {
+ console.error('There was an error while uploading featured image!', error);
+ if (error.status === 413) {
+ this.showTemporaryError('File size too large! Must be under 3MB.');
+ return;
+ }
+ if (error?.error?.error) {
+ this.showTemporaryError(`Error: ${error.error.error}`);
+ return;
+ }
+ this.showTemporaryError('There was an error while uploading the featured image.');
+ },
+ complete: () => {
+ this.uploadingFeaturedImage = false;
+ inputElement.value = '';
+ }
+ });
+ };
+
+ reader.onerror = () => {
+ this.showTemporaryError('There was an error while reading the image file.');
+ inputElement.value = '';
+ };
+
+ reader.readAsDataURL(file);
+ }
+
+ removeFeaturedImage() {
+ this.entryForm.controls['featuredImage'].setValue('');
+ }
+
+ getFeaturedImageUrl(): string {
+ return this.extractFeaturedImageUrl(this.entryForm.controls['featuredImage'].value);
+ }
+
+ parseTagsFromForm(): string[] {
+ const rawValue = (this.entryForm.controls['tags'].value || '').toString();
+ if (!rawValue.trim()) {
+ return [];
+ }
+
+ const dedupedTags = new Map();
+ rawValue.split(',').forEach((tag) => {
+ const normalizedTag = tag.trim().replace(/\s+/g, ' ');
+ if (!normalizedTag) {
+ return;
+ }
+
+ const dedupeKey = normalizedTag.toLowerCase();
+ if (!dedupedTags.has(dedupeKey)) {
+ dedupedTags.set(dedupeKey, normalizedTag);
+ }
+ });
+
+ return Array.from(dedupedTags.values());
+ }
+
+ private formatTagsForInput(tags: any): string {
+ if (Array.isArray(tags)) {
+ return tags
+ .map((tag) => (tag ?? '').toString().trim())
+ .filter((tag) => tag.length > 0)
+ .join(', ');
+ }
+
+ if (typeof tags === 'string') {
+ return tags
+ .split(',')
+ .map((tag) => tag.trim())
+ .filter((tag) => tag.length > 0)
+ .join(', ');
+ }
+
+ return '';
+ }
+
+ private extractFeaturedImageUrl(featuredImage: any): string {
+ if (!featuredImage) {
+ return '';
+ }
+
+ if (typeof featuredImage === 'string') {
+ return featuredImage.trim();
+ }
+
+ if (typeof featuredImage?.url === 'string') {
+ return featuredImage.url.trim();
+ }
+
+ return '';
+ }
+
+ private showTemporaryError(message: string) {
+ this.errorMessage = message;
+ this.showError = true;
+ this.uploadingFeaturedImage = false;
+ setTimeout(() => {
+ this.showError = false;
+ }, 3000);
+ }
}
diff --git a/src/app/services/dome-blog-service.service.spec.ts b/src/app/services/dome-blog-service.service.spec.ts
index a5bea97c..15bcbb44 100644
--- a/src/app/services/dome-blog-service.service.spec.ts
+++ b/src/app/services/dome-blog-service.service.spec.ts
@@ -90,4 +90,17 @@ describe('DomeBlogServiceService', () => {
await expectAsync(promise).toBeResolvedTo(responseBody);
});
+
+ it('deleteBlogEntry should DELETE /domeblog/:id', async () => {
+ const id = 'blog-1';
+ const responseBody = { success: true };
+
+ const promise = service.deleteBlogEntry(id);
+ const req = httpMock.expectOne(`${environment.BASE_URL}/domeblog/${id}`);
+
+ expect(req.request.method).toBe('DELETE');
+ req.flush(responseBody);
+
+ await expectAsync(promise).toBeResolvedTo(responseBody);
+ });
});
diff --git a/src/app/services/dome-blog-service.service.ts b/src/app/services/dome-blog-service.service.ts
index f19bc21d..24045c46 100644
--- a/src/app/services/dome-blog-service.service.ts
+++ b/src/app/services/dome-blog-service.service.ts
@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
-import { HttpClient, HttpHeaders } from '@angular/common/http';
-import { lastValueFrom, map } from 'rxjs';
+import { HttpClient } from '@angular/common/http';
+import { lastValueFrom } from 'rxjs';
import { environment } from 'src/environments/environment';
import {LocalStorageService} from "./local-storage.service";
@@ -34,4 +34,10 @@ export class DomeBlogServiceService {
return lastValueFrom(this.http.patch(url, body))
}
+
+ deleteBlogEntry(id:any){
+ let url = `${DomeBlogServiceService.BASE_URL}/domeblog/${id}`;
+
+ return lastValueFrom(this.http.delete(url));
+ }
}