diff --git a/core-web/apps/mcp-server/.eslintrc.json b/core-web/apps/mcp-server/.eslintrc.json index e2d29dd91dcb..35e199a6e864 100644 --- a/core-web/apps/mcp-server/.eslintrc.json +++ b/core-web/apps/mcp-server/.eslintrc.json @@ -1,6 +1,6 @@ { "extends": ["../../.eslintrc.base.json"], - "ignorePatterns": ["!**/*"], + "ignorePatterns": ["!**/*", "**/node_modules/**", "node_modules/**"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], diff --git a/core-web/libs/data-access/jest.config.ts b/core-web/libs/data-access/jest.config.ts index e560babc229f..085888f8f8be 100644 --- a/core-web/libs/data-access/jest.config.ts +++ b/core-web/libs/data-access/jest.config.ts @@ -21,7 +21,6 @@ export default { '^.+\\.(ts|mjs|js|html)$': [ 'jest-preset-angular', { - isolatedModules: true, // Prevent type checking in tests and deps tsconfig: '/tsconfig.spec.json', stringifyContentPathRegex: '\\.(html|svg)$' } diff --git a/core-web/libs/data-access/src/lib/dot-contentlet/dot-contentlet-service.spec.ts b/core-web/libs/data-access/src/lib/dot-contentlet/dot-contentlet-service.spec.ts index 95566cc9e319..5b5ac5893dd9 100644 --- a/core-web/libs/data-access/src/lib/dot-contentlet/dot-contentlet-service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-contentlet/dot-contentlet-service.spec.ts @@ -1,68 +1,44 @@ -import { createHttpFactory, HttpMethod, SpectatorHttp } from '@ngneat/spectator/jest'; - -import { DotCMSContentlet, DotLanguage } from '@dotcms/dotcms-models'; +import { + createHttpFactory, + HttpMethod, + mockProvider, + SpectatorHttp, + SpyObject +} from '@ngneat/spectator/jest'; +import { of } from 'rxjs'; + +import { DotContentletCanLock } from '@dotcms/dotcms-models'; +import { createFakeContentlet, createFakeLanguage } from '@dotcms/utils-testing'; import { DotContentletService } from './dot-contentlet.service'; -const mockContentletVersionsResponse = { - entity: { - versions: { - en: [{ content: 'one' }, { content: 'two' }] as unknown as DotCMSContentlet[] - } - } -}; - -const mockContentletByInodeResponse = { - entity: { - archived: false, - baseType: 'CONTENT', - caategory: [{ boys: 'Boys' }, { girls: 'Girls' }], - contentType: 'ContentType1', - date: 1639548000000, - dateTime: 1639612800000, - folder: 'SYSTEM_FOLDER', - hasLiveVersion: true, - hasTitleImage: false, - host: '48190c8c-42c4-46af-8d1a-0cd5db894797', - hostName: 'demo.dotcms.com', - identifier: '758cb37699eae8500d64acc16ebc468e', - inode: '18f707db-ebf3-45f8-9b5a-d8bf6a6f383a', - keyValue: { Colorado: 'snow', 'Costa Rica': 'summer' }, - languageId: 1, - live: true, - locked: false, - modDate: 1639784363639, - modUser: 'dotcms.org.1', - modUserName: 'Admin User', - owner: 'dotcms.org.1', - publishDate: 1639784363639, - sortOrder: 0, - stInode: '0121c052881956cd95bfe5dde968ca07', - text: 'final value', - time: 104400000, - title: '758cb37699eae8500d64acc16ebc468e', - titleImage: 'TITLE_IMAGE_NOT_FOUND', - url: '/content.40e5d7cd-2117-47d5-b96d-3278b188deeb', - working: true - } as unknown as DotCMSContentlet -}; - -export const mockDotContentletCanLock = { - entity: { - canLock: true, - id: '1', - inode: '1', - locked: true - } -}; +import { DotUploadFileService } from '../dot-upload-file/dot-upload-file.service'; describe('DotContentletService', () => { let spectator: SpectatorHttp; - const createHttp = createHttpFactory(DotContentletService); + let dotUploadFileService: SpyObject; + + const createHttp = createHttpFactory({ + service: DotContentletService, + providers: [mockProvider(DotUploadFileService)] + }); - beforeEach(() => (spectator = createHttp())); + beforeEach(() => { + spectator = createHttp(); + dotUploadFileService = spectator.inject(DotUploadFileService); + }); it('should bring the contentlet versions by language', () => { + const mockContentlet1 = createFakeContentlet({ content: 'one' }); + const mockContentlet2 = createFakeContentlet({ content: 'two' }); + const mockContentletVersionsResponse = { + entity: { + versions: { + en: [mockContentlet1, mockContentlet2] + } + } + }; + spectator.service.getContentletVersions('123', 'en').subscribe((res) => { expect(res).toEqual(mockContentletVersionsResponse.entity.versions.en); }); @@ -75,29 +51,50 @@ describe('DotContentletService', () => { }); it('should retrieve a contentlet by its inode', () => { - spectator.service - .getContentletByInode(mockContentletByInodeResponse.entity.inode) - .subscribe((res) => { - expect(res).toEqual(mockContentletByInodeResponse.entity); - }); + const mockContentlet = createFakeContentlet({ + inode: '18f707db-ebf3-45f8-9b5a-d8bf6a6f383a' + }); + const mockResponse = { + entity: mockContentlet + }; - const req = spectator.expectOne( - '/api/v1/content/' + mockContentletByInodeResponse.entity.inode, - HttpMethod.GET - ); - req.flush(mockContentletByInodeResponse); + spectator.service.getContentletByInode(mockContentlet.inode).subscribe((res) => { + expect(res).toEqual(mockContentlet); + }); + + const req = spectator.expectOne(`/api/v1/content/${mockContentlet.inode}`, HttpMethod.GET); + req.flush(mockResponse); + }); + + it('should retrieve a contentlet by its inode with content', () => { + const mockContentlet = createFakeContentlet({ + inode: '18f707db-ebf3-45f8-9b5a-d8bf6a6f383a' + }); + const mockContentletWithContent = { ...mockContentlet, content: 'file content' }; + const mockResponse = { + entity: mockContentlet + }; + + dotUploadFileService.addContent.mockReturnValue(of(mockContentletWithContent)); + + spectator.service.getContentletByInodeWithContent(mockContentlet.inode).subscribe((res) => { + expect(res).toEqual(mockContentletWithContent); + expect(dotUploadFileService.addContent).toHaveBeenCalledWith(mockContentlet); + }); + + const req = spectator.expectOne(`/api/v1/content/${mockContentlet.inode}`, HttpMethod.GET); + req.flush(mockResponse); }); it('should retrieve available languages for a contentlet', () => { + const mockLanguage1 = createFakeLanguage({ id: 1, language: 'English' }); + const mockLanguage2 = createFakeLanguage({ id: 2, language: 'Spanish' }); const mockLanguagesResponse = { - entity: [ - { languageId: 1, language: 'English' }, - { languageId: 2, language: 'Spanish' } - ] + entity: [mockLanguage1, mockLanguage2] }; spectator.service.getLanguages('1').subscribe((res) => { - expect(res).toEqual(mockLanguagesResponse.entity as unknown as DotLanguage[]); + expect(res).toEqual(mockLanguagesResponse.entity); }); const req = spectator.expectOne('/api/v1/content/1/languages', HttpMethod.GET); @@ -105,29 +102,50 @@ describe('DotContentletService', () => { }); it('should lock a contentlet', () => { + const mockContentlet = createFakeContentlet({ inode: '1' }); + const mockResponse = { + entity: mockContentlet + }; + spectator.service.lockContent('1').subscribe((res) => { - expect(res).toEqual(mockContentletByInodeResponse.entity); + expect(res).toEqual(mockContentlet); }); const req = spectator.expectOne('/api/v1/content/_lock/1', HttpMethod.PUT); - req.flush(mockContentletByInodeResponse); + req.flush(mockResponse); }); it('should unlock a contentlet', () => { + const mockContentlet = createFakeContentlet({ inode: '1' }); + const mockResponse = { + entity: mockContentlet + }; + spectator.service.unlockContent('1').subscribe((res) => { - expect(res).toEqual(mockContentletByInodeResponse.entity); + expect(res).toEqual(mockContentlet); }); const req = spectator.expectOne('/api/v1/content/_unlock/1', HttpMethod.PUT); - req.flush(mockContentletByInodeResponse); + req.flush(mockResponse); }); it('should check if a contentlet can be locked', () => { + const mockDotContentletCanLock: DotContentletCanLock = { + canLock: true, + id: '1', + inode: '1', + locked: true, + lockedBy: 'user1' + }; + const mockResponse = { + entity: mockDotContentletCanLock + }; + spectator.service.canLock('1').subscribe((res) => { - expect(res).toEqual(mockDotContentletCanLock.entity); + expect(res).toEqual(mockDotContentletCanLock); }); const req = spectator.expectOne('/api/v1/content/_canlock/1', HttpMethod.GET); - req.flush(mockDotContentletCanLock); + req.flush(mockResponse); }); }); diff --git a/core-web/libs/data-access/src/lib/dot-contentlet/dot-contentlet.service.ts b/core-web/libs/data-access/src/lib/dot-contentlet/dot-contentlet.service.ts index 1d4adb45a141..756b7470d334 100644 --- a/core-web/libs/data-access/src/lib/dot-contentlet/dot-contentlet.service.ts +++ b/core-web/libs/data-access/src/lib/dot-contentlet/dot-contentlet.service.ts @@ -1,17 +1,25 @@ import { Observable } from 'rxjs'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { pluck, take } from 'rxjs/operators'; +import { map, pluck, switchMap } from 'rxjs/operators'; -import { DotCMSContentlet, DotContentletCanLock, DotLanguage } from '@dotcms/dotcms-models'; +import { + DotCMSAPIResponse, + DotCMSContentlet, + DotContentletCanLock, + DotLanguage +} from '@dotcms/dotcms-models'; + +import { DotUploadFileService } from '../dot-upload-file/dot-upload-file.service'; @Injectable({ providedIn: 'root' }) export class DotContentletService { - private http = inject(HttpClient); + readonly #http = inject(HttpClient); + readonly #dotUploadFileService = inject(DotUploadFileService); private readonly CONTENTLET_API_URL = '/api/v1/content/'; @@ -24,9 +32,11 @@ export class DotContentletService { * @memberof DotContentletService */ getContentletVersions(identifier: string, language: string): Observable { - return this.http - .get(`${this.CONTENTLET_API_URL}versions?identifier=${identifier}&groupByLang=1`) - .pipe(take(1), pluck('entity', 'versions', language)); + return this.#http + .get< + DotCMSAPIResponse + >(`${this.CONTENTLET_API_URL}versions?identifier=${identifier}&groupByLang=1`) + .pipe(pluck('entity', 'versions', language)); } /** @@ -36,8 +46,28 @@ export class DotContentletService { * @returns {Observable} An observable emitting the contentlet. * @memberof DotContentletService */ - getContentletByInode(inode: string): Observable { - return this.http.get(`${this.CONTENTLET_API_URL}${inode}`).pipe(take(1), pluck('entity')); + getContentletByInode(inode: string, httpParams?: HttpParams): Observable { + return this.#http + .get< + DotCMSAPIResponse + >(`${this.CONTENTLET_API_URL}${inode}`, { params: httpParams }) + .pipe(map((response) => response.entity)); + } + + /** + * Get the Contentlet by its inode and adds the content if it's a editable as text file. + * + * @param {string} inode - The inode of the contentlet. + * @returns {Observable} An observable emitting the contentlet. + * @memberof DotContentletService + */ + getContentletByInodeWithContent( + inode: string, + httpParams?: HttpParams + ): Observable { + return this.getContentletByInode(inode, httpParams).pipe( + switchMap((contentlet) => this.#dotUploadFileService.addContent(contentlet)) + ); } /** @@ -48,9 +78,11 @@ export class DotContentletService { * @memberof DotContentletService */ getLanguages(identifier: string): Observable { - return this.http - .get(`${this.CONTENTLET_API_URL}${identifier}/languages`) - .pipe(take(1), pluck('entity')); + return this.#http + .get< + DotCMSAPIResponse + >(`${this.CONTENTLET_API_URL}${identifier}/languages`) + .pipe(map((response) => response.entity)); } /** @@ -61,9 +93,11 @@ export class DotContentletService { * @memberof DotContentletService */ lockContent(inode: string): Observable { - return this.http - .put(`${this.CONTENTLET_API_URL}_lock/${inode}`, {}) - .pipe(take(1), pluck('entity')); + return this.#http + .put< + DotCMSAPIResponse + >(`${this.CONTENTLET_API_URL}_lock/${inode}`, {}) + .pipe(map((response) => response.entity)); } /** @@ -74,9 +108,11 @@ export class DotContentletService { * @memberof DotContentletService */ unlockContent(inode: string): Observable { - return this.http - .put(`${this.CONTENTLET_API_URL}_unlock/${inode}`, {}) - .pipe(take(1), pluck('entity')); + return this.#http + .put< + DotCMSAPIResponse + >(`${this.CONTENTLET_API_URL}_unlock/${inode}`, {}) + .pipe(map((response) => response.entity)); } /** @@ -87,8 +123,10 @@ export class DotContentletService { * @memberof DotContentletService */ canLock(inode: string): Observable { - return this.http - .get(`${this.CONTENTLET_API_URL}_canlock/${inode}`) - .pipe(take(1), pluck('entity')); + return this.#http + .get< + DotCMSAPIResponse + >(`${this.CONTENTLET_API_URL}_canlock/${inode}`) + .pipe(map((response) => response.entity)); } } diff --git a/core-web/libs/data-access/src/lib/dot-folder/dot-folder.service.ts b/core-web/libs/data-access/src/lib/dot-folder/dot-folder.service.ts index 24f271f283d6..c2caab88ffa4 100644 --- a/core-web/libs/data-access/src/lib/dot-folder/dot-folder.service.ts +++ b/core-web/libs/data-access/src/lib/dot-folder/dot-folder.service.ts @@ -5,8 +5,10 @@ import { Injectable, inject } from '@angular/core'; import { map } from 'rxjs/operators'; -import { DotFolder, DotFolderEntity } from '@dotcms/dotcms-models'; -@Injectable() +import { DotFolder, DotFolderEntity, DotCMSAPIResponse } from '@dotcms/dotcms-models'; +@Injectable({ + providedIn: 'root' +}) export class DotFolderService { readonly #http = inject(HttpClient); @@ -20,8 +22,8 @@ export class DotFolderService { const folderPath = this.normalizePath(path); return this.#http - .post<{ entity: DotFolder[] }>(`/api/v1/folder/byPath`, { path: folderPath }) - .pipe(map((response: { entity: DotFolder[] }) => response.entity)); + .post>(`/api/v1/folder/byPath`, { path: folderPath }) + .pipe(map((response) => response.entity)); } /** @@ -32,8 +34,8 @@ export class DotFolderService { */ createFolder(body: DotFolderEntity): Observable { return this.#http - .post<{ entity: DotFolder }>(`/api/v1/assets/folders`, body) - .pipe(map((response: { entity: DotFolder }) => response.entity)); + .post>(`/api/v1/assets/folders`, body) + .pipe(map((response) => response.entity)); } /** @@ -44,8 +46,8 @@ export class DotFolderService { */ saveFolder(body: DotFolderEntity): Observable { return this.#http - .put<{ entity: DotFolder }>(`/api/v1/assets/folders`, body) - .pipe(map((response: { entity: DotFolder }) => response.entity)); + .put>(`/api/v1/assets/folders`, body) + .pipe(map((response) => response.entity)); } /** diff --git a/core-web/libs/data-access/src/lib/dot-seo-meta-tags-utils/dot-seo-meta-tags-util.service.ts b/core-web/libs/data-access/src/lib/dot-seo-meta-tags-utils/dot-seo-meta-tags-util.service.ts index 398a423061e1..2b4410b9d26d 100644 --- a/core-web/libs/data-access/src/lib/dot-seo-meta-tags-utils/dot-seo-meta-tags-util.service.ts +++ b/core-web/libs/data-access/src/lib/dot-seo-meta-tags-utils/dot-seo-meta-tags-util.service.ts @@ -36,7 +36,7 @@ export class DotSeoMetaTagsUtilService { const metaTags = pageDocument.getElementsByTagName('meta'); const metaTagsObject = {}; - for (const metaTag of metaTags) { + for (const metaTag of Array.from(metaTags)) { const name = metaTag.getAttribute('name'); const property = metaTag.getAttribute('property'); const content = metaTag.getAttribute('content'); diff --git a/core-web/libs/data-access/src/lib/dot-site/dot-site.service.ts b/core-web/libs/data-access/src/lib/dot-site/dot-site.service.ts index b2b47f4b2aff..14ab1b93c0a2 100644 --- a/core-web/libs/data-access/src/lib/dot-site/dot-site.service.ts +++ b/core-web/libs/data-access/src/lib/dot-site/dot-site.service.ts @@ -3,29 +3,17 @@ import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { pluck } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { Site } from '@dotcms/dotcms-js'; -import { DotCMSContentlet, SiteEntity } from '@dotcms/dotcms-models'; +import { ContentByFolderParams, DotCMSContentlet, SiteEntity } from '@dotcms/dotcms-models'; export interface SiteParams { archived: boolean; live: boolean; system: boolean; } -export interface ContentByFolderParams { - hostFolderId: string; - showLinks?: boolean; - showDotAssets?: boolean; - showArchived?: boolean; - sortByDesc?: boolean; - showPages?: boolean; - showFiles?: boolean; - showFolders?: boolean; - showWorking?: boolean; - extensions?: string[]; - mimeTypes?: string[]; -} + export const BASE_SITE_URL = '/api/v1/site'; export const DEFAULT_PER_PAGE = 10; export const DEFAULT_PAGE = 1; @@ -56,7 +44,7 @@ export class DotSiteService { getSites(filter = '*', perPage?: number, page?: number): Observable { return this.#http .get<{ entity: Site[] }>(this.getSiteURL(filter, perPage, page)) - .pipe(pluck('entity')); + .pipe(map((response) => response.entity)); } private getSiteURL(filter: string, perPage?: number, page?: number): string { @@ -81,7 +69,7 @@ export class DotSiteService { getCurrentSite(): Observable { return this.#http .get<{ entity: SiteEntity }>(`${BASE_SITE_URL}/currentSite`) - .pipe(pluck('entity')); + .pipe(map((response) => response.entity)); } /** @@ -93,6 +81,6 @@ export class DotSiteService { getContentByFolder(params: ContentByFolderParams) { return this.#http .post<{ entity: { list: DotCMSContentlet[] } }>('/api/v1/browser', params) - .pipe(pluck('entity', 'list')); + .pipe(map((response) => response.entity.list)); } } diff --git a/core-web/libs/data-access/src/lib/dot-tags/dot-tags.service.spec.ts b/core-web/libs/data-access/src/lib/dot-tags/dot-tags.service.spec.ts index ecf9eee5d3f0..1f770baf30ea 100644 --- a/core-web/libs/data-access/src/lib/dot-tags/dot-tags.service.spec.ts +++ b/core-web/libs/data-access/src/lib/dot-tags/dot-tags.service.spec.ts @@ -1,50 +1,76 @@ -import { HttpTestingController, HttpClientTestingModule } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; +import { createHttpFactory, HttpMethod, SpectatorHttp } from '@ngneat/spectator/jest'; -import { CoreWebService } from '@dotcms/dotcms-js'; -import { CoreWebServiceMock } from '@dotcms/utils-testing'; +import { DotCMSAPIResponse, DotTag } from '@dotcms/dotcms-models'; import { DotTagsService } from './dot-tags.service'; describe('DotTagsService', () => { - let dotTagsService: DotTagsService; - let httpMock: HttpTestingController; + let spectator: SpectatorHttp; - const mockResponse = { - test: { label: 'test', siteId: '1', siteName: 'Site', persona: false }, - united: { label: 'united', siteId: '1', siteName: 'Site', persona: false } - }; + const createFakeTag = (overrides: Partial = {}): DotTag => ({ + label: 'test', + siteId: '1', + siteName: 'Site', + persona: false, + ...overrides + }); + + const createHttp = createHttpFactory({ + service: DotTagsService + }); beforeEach(() => { - TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], - providers: [{ provide: CoreWebService, useClass: CoreWebServiceMock }, DotTagsService] - }); - dotTagsService = TestBed.inject(DotTagsService); - httpMock = TestBed.inject(HttpTestingController); + spectator = createHttp(); }); - it('should get Tags', () => { - dotTagsService.getSuggestions().subscribe((res) => { - expect(res).toEqual([mockResponse.test, mockResponse.united]); + it('should get tags suggestions without name filter', () => { + const mockTag1 = createFakeTag({ label: 'test' }); + const mockTag2 = createFakeTag({ label: 'united' }); + const mockResponse: Record = { + test: mockTag1, + united: mockTag2 + }; + + spectator.service.getSuggestions().subscribe((res) => { + expect(res).toEqual([mockTag1, mockTag2]); }); - const req = httpMock.expectOne('v1/tags'); - expect(req.request.method).toBe('GET'); + const req = spectator.expectOne('/api/v1/tags', HttpMethod.GET); req.flush(mockResponse); }); - it('should get Tags filtered by name ', () => { - dotTagsService.getSuggestions('test').subscribe((res) => { - expect(res).toEqual([mockResponse.test, mockResponse.united]); + it('should get tags suggestions filtered by name', () => { + const mockTag1 = createFakeTag({ label: 'test' }); + const mockTag2 = createFakeTag({ label: 'testing' }); + const mockResponse: Record = { + test: mockTag1, + testing: mockTag2 + }; + + spectator.service.getSuggestions('test').subscribe((res) => { + expect(res).toEqual([mockTag1, mockTag2]); }); - const req = httpMock.expectOne('v1/tags?name=test'); - expect(req.request.method).toBe('GET'); + const req = spectator.expectOne('/api/v1/tags?name=test', HttpMethod.GET); req.flush(mockResponse); }); - afterEach(() => { - httpMock.verify(); + it('should get tags by name', () => { + const mockTag1 = createFakeTag({ label: 'angular' }); + const mockTag2 = createFakeTag({ label: 'typescript' }); + const mockResponse: DotCMSAPIResponse = { + entity: [mockTag1, mockTag2], + errors: [], + messages: [], + permissions: [], + i18nMessagesMap: {} + }; + + spectator.service.getTags('angular').subscribe((res) => { + expect(res).toEqual([mockTag1, mockTag2]); + }); + + const req = spectator.expectOne('/api/v2/tags?name=angular', HttpMethod.GET); + req.flush(mockResponse); }); }); diff --git a/core-web/libs/data-access/src/lib/dot-tags/dot-tags.service.ts b/core-web/libs/data-access/src/lib/dot-tags/dot-tags.service.ts index 63f7b584ac99..6643f234d770 100644 --- a/core-web/libs/data-access/src/lib/dot-tags/dot-tags.service.ts +++ b/core-web/libs/data-access/src/lib/dot-tags/dot-tags.service.ts @@ -1,11 +1,11 @@ import { Observable } from 'rxjs'; +import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { map, pluck } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; -import { CoreWebService } from '@dotcms/dotcms-js'; -import { DotTag } from '@dotcms/dotcms-models'; +import { DotCMSAPIResponse, DotTag } from '@dotcms/dotcms-models'; /** * Provide util methods to get Tags available in the system. @@ -16,7 +16,7 @@ import { DotTag } from '@dotcms/dotcms-models'; providedIn: 'root' }) export class DotTagsService { - private coreWebService = inject(CoreWebService); + readonly #http = inject(HttpClient); /** * Get tags suggestions @@ -24,15 +24,22 @@ export class DotTagsService { * @memberof DotTagDotTagsServicesService */ getSuggestions(name?: string): Observable { - return this.coreWebService - .requestView({ - url: `v1/tags${name ? `?name=${name}` : ''}` - }) - .pipe( - pluck('bodyJsonObject'), - map((tags: { [key: string]: DotTag }) => { - return Object.entries(tags).map(([_key, value]) => value); - }) - ); + const params = name ? new HttpParams().set('name', name) : new HttpParams(); + return this.#http + .get>(`/api/v1/tags`, { params }) + .pipe(map((tags) => Object.values(tags))); + } + + /** + * Retrieves tags based on the provided name. + * @param name - The name of the tags to retrieve. + * @returns An Observable that emits an array of tag labels. + */ + getTags(name: string): Observable { + const params = new HttpParams().set('name', name); + + return this.#http + .get>('/api/v2/tags', { params }) + .pipe(map((response) => response.entity)); } } diff --git a/core-web/libs/data-access/src/lib/dot-upload-file/dot-upload-file.service.ts b/core-web/libs/data-access/src/lib/dot-upload-file/dot-upload-file.service.ts index f37709830e02..77e8a0712cf9 100644 --- a/core-web/libs/data-access/src/lib/dot-upload-file/dot-upload-file.service.ts +++ b/core-web/libs/data-access/src/lib/dot-upload-file/dot-upload-file.service.ts @@ -1,11 +1,12 @@ -import { from, Observable, throwError } from 'rxjs'; +import { from, Observable, of, throwError } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { catchError, pluck, switchMap } from 'rxjs/operators'; +import { catchError, map, pluck, switchMap } from 'rxjs/operators'; import { DotCMSContentlet, DotCMSTempFile } from '@dotcms/dotcms-models'; +import { getFileMetadata, getFileVersion } from '@dotcms/utils'; import { DotUploadService } from '../dot-upload/dot-upload.service'; import { @@ -35,7 +36,7 @@ interface PublishContentProps { @Injectable({ providedIn: 'root' }) export class DotUploadFileService { readonly #BASE_URL = '/api/v1/workflow/actions/default'; - readonly #httpClient = inject(HttpClient); + readonly #http = inject(HttpClient); readonly #uploadService = inject(DotUploadService); readonly #workflowActionsFireService = inject(DotWorkflowActionsFireService); @@ -64,7 +65,7 @@ export class DotUploadFileService { statusCallback(FileStatus.IMPORT); - return this.#httpClient + return this.#http .post(`${this.#BASE_URL}/fire/PUBLISH`, JSON.stringify({ contentlets }), { headers: { Origin: window.location.hostname, @@ -123,4 +124,46 @@ export class DotUploadFileService { asset: file }); } + + /** + * Uploads a file and returns a contentlet with the content if it's a editable as text file. + * @param file the file to be uploaded + * @param extraData additional data to be included in the contentlet object + * @returns a contentlet with the content if it's a editable as text file + */ + uploadDotAssetWithContent( + file: File | string, + extraData?: DotActionRequestOptions['data'] + ): Observable { + return this.uploadDotAsset(file, extraData).pipe( + switchMap((contentlet) => this.addContent(contentlet)) + ); + } + + /** + * Adds the content of a contentlet if it's a editable as text file. + * @param contentlet the contentlet to be processed + * @returns a contentlet with the content if it's a editable as text file, otherwise the original contentlet + */ + addContent(contentlet: DotCMSContentlet): Observable { + const { editableAsText } = getFileMetadata(contentlet); + const contentURL = getFileVersion(contentlet); + + if (editableAsText && contentURL) { + return this.#getContentFile(contentURL).pipe( + map((content) => ({ ...contentlet, content })) + ); + } + + return of(contentlet); + } + + /** + * Downloads the content of a file by its URL. + * @param contentURL the URL of the file content + * @returns an observable of the file content + */ + #getContentFile(contentURL: string) { + return this.#http.get(contentURL, { responseType: 'text' }); + } } diff --git a/core-web/libs/data-access/tsconfig.spec.json b/core-web/libs/data-access/tsconfig.spec.json index fd3137bdd607..8e8897e2e636 100644 --- a/core-web/libs/data-access/tsconfig.spec.json +++ b/core-web/libs/data-access/tsconfig.spec.json @@ -4,7 +4,8 @@ "outDir": "../../dist/out-tsc", "module": "commonjs", "types": ["jest", "node"], - "target": "es2016" + "target": "es2016", + "isolatedModules": true }, "files": ["src/test-setup.ts"], "include": ["jest.config.ts", "**/*.test.ts", "**/*.spec.ts", "**/*.d.ts"] diff --git a/core-web/libs/dotcms-models/src/index.ts b/core-web/libs/dotcms-models/src/index.ts index aad8559d22a5..e287adca7b9b 100644 --- a/core-web/libs/dotcms-models/src/index.ts +++ b/core-web/libs/dotcms-models/src/index.ts @@ -83,5 +83,6 @@ export * from './lib/page-model-change-event.type'; export * from './lib/shared-models'; export * from './lib/structure-type-view.model'; export * from './lib/structure-type.model'; +export * from './lib/dot-browser-selector.model'; export * from './lib/dot-folder.model'; export * from './lib/dot-api-response'; diff --git a/core-web/libs/edit-content/src/lib/models/dot-edit-content-host-folder-field.interface.ts b/core-web/libs/dotcms-models/src/lib/dot-browser-selector.model.ts similarity index 81% rename from core-web/libs/edit-content/src/lib/models/dot-edit-content-host-folder-field.interface.ts rename to core-web/libs/dotcms-models/src/lib/dot-browser-selector.model.ts index 1ef0c2dffede..d3ab77b34768 100644 --- a/core-web/libs/edit-content/src/lib/models/dot-edit-content-host-folder-field.interface.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-browser-selector.model.ts @@ -23,10 +23,3 @@ export interface TreeNodeSelectEvent { originalEvent: Event; node: TreeNode; } - -export interface DotFolder { - id: string; - hostName: string; - path: string; - addChildrenAllowed: boolean; -} diff --git a/core-web/libs/dotcms-models/src/lib/dot-site.model.ts b/core-web/libs/dotcms-models/src/lib/dot-site.model.ts index 6321d6467562..2362145dbb27 100644 --- a/core-web/libs/dotcms-models/src/lib/dot-site.model.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-site.model.ts @@ -77,3 +77,17 @@ export interface SiteEntity { working: boolean; googleMap?: string; } + +export interface ContentByFolderParams { + hostFolderId: string; + showLinks?: boolean; + showDotAssets?: boolean; + showArchived?: boolean; + sortByDesc?: boolean; + showPages?: boolean; + showFiles?: boolean; + showFolders?: boolean; + showWorking?: boolean; + extensions?: string[]; + mimeTypes?: string[]; +} diff --git a/core-web/libs/edit-content-bridge/.eslintrc.json b/core-web/libs/edit-content-bridge/.eslintrc.json index 610642e75a3a..8bc64b2d075c 100644 --- a/core-web/libs/edit-content-bridge/.eslintrc.json +++ b/core-web/libs/edit-content-bridge/.eslintrc.json @@ -8,7 +8,14 @@ }, { "files": ["*.ts", "*.tsx"], - "rules": {} + "rules": { + "@nx/enforce-module-boundaries": [ + "error", + { + "allow": ["@dotcms/ui"] + } + ] + } }, { "files": ["*.js", "*.jsx"], diff --git a/core-web/libs/edit-content-bridge/jest.config.ts b/core-web/libs/edit-content-bridge/jest.config.ts index b6345eca8b11..e03f9a896238 100644 --- a/core-web/libs/edit-content-bridge/jest.config.ts +++ b/core-web/libs/edit-content-bridge/jest.config.ts @@ -2,8 +2,21 @@ export default { displayName: 'edit-content-bridge', preset: '../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], transform: { - '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }] + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$' + } + ] }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment' + ], moduleFileExtensions: ['ts', 'js', 'html'] }; diff --git a/core-web/libs/edit-content-bridge/package.json b/core-web/libs/edit-content-bridge/package.json index 5739bc51301d..1d0610d61a6c 100644 --- a/core-web/libs/edit-content-bridge/package.json +++ b/core-web/libs/edit-content-bridge/package.json @@ -5,7 +5,9 @@ "rxjs": "~6.6.3", "@angular/core": "20.3.15", "@angular/forms": "20.3.15", - "vite": "7.2.7" + "vite": "7.2.7", + "primeng": "17.18.11", + "@nx/vite": "21.6.9" }, "type": "module", "main": "./index.js", diff --git a/core-web/libs/edit-content-bridge/src/lib/bridges/angular-form-bridge.spec.ts b/core-web/libs/edit-content-bridge/src/lib/bridges/angular-form-bridge.spec.ts index 20d10e962f8e..523e8e5ca4f0 100644 --- a/core-web/libs/edit-content-bridge/src/lib/bridges/angular-form-bridge.spec.ts +++ b/core-web/libs/edit-content-bridge/src/lib/bridges/angular-form-bridge.spec.ts @@ -15,6 +15,8 @@ const mockFormControl = { markAsTouched: jest.fn(), markAsDirty: jest.fn(), updateValueAndValidity: jest.fn(), + enable: jest.fn(), + disable: jest.fn(), valueChanges: { subscribe: jest.fn((callback) => { mockFormControl.valueChanges._callback = callback; @@ -31,6 +33,23 @@ const mockNgZone = { run: (fn: () => void) => fn() }; +const mockDialogRef = { + close: jest.fn(), + onClose: { + subscribe: jest.fn((callback) => { + mockDialogRef.onClose._callback = callback; + return { + unsubscribe: jest.fn() + }; + }), + _callback: null as ((content: any) => void) | null + } +}; + +const mockDialogService = { + open: jest.fn().mockReturnValue(mockDialogRef) +}; + describe('AngularFormBridge', () => { let bridge: AngularFormBridge; @@ -38,7 +57,13 @@ describe('AngularFormBridge', () => { // Reset singleton instance before each test AngularFormBridge.resetInstance(); mockFormGroup.get.mockReturnValue(mockFormControl); - bridge = AngularFormBridge.getInstance(mockFormGroup as any, mockNgZone as any); + mockFormControl.valueChanges._callback = null; + mockDialogRef.onClose._callback = null; + bridge = AngularFormBridge.getInstance( + mockFormGroup as any, + mockNgZone as any, + mockDialogService as any + ); jest.clearAllMocks(); }); @@ -175,7 +200,8 @@ describe('AngularFormBridge', () => { const zoneRunSpy = jest.spyOn(mockNgZone, 'run'); const testBridge = AngularFormBridge.getInstance( mockFormGroup as any, - mockNgZone as any + mockNgZone as any, + mockDialogService as any ); const callback = jest.fn(); @@ -244,11 +270,13 @@ describe('AngularFormBridge', () => { it('should return the same instance when getInstance is called multiple times', () => { const instance1 = AngularFormBridge.getInstance( mockFormGroup as any, - mockNgZone as any + mockNgZone as any, + mockDialogService as any ); const instance2 = AngularFormBridge.getInstance( mockFormGroup as any, - mockNgZone as any + mockNgZone as any, + mockDialogService as any ); expect(instance1).toBe(instance2); @@ -260,9 +288,14 @@ describe('AngularFormBridge', () => { const instance1 = AngularFormBridge.getInstance( mockFormGroup as any, - mockNgZone as any + mockNgZone as any, + mockDialogService as any + ); + const instance2 = AngularFormBridge.getInstance( + differentFormGroup, + mockNgZone as any, + mockDialogService as any ); - const instance2 = AngularFormBridge.getInstance(differentFormGroup, mockNgZone as any); expect(instance1).toBe(instance2); expect(consoleSpy).toHaveBeenCalledWith( @@ -277,12 +310,14 @@ describe('AngularFormBridge', () => { it('should reset instance when resetInstance is called', () => { const instance1 = AngularFormBridge.getInstance( mockFormGroup as any, - mockNgZone as any + mockNgZone as any, + mockDialogService as any ); AngularFormBridge.resetInstance(); const instance2 = AngularFormBridge.getInstance( mockFormGroup as any, - mockNgZone as any + mockNgZone as any, + mockDialogService as any ); expect(instance1).not.toBe(instance2); @@ -292,7 +327,11 @@ describe('AngularFormBridge', () => { const unsubscribeSpy = jest.fn(); mockFormControl.valueChanges.subscribe.mockReturnValue({ unsubscribe: unsubscribeSpy }); - const instance = AngularFormBridge.getInstance(mockFormGroup as any, mockNgZone as any); + const instance = AngularFormBridge.getInstance( + mockFormGroup as any, + mockNgZone as any, + mockDialogService as any + ); instance.onChangeField('testField', () => {}); AngularFormBridge.resetInstance(); @@ -303,22 +342,389 @@ describe('AngularFormBridge', () => { it('should not allow direct instantiation with new', () => { // TypeScript will prevent this at compile time, but we can verify the constructor is private // by checking that getInstance is the only way to create an instance - const instance = AngularFormBridge.getInstance(mockFormGroup as any, mockNgZone as any); + const instance = AngularFormBridge.getInstance( + mockFormGroup as any, + mockNgZone as any, + mockDialogService as any + ); expect(instance).toBeInstanceOf(AngularFormBridge); }); it('should reset instance in destroy method', () => { const instance1 = AngularFormBridge.getInstance( mockFormGroup as any, - mockNgZone as any + mockNgZone as any, + mockDialogService as any ); instance1.destroy(); const instance2 = AngularFormBridge.getInstance( mockFormGroup as any, - mockNgZone as any + mockNgZone as any, + mockDialogService as any ); expect(instance1).not.toBe(instance2); }); }); + + describe('getField', () => { + it('should return FormFieldAPI object', () => { + const fieldAPI = bridge.getField('testField'); + expect(fieldAPI).toBeDefined(); + expect(typeof fieldAPI.getValue).toBe('function'); + expect(typeof fieldAPI.setValue).toBe('function'); + expect(typeof fieldAPI.onChange).toBe('function'); + expect(typeof fieldAPI.enable).toBe('function'); + expect(typeof fieldAPI.disable).toBe('function'); + }); + + it('should get value using getValue', () => { + mockFormControl.value = 'test value'; + const fieldAPI = bridge.getField('testField'); + const value = fieldAPI.getValue(); + expect(value).toBe('test value'); + expect(mockFormGroup.get).toHaveBeenCalledWith('testField'); + }); + + it('should set value using setValue', () => { + const fieldAPI = bridge.getField('testField'); + fieldAPI.setValue('new value'); + expect(mockFormControl.setValue).toHaveBeenCalledWith('new value', { + emitEvent: true + }); + expect(mockFormControl.markAsTouched).toHaveBeenCalled(); + expect(mockFormControl.markAsDirty).toHaveBeenCalled(); + }); + + it('should subscribe to changes using onChange', () => { + const callback = jest.fn(); + const fieldAPI = bridge.getField('testField'); + fieldAPI.onChange(callback); + + expect(mockFormControl.valueChanges.subscribe).toHaveBeenCalled(); + + if (mockFormControl.valueChanges._callback) { + mockFormControl.valueChanges._callback('changed value'); + expect(callback).toHaveBeenCalledWith('changed value'); + } + }); + + it('should enable field using enable', () => { + const fieldAPI = bridge.getField('testField'); + fieldAPI.enable(); + expect(mockFormControl.enable).toHaveBeenCalledWith({ emitEvent: true }); + }); + + it('should disable field using disable', () => { + const fieldAPI = bridge.getField('testField'); + fieldAPI.disable(); + expect(mockFormControl.disable).toHaveBeenCalledWith({ emitEvent: true }); + }); + + it('should not enable field if control is not found', () => { + mockFormGroup.get.mockReturnValue(null); + const fieldAPI = bridge.getField('nonExistentField'); + fieldAPI.enable(); + expect(mockFormControl.enable).not.toHaveBeenCalled(); + }); + + it('should not disable field if control is not found', () => { + mockFormGroup.get.mockReturnValue(null); + const fieldAPI = bridge.getField('nonExistentField'); + fieldAPI.disable(); + expect(mockFormControl.disable).not.toHaveBeenCalled(); + }); + + it('should run enable inside NgZone', () => { + const zoneRunSpy = jest.spyOn(mockNgZone, 'run'); + const fieldAPI = bridge.getField('testField'); + fieldAPI.enable(); + expect(zoneRunSpy).toHaveBeenCalled(); + }); + + it('should run disable inside NgZone', () => { + const zoneRunSpy = jest.spyOn(mockNgZone, 'run'); + const fieldAPI = bridge.getField('testField'); + fieldAPI.disable(); + expect(zoneRunSpy).toHaveBeenCalled(); + }); + }); + + describe('ready', () => { + it('should execute callback with bridge instance', () => { + const callback = jest.fn(); + bridge.ready(callback); + expect(callback).toHaveBeenCalledWith(bridge); + }); + }); + + describe('openBrowserModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should open dialog with correct configuration', () => { + const onClose = jest.fn(); + bridge.openBrowserModal({ + header: 'Select a Page', + params: { + hostFolderId: 'test-folder-id', + mimeTypes: ['application/dotpage'] + }, + onClose + }); + + expect(mockDialogService.open).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + header: 'Select a Page', + appendTo: 'body', + closeOnEscape: false, + draggable: false, + keepInViewport: false, + maskStyleClass: 'p-dialog-mask-dynamic', + resizable: false, + modal: true, + width: '90%', + style: { 'max-width': '1040px' }, + data: { + hostFolderId: 'test-folder-id', + mimeTypes: ['application/dotpage'] + } + }) + ); + }); + + it('should use default header if not provided', () => { + const onClose = jest.fn(); + bridge.openBrowserModal({ + header: undefined as any, + params: { + hostFolderId: 'test-folder-id', + mimeTypes: ['image'] + }, + onClose + }); + + expect(mockDialogService.open).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + header: 'Select Content' + }) + ); + }); + + it('should pass params to dialog data', () => { + const onClose = jest.fn(); + const params = { + hostFolderId: 'test-folder-id', + mimeTypes: ['image/jpeg', 'image/png'], + showPages: true, + showFiles: false + }; + bridge.openBrowserModal({ + header: 'Select Content', + params, + onClose + }); + + expect(mockDialogService.open).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + data: params + }) + ); + }); + + it('should handle onClose callback with content', () => { + const onClose = jest.fn(); + bridge.openBrowserModal({ + header: 'Select Content', + params: { + hostFolderId: 'test-folder-id' + }, + onClose + }); + + const content = { + identifier: 'test-id', + inode: 'test-inode', + title: 'Test Title', + name: 'test-name', + url: 'https://example.com/test', + mimeType: 'image/png', + baseType: 'FILEASSET', + contentType: 'FileAsset' + }; + + if (mockDialogRef.onClose._callback) { + mockDialogRef.onClose._callback(content); + } + + expect(onClose).toHaveBeenCalledWith({ + identifier: 'test-id', + inode: 'test-inode', + title: 'Test Title', + name: 'test-name', + url: 'https://example.com/test', + mimeType: 'image/png', + baseType: 'FILEASSET', + contentType: 'FileAsset' + }); + }); + + it('should handle onClose callback with null', () => { + const onClose = jest.fn(); + bridge.openBrowserModal({ + header: 'Select Content', + params: { + hostFolderId: 'test-folder-id' + }, + onClose + }); + + if (mockDialogRef.onClose._callback) { + mockDialogRef.onClose._callback(null); + } + + expect(onClose).toHaveBeenCalledWith(null); + }); + + it('should handle content with fileName instead of name', () => { + const onClose = jest.fn(); + bridge.openBrowserModal({ + header: 'Select Content', + params: { + hostFolderId: 'test-folder-id' + }, + onClose + }); + + const content = { + identifier: 'test-id', + inode: 'test-inode', + title: 'Test Title', + fileName: 'test-file.png', + url: 'https://example.com/test', + mimeType: 'image/png' + }; + + if (mockDialogRef.onClose._callback) { + mockDialogRef.onClose._callback(content); + } + + expect(onClose).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test-file.png' + }) + ); + }); + + it('should handle content with urlMap instead of url', () => { + const onClose = jest.fn(); + bridge.openBrowserModal({ + header: 'Select Content', + params: { + hostFolderId: 'test-folder-id' + }, + onClose + }); + + const content = { + identifier: 'test-id', + inode: 'test-inode', + title: 'Test Title', + urlMap: 'https://example.com/mapped-url' + }; + + if (mockDialogRef.onClose._callback) { + mockDialogRef.onClose._callback(content); + } + + expect(onClose).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://example.com/mapped-url' + }) + ); + }); + + it('should handle content with empty url and urlMap', () => { + const onClose = jest.fn(); + bridge.openBrowserModal({ + header: 'Select Content', + params: { + hostFolderId: 'test-folder-id' + }, + onClose + }); + + const content = { + identifier: 'test-id', + inode: 'test-inode', + title: 'Test Title' + }; + + if (mockDialogRef.onClose._callback) { + mockDialogRef.onClose._callback(content); + } + + expect(onClose).toHaveBeenCalledWith( + expect.objectContaining({ + url: '' + }) + ); + }); + + it('should return controller with close method', () => { + const onClose = jest.fn(); + const controller = bridge.openBrowserModal({ + header: 'Select Content', + params: { + hostFolderId: 'test-folder-id' + }, + onClose + }); + + expect(controller).toBeDefined(); + expect(typeof controller.close).toBe('function'); + + controller.close(); + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + + it('should run openBrowserModal inside NgZone', () => { + const zoneRunSpy = jest.spyOn(mockNgZone, 'run'); + const onClose = jest.fn(); + bridge.openBrowserModal({ + header: 'Select Content', + params: { + hostFolderId: 'test-folder-id' + }, + onClose + }); + + expect(zoneRunSpy).toHaveBeenCalled(); + }); + }); + + describe('destroy with dialog cleanup', () => { + it('should close dialog when destroyed', () => { + const onClose = jest.fn(); + bridge.openBrowserModal({ + header: 'Select Content', + params: { + hostFolderId: 'test-folder-id' + }, + onClose + }); + + bridge.destroy(); + + expect(mockDialogRef.close).toHaveBeenCalled(); + }); + + it('should not throw if no dialog is open when destroyed', () => { + expect(() => bridge.destroy()).not.toThrow(); + }); + }); }); diff --git a/core-web/libs/edit-content-bridge/src/lib/bridges/angular-form-bridge.ts b/core-web/libs/edit-content-bridge/src/lib/bridges/angular-form-bridge.ts index 596a0acfcf22..ec4d0740dcd4 100644 --- a/core-web/libs/edit-content-bridge/src/lib/bridges/angular-form-bridge.ts +++ b/core-web/libs/edit-content-bridge/src/lib/bridges/angular-form-bridge.ts @@ -1,7 +1,13 @@ import { NgZone } from '@angular/core'; import { FormGroup } from '@angular/forms'; +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { DotBrowserSelectorComponent } from '@dotcms/ui'; + import { + BrowserSelectorController, + BrowserSelectorOptions, FieldCallback, FieldSubscription, FormBridge, @@ -22,10 +28,12 @@ import { export class AngularFormBridge implements FormBridge { private static instance: AngularFormBridge | null = null; private fieldSubscriptions: Map = new Map(); + #dialogRef: DynamicDialogRef | null = null; private constructor( private form: FormGroup, - private zone: NgZone + private zone: NgZone, + private dialogService: DialogService ) {} /** @@ -34,11 +42,16 @@ export class AngularFormBridge implements FormBridge { * * @param form - The Angular FormGroup to bridge * @param zone - The NgZone for change detection + * @param dialogService - The PrimeNG DialogService for opening dialogs * @returns The singleton instance of AngularFormBridge */ - static getInstance(form: FormGroup, zone: NgZone): AngularFormBridge { + static getInstance( + form: FormGroup, + zone: NgZone, + dialogService: DialogService + ): AngularFormBridge { if (!AngularFormBridge.instance) { - AngularFormBridge.instance = new AngularFormBridge(form, zone); + AngularFormBridge.instance = new AngularFormBridge(form, zone, dialogService); } else if ( AngularFormBridge.instance.form !== form || AngularFormBridge.instance.zone !== zone @@ -162,7 +175,7 @@ export class AngularFormBridge implements FormBridge { /** * Cleans up all subscriptions when the bridge is destroyed. - * Also resets the singleton instance. + * Also resets the singleton instance and closes any open dialogs. */ destroy(): void { this.fieldSubscriptions.forEach((fieldSubscription) => { @@ -170,6 +183,10 @@ export class AngularFormBridge implements FormBridge { }); this.fieldSubscriptions.clear(); + // Close any open dialog + this.#dialogRef?.close(); + this.#dialogRef = null; + // Reset singleton instance if this is the current instance if (AngularFormBridge.instance === this) { AngularFormBridge.instance = null; @@ -225,4 +242,106 @@ export class AngularFormBridge implements FormBridge { ready(callback: (api: FormBridge) => void): void { callback(this); } + + /** + * Opens a browser selector modal to allow the user to select content (pages, files, etc.). + * Uses PrimeNG DialogService to open the DotBrowserSelectorComponent. + * + * @param options - Configuration options for the browser selector. + * @param options.header - The title/header of the dialog. Defaults to 'Select Content' if not provided. + * @param options.params - The parameters for the browser selector (ContentByFolderParams). + * @param options.params.hostFolderId - The ID of the host folder to browse (required). + * @param options.params.mimeTypes - Optional array of MIME types to filter by. + * @param options.params.showPages - Optional flag to show pages. + * @param options.params.showFiles - Optional flag to show files. + * @param options.params.showFolders - Optional flag to show folders. + * @param options.params.showLinks - Optional flag to show links. + * @param options.params.showDotAssets - Optional flag to show dotCMS assets. + * @param options.params.showArchived - Optional flag to show archived content. + * @param options.params.showWorking - Optional flag to show working content. + * @param options.params.sortByDesc - Optional flag to sort in descending order. + * @param options.params.extensions - Optional array of file extensions to filter by. + * @param options.onClose - Callback function executed when the browser selector is closed. + * @returns A controller object to manage the dialog. + * + * @example + * // Select a page + * bridge.openBrowserModal({ + * header: 'Select a Page', + * params: { + * hostFolderId: 'folder-id', + * mimeTypes: ['application/dotpage'] + * }, + * onClose: (result) => console.log(result) + * }); + * + * @example + * // Select an image + * bridge.openBrowserModal({ + * header: 'Select an Image', + * params: { + * hostFolderId: 'folder-id', + * mimeTypes: ['image'] + * }, + * onClose: (result) => console.log(result) + * }); + * + * @example + * // Select any file with additional filters + * bridge.openBrowserModal({ + * header: 'Select a File', + * params: { + * hostFolderId: 'folder-id', + * showFiles: true, + * showPages: false, + * showFolders: false, + * extensions: ['.jpg', '.png', '.gif'] + * }, + * onClose: (result) => console.log(result) + * }); + */ + openBrowserModal(options: BrowserSelectorOptions): BrowserSelectorController { + const header = options.header ?? 'Select Content'; + + this.zone.run(() => { + this.#dialogRef = this.dialogService.open(DotBrowserSelectorComponent, { + header, + appendTo: 'body', + closeOnEscape: false, + draggable: false, + keepInViewport: false, + maskStyleClass: 'p-dialog-mask-dynamic', + resizable: false, + modal: true, + width: '90%', + style: { 'max-width': '1040px' }, + data: { + ...options.params + } + }); + + this.#dialogRef.onClose.subscribe((content) => { + if (content) { + options.onClose({ + identifier: content.identifier, + inode: content.inode, + title: content.title, + name: content.name || content.fileName, + url: content.url || content.urlMap || '', + mimeType: content.mimeType, + baseType: content.baseType, + contentType: content.contentType + }); + } else { + options.onClose(null); + } + }); + }); + + return { + close: () => { + this.#dialogRef?.close(); + } + }; + } } diff --git a/core-web/libs/edit-content-bridge/src/lib/bridges/dojo-form-bridge.ts b/core-web/libs/edit-content-bridge/src/lib/bridges/dojo-form-bridge.ts index 65cc00d9023e..c001d4497ac1 100644 --- a/core-web/libs/edit-content-bridge/src/lib/bridges/dojo-form-bridge.ts +++ b/core-web/libs/edit-content-bridge/src/lib/bridges/dojo-form-bridge.ts @@ -1,4 +1,10 @@ -import { FormBridge, FormFieldAPI, FormFieldValue } from '../interfaces/form-bridge.interface'; +import { + BrowserSelectorController, + BrowserSelectorOptions, + FormBridge, + FormFieldAPI, + FormFieldValue +} from '../interfaces/form-bridge.interface'; interface FieldCallback { id: symbol; @@ -260,4 +266,26 @@ export class DojoFormBridge implements FormBridge { window.addEventListener('load', this.loadHandler); } + + /** + * Opens a browser selector modal to allow the user to select content (pages, files, etc.). + * + * @param _options - Configuration options for the browser selector. + * @returns A controller object to manage the dialog. + * + * @example + * // Select a page + * bridge.openBrowserModal({ + * header: 'Select a Page', + * mimeTypes: ['application/dotpage'], + * onClose: (result) => console.log(result) + * }); + */ + openBrowserModal(_options: BrowserSelectorOptions): BrowserSelectorController { + // TODO: Implement browser selector modal for Dojo + return { + // eslint-disable-next-line @typescript-eslint/no-empty-function + close: () => {} + }; + } } diff --git a/core-web/libs/edit-content-bridge/src/lib/factories/form-bridge.factory.ts b/core-web/libs/edit-content-bridge/src/lib/factories/form-bridge.factory.ts index e23ed8d3a950..12f7a57f2bf6 100644 --- a/core-web/libs/edit-content-bridge/src/lib/factories/form-bridge.factory.ts +++ b/core-web/libs/edit-content-bridge/src/lib/factories/form-bridge.factory.ts @@ -1,6 +1,8 @@ import { NgZone } from '@angular/core'; import { FormGroup } from '@angular/forms'; +import { DialogService } from 'primeng/dynamicdialog'; + import { AngularFormBridge } from '../bridges/angular-form-bridge'; import { DojoFormBridge } from '../bridges/dojo-form-bridge'; import { FormBridge } from '../interfaces/form-bridge.interface'; @@ -13,6 +15,7 @@ interface AngularConfig { type: 'angular'; form: FormGroup; zone: NgZone; + dialogService: DialogService; } /** @@ -36,7 +39,7 @@ type BridgeConfig = AngularConfig | DojoConfig; */ export function createFormBridge(config: BridgeConfig): FormBridge { if (config.type === 'angular') { - return AngularFormBridge.getInstance(config.form, config.zone); + return AngularFormBridge.getInstance(config.form, config.zone, config.dialogService); } return new DojoFormBridge(); diff --git a/core-web/libs/edit-content-bridge/src/lib/interfaces/browser-selector.interface.ts b/core-web/libs/edit-content-bridge/src/lib/interfaces/browser-selector.interface.ts new file mode 100644 index 000000000000..524391ae5bd2 --- /dev/null +++ b/core-web/libs/edit-content-bridge/src/lib/interfaces/browser-selector.interface.ts @@ -0,0 +1,79 @@ +import { ContentByFolderParams } from '@dotcms/dotcms-models'; + +/** + * Result returned when content is selected from the browser selector. + * This is a unified result that can represent pages, files, or other content types. + */ +export interface BrowserSelectorResult { + /** + * The unique identifier of the selected content. + */ + identifier: string; + + /** + * The inode of the selected content. + */ + inode: string; + + /** + * The title of the selected content. + */ + title: string; + + /** + * The name of the selected content (for files). + */ + name?: string; + + /** + * The URL of the selected content. + */ + url: string; + + /** + * The MIME type of the selected content (for files). + */ + mimeType?: string; + + /** + * The base type of the content (e.g., 'CONTENT', 'FILEASSET', 'HTMLPAGE'). + */ + baseType?: string; + + /** + * The content type variable name. + */ + contentType?: string; +} + +/** + * Options for configuring the browser selector dialog. + */ +export interface BrowserSelectorOptions { + /** + * The title/header of the dialog. + * @default 'Select Content' + */ + header: string; + + /** + * The parameters for the browser selector. + */ + params: ContentByFolderParams; + + /** + * Callback function executed when the browser selector is closed. + * @param result - The selected content result, or null if canceled + */ + onClose: (result: BrowserSelectorResult | null) => void; +} + +/** + * Controller interface for managing an open browser selector dialog. + */ +export interface BrowserSelectorController { + /** + * Closes the browser selector dialog programmatically. + */ + close(): void; +} diff --git a/core-web/libs/edit-content-bridge/src/lib/interfaces/form-bridge.interface.ts b/core-web/libs/edit-content-bridge/src/lib/interfaces/form-bridge.interface.ts index 7e00235a0a6a..221335128fd2 100644 --- a/core-web/libs/edit-content-bridge/src/lib/interfaces/form-bridge.interface.ts +++ b/core-web/libs/edit-content-bridge/src/lib/interfaces/form-bridge.interface.ts @@ -1,37 +1,5 @@ -import { Subscription } from 'rxjs'; - -/** - * Interface for a form field API that provides methods to interact with a specific field. - */ -export interface FormFieldAPI { - /** - * Gets the current value of the field. - * @returns The current value of the field - */ - getValue(): FormFieldValue; - - /** - * Sets the value of the field. - * @param value - The value to set for the field - */ - setValue(value: FormFieldValue): void; - - /** - * Subscribes to changes of the field. - * @param callback - Function to execute when the field value changes - */ - onChange(callback: (value: FormFieldValue) => void): void; - - /** - * Enables the field, allowing user interaction. - */ - enable(): void; - - /** - * Disables the field, preventing user interaction. - */ - disable(): void; -} +import { BrowserSelectorController, BrowserSelectorOptions } from './browser-selector.interface'; +import { FormFieldAPI, FormFieldValue } from './form-field.interface'; /** * Interface for bridging form functionality between different frameworks. @@ -79,30 +47,41 @@ export interface FormBridge { * Cleans up resources and event listeners when the bridge is destroyed. */ destroy(): void; -} - -/** - * Valid types for form field values. - */ -export type FormFieldValue = string | number | boolean | null; -/** - * A callback function that is executed when the value of a form field changes. - * - * @param {FormFieldValue} value - The new value of the field. - */ -export interface FieldCallback { - id: symbol; - callback: (value: FormFieldValue) => void; + /** + * Opens a browser selector modal to allow the user to select content (pages, files, etc.). + * The content type can be filtered using the mimeTypes option. + * + * @param options - Configuration options for the browser selector + * @returns A controller object to manage the dialog + * + * @example + * // Select a page + * bridge.openBrowserModal({ + * header: 'Select a Page', + * mimeTypes: ['application/dotpage'], + * onClose: (result) => console.log(result) + * }); + * + * @example + * // Select an image + * bridge.openBrowserModal({ + * header: 'Select an Image', + * mimeTypes: ['image'], + * onClose: (result) => console.log(result) + * }); + * + * @example + * // Select any file + * bridge.openBrowserModal({ + * header: 'Select a File', + * includeDotAssets: true, + * onClose: (result) => console.log(result) + * }); + */ + openBrowserModal(options: BrowserSelectorOptions): BrowserSelectorController; } -/** - * A subscription to a form field. - * - * @param {Subscription} subscription - The subscription to the field. - * @param {FieldCallback[]} callbacks - The callbacks to execute when the field value changes. - */ -export interface FieldSubscription { - subscription: Subscription; - callbacks: FieldCallback[]; -} +// Re-export all interfaces for backwards compatibility +export * from './browser-selector.interface'; +export * from './form-field.interface'; diff --git a/core-web/libs/edit-content-bridge/src/lib/interfaces/form-field.interface.ts b/core-web/libs/edit-content-bridge/src/lib/interfaces/form-field.interface.ts new file mode 100644 index 000000000000..18cae139afb7 --- /dev/null +++ b/core-web/libs/edit-content-bridge/src/lib/interfaces/form-field.interface.ts @@ -0,0 +1,60 @@ +import { Subscription } from 'rxjs'; + +/** + * Valid types for form field values. + */ +export type FormFieldValue = string | number | boolean | null; + +/** + * Interface for a form field API that provides methods to interact with a specific field. + */ +export interface FormFieldAPI { + /** + * Gets the current value of the field. + * @returns The current value of the field + */ + getValue(): FormFieldValue; + + /** + * Sets the value of the field. + * @param value - The value to set for the field + */ + setValue(value: FormFieldValue): void; + + /** + * Subscribes to changes of the field. + * @param callback - Function to execute when the field value changes + */ + onChange(callback: (value: FormFieldValue) => void): void; + + /** + * Enables the field, allowing user interaction. + */ + enable(): void; + + /** + * Disables the field, preventing user interaction. + */ + disable(): void; +} + +/** + * A callback function that is executed when the value of a form field changes. + * + * @param {FormFieldValue} value - The new value of the field. + */ +export interface FieldCallback { + id: symbol; + callback: (value: FormFieldValue) => void; +} + +/** + * A subscription to a form field. + * + * @param {Subscription} subscription - The subscription to the field. + * @param {FieldCallback[]} callbacks - The callbacks to execute when the field value changes. + */ +export interface FieldSubscription { + subscription: Subscription; + callbacks: FieldCallback[]; +} diff --git a/core-web/libs/edit-content-bridge/src/test-setup.ts b/core-web/libs/edit-content-bridge/src/test-setup.ts new file mode 100644 index 000000000000..b13563bb93c0 --- /dev/null +++ b/core-web/libs/edit-content-bridge/src/test-setup.ts @@ -0,0 +1,6 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true +}); diff --git a/core-web/libs/edit-content-bridge/tsconfig.json b/core-web/libs/edit-content-bridge/tsconfig.json index 303d7b33465f..c2dac09c8890 100644 --- a/core-web/libs/edit-content-bridge/tsconfig.json +++ b/core-web/libs/edit-content-bridge/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "module": "commonjs", "forceConsistentCasingInFileNames": true, - "strict": true, + "strict": false, "noImplicitOverride": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, diff --git a/core-web/libs/edit-content-bridge/tsconfig.lib.json b/core-web/libs/edit-content-bridge/tsconfig.lib.json index 24f4d10f5b5f..e4073f2e260f 100644 --- a/core-web/libs/edit-content-bridge/tsconfig.lib.json +++ b/core-web/libs/edit-content-bridge/tsconfig.lib.json @@ -3,11 +3,15 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "declaration": true, - "module": "ES2015", + "module": "ESNext", "moduleResolution": "bundler", "target": "ES2015", "lib": ["es2020", "dom"], - "types": ["node", "vite/client"] + "types": ["node", "vite/client"], + "skipLibCheck": true, + "noPropertyAccessFromIndexSignature": false, + "downlevelIteration": true, + "strictPropertyInitialization": false }, "include": ["src/**/*.ts"], "exclude": [ diff --git a/core-web/libs/edit-content-bridge/tsconfig.spec.json b/core-web/libs/edit-content-bridge/tsconfig.spec.json index c354ed6394f6..da38b5a881f4 100644 --- a/core-web/libs/edit-content-bridge/tsconfig.spec.json +++ b/core-web/libs/edit-content-bridge/tsconfig.spec.json @@ -3,7 +3,10 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "module": "commonjs", - "types": ["jest", "node"] + "types": ["jest", "node"], + "strict": false, + "noPropertyAccessFromIndexSignature": false }, + "files": ["src/test-setup.ts"], "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] } diff --git a/core-web/libs/edit-content-bridge/vite.config.ts b/core-web/libs/edit-content-bridge/vite.config.ts index 6b5a7bd49ed7..481b224627a9 100644 --- a/core-web/libs/edit-content-bridge/vite.config.ts +++ b/core-web/libs/edit-content-bridge/vite.config.ts @@ -1,3 +1,4 @@ +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; import { defineConfig } from 'vite'; import { resolve } from 'path'; @@ -11,6 +12,7 @@ export default defineConfig(() => { const outDir = resolve(__dirname, '../../dist/libs/edit-content-bridge'); return { + plugins: [nxViteTsPaths()], build: { // Explicitly set outDir to prevent Vite from resolving paths incorrectly // This is critical for reproducible builds, especially when dist folders @@ -22,7 +24,12 @@ export default defineConfig(() => { formats: ['iife'], fileName: () => 'edit-content-bridge.js' }, - minify: true + minify: true, + rollupOptions: { + // Externalize Angular and UI dependencies since they're not needed in the Dojo IIFE build + // The IIFE only uses DojoFormBridge, not AngularFormBridge + external: ['@angular/core', '@angular/forms', 'primeng/dynamicdialog', '@dotcms/ui'] + } } }; }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-custom-field/components/iframe-field/iframe-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-custom-field/components/iframe-field/iframe-field.component.ts index 61a631dfb2d1..e792758a147e 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-custom-field/components/iframe-field/iframe-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-custom-field/components/iframe-field/iframe-field.component.ts @@ -15,6 +15,7 @@ import { ControlContainer, FormGroupDirective, ReactiveFormsModule } from '@angu import { ButtonModule } from 'primeng/button'; import { DialogModule } from 'primeng/dialog'; +import { DialogService } from 'primeng/dynamicdialog'; import { InputTextModule } from 'primeng/inputtext'; import { DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; @@ -48,7 +49,8 @@ import { INPUT_TEXT_OPTIONS } from '../../../dot-edit-content-text-field/utils'; { provide: WINDOW, useValue: window - } + }, + DialogService ], host: { '[class.no-label]': '!$showLabel()' @@ -166,7 +168,12 @@ export class IframeFieldComponent implements OnDestroy { * The zone to run the code in. */ #zone = inject(NgZone); - + /** + * A private field that holds an instance of the DialogService. + * This service is injected using Angular's dependency injection mechanism. + * It is used to manage dialog interactions within the component. + */ + readonly #dialogService = inject(DialogService); /** * The form to get the form. */ @@ -288,7 +295,8 @@ export class IframeFieldComponent implements OnDestroy { this.#formBridge = createFormBridge({ type: 'angular', form, - zone: this.#zone + zone: this.#zone, + dialogService: this.#dialogService }); } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-custom-field/components/native-field/native-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-custom-field/components/native-field/native-field.component.ts index 3cb8f8c89f6d..5d2c7a6056b7 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-custom-field/components/native-field/native-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-custom-field/components/native-field/native-field.component.ts @@ -19,6 +19,7 @@ import { DomSanitizer } from '@angular/platform-browser'; import { ButtonModule } from 'primeng/button'; import { DialogModule } from 'primeng/dialog'; +import { DialogService } from 'primeng/dynamicdialog'; import { InputTextModule } from 'primeng/inputtext'; import { DotCMSContentTypeField, DotCMSContentlet } from '@dotcms/dotcms-models'; @@ -45,7 +46,8 @@ import { WINDOW } from '@dotcms/utils'; { provide: WINDOW, useValue: window - } + }, + DialogService ], imports: [ButtonModule, InputTextModule, DialogModule, ReactiveFormsModule] }) @@ -64,6 +66,12 @@ export class NativeFieldComponent implements OnInit, OnDestroy { * The content type to render the field for. */ $contentlet = input.required({ alias: 'contentlet' }); + /** + * A readonly field that holds an instance of the DialogService. + * This service is injected using Angular's dependency injection mechanism. + * It is used to manage dialog interactions within the component. + */ + readonly #dialogService = inject(DialogService); /** * The template code of the field. * This content is expected to be sanitized on the backend before reaching this component. @@ -115,7 +123,8 @@ export class NativeFieldComponent implements OnInit, OnDestroy { this.#formBridge = createFormBridge({ type: 'angular', form, - zone: this.#zone + zone: this.#zone, + dialogService: this.#dialogService }); this.#window['DotCustomFieldApi'] = this.#formBridge; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.ts index 799bd8a34a55..b552b449c7ec 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field-preview/dot-file-field-preview.component.ts @@ -29,13 +29,13 @@ import { DotMessagePipe, DotCopyButtonComponent } from '@dotcms/ui'; +import { getFileMetadata } from '@dotcms/utils'; import { DotPreviewResourceLink, UploadedFile } from '../../../../models/dot-edit-content-file.model'; import { CONTENT_TYPES, DEFAULT_CONTENT_TYPE } from '../../dot-edit-content-file-field.const'; -import { getFileMetadata } from '../../utils'; type FileInfo = UploadedFile & { contentType: string; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.ts index e5ab787c83ec..ba80abf30de9 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-file-field/dot-file-field.component.ts @@ -32,7 +32,8 @@ import { DotAIImagePromptComponent, DotSpinnerComponent, DropZoneFileEvent, - DropZoneFileValidity + DropZoneFileValidity, + DotBrowserSelectorComponent } from '@dotcms/ui'; import { DotFileFieldUploadService } from './../../services/upload-file/upload-file.service'; @@ -42,7 +43,6 @@ import { DotFileFieldPreviewComponent } from './../dot-file-field-preview/dot-fi import { DotFileFieldUiMessageComponent } from './../dot-file-field-ui-message/dot-file-field-ui-message.component'; import { DotFormFileEditorComponent } from './../dot-form-file-editor/dot-form-file-editor.component'; import { DotFormImportUrlComponent } from './../dot-form-import-url/dot-form-import-url.component'; -import { DotSelectExistingFileComponent } from './../dot-select-existing-file/dot-select-existing-file.component'; import { INPUT_TYPE, @@ -415,7 +415,7 @@ export class DotFileFieldComponent const header = this.#dotMessageService.get(title); - this.#dialogRef = this.#dialogService.open(DotSelectExistingFileComponent, { + this.#dialogRef = this.#dialogService.open(DotBrowserSelectorComponent, { header, appendTo: 'body', closeOnEscape: false, @@ -427,7 +427,15 @@ export class DotFileFieldComponent width: '90%', style: { 'max-width': '1040px' }, data: { - mimeTypes + mimeTypes, + showLinks: false, + showDotAssets: true, + showPages: false, + showFiles: true, + showFolders: false, + showWorking: true, + showArchived: false, + sortByDesc: true } }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.ts deleted file mode 100644 index 23a23b9d7e0a..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - effect, - inject, - OnInit, - viewChild -} from '@angular/core'; - -import { ButtonModule } from 'primeng/button'; -import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog'; - -import { DotMessagePipe } from '@dotcms/ui'; - -import { DotDataViewComponent } from './components/dot-dataview/dot-dataview.component'; -import { DotSideBarComponent } from './components/dot-sidebar/dot-sidebar.component'; -import { SelectExisingFileStore } from './store/select-existing-file.store'; - -import { DotFileFieldUploadService } from '../../services/upload-file/upload-file.service'; - -type DialogData = { - mimeTypes: string[]; -}; - -@Component({ - selector: 'dot-select-existing-file', - imports: [DotSideBarComponent, DotDataViewComponent, ButtonModule, DotMessagePipe], - templateUrl: './dot-select-existing-file.component.html', - styleUrls: ['./dot-select-existing-file.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - providers: [SelectExisingFileStore] -}) -export class DotSelectExistingFileComponent implements OnInit { - /** - * Injects the SelectExistingFileStore into the component. - * - * @readonly - * @type {SelectExistingFileStore} - */ - /** - * A readonly property that injects the `SelectExisingFileStore` service. - * This store is used to manage the state and actions related to selecting existing files. - */ - readonly store = inject(SelectExisingFileStore); - - /** - * A readonly property that injects the `DotFileFieldUploadService` service. - * This service is used to manage the state and actions related to selecting existing files. - */ - readonly #uploadService = inject(DotFileFieldUploadService); - /** - * A reference to the dynamic dialog instance. - * This is a read-only property that is injected using Angular's dependency injection. - * It provides access to the dialog's methods and properties. - */ - readonly #dialogRef = inject(DynamicDialogRef); - - /** - * Reference to the DotSideBarComponent instance. - * This is used to interact with the sidebar component within the template. - * - * @type {DotSideBarComponent} - */ - $sideBarRef = viewChild.required(DotSideBarComponent); - - /** - * A readonly property that injects the `DynamicDialogConfig` service. - * This service is used to get the dialog data. - */ - readonly #dialogConfig = inject(DynamicDialogConfig); - - constructor() { - effect(() => { - const folders = this.store.folders(); - - if (folders.nodeExpaned) { - this.$sideBarRef().detectChanges(); - } - }); - } - - ngOnInit() { - const data = this.#dialogConfig?.data as DialogData; - const mimeTypes = data?.mimeTypes ?? []; - this.store.setMimeTypes(mimeTypes); - this.store.loadContent(); - } - - /** - * Cancels the current file upload and closes the dialog. - * - * @remarks - * This method is used to terminate the ongoing file upload process and - * close the associated dialog reference. - */ - closeDialog(): void { - this.#dialogRef.close(); - } - - /** - * Retrieves the selected content from the store, fetches it by ID using the upload service, - * and closes the dialog with the retrieved content. - */ - addContent(): void { - const content = this.store.selectedContent(); - this.#uploadService.getContentById(content.identifier).subscribe((content) => { - this.#dialogRef.close(content); - }); - } -} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/store/select-existing-file.store.test.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/store/select-existing-file.store.test.ts deleted file mode 100644 index d53fb3a95771..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/store/select-existing-file.store.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { createFakeEvent } from '@ngneat/spectator'; -import { SpyObject, mockProvider } from '@ngneat/spectator/jest'; -import { of, throwError } from 'rxjs'; - -import { fakeAsync, TestBed, tick } from '@angular/core/testing'; - -import { ComponentStatus } from '@dotcms/dotcms-models'; - -import { SelectExisingFileStore } from './select-existing-file.store'; - -import { DotEditContentService } from '../../../../../services/dot-edit-content.service'; -import { TREE_SELECT_MOCK, TREE_SELECT_SITES_MOCK } from '../../../../../utils/mocks'; - -describe('SelectExisingFileStore', () => { - let store: InstanceType; - let editContentService: SpyObject; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - SelectExisingFileStore, - mockProvider(DotEditContentService, { - getSitesTreePath: jest.fn().mockReturnValue(of(TREE_SELECT_SITES_MOCK)), - getContentByFolder: jest.fn().mockReturnValue(of([])) - }) - ] - }); - - store = TestBed.inject(SelectExisingFileStore); - editContentService = TestBed.inject( - DotEditContentService - ) as SpyObject; - }); - - it('should be created', () => { - expect(store).toBeTruthy(); - }); - - describe('Method: loadFolders', () => { - it('should set folders status to LOADING and then to LOADED with data', fakeAsync(() => { - editContentService.getSitesTreePath.mockReturnValue(of(TREE_SELECT_SITES_MOCK)); - - store.loadFolders(); - - tick(50); - - expect(store.folders().status).toBe(ComponentStatus.LOADED); - expect(store.folders().data).toEqual(TREE_SELECT_SITES_MOCK); - })); - - it('should set folders status to ERROR on service error', fakeAsync(() => { - editContentService.getSitesTreePath.mockReturnValue(throwError('error')); - - store.loadFolders(); - - tick(50); - - expect(store.folders().status).toBe(ComponentStatus.ERROR); - expect(store.folders().data).toEqual([]); - })); - }); - - describe('Method: loadChildren', () => { - it('should load children for a node', fakeAsync(() => { - const mockChildren = { - parent: { - id: 'demo.dotcms.com', - hostName: 'demo.dotcms.com', - path: '', - type: 'site', - addChildrenAllowed: true - }, - folders: [...TREE_SELECT_SITES_MOCK] - }; - - editContentService.getFoldersTreeNode.mockReturnValue(of(mockChildren)); - - const node = { ...TREE_SELECT_MOCK[0] }; - - const mockItem = { - originalEvent: createFakeEvent('click'), - node - }; - store.loadChildren(mockItem); - - tick(50); - - expect(node.children).toEqual(mockChildren.folders); - expect(node.loading).toBe(false); - expect(node.leaf).toBe(true); - expect(node.icon).toBe('pi pi-folder-open'); - expect(store.folders().nodeExpaned).toBe(node); - })); - - it('should handle error when loading children', fakeAsync(() => { - editContentService.getFoldersTreeNode.mockReturnValue(throwError('error')); - - const node = { ...TREE_SELECT_MOCK[0], children: [] }; - - const mockItem = { - originalEvent: createFakeEvent('click'), - node - }; - store.loadChildren(mockItem); - - tick(50); - - expect(node.children).toEqual([]); - expect(node.loading).toBe(false); - })); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.spec.ts index 094079f864d8..f40b702f823c 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.spec.ts @@ -1,30 +1,24 @@ -import { - createHttpFactory, - mockProvider, - SpectatorHttp, - SpyObject, - HttpMethod -} from '@ngneat/spectator/jest'; +import { createHttpFactory, mockProvider, SpectatorHttp, SpyObject } from '@ngneat/spectator/jest'; import { of } from 'rxjs'; -import { DotUploadFileService, DotUploadService } from '@dotcms/data-access'; +import { DotContentletService, DotUploadFileService, DotUploadService } from '@dotcms/data-access'; +import { createFakeContentlet } from '@dotcms/utils-testing'; import { DotFileFieldUploadService, UploadFileProps } from './upload-file.service'; -import { DotEditContentService } from '../../../../services/dot-edit-content.service'; -import { NEW_FILE_MOCK, NEW_FILE_EDITABLE_MOCK, TEMP_FILE_MOCK } from '../../../../utils/mocks'; +import { TEMP_FILE_MOCK } from '../../../../utils/mocks'; describe('DotFileFieldUploadService', () => { let spectator: SpectatorHttp; let dotUploadFileService: SpyObject; - let dotEditContentService: SpyObject; + let dotContentletService: SpyObject; let tempFileService: SpyObject; const createHttp = createHttpFactory({ service: DotFileFieldUploadService, providers: [ mockProvider(DotUploadFileService), - mockProvider(DotEditContentService), + mockProvider(DotContentletService), mockProvider(DotUploadService) ] }); @@ -32,7 +26,7 @@ describe('DotFileFieldUploadService', () => { beforeEach(() => { spectator = createHttp(); dotUploadFileService = spectator.inject(DotUploadFileService); - dotEditContentService = spectator.inject(DotEditContentService); + dotContentletService = spectator.inject(DotContentletService); tempFileService = spectator.inject(DotUploadService); }); @@ -41,110 +35,355 @@ describe('DotFileFieldUploadService', () => { }); describe('uploadFile', () => { - it('should upload a file without content', () => { - dotUploadFileService.uploadDotAsset.mockReturnValue(of(NEW_FILE_MOCK.entity)); + it('should upload a file with temp upload type', () => { + tempFileService.uploadFile.mockResolvedValue(TEMP_FILE_MOCK); + + const file = new File([''], 'test.png', { type: 'image/png' }); + const uploadType = 'temp'; + const params: UploadFileProps = { file, uploadType, acceptedFiles: [], maxSize: null }; - const file = new File([''], 'test.png', { - type: 'image/png' + spectator.service.uploadFile(params).subscribe((result) => { + expect(result.source).toBe('temp'); + expect(result.file).toBe(TEMP_FILE_MOCK); + expect(tempFileService.uploadFile).toHaveBeenCalledTimes(1); + expect(tempFileService.uploadFile).toHaveBeenCalledWith({ + file, + signal: undefined + }); }); + }); + + it('should upload a file with temp upload type and abort signal', () => { + const abortSignal = new AbortController().signal; + tempFileService.uploadFile.mockResolvedValue(TEMP_FILE_MOCK); - spectator.service.uploadDotAsset(file).subscribe(); + const file = new File([''], 'test.png', { type: 'image/png' }); + const uploadType = 'temp'; + const params: UploadFileProps = { + file, + uploadType, + acceptedFiles: [], + maxSize: null, + abortSignal + }; - expect(dotUploadFileService.uploadDotAsset).toHaveBeenCalled(); + spectator.service.uploadFile(params).subscribe((result) => { + expect(result.source).toBe('temp'); + expect(result.file).toBe(TEMP_FILE_MOCK); + expect(tempFileService.uploadFile).toHaveBeenCalledWith({ + file, + signal: abortSignal + }); + }); }); - it('should upload a file with content', () => { - dotUploadFileService.uploadDotAsset.mockReturnValue(of(NEW_FILE_EDITABLE_MOCK.entity)); + it('should upload a file with contentlet upload type when file is a File instance', () => { + const mockContentlet = createFakeContentlet({ + mimeType: 'image/png', + asset: '/dA/test-id/asset/test.png' + }); + dotUploadFileService.uploadDotAssetWithContent.mockReturnValue(of(mockContentlet)); + + const file = new File([''], 'test.png', { type: 'image/png' }); + const uploadType = 'dotasset'; + const params: UploadFileProps = { file, uploadType, acceptedFiles: [], maxSize: null }; - const file = new File(['my content'], 'docker-compose.yml', { - type: 'text/plain' + spectator.service.uploadFile(params).subscribe((result) => { + expect(result.source).toBe('contentlet'); + expect(result.file).toBe(mockContentlet); + expect(dotUploadFileService.uploadDotAssetWithContent).toHaveBeenCalledWith(file); }); + }); - spectator.service.uploadDotAsset(file).subscribe((fileContent) => { - expect(fileContent.content).toEqual('my content'); + it('should upload a file with contentlet upload type when file is a string', () => { + const mockContentlet = createFakeContentlet({ + mimeType: 'image/png', + asset: '/dA/test-id/asset/test.png' }); + dotUploadFileService.uploadDotAssetWithContent.mockReturnValue(of(mockContentlet)); + tempFileService.uploadFile.mockResolvedValue(TEMP_FILE_MOCK); - const req = spectator.expectOne( - NEW_FILE_EDITABLE_MOCK.entity.assetVersion, - HttpMethod.GET - ); - req.flush('my content'); + const file = 'temp-file-id'; + const uploadType = 'dotasset'; + const params: UploadFileProps = { file, uploadType, acceptedFiles: [], maxSize: null }; + + spectator.service.uploadFile(params).subscribe((result) => { + expect(result.source).toBe('contentlet'); + expect(result.file).toBe(mockContentlet); + expect(tempFileService.uploadFile).toHaveBeenCalledWith({ + file, + signal: undefined + }); + expect(dotUploadFileService.uploadDotAssetWithContent).toHaveBeenCalledWith( + TEMP_FILE_MOCK.id + ); + }); + }); + + it('should upload a file with contentlet upload type when file is a string and has abort signal', () => { + const abortSignal = new AbortController().signal; + const mockContentlet = createFakeContentlet({ + mimeType: 'image/png', + asset: '/dA/test-id/asset/test.png' + }); + dotUploadFileService.uploadDotAssetWithContent.mockReturnValue(of(mockContentlet)); + tempFileService.uploadFile.mockResolvedValue(TEMP_FILE_MOCK); - expect(dotUploadFileService.uploadDotAsset).toHaveBeenCalled(); + const file = 'temp-file-id'; + const uploadType = 'dotasset'; + const params: UploadFileProps = { + file, + uploadType, + acceptedFiles: [], + maxSize: null, + abortSignal + }; + + spectator.service.uploadFile(params).subscribe((result) => { + expect(result.source).toBe('contentlet'); + expect(result.file).toBe(mockContentlet); + expect(tempFileService.uploadFile).toHaveBeenCalledWith({ + file, + signal: abortSignal + }); + expect(dotUploadFileService.uploadDotAssetWithContent).toHaveBeenCalledWith( + TEMP_FILE_MOCK.id + ); + }); }); }); - describe('getContentById', () => { - it('should get a contentlet without content', () => { - dotEditContentService.getContentById.mockReturnValue(of(NEW_FILE_MOCK.entity)); + describe('uploadTempFile', () => { + it('should upload a file to temp service', () => { + tempFileService.uploadFile.mockResolvedValue(TEMP_FILE_MOCK); - spectator.service.getContentById(NEW_FILE_MOCK.entity.identifier).subscribe(); + const file = new File([''], 'test.png', { type: 'image/png' }); + const acceptedFiles: string[] = []; - expect(dotEditContentService.getContentById).toHaveBeenCalled(); + spectator.service.uploadTempFile(file, acceptedFiles).subscribe((result) => { + expect(result).toBe(TEMP_FILE_MOCK); + expect(tempFileService.uploadFile).toHaveBeenCalledWith({ + file, + signal: undefined + }); + }); }); - it('should get a contentlet with content', () => { - dotEditContentService.getContentById.mockReturnValue(of(NEW_FILE_EDITABLE_MOCK.entity)); + it('should upload a file to temp service with abort signal', () => { + const abortSignal = new AbortController().signal; + tempFileService.uploadFile.mockResolvedValue(TEMP_FILE_MOCK); + + const file = new File([''], 'test.png', { type: 'image/png' }); + const acceptedFiles: string[] = []; spectator.service - .getContentById(NEW_FILE_EDITABLE_MOCK.entity.identifier) - .subscribe((fileContent) => { - expect(fileContent.content).toEqual('my content'); + .uploadTempFile(file, acceptedFiles, abortSignal) + .subscribe((result) => { + expect(result).toBe(TEMP_FILE_MOCK); + expect(tempFileService.uploadFile).toHaveBeenCalledWith({ + file, + signal: abortSignal + }); }); + }); - const req = spectator.expectOne( - NEW_FILE_EDITABLE_MOCK.entity.assetVersion, - HttpMethod.GET - ); - req.flush('my content'); + it('should throw error when file type is not accepted', () => { + const tempFile = { ...TEMP_FILE_MOCK, mimeType: 'application/pdf' }; + tempFileService.uploadFile.mockResolvedValue(tempFile); - expect(dotEditContentService.getContentById).toHaveBeenCalled(); + const file = new File([''], 'test.pdf', { type: 'application/pdf' }); + const acceptedFiles: string[] = ['image/png', 'image/jpeg']; + + spectator.service.uploadTempFile(file, acceptedFiles).subscribe({ + next: () => fail('should have thrown an error'), + error: (error) => { + expect(error).toEqual(new Error('Invalid file type')); + } + }); }); - }); - describe('uploadFile', () => { - it('should upload a file with temp upload type', () => { + it('should accept file when acceptedFiles is empty', () => { tempFileService.uploadFile.mockResolvedValue(TEMP_FILE_MOCK); const file = new File([''], 'test.png', { type: 'image/png' }); - const uploadType = 'temp'; - const params: UploadFileProps = { file, uploadType, acceptedFiles: [], maxSize: '' }; + const acceptedFiles: string[] = []; - spectator.service.uploadFile(params).subscribe((result) => { - expect(result.source).toBe('temp'); - expect(result.file).toBe(TEMP_FILE_MOCK); - expect(tempFileService.uploadFile).toHaveBeenCalledTimes(1); + spectator.service.uploadTempFile(file, acceptedFiles).subscribe((result) => { + expect(result).toBe(TEMP_FILE_MOCK); }); }); + }); - it('should upload a file with contentlet upload type', () => { - dotUploadFileService.uploadDotAsset.mockReturnValue(of(NEW_FILE_MOCK.entity)); + describe('uploadDotAssetByFile', () => { + it('should upload a file as dotAsset', () => { + const mockContentlet = createFakeContentlet({ + mimeType: 'image/png', + asset: '/dA/test-id/asset/test.png' + }); + dotUploadFileService.uploadDotAssetWithContent.mockReturnValue(of(mockContentlet)); const file = new File([''], 'test.png', { type: 'image/png' }); - const uploadType = 'dotasset'; - const params: UploadFileProps = { file, uploadType, acceptedFiles: [], maxSize: '' }; + const acceptedFiles: string[] = ['image/png']; - spectator.service.uploadFile(params).subscribe((result) => { - expect(result.source).toBe('contentlet'); - expect(result.file).toBe(NEW_FILE_MOCK.entity); - expect(dotUploadFileService.uploadDotAsset).toHaveBeenCalledTimes(1); + spectator.service.uploadDotAssetByFile(file, acceptedFiles).subscribe((result) => { + expect(result).toBe(mockContentlet); + expect(dotUploadFileService.uploadDotAssetWithContent).toHaveBeenCalledWith(file); }); }); - it('should upload a file with contentlet upload type', () => { - dotUploadFileService.uploadDotAsset.mockReturnValue(of(NEW_FILE_MOCK.entity)); + it('should throw error when file type is not accepted', () => { + const mockContentlet = createFakeContentlet({ + mimeType: 'application/pdf', + asset: '/dA/test-id/asset/test.pdf' + }); + dotUploadFileService.uploadDotAssetWithContent.mockReturnValue(of(mockContentlet)); + + const file = new File([''], 'test.pdf', { type: 'application/pdf' }); + const acceptedFiles: string[] = ['image/png', 'image/jpeg']; + + spectator.service.uploadDotAssetByFile(file, acceptedFiles).subscribe({ + next: () => fail('should have thrown an error'), + error: (error) => { + expect(error).toEqual(new Error('Invalid file type')); + } + }); + }); + }); + + describe('uploadDotAssetByUrl', () => { + it('should upload a file by URL as dotAsset', () => { + const mockContentlet = createFakeContentlet({ + mimeType: 'image/png', + asset: '/dA/test-id/asset/test.png' + }); + dotUploadFileService.uploadDotAssetWithContent.mockReturnValue(of(mockContentlet)); tempFileService.uploadFile.mockResolvedValue(TEMP_FILE_MOCK); - const file = 'file'; - const uploadType = 'dotasset'; - const params: UploadFileProps = { file, uploadType, acceptedFiles: [], maxSize: '' }; + const file = 'temp-file-id'; + const acceptedFiles: string[] = ['image/png']; - spectator.service.uploadFile(params).subscribe((result) => { - expect(result.source).toBe('contentlet'); - expect(result.file).toBe(NEW_FILE_MOCK.entity); - expect(tempFileService.uploadFile).toHaveBeenCalledTimes(1); - expect(dotUploadFileService.uploadDotAsset).toHaveBeenCalledTimes(1); - expect(dotUploadFileService.uploadDotAsset).toHaveBeenCalledWith(TEMP_FILE_MOCK.id); + spectator.service.uploadDotAssetByUrl(file, acceptedFiles).subscribe((result) => { + expect(result).toBe(mockContentlet); + expect(tempFileService.uploadFile).toHaveBeenCalledWith({ + file, + signal: undefined + }); + expect(dotUploadFileService.uploadDotAssetWithContent).toHaveBeenCalledWith( + TEMP_FILE_MOCK.id + ); + }); + }); + + it('should upload a file by URL with abort signal', () => { + const abortSignal = new AbortController().signal; + const mockContentlet = createFakeContentlet({ + mimeType: 'image/png', + asset: '/dA/test-id/asset/test.png' + }); + dotUploadFileService.uploadDotAssetWithContent.mockReturnValue(of(mockContentlet)); + tempFileService.uploadFile.mockResolvedValue(TEMP_FILE_MOCK); + + const file = 'temp-file-id'; + const acceptedFiles: string[] = ['image/png']; + + spectator.service + .uploadDotAssetByUrl(file, acceptedFiles, abortSignal) + .subscribe((result) => { + expect(result).toBe(mockContentlet); + expect(tempFileService.uploadFile).toHaveBeenCalledWith({ + file, + signal: abortSignal + }); + expect(dotUploadFileService.uploadDotAssetWithContent).toHaveBeenCalledWith( + TEMP_FILE_MOCK.id + ); + }); + }); + + it('should throw error when file type is not accepted', () => { + const tempFile = { ...TEMP_FILE_MOCK, mimeType: 'application/pdf' }; + tempFileService.uploadFile.mockResolvedValue(tempFile); + + const file = 'temp-file-id'; + const acceptedFiles: string[] = ['image/png', 'image/jpeg']; + + spectator.service.uploadDotAssetByUrl(file, acceptedFiles).subscribe({ + next: () => fail('should have thrown an error'), + error: (error) => { + expect(error).toEqual(new Error('Invalid file type')); + } + }); + }); + }); + + describe('uploadDotAsset', () => { + it('should upload a file and return contentlet', () => { + const mockContentlet = createFakeContentlet({ + mimeType: 'image/png', + asset: '/dA/test-id/asset/test.png' + }); + dotUploadFileService.uploadDotAssetWithContent.mockReturnValue(of(mockContentlet)); + + const file = new File([''], 'test.png', { type: 'image/png' }); + + spectator.service.uploadDotAsset(file).subscribe((result) => { + expect(result).toBe(mockContentlet); + expect(dotUploadFileService.uploadDotAssetWithContent).toHaveBeenCalledWith(file); + }); + }); + + it('should upload a file by string id and return contentlet', () => { + const mockContentlet = createFakeContentlet({ + mimeType: 'image/png', + asset: '/dA/test-id/asset/test.png' + }); + dotUploadFileService.uploadDotAssetWithContent.mockReturnValue(of(mockContentlet)); + + const fileId = 'temp-file-id'; + + spectator.service.uploadDotAsset(fileId).subscribe((result) => { + expect(result).toBe(mockContentlet); + expect(dotUploadFileService.uploadDotAssetWithContent).toHaveBeenCalledWith(fileId); + }); + }); + }); + + describe('getContentById', () => { + it('should get a contentlet by identifier', () => { + const mockContentlet = createFakeContentlet({ + identifier: 'test-identifier', + mimeType: 'image/png' + }); + dotContentletService.getContentletByInodeWithContent.mockReturnValue( + of(mockContentlet) + ); + + spectator.service.getContentById('test-identifier').subscribe((result) => { + expect(result).toBe(mockContentlet); + expect(dotContentletService.getContentletByInodeWithContent).toHaveBeenCalledWith( + 'test-identifier' + ); + }); + }); + + it('should get a contentlet with content when editable as text', () => { + const mockContentlet = createFakeContentlet({ + identifier: 'test-identifier', + mimeType: 'text/plain', + asset: '/dA/test-id/asset/test.txt', + assetMetaData: { + editableAsText: true + } + }); + dotContentletService.getContentletByInodeWithContent.mockReturnValue( + of(mockContentlet) + ); + + spectator.service.getContentById('test-identifier').subscribe((result) => { + expect(result).toBe(mockContentlet); + expect(dotContentletService.getContentletByInodeWithContent).toHaveBeenCalledWith( + 'test-identifier' + ); }); }); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.ts index abc5fdc1b712..0dc00aa29673 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/services/upload-file/upload-file.service.ts @@ -1,16 +1,14 @@ -import { from, Observable, of } from 'rxjs'; +import { from, Observable } from 'rxjs'; -import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { map, switchMap, tap } from 'rxjs/operators'; -import { DotUploadFileService, DotUploadService } from '@dotcms/data-access'; +import { DotContentletService, DotUploadFileService, DotUploadService } from '@dotcms/data-access'; import { DotCMSContentlet, DotCMSTempFile } from '@dotcms/dotcms-models'; +import { checkMimeType } from '@dotcms/utils'; import { UploadedFile, UPLOAD_TYPE } from '../../../../models/dot-edit-content-file.model'; -import { DotEditContentService } from '../../../../services/dot-edit-content.service'; -import { getFileMetadata, getFileVersion, checkMimeType } from '../../utils'; export type UploadFileProps = { file: File | string; @@ -24,8 +22,7 @@ export type UploadFileProps = { export class DotFileFieldUploadService { readonly #fileService = inject(DotUploadFileService); readonly #tempFileService = inject(DotUploadService); - readonly #contentService = inject(DotEditContentService); - readonly #httpClient = inject(HttpClient); + readonly #dotContentletService = inject(DotContentletService); /** * Uploads a file or a string as a dotAsset contentlet. @@ -118,9 +115,7 @@ export class DotFileFieldUploadService { * @returns a contentlet with the file metadata and id */ uploadDotAsset(file: File | string) { - return this.#fileService - .uploadDotAsset(file) - .pipe(switchMap((contentlet) => this.#addContent(contentlet))); + return this.#fileService.uploadDotAssetWithContent(file); } /** @@ -129,35 +124,6 @@ export class DotFileFieldUploadService { * @returns a contentlet with the content if it's a editable as text file */ getContentById(identifier: string) { - return this.#contentService - .getContentById({ id: identifier }) - .pipe(switchMap((contentlet) => this.#addContent(contentlet))); - } - - /** - * Adds the content of a contentlet if it's a editable as text file. - * @param contentlet the contentlet to be processed - * @returns a contentlet with the content if it's a editable as text file, otherwise the original contentlet - */ - #addContent(contentlet: DotCMSContentlet) { - const { editableAsText } = getFileMetadata(contentlet); - const contentURL = getFileVersion(contentlet); - - if (editableAsText && contentURL) { - return this.#getContentFile(contentURL).pipe( - map((content) => ({ ...contentlet, content })) - ); - } - - return of(contentlet); - } - - /** - * Downloads the content of a file by its URL. - * @param contentURL the URL of the file content - * @returns an observable of the file content - */ - #getContentFile(contentURL: string) { - return this.#httpClient.get(contentURL, { responseType: 'text' }); + return this.#dotContentletService.getContentletByInodeWithContent(identifier); } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/components/host-folder-field/host-folder-field.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/components/host-folder-field/host-folder-field.component.html index b8d5eba6cd57..be75a136fa76 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/components/host-folder-field/host-folder-field.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/components/host-folder-field/host-folder-field.component.html @@ -23,7 +23,7 @@ filterMode="lenient" selectionMode="single"> - {{ node.label | truncatePath }} + {{ node.label | dotTruncatePath }} //{{ item?.label }} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/components/host-folder-field/host-folder-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/components/host-folder-field/host-folder-field.component.ts index a36e3218a76a..ff0ce51c70a6 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/components/host-folder-field/host-folder-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/components/host-folder-field/host-folder-field.component.ts @@ -12,11 +12,9 @@ import { FormControl, ReactiveFormsModule, FormsModule, NG_VALUE_ACCESSOR } from import { TreeSelect, TreeSelectModule } from 'primeng/treeselect'; -import { - TreeNodeItem, - TreeNodeSelectItem -} from '../../../../models/dot-edit-content-host-folder-field.interface'; -import { TruncatePathPipe } from '../../../../pipes/truncate-path.pipe'; +import { TreeNodeItem, TreeNodeSelectItem } from '@dotcms/dotcms-models'; +import { DotTruncatePathPipe } from '@dotcms/ui'; + import { BaseControlValueAccessor } from '../../../shared/base-control-value-accesor'; import { HostFolderFiledStore } from '../../store/host-folder-field.store'; @@ -28,7 +26,7 @@ import { HostFolderFiledStore } from '../../store/host-folder-field.store'; */ @Component({ selector: 'dot-host-folder-field', - imports: [TreeSelectModule, ReactiveFormsModule, TruncatePathPipe, FormsModule], + imports: [TreeSelectModule, ReactiveFormsModule, DotTruncatePathPipe, FormsModule], templateUrl: './host-folder-field.component.html', changeDetection: ChangeDetectionStrategy.OnPush, providers: [ diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/dot-edit-content-host-folder-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/dot-edit-content-host-folder-field.component.spec.ts index e733ffcde7f3..ad6ba7e2cc35 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/dot-edit-content-host-folder-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/dot-edit-content-host-folder-field.component.spec.ts @@ -7,13 +7,13 @@ import { fakeAsync, tick } from '@angular/core/testing'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; +import { DotBrowsingService } from '@dotcms/ui'; import { createFakeContentlet, mockMatchMedia } from '@dotcms/utils-testing'; import { DotHostFolderFieldComponent } from './components/host-folder-field/host-folder-field.component'; import { DotEditContentHostFolderFieldComponent } from './dot-edit-content-host-folder-field.component'; import { HostFolderFiledStore } from './store/host-folder-field.store'; -import { DotEditContentService } from '../../services/dot-edit-content.service'; import { TREE_SELECT_SITES_MOCK, TREE_SELECT_MOCK, HOST_FOLDER_TEXT_MOCK } from '../../utils/mocks'; @Component({ @@ -31,7 +31,7 @@ export class MockFormComponent { describe('DotEditContentHostFolderFieldComponent', () => { let spectator: SpectatorHost; let store: InstanceType; - let service: SpyObject; + let service: SpyObject; let hostFormControl: FormControl; let field: DotHostFolderFieldComponent; @@ -41,7 +41,7 @@ describe('DotEditContentHostFolderFieldComponent', () => { imports: [ReactiveFormsModule], providers: [ HostFolderFiledStore, - mockProvider(DotEditContentService, { + mockProvider(DotBrowsingService, { getSitesTreePath: jest.fn(() => of(TREE_SELECT_SITES_MOCK)), getCurrentSiteAsTreeNodeItem: jest.fn(() => of(TREE_SELECT_SITES_MOCK[0])), buildTreeByPaths: jest.fn(() => of({ node: TREE_SELECT_SITES_MOCK[0], tree: null })) @@ -69,7 +69,7 @@ describe('DotEditContentHostFolderFieldComponent', () => { ); field = spectator.query(DotHostFolderFieldComponent); store = field.store; - service = spectator.inject(DotEditContentService); + service = spectator.inject(DotBrowsingService); hostFormControl = spectator.hostComponent.formGroup.get( HOST_FOLDER_TEXT_MOCK.variable ) as FormControl; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/store/host-folder-field.store.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/store/host-folder-field.store.spec.ts index f0e4276b5bc4..61d67c38285c 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/store/host-folder-field.store.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/store/host-folder-field.store.spec.ts @@ -4,20 +4,21 @@ import { of } from 'rxjs'; import { TestBed } from '@angular/core/testing'; +import { DotBrowsingService } from '@dotcms/ui'; + import { SYSTEM_HOST_NAME, HostFolderFiledStore } from './host-folder-field.store'; -import { DotEditContentService } from '../../../services/dot-edit-content.service'; import { TREE_SELECT_SITES_MOCK, TREE_SELECT_MOCK } from '../../../utils/mocks'; describe('HostFolderFiledStore', () => { let store: InstanceType; - let service: SpyObject; + let service: SpyObject; beforeEach(() => { TestBed.configureTestingModule({ providers: [ HostFolderFiledStore, - mockProvider(DotEditContentService, { + mockProvider(DotBrowsingService, { getSitesTreePath: jest.fn(() => of(TREE_SELECT_SITES_MOCK)) }) ] @@ -25,7 +26,7 @@ describe('HostFolderFiledStore', () => { store = TestBed.inject(HostFolderFiledStore); - service = TestBed.inject(DotEditContentService) as SpyObject; + service = TestBed.inject(DotBrowsingService) as SpyObject; }); describe('Method: loadSites', () => { diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/store/host-folder-field.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/store/host-folder-field.store.ts index 568a4348883e..1dc414587b34 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/store/host-folder-field.store.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-host-folder-field/store/host-folder-field.store.ts @@ -7,13 +7,8 @@ import { computed, inject } from '@angular/core'; import { tap, exhaustMap, switchMap, map, filter } from 'rxjs/operators'; -import { ComponentStatus } from '@dotcms/dotcms-models'; - -import { - TreeNodeItem, - TreeNodeSelectItem -} from '../../../models/dot-edit-content-host-folder-field.interface'; -import { DotEditContentService } from '../../../services/dot-edit-content.service'; +import { ComponentStatus, TreeNodeItem, TreeNodeSelectItem } from '@dotcms/dotcms-models'; +import { DotBrowsingService } from '@dotcms/ui'; export const PEER_PAGE_LIMIT = 7000; @@ -60,7 +55,7 @@ export const HostFolderFiledStore = signalStore( }) })), withMethods((store) => { - const dotEditContentService = inject(DotEditContentService); + const dotBrowsingService = inject(DotBrowsingService); return { /** @@ -70,7 +65,7 @@ export const HostFolderFiledStore = signalStore( pipe( tap(() => patchState(store, { status: ComponentStatus.LOADING })), switchMap(({ path, isRequired }) => { - return dotEditContentService + return dotBrowsingService .getSitesTreePath({ perPage: PEER_PAGE_LIMIT, filter: '*', @@ -111,7 +106,7 @@ export const HostFolderFiledStore = signalStore( } if (isRequired) { - return dotEditContentService.getCurrentSiteAsTreeNodeItem().pipe( + return dotBrowsingService.getCurrentSiteAsTreeNodeItem().pipe( switchMap((currentSite) => { const node = sites.find( (item) => item.label === currentSite.label @@ -145,7 +140,7 @@ export const HostFolderFiledStore = signalStore( return of(response); } - return dotEditContentService.buildTreeByPaths(path); + return dotBrowsingService.buildTreeByPaths(path); }), tap(({ node, tree }) => { const changes: Partial = {}; @@ -184,7 +179,7 @@ export const HostFolderFiledStore = signalStore( const fullPath = `${hostname}/${path}`; - return dotEditContentService.getFoldersTreeNode(fullPath).pipe( + return dotBrowsingService.getFoldersTreeNode(fullPath).pipe( tap(({ folders }) => { node.leaf = true; node.icon = 'pi pi-folder-open'; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.component.html index 94ae7acf47b8..a31d551d615c 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.component.html @@ -27,7 +27,7 @@ filterMode="lenient" selectionMode="single"> - {{ node?.label | truncatePath }} + {{ node?.label | dotTruncatePath }} @if (item?.label) { diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.component.spec.ts index ffbe27471f35..9c87fa8bf929 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.component.spec.ts @@ -7,19 +7,13 @@ import { ReactiveFormsModule } from '@angular/forms'; import { TreeSelectModule } from 'primeng/treeselect'; import { DotMessageService } from '@dotcms/data-access'; -import { DotMessagePipe } from '@dotcms/ui'; +import { TreeNodeItem, TreeNodeSelectItem } from '@dotcms/dotcms-models'; +import { DotMessagePipe, DotTruncatePathPipe, DotBrowsingService } from '@dotcms/ui'; import { MockDotMessageService } from '@dotcms/utils-testing'; import { SiteFieldComponent } from './site-field.component'; import { SiteFieldStore } from './site-field.store'; -import { - TreeNodeItem, - TreeNodeSelectItem -} from '../../../../../../../../models/dot-edit-content-host-folder-field.interface'; -import { TruncatePathPipe } from '../../../../../../../../pipes/truncate-path.pipe'; -import { DotEditContentService } from '../../../../../../../../services/dot-edit-content.service'; - describe('SiteFieldComponent', () => { let spectator: Spectator; let component: SiteFieldComponent; @@ -69,11 +63,11 @@ describe('SiteFieldComponent', () => { const createComponent = createComponentFactory({ component: SiteFieldComponent, - imports: [ReactiveFormsModule, TreeSelectModule, TruncatePathPipe, DotMessagePipe], + imports: [ReactiveFormsModule, TreeSelectModule, DotTruncatePathPipe, DotMessagePipe], componentProviders: [SiteFieldStore], providers: [ { provide: DotMessageService, useValue: messageServiceMock }, - mockProvider(DotEditContentService, { + mockProvider(DotBrowsingService, { getSitesTreePath: jest.fn().mockReturnValue(of(mockSites)), getFoldersTreeNode: jest.fn().mockReturnValue(of(mockFolders)) }) diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.component.ts index 04ee51b34a43..bd3a85946045 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.component.ts @@ -16,12 +16,10 @@ import { import { TreeSelect, TreeSelectModule } from 'primeng/treeselect'; -import { DotMessagePipe } from '@dotcms/ui'; +import { DotMessagePipe, DotTruncatePathPipe } from '@dotcms/ui'; import { SiteFieldStore } from './site-field.store'; -import { TruncatePathPipe } from '../../../../../../../../pipes/truncate-path.pipe'; - /** * Component for selecting a site from a tree structure. * Implements ControlValueAccessor to work with Angular forms. @@ -29,7 +27,7 @@ import { TruncatePathPipe } from '../../../../../../../../pipes/truncate-path.pi */ @Component({ selector: 'dot-site-field', - imports: [ReactiveFormsModule, TreeSelectModule, TruncatePathPipe, DotMessagePipe], + imports: [ReactiveFormsModule, TreeSelectModule, DotTruncatePathPipe, DotMessagePipe], providers: [ SiteFieldStore, { diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.store.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.store.spec.ts index b1f7795b36cd..85a63ffd4505 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.store.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.store.spec.ts @@ -1,24 +1,21 @@ import { createFakeEvent } from '@ngneat/spectator'; import { mockProvider, SpyObject } from '@ngneat/spectator/jest'; +import { patchState } from '@ngrx/signals'; +import { unprotected } from '@ngrx/signals/testing'; import { of, throwError } from 'rxjs'; import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { delay } from 'rxjs/operators'; -import { ComponentStatus } from '@dotcms/dotcms-models'; +import { ComponentStatus, TreeNodeItem, TreeNodeSelectItem } from '@dotcms/dotcms-models'; +import { DotBrowsingService } from '@dotcms/ui'; import { PEER_PAGE_LIMIT, SiteFieldStore } from './site-field.store'; -import { - TreeNodeItem, - TreeNodeSelectItem -} from '../../../../../../../../models/dot-edit-content-host-folder-field.interface'; -import { DotEditContentService } from '../../../../../../../../services/dot-edit-content.service'; - describe('SiteFieldStore', () => { let store: InstanceType; - let dotEditContentService: SpyObject; + let dotBrowsingService: SpyObject; const mockSites: TreeNodeItem[] = [ { @@ -62,7 +59,7 @@ describe('SiteFieldStore', () => { TestBed.configureTestingModule({ providers: [ SiteFieldStore, - mockProvider(DotEditContentService, { + mockProvider(DotBrowsingService, { getSitesTreePath: jest.fn().mockReturnValue(of(mockSites)), getFoldersTreeNode: jest.fn().mockReturnValue(of(mockFolders)) }) @@ -70,9 +67,7 @@ describe('SiteFieldStore', () => { }); store = TestBed.inject(SiteFieldStore); - dotEditContentService = TestBed.inject( - DotEditContentService - ) as SpyObject; + dotBrowsingService = TestBed.inject(DotBrowsingService) as SpyObject; }); describe('Initial State', () => { @@ -86,9 +81,29 @@ describe('SiteFieldStore', () => { }); describe('Computed Properties', () => { + it('should compute isLoading as true when status is LOADING', () => { + patchState(unprotected(store), { status: ComponentStatus.LOADING }); + expect(store.isLoading()).toBeTruthy(); + }); + + it('should compute isLoading as false when status is not LOADING', () => { + patchState(unprotected(store), { status: ComponentStatus.LOADED }); + expect(store.isLoading()).toBeFalsy(); + }); + + it('should compute isLoading as false when status is ERROR', () => { + patchState(unprotected(store), { status: ComponentStatus.ERROR }); + expect(store.isLoading()).toBeFalsy(); + }); + + it('should compute isLoading as false when status is INIT', () => { + patchState(unprotected(store), { status: ComponentStatus.INIT }); + expect(store.isLoading()).toBeFalsy(); + }); + it('should indicate loading state correctly', fakeAsync(() => { const mockObservable = of(mockSites).pipe(delay(100)); - dotEditContentService.getSitesTreePath.mockReturnValue(mockObservable); + dotBrowsingService.getSitesTreePath.mockReturnValue(mockObservable); store.loadSites(); expect(store.isLoading()).toBeTruthy(); @@ -100,72 +115,98 @@ describe('SiteFieldStore', () => { })); it('should return correct value for valueToSave when node is selected (type: folder)', () => { - const mockNode: TreeNodeSelectItem = { - originalEvent: createFakeEvent('click'), - node: { - label: 'Test Node', - data: { - id: '123', - hostname: 'test.com', - path: 'test', - type: 'folder' - }, - icon: 'pi pi-folder', - leaf: true, - children: [] - } + const mockNode: TreeNodeItem = { + label: 'Test Node', + data: { + id: '123', + hostname: 'test.com', + path: 'test', + type: 'folder' + }, + icon: 'pi pi-folder', + leaf: true, + children: [] }; - store.chooseNode(mockNode); + patchState(unprotected(store), { nodeSelected: mockNode }); expect(store.valueToSave()).toBe('folder:123'); }); it('should return correct value for valueToSave when node is selected (type: site)', () => { - const mockNode: TreeNodeSelectItem = { - originalEvent: createFakeEvent('click'), - node: { - label: 'Test Node', - data: { - id: '456', - hostname: 'test.com', - path: '', - type: 'site' - }, - icon: 'pi pi-globe', - leaf: true, - children: [] - } + const mockNode: TreeNodeItem = { + label: 'Test Node', + data: { + id: '456', + hostname: 'test.com', + path: '', + type: 'site' + }, + icon: 'pi pi-globe', + leaf: true, + children: [] }; - store.chooseNode(mockNode); + patchState(unprotected(store), { nodeSelected: mockNode }); expect(store.valueToSave()).toBe('site:456'); }); it('should return null for valueToSave when no node is selected', () => { + patchState(unprotected(store), { nodeSelected: null }); expect(store.valueToSave()).toBeNull(); }); it('should return null for valueToSave when node data is missing', () => { - const mockNode: TreeNodeSelectItem = { - originalEvent: createFakeEvent('click'), - node: { - label: 'Invalid Node', - data: null, - icon: 'pi pi-folder', - leaf: true, - children: [] - } + const mockNode: TreeNodeItem = { + label: 'Invalid Node', + data: null, + icon: 'pi pi-folder', + leaf: true, + children: [] + }; + patchState(unprotected(store), { nodeSelected: mockNode }); + expect(store.valueToSave()).toBeNull(); + }); + + it('should return null for valueToSave when node data id is missing', () => { + const mockNode: TreeNodeItem = { + label: 'Invalid Node', + data: { + id: '', + hostname: 'test.com', + path: 'test', + type: 'folder' + }, + icon: 'pi pi-folder', + leaf: true, + children: [] + }; + patchState(unprotected(store), { nodeSelected: mockNode }); + expect(store.valueToSave()).toBeNull(); + }); + + it('should return null for valueToSave when node data type is missing', () => { + const mockNode: TreeNodeItem = { + label: 'Invalid Node', + data: { + id: '123', + hostname: 'test.com', + path: 'test', + type: undefined as 'site' | 'folder' | undefined + }, + icon: 'pi pi-folder', + leaf: true, + children: [] }; - store.chooseNode(mockNode); + patchState(unprotected(store), { nodeSelected: mockNode }); expect(store.valueToSave()).toBeNull(); }); }); describe('loadSites', () => { it('should load sites successfully', () => { - dotEditContentService.getSitesTreePath.mockReturnValue(of(mockSites)); + dotBrowsingService.getSitesTreePath.mockReturnValue(of(mockSites)); store.loadSites(); - expect(dotEditContentService.getSitesTreePath).toHaveBeenCalledWith({ + expect(dotBrowsingService.getSitesTreePath).toHaveBeenCalledWith({ perPage: PEER_PAGE_LIMIT, filter: '*', page: 1 @@ -176,7 +217,7 @@ describe('SiteFieldStore', () => { }); it('should handle error when loading sites fails', () => { - dotEditContentService.getSitesTreePath.mockReturnValue( + dotBrowsingService.getSitesTreePath.mockReturnValue( throwError(() => new Error('Failed to load sites')) ); @@ -190,9 +231,9 @@ describe('SiteFieldStore', () => { describe('loadChildren', () => { it('should load children nodes successfully', () => { - dotEditContentService.getFoldersTreeNode.mockReturnValue(of(mockFolders)); + dotBrowsingService.getFoldersTreeNode.mockReturnValue(of(mockFolders)); - const mockEvent = { + const mockEvent: TreeNodeSelectItem = { originalEvent: createFakeEvent('click'), node: { label: 'Parent', @@ -210,7 +251,7 @@ describe('SiteFieldStore', () => { store.loadChildren(mockEvent); - expect(dotEditContentService.getFoldersTreeNode).toHaveBeenCalledWith( + expect(dotBrowsingService.getFoldersTreeNode).toHaveBeenCalledWith( 'demo.dotcms.com/parent' ); expect(store.nodeExpanded()).toEqual({ @@ -222,11 +263,11 @@ describe('SiteFieldStore', () => { }); it('should handle error when loading children fails', () => { - dotEditContentService.getFoldersTreeNode.mockReturnValue( + dotBrowsingService.getFoldersTreeNode.mockReturnValue( throwError(() => new Error('Failed to load folders')) ); - const mockEvent = { + const mockEvent: TreeNodeSelectItem = { originalEvent: createFakeEvent('click'), node: { label: 'Parent', @@ -250,7 +291,7 @@ describe('SiteFieldStore', () => { describe('chooseNode', () => { it('should update selected node', () => { - const mockEvent = { + const mockEvent: TreeNodeSelectItem = { originalEvent: createFakeEvent('click'), node: { label: 'Selected Node', @@ -271,7 +312,7 @@ describe('SiteFieldStore', () => { }); it('should not update selected node when data is missing', () => { - const mockEvent = { + const mockEvent: TreeNodeSelectItem = { originalEvent: createFakeEvent('click'), node: { label: 'Invalid Node', @@ -290,7 +331,7 @@ describe('SiteFieldStore', () => { describe('clearSelection', () => { it('should clear the selected node', () => { // First select a node - const mockEvent = { + const mockEvent: TreeNodeSelectItem = { originalEvent: createFakeEvent('click'), node: { label: 'Selected Node', diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.store.ts index 5218de1b6243..f2c7aa02f408 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.store.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/components/site-field/site-field.store.ts @@ -7,13 +7,8 @@ import { computed, inject } from '@angular/core'; import { tap, exhaustMap, switchMap } from 'rxjs/operators'; -import { ComponentStatus } from '@dotcms/dotcms-models'; - -import { - TreeNodeItem, - TreeNodeSelectItem -} from '../../../../../../../../models/dot-edit-content-host-folder-field.interface'; -import { DotEditContentService } from '../../../../../../../../services/dot-edit-content.service'; +import { ComponentStatus, TreeNodeItem, TreeNodeSelectItem } from '@dotcms/dotcms-models'; +import { DotBrowsingService } from '@dotcms/ui'; /** Maximum number of items to fetch per page */ export const PEER_PAGE_LIMIT = 7000; @@ -62,7 +57,7 @@ export const SiteFieldStore = signalStore( }) })), withMethods((store) => { - const dotEditContentService = inject(DotEditContentService); + const dotBrowsingService = inject(DotBrowsingService); return { /** @@ -74,7 +69,7 @@ export const SiteFieldStore = signalStore( pipe( tap(() => patchState(store, { status: ComponentStatus.LOADING })), switchMap(() => { - return dotEditContentService + return dotBrowsingService .getSitesTreePath({ perPage: PEER_PAGE_LIMIT, filter: '*', @@ -110,7 +105,7 @@ export const SiteFieldStore = signalStore( const fullPath = `${hostname}/${path}`; - return dotEditContentService.getFoldersTreeNode(fullPath).pipe( + return dotBrowsingService.getFoldersTreeNode(fullPath).pipe( tap(({ folders }) => { node.leaf = true; node.icon = 'pi pi-folder-open'; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/search.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/search.component.spec.ts index 4c3c4eaf863c..e487dea2a162 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/search.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/search.component.spec.ts @@ -18,15 +18,14 @@ import { InputTextModule } from 'primeng/inputtext'; import { OverlayPanelModule } from 'primeng/overlaypanel'; import { DotLanguagesService, DotMessageService } from '@dotcms/data-access'; -import { DotMessagePipe } from '@dotcms/ui'; +import { TreeNodeItem } from '@dotcms/dotcms-models'; +import { DotMessagePipe, DotBrowsingService } from '@dotcms/ui'; import { MockDotMessageService, mockLocales } from '@dotcms/utils-testing'; import { LanguageFieldComponent } from './components/language-field/language-field.component'; import { SiteFieldComponent } from './components/site-field/site-field.component'; import { SearchComponent, DEBOUNCE_TIME } from './search.component'; -import { TreeNodeItem } from '../../../../../../models/dot-edit-content-host-folder-field.interface'; -import { DotEditContentService } from '../../../../../../services/dot-edit-content.service'; import { SearchParams } from '../../../../models/search.model'; // Mock components for testing @@ -140,7 +139,7 @@ describe('SearchComponent', () => { detectChanges: true, providers: [ { provide: DotMessageService, useValue: messageServiceMock }, - mockProvider(DotEditContentService, { + mockProvider(DotBrowsingService, { getSitesTreePath: jest.fn().mockReturnValue(of(mockSites)), getFoldersTreeNode: jest.fn().mockReturnValue(of(mockFolders)) }), diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/search.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/search.component.ts index 708a3c69067c..d2e90d35987c 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/search.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/search.component.ts @@ -12,12 +12,12 @@ import { OverlayPanel, OverlayPanelModule } from 'primeng/overlaypanel'; import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { TreeNodeItem } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; import { LanguageFieldComponent } from './components/language-field/language-field.component'; import { SiteFieldComponent } from './components/site-field/site-field.component'; -import { TreeNodeItem } from '../../../../../../models/dot-edit-content-host-folder-field.interface'; import { SearchParams } from '../../../../models/search.model'; export const DEBOUNCE_TIME = 300; diff --git a/core-web/libs/edit-content/src/lib/pipes/truncate-path.spec.ts b/core-web/libs/edit-content/src/lib/pipes/truncate-path.spec.ts deleted file mode 100644 index 3cf05a7379a2..000000000000 --- a/core-web/libs/edit-content/src/lib/pipes/truncate-path.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createPipeFactory, SpectatorPipe } from '@ngneat/spectator/jest'; - -import { TruncatePathPipe } from './truncate-path.pipe'; - -describe('TruncatePathPipe', () => { - let spectator: SpectatorPipe; - - const createPipe = createPipeFactory({ - pipe: TruncatePathPipe - }); - - it('should return just the path with root level', () => { - spectator = createPipe(`{{ 'demo.com' | truncatePath }}`); - expect(spectator.element).toHaveText('demo.com'); - }); - - it('should return just the path with one level', () => { - spectator = createPipe(`{{ 'demo.com/level1' | truncatePath }}`); - expect(spectator.element).toHaveText('level1'); - }); - - it('should return just the path with one level ending in slash', () => { - spectator = createPipe(`{{ 'demo.com/level1/' | truncatePath }}`); - expect(spectator.element).toHaveText('level1'); - }); - - it('should return just the path with two levels', () => { - spectator = createPipe(`{{ 'demo.com/level1/level2' | truncatePath }}`); - expect(spectator.element).toHaveText('level2'); - }); - - it('should return just the path with two levels ending in slash', () => { - spectator = createPipe(`{{ 'demo.com/level1/level2/' | truncatePath }}`); - expect(spectator.element).toHaveText('level2'); - }); -}); diff --git a/core-web/libs/edit-content/src/lib/services/dot-edit-content.service.spec.ts b/core-web/libs/edit-content/src/lib/services/dot-edit-content.service.spec.ts index 931c4041041f..83545d441449 100644 --- a/core-web/libs/edit-content/src/lib/services/dot-edit-content.service.spec.ts +++ b/core-web/libs/edit-content/src/lib/services/dot-edit-content.service.spec.ts @@ -7,12 +7,10 @@ import { } from '@ngneat/spectator/jest'; import { of } from 'rxjs'; -import { - DotContentTypeService, - DotSiteService, - DotWorkflowActionsFireService -} from '@dotcms/data-access'; +import { DotContentTypeService, DotWorkflowActionsFireService } from '@dotcms/data-access'; import { DotContentletDepths } from '@dotcms/dotcms-models'; +import { DotBrowsingService } from '@dotcms/ui'; +import { createFakeContentlet } from '@dotcms/utils-testing'; import { DotEditContentService } from './dot-edit-content.service'; @@ -25,12 +23,12 @@ describe('DotEditContentService', () => { let spectator: SpectatorHttp; let dotContentTypeService: SpyObject; let dotWorkflowActionsFireService: SpyObject; - let dotSiteService: SpyObject; + let dotBrowsingService: SpyObject; const createHttp = createHttpFactory({ service: DotEditContentService, providers: [ - mockProvider(DotSiteService), + mockProvider(DotBrowsingService), mockProvider(DotContentTypeService), mockProvider(DotWorkflowActionsFireService) ] @@ -39,7 +37,7 @@ describe('DotEditContentService', () => { spectator = createHttp(); dotContentTypeService = spectator.inject(DotContentTypeService); dotWorkflowActionsFireService = spectator.inject(DotWorkflowActionsFireService); - dotSiteService = spectator.inject(DotSiteService); + dotBrowsingService = spectator.inject(DotBrowsingService); }); describe('Endpoints', () => { @@ -379,21 +377,51 @@ describe('DotEditContentService', () => { }); describe('getContentByFolder', () => { - it('should call siteService with correct params when only folderId is provided', () => { - dotSiteService.getContentByFolder.mockReturnValue(of([])); - spectator.service.getContentByFolder({ folderId: '123' }); + it('should call dotBrowsingService with correct params when only hostFolderId is provided', () => { + const mockContentlets = []; + dotBrowsingService.getContentByFolder.mockReturnValue(of(mockContentlets)); + + const params = { hostFolderId: '123' }; + spectator.service.getContentByFolder(params); + + expect(dotBrowsingService.getContentByFolder).toHaveBeenCalledWith(params); + }); - expect(dotSiteService.getContentByFolder).toHaveBeenCalledWith({ - mimeTypes: [], + it('should call dotBrowsingService with all provided params', () => { + const mockContentlets = []; + const params = { hostFolderId: '123', - showLinks: false, - showDotAssets: true, - showPages: false, - showFiles: true, - showFolders: false, - showWorking: true, - sortByDesc: true, - showArchived: false + mimeTypes: ['image/jpeg', 'image/png'], + showLinks: true, + showDotAssets: false, + showPages: true, + showFiles: false, + showFolders: true, + showWorking: false, + sortByDesc: false, + showArchived: true + }; + dotBrowsingService.getContentByFolder.mockReturnValue(of(mockContentlets)); + + spectator.service.getContentByFolder(params); + + expect(dotBrowsingService.getContentByFolder).toHaveBeenCalledWith(params); + }); + + it('should return content from dotBrowsingService', (done) => { + const mockContentlets = [ + createFakeContentlet({ + inode: 'content-1', + title: 'Test Content', + identifier: 'content-1' + }) + ]; + const params = { hostFolderId: '123' }; + dotBrowsingService.getContentByFolder.mockReturnValue(of(mockContentlets)); + + spectator.service.getContentByFolder(params).subscribe((result) => { + expect(result).toEqual(mockContentlets); + done(); }); }); }); diff --git a/core-web/libs/edit-content/src/lib/services/dot-edit-content.service.ts b/core-web/libs/edit-content/src/lib/services/dot-edit-content.service.ts index fd87386843ef..e2147a48f46a 100644 --- a/core-web/libs/edit-content/src/lib/services/dot-edit-content.service.ts +++ b/core-web/libs/edit-content/src/lib/services/dot-edit-content.service.ts @@ -1,4 +1,4 @@ -import { Observable, forkJoin } from 'rxjs'; +import { Observable } from 'rxjs'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; @@ -7,8 +7,9 @@ import { map, pluck } from 'rxjs/operators'; import { DotContentTypeService, - DotSiteService, - DotWorkflowActionsFireService + DotWorkflowActionsFireService, + DotTagsService, + DotContentletService } from '@dotcms/data-access'; import { DotCMSContentType, @@ -16,21 +17,23 @@ import { DotCMSContentletVersion, DotContentletDepth, DotCMSResponse, - PaginationParams -} from '@dotcms/dotcms-models'; - -import { + PaginationParams, CustomTreeNode, DotFolder, - TreeNodeItem -} from '../models/dot-edit-content-host-folder-field.interface'; + TreeNodeItem, + ContentByFolderParams, + DotCMSAPIResponse +} from '@dotcms/dotcms-models'; +import { DotBrowsingService } from '@dotcms/ui'; + import { Activity, DotPushPublishHistoryItem } from '../models/dot-edit-content.model'; -import { createPaths } from '../utils/functions.util'; @Injectable() export class DotEditContentService { readonly #dotContentTypeService = inject(DotContentTypeService); - readonly #siteService = inject(DotSiteService); + readonly #dotContentletService = inject(DotContentletService); + readonly #dotBrowsingService = inject(DotBrowsingService); + readonly #dotTagsService = inject(DotTagsService); readonly #dotWorkflowActionsFireService = inject(DotWorkflowActionsFireService); readonly #http = inject(HttpClient); @@ -58,9 +61,9 @@ export class DotEditContentService { httpParams = httpParams.set('depth', depth); } - return this.#http - .get(`/api/v1/content/${id}`, { params: httpParams }) - .pipe(pluck('entity')); + return this.#dotContentletService + .getContentletByInode(id, httpParams) + .pipe(map((contentlet) => contentlet)); } /** @@ -80,12 +83,7 @@ export class DotEditContentService { * @returns An Observable that emits an array of tag labels. */ getTags(name: string): Observable { - const params = new HttpParams().set('name', name); - - return this.#http.get('/api/v2/tags', { params }).pipe( - pluck('entity'), - map((res) => Object.values(res).map((obj) => obj.label)) - ); + return this.#dotTagsService.getTags(name).pipe(map((tags) => tags.map((tag) => tag.label))); } /** * Saves a contentlet with the provided data. @@ -113,25 +111,7 @@ export class DotEditContentService { perPage?: number; page?: number; }): Observable { - const { filter, perPage, page } = data; - - return this.#siteService.getSites(filter, perPage, page).pipe( - map((sites) => { - return sites.map((site) => ({ - key: site.identifier, - label: site.hostname, - data: { - id: site.identifier, - hostname: site.hostname, - path: '', - type: 'site' - }, - expandedIcon: 'pi pi-folder-open', - collapsedIcon: 'pi pi-folder', - leaf: false - })); - }) - ); + return this.#dotBrowsingService.getSitesTreePath(data); } /** @@ -142,7 +122,7 @@ export class DotEditContentService { * @memberof DotEditContentService */ getFolders(path: string): Observable { - return this.#http.post('/api/v1/folder/byPath', { path }).pipe(pluck('entity')); + return this.#dotBrowsingService.getFolders(path); } /** @@ -153,28 +133,7 @@ export class DotEditContentService { * @returns {Observable<{ parent: DotFolder; folders: TreeNodeItem[] }>} Observable that emits an object containing the parent folder and child folders as TreeNodeItems */ getFoldersTreeNode(path: string): Observable<{ parent: DotFolder; folders: TreeNodeItem[] }> { - return this.getFolders(`//${path}`).pipe( - map((folders) => { - const parent = folders.shift(); - - return { - parent, - folders: folders.map((folder) => ({ - key: folder.id, - label: `${folder.hostName}${folder.path}`, - data: { - id: folder.id, - hostname: folder.hostName, - path: folder.path, - type: 'folder' - }, - expandedIcon: 'pi pi-folder-open', - collapsedIcon: 'pi pi-folder', - leaf: false - })) - }; - }) - ); + return this.#dotBrowsingService.getFoldersTreeNode(path); } /** @@ -186,43 +145,7 @@ export class DotEditContentService { * @returns {Observable} Observable that emits a CustomTreeNode containing the complete tree structure and the target node */ buildTreeByPaths(path: string): Observable { - const paths = createPaths(path); - - const requests = paths.reverse().map((path) => { - const split = path.split('/'); - const [hostName] = split; - const subPath = split.slice(1).join('/'); - - const fullPath = `${hostName}/${subPath}`; - - return this.getFoldersTreeNode(fullPath); - }); - - return forkJoin(requests).pipe( - map((response) => { - const [mainNode] = response; - - return response.reduce( - (rta, node, index, array) => { - const next = array[index + 1]; - if (next) { - const folder = next.folders.find((item) => item.key === node.parent.id); - if (folder) { - folder.children = node.folders; - if (mainNode.parent.id === folder.key) { - rta.node = folder; - } - } - } - - rta.tree = node; - - return rta; - }, - { tree: null, node: null } - ); - }) - ); + return this.#dotBrowsingService.buildTreeByPaths(path); } /** @@ -232,21 +155,7 @@ export class DotEditContentService { * @returns {Observable} Observable that emits the current site as a TreeNodeItem */ getCurrentSiteAsTreeNodeItem(): Observable { - return this.#siteService.getCurrentSite().pipe( - map((site) => ({ - key: site.identifier, - label: site.hostname, - data: { - id: site.identifier, - hostname: site.hostname, - path: '', - type: 'site' - }, - expandedIcon: 'pi pi-folder-open', - collapsedIcon: 'pi pi-folder', - leaf: false - })) - ); + return this.#dotBrowsingService.getCurrentSiteAsTreeNodeItem(); } /** @@ -267,21 +176,8 @@ export class DotEditContentService { * @return {*} * @memberof DotEditContentService */ - getContentByFolder({ folderId, mimeTypes }: { folderId: string; mimeTypes?: string[] }) { - const params = { - hostFolderId: folderId, - showLinks: false, - showDotAssets: true, - showPages: false, - showFiles: true, - showFolders: false, - showWorking: true, - showArchived: false, - sortByDesc: true, - mimeTypes: mimeTypes || [] - }; - - return this.#siteService.getContentByFolder(params); + getContentByFolder(params: ContentByFolderParams) { + return this.#dotBrowsingService.getContentByFolder(params); } /** @@ -303,8 +199,10 @@ export class DotEditContentService { */ createActivity(identifier: string, comment: string): Observable { return this.#http - .post(`/api/v1/workflow/${identifier}/comments`, { comment }) - .pipe(pluck('entity')); + .post< + DotCMSAPIResponse + >(`/api/v1/workflow/${identifier}/comments`, { comment }) + .pipe(map((response) => response.entity)); } /** diff --git a/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts b/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts index 7827cb45f952..238c18e40de1 100644 --- a/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts +++ b/core-web/libs/edit-content/src/lib/utils/functions.util.spec.ts @@ -16,7 +16,6 @@ import { createFakeContentlet } from '@dotcms/utils-testing'; import { MOCK_CONTENTTYPE_2_TABS, MOCK_FORM_CONTROL_FIELDS } from './edit-content.mock'; import * as functionsUtil from './functions.util'; import { - createPaths, generatePreviewUrl, getFieldVariablesParsed, getStoredUIState, @@ -1117,7 +1116,7 @@ describe('Utils Functions', () => { expect(result).toEqual({}); }); }); - + /* describe('createPaths function', () => { it('with the root path', () => { const path = 'nico.demo.ts'; @@ -1164,7 +1163,7 @@ describe('Utils Functions', () => { expect(paths).toStrictEqual([]); }); }); - +*/ describe('UI State Storage', () => { beforeEach(() => { sessionStorage.clear(); diff --git a/core-web/libs/edit-content/src/lib/utils/functions.util.ts b/core-web/libs/edit-content/src/lib/utils/functions.util.ts index e3a904fb178a..2f4882ff9881 100644 --- a/core-web/libs/edit-content/src/lib/utils/functions.util.ts +++ b/core-web/libs/edit-content/src/lib/utils/functions.util.ts @@ -283,39 +283,6 @@ export const stringToJson = (value: string) => { return isValidJson(value) ? JSON.parse(value) : {}; }; -/** - * Converts a JSON string into a JavaScript object. - * Create all paths based in a Path - * - * @param {string} path - the path - * @return {string[]} - An arrray with all posibles pats - * - * @usageNotes - * - * ### Example - * - * ```ts - * const path = 'demo.com/level1/level2'; - * const paths = createPaths(path); - * console.log(paths); // ['demo.com/', 'demo.com/level1/', 'demo.com/level1/level2/'] - * ``` - */ -export const createPaths = (path: string): string[] => { - const split = path.split('/').filter((item) => item !== ''); - - return split.reduce((array, item, index) => { - const prev = array[index - 1]; - let path = `${item}/`; - if (prev) { - path = `${prev}${path}`; - } - - array.push(path); - - return array; - }, [] as string[]); -}; - /** * Checks if a given content type field is of a filtered type. * diff --git a/core-web/libs/edit-content/src/lib/utils/mocks.ts b/core-web/libs/edit-content/src/lib/utils/mocks.ts index 7c5fd893f10c..5d9995a3485c 100644 --- a/core-web/libs/edit-content/src/lib/utils/mocks.ts +++ b/core-web/libs/edit-content/src/lib/utils/mocks.ts @@ -15,7 +15,9 @@ import { DotCMSContentTypeLayoutRow, DotCMSTempFile, DotCMSWorkflowStatus, - FeaturedFlags + FeaturedFlags, + TreeNodeItem, + CustomTreeNode } from '@dotcms/dotcms-models'; import { MockDotMessageService } from '@dotcms/utils-testing'; @@ -23,10 +25,6 @@ import { WYSIWYG_MOCK } from '../fields/dot-edit-content-wysiwyg-field/mocks/dot import { DISABLED_WYSIWYG_FIELD } from '../models/disabledWYSIWYG.constant'; import { FIELD_TYPES } from '../models/dot-edit-content-field.enum'; import { DotFormData } from '../models/dot-edit-content-form.interface'; -import { - CustomTreeNode, - TreeNodeItem -} from '../models/dot-edit-content-host-folder-field.interface'; import { DotWorkflowState } from '../models/dot-edit-content.model'; /* FIELDS MOCK BY TYPE */ diff --git a/core-web/libs/ui/src/index.ts b/core-web/libs/ui/src/index.ts index a77a9adb7717..6c79a80f0545 100644 --- a/core-web/libs/ui/src/index.ts +++ b/core-web/libs/ui/src/index.ts @@ -28,6 +28,7 @@ export * from './lib/components/dot-sidebar-accordion'; export * from './lib/components/dot-sidebar-header/dot-sidebar-header.component'; export * from './lib/components/dot-temp-file-thumbnail/dot-temp-file-thumbnail.component'; export * from './lib/components/dot-workflow-actions/dot-workflow-actions.component'; +export * from './lib/components/dot-browser-selector/dot-browser-selector.component'; export * from './lib/dot-icon/dot-icon.component'; export * from './lib/dot-spinner/dot-spinner.component'; export * from './lib/dot-tab-buttons/dot-tab-buttons.component'; @@ -50,6 +51,7 @@ export * from './lib/dot-site-selector/dot-site-selector.directive'; // Services export * from './lib/services/clipboard/ClipboardUtil'; export * from './lib/services/dot-copy-content-modal/dot-copy-content-modal.service'; +export * from './lib/services/dot-browsing/dot-browsing.service'; // Pipes export * from './lib/dot-contentlet-status/dot-contentlet-status.pipe'; export * from './lib/dot-message/dot-message.pipe'; @@ -64,6 +66,7 @@ export * from './lib/pipes/dot-safe-html/dot-safe-html.pipe'; export * from './lib/pipes/dot-string-format/dot-string-format.pipe'; export * from './lib/pipes/dot-timestamp-to-date/dot-timestamp-to-date.pipe'; export * from './lib/pipes/safe-url/safe-url.pipe'; +export * from './lib/pipes/dot-truncate-path/dot-truncate-path.pipe'; // Resolvers export * from './lib/resolvers/dot-analytics-health-check.resolver.service'; export * from './lib/resolvers/dot-enterprise-license-resolver.service'; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-dataview/dot-dataview.component.html b/core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-dataview/dot-dataview.component.html similarity index 100% rename from core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-dataview/dot-dataview.component.html rename to core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-dataview/dot-dataview.component.html diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-dataview/dot-dataview.component.scss b/core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-dataview/dot-dataview.component.scss similarity index 100% rename from core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-dataview/dot-dataview.component.scss rename to core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-dataview/dot-dataview.component.scss diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-dataview/dot-dataview.component.ts b/core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-dataview/dot-dataview.component.ts similarity index 96% rename from core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-dataview/dot-dataview.component.ts rename to core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-dataview/dot-dataview.component.ts index a972fb227e53..9be014e3b7a4 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-dataview/dot-dataview.component.ts +++ b/core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-dataview/dot-dataview.component.ts @@ -17,7 +17,8 @@ import { SkeletonModule } from 'primeng/skeleton'; import { TableModule } from 'primeng/table'; import { DotCMSContentlet } from '@dotcms/dotcms-models'; -import { DotMessagePipe } from '@dotcms/ui'; + +import { DotMessagePipe } from '../../../../dot-message/dot-message.pipe'; @Component({ selector: 'dot-dataview', diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.html b/core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-sidebar/dot-sidebar.component.html similarity index 92% rename from core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.html rename to core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-sidebar/dot-sidebar.component.html index 0db420b65008..b9ec5906b856 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.html +++ b/core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-sidebar/dot-sidebar.component.html @@ -13,7 +13,7 @@ (onNodeSelect)="onNodeSelect.emit($event)" (onNodeExpand)="onNodeExpand.emit($event)"> - {{ node.label | truncatePath }} + {{ node.label | dotTruncatePath }} } @else { diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.scss b/core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-sidebar/dot-sidebar.component.scss similarity index 100% rename from core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.scss rename to core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-sidebar/dot-sidebar.component.scss diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.ts b/core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-sidebar/dot-sidebar.component.ts similarity index 93% rename from core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.ts rename to core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-sidebar/dot-sidebar.component.ts index 7c647fa0b913..8350ac5125c2 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/components/dot-sidebar/dot-sidebar.component.ts +++ b/core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-sidebar/dot-sidebar.component.ts @@ -14,12 +14,12 @@ import { TreeNode } from 'primeng/api'; import { SkeletonModule } from 'primeng/skeleton'; import { TreeModule, TreeNodeExpandEvent } from 'primeng/tree'; -import { TruncatePathPipe } from '../../../../../../pipes/truncate-path.pipe'; -import { SYSTEM_HOST_ID } from '../../store/select-existing-file.store'; +import { DotTruncatePathPipe } from '../../../../pipes/dot-truncate-path/dot-truncate-path.pipe'; +import { SYSTEM_HOST_ID } from '../../store/browser.store'; @Component({ selector: 'dot-sidebar', - imports: [TreeModule, TruncatePathPipe, SkeletonModule], + imports: [TreeModule, DotTruncatePathPipe, SkeletonModule], templateUrl: './dot-sidebar.component.html', styleUrls: ['./dot-sidebar.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.html b/core-web/libs/ui/src/lib/components/dot-browser-selector/dot-browser-selector.component.html similarity index 95% rename from core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.html rename to core-web/libs/ui/src/lib/components/dot-browser-selector/dot-browser-selector.component.html index 2a033c41b5cc..df4d59b5189a 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.html +++ b/core-web/libs/ui/src/lib/components/dot-browser-selector/dot-browser-selector.component.html @@ -7,7 +7,7 @@
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.scss b/core-web/libs/ui/src/lib/components/dot-browser-selector/dot-browser-selector.component.scss similarity index 100% rename from core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/dot-select-existing-file.component.scss rename to core-web/libs/ui/src/lib/components/dot-browser-selector/dot-browser-selector.component.scss diff --git a/core-web/libs/ui/src/lib/components/dot-browser-selector/dot-browser-selector.component.ts b/core-web/libs/ui/src/lib/components/dot-browser-selector/dot-browser-selector.component.ts new file mode 100644 index 000000000000..42182997e25c --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-browser-selector/dot-browser-selector.component.ts @@ -0,0 +1,150 @@ +import { signalMethod } from '@ngrx/signals'; + +import { + ChangeDetectionStrategy, + Component, + inject, + OnInit, + signal, + viewChild +} from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; +import { DynamicDialogRef, DynamicDialogConfig } from 'primeng/dynamicdialog'; + +import { DotContentletService } from '@dotcms/data-access'; +import { ContentByFolderParams, TreeNodeSelectItem } from '@dotcms/dotcms-models'; + +import { DotDataViewComponent } from './components/dot-dataview/dot-dataview.component'; +import { DotSideBarComponent } from './components/dot-sidebar/dot-sidebar.component'; +import { + DotBrowserSelectorStore, + BrowserSelectorState, + SYSTEM_HOST_ID +} from './store/browser.store'; + +import { DotMessagePipe } from '../../dot-message/dot-message.pipe'; +@Component({ + selector: 'dot-select-existing-file', + imports: [DotSideBarComponent, DotDataViewComponent, ButtonModule, DotMessagePipe], + templateUrl: './dot-browser-selector.component.html', + styleUrls: ['./dot-browser-selector.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DotBrowserSelectorStore] +}) +export class DotBrowserSelectorComponent implements OnInit { + /** + * Injects the SelectExistingFileStore into the component. + * + * @readonly + * @type {SelectExistingFileStore} + */ + /** + * A readonly property that injects the `DotBrowserSelectorStore` service. + * This store is used to manage the state and actions related to selecting existing files. + */ + readonly store = inject(DotBrowserSelectorStore); + + /** + * A readonly property that injects the `dotContentletService` service. + * This service is used to manage the state and actions related to selecting existing files. + */ + readonly #dotContentletService = inject(DotContentletService); + /** + * A reference to the dynamic dialog instance. + * This is a read-only property that is injected using Angular's dependency injection. + * It provides access to the dialog's methods and properties. + */ + readonly #dialogRef = inject(DynamicDialogRef); + + /** + * Reference to the DotSideBarComponent instance. + * This is used to interact with the sidebar component within the template. + * + * @type {DotSideBarComponent} + */ + $sideBarRef = viewChild.required(DotSideBarComponent); + + /** + * A readonly property that injects the `DynamicDialogConfig` service. + * This service is used to get the dialog data. + */ + readonly #dialogConfig = inject(DynamicDialogConfig); + + /** + * Signal representing the folder parameters. + * This is used to store the folder parameters. + */ + $folderParams = signal({ + hostFolderId: SYSTEM_HOST_ID, + mimeTypes: [] + }); + + constructor() { + this.loadContent(this.$folderParams); + this.sideBarRefresh(this.store.folders); + } + + ngOnInit() { + const params = this.#dialogConfig?.data as ContentByFolderParams; + this.$folderParams.update((prev) => ({ ...prev, ...params })); + } + + onNodeSelect(event: TreeNodeSelectItem): void { + const hostFolderId = event?.node?.data?.id; + if (!hostFolderId) { + throw new Error('Host folder ID is required'); + } + + this.$folderParams.update((prev) => ({ + ...prev, + hostFolderId + })); + } + + /** + * Cancels the current file upload and closes the dialog. + * + * @remarks + * This method is used to terminate the ongoing file upload process and + * close the associated dialog reference. + */ + closeDialog(): void { + this.#dialogRef.close(); + } + + /** + * Retrieves the selected content from the store, fetches it by ID using the upload service, + * and closes the dialog with the retrieved content. + */ + addContent(): void { + const content = this.store.selectedContent(); + this.#dotContentletService + .getContentletByInodeWithContent(content.inode) + .subscribe((content) => { + this.#dialogRef.close(content); + }); + } + + /** + * Loads the content for the given folder parameters. + * + * @param {ContentByFolderParams} params - The folder parameters. + * @returns {void} + */ + readonly loadContent = signalMethod((params) => { + this.store.loadContent(params); + }); + + /** + * Refreshes the sidebar when the node is expanded. + * + * @param {BrowserSelectorState['folders']} folders - The folders state. + * @returns {void} + */ + readonly sideBarRefresh = signalMethod((folders) => { + if (folders.nodeExpaned) { + this.$sideBarRef().detectChanges(); + } + }); +} diff --git a/core-web/libs/ui/src/lib/components/dot-browser-selector/store/browser.store.test.ts b/core-web/libs/ui/src/lib/components/dot-browser-selector/store/browser.store.test.ts new file mode 100644 index 000000000000..83a6ee9c10ac --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-browser-selector/store/browser.store.test.ts @@ -0,0 +1,530 @@ +import { + createServiceFactory, + SpectatorService, + mockProvider, + SpyObject +} from '@ngneat/spectator/jest'; +import { patchState } from '@ngrx/signals'; +import { unprotected } from '@ngrx/signals/testing'; +import { of, throwError } from 'rxjs'; + +import { fakeAsync, tick } from '@angular/core/testing'; + +import { delay } from 'rxjs/operators'; + +import { + ComponentStatus, + ContentByFolderParams, + TreeNodeItem, + TreeNodeSelectItem, + DotFolder +} from '@dotcms/dotcms-models'; +import { createFakeContentlet, createFakeEvent } from '@dotcms/utils-testing'; + +import { DotBrowserSelectorStore, SYSTEM_HOST_ID } from './browser.store'; + +import { DotBrowsingService } from '../../../services/dot-browsing/dot-browsing.service'; + +const TREE_SELECT_SITES_MOCK: TreeNodeItem[] = [ + { + key: 'demo.dotcms.com', + label: 'demo.dotcms.com', + data: { + id: 'demo.dotcms.com', + hostname: 'demo.dotcms.com', + path: '', + type: 'site' + }, + expandedIcon: 'pi pi-folder-open', + collapsedIcon: 'pi pi-folder' + }, + { + key: 'nico.dotcms.com', + label: 'nico.dotcms.com', + data: { + id: 'nico.dotcms.com', + hostname: 'nico.dotcms.com', + path: '', + type: 'site' + }, + expandedIcon: 'pi pi-folder-open', + collapsedIcon: 'pi pi-folder' + }, + { + key: 'System Host', + label: 'System Host', + data: { + id: 'System Host', + hostname: 'System Host', + path: '', + type: 'site' + }, + expandedIcon: 'pi pi-folder-open', + collapsedIcon: 'pi pi-folder' + } +]; + +const TREE_SELECT_MOCK: TreeNodeItem[] = [ + { + key: 'demo.dotcms.com', + label: 'demo.dotcms.com', + data: { + id: 'demo.dotcms.com', + hostname: 'demo.dotcms.com', + path: '', + type: 'site' + }, + expandedIcon: 'pi pi-folder-open', + collapsedIcon: 'pi pi-folder', + children: [ + { + key: 'demo.dotcms.comlevel1', + label: 'demo.dotcms.com/level1/', + data: { + id: 'demo.dotcms.comlevel1', + hostname: 'demo.dotcms.com', + path: '/level1/', + type: 'folder' + }, + expandedIcon: 'pi pi-folder-open', + collapsedIcon: 'pi pi-folder', + children: [ + { + key: 'demo.dotcms.comlevel1child1', + label: 'demo.dotcms.com/level1/child1/', + data: { + id: 'demo.dotcms.comlevel1child1', + hostname: 'demo.dotcms.com', + path: '/level1/child1/', + type: 'folder' + }, + expandedIcon: 'pi pi-folder-open', + collapsedIcon: 'pi pi-folder' + } + ] + }, + { + key: 'demo.dotcms.comlevel2', + label: 'demo.dotcms.com/level2/', + data: { + id: 'demo.dotcms.comlevel2', + hostname: 'demo.dotcms.com', + path: '/level2/', + type: 'folder' + }, + expandedIcon: 'pi pi-folder-open', + collapsedIcon: 'pi pi-folder' + } + ] + }, + { + key: 'nico.dotcms.com', + label: 'nico.dotcms.com', + data: { + id: 'nico.dotcms.com', + hostname: 'nico.dotcms.com', + path: '', + type: 'site' + }, + expandedIcon: 'pi pi-folder-open', + collapsedIcon: 'pi pi-folder' + } +]; + +describe('DotBrowserSelectorStore', () => { + let spectator: SpectatorService>; + let store: InstanceType; + let dotBrowsingService: SpyObject; + + const createService = createServiceFactory({ + service: DotBrowserSelectorStore, + providers: [ + mockProvider(DotBrowsingService, { + getSitesTreePath: jest.fn().mockReturnValue(of(TREE_SELECT_SITES_MOCK)), + getContentByFolder: jest.fn().mockReturnValue(of([])), + getFoldersTreeNode: jest.fn().mockReturnValue( + of({ + parent: { + id: '', + hostName: '', + path: '', + addChildrenAllowed: false + } as DotFolder, + folders: [] + }) + ) + }) + ] + }); + + beforeEach(fakeAsync(() => { + spectator = createService(); + store = spectator.service; + dotBrowsingService = spectator.inject(DotBrowsingService); + // Wait for onInit to complete (it calls loadFolders) + tick(50); + })); + + it('should be created', () => { + expect(store).toBeTruthy(); + }); + + describe('Initial state', () => { + it('should have initial state values after onInit', () => { + // After onInit (which runs in beforeEach), folders should be loaded + expect(store.folders().data).toEqual(TREE_SELECT_SITES_MOCK); + expect(store.folders().status).toBe(ComponentStatus.LOADED); + expect(store.folders().nodeExpaned).toBeNull(); + expect(store.content().data).toEqual([]); + expect(store.content().status).toBe(ComponentStatus.INIT); + expect(store.content().error).toBeNull(); + expect(store.selectedContent()).toBeNull(); + expect(store.searchQuery()).toBe(''); + expect(store.viewMode()).toBe('list'); + }); + }); + + describe('Computed properties', () => { + it('should compute foldersIsLoading as true when folders status is LOADING', () => { + patchState(unprotected(store), { + folders: { ...store.folders(), status: ComponentStatus.LOADING } + }); + expect(store.foldersIsLoading()).toBe(true); + }); + + it('should compute foldersIsLoading as false when folders status is not LOADING', () => { + patchState(unprotected(store), { + folders: { ...store.folders(), status: ComponentStatus.LOADED } + }); + expect(store.foldersIsLoading()).toBe(false); + }); + + it('should compute foldersIsLoading as false when folders status is ERROR', () => { + patchState(unprotected(store), { + folders: { ...store.folders(), status: ComponentStatus.ERROR } + }); + expect(store.foldersIsLoading()).toBe(false); + }); + + it('should compute contentIsLoading as true when content status is LOADING', () => { + patchState(unprotected(store), { + content: { ...store.content(), status: ComponentStatus.LOADING } + }); + expect(store.contentIsLoading()).toBe(true); + }); + + it('should compute contentIsLoading as false when content status is LOADED', () => { + const mockContentlets = [createFakeContentlet()]; + patchState(unprotected(store), { + content: { data: mockContentlets, status: ComponentStatus.LOADED, error: null } + }); + expect(store.contentIsLoading()).toBe(false); + }); + + it('should compute contentIsLoading as false when content status is ERROR', () => { + patchState(unprotected(store), { + content: { data: [], status: ComponentStatus.ERROR, error: 'Error message' } + }); + expect(store.contentIsLoading()).toBe(false); + }); + }); + + describe('Method: setSelectedContent', () => { + it('should set selected content', () => { + const mockContentlet = createFakeContentlet({ title: 'Test Content' }); + store.setSelectedContent(mockContentlet); + expect(store.selectedContent()).toEqual(mockContentlet); + }); + + it('should update selected content', () => { + const mockContentlet1 = createFakeContentlet({ title: 'Content 1' }); + const mockContentlet2 = createFakeContentlet({ title: 'Content 2' }); + + store.setSelectedContent(mockContentlet1); + expect(store.selectedContent()).toEqual(mockContentlet1); + + store.setSelectedContent(mockContentlet2); + expect(store.selectedContent()).toEqual(mockContentlet2); + }); + }); + + describe('Method: loadFolders', () => { + it('should set folders status to LOADING and then to LOADED with data', fakeAsync(() => { + // Use timer to make the observable async so we can verify LOADING state + dotBrowsingService.getSitesTreePath.mockReturnValue( + of(TREE_SELECT_SITES_MOCK).pipe(delay(1)) + ); + + store.loadFolders(); + expect(store.folders().status).toBe(ComponentStatus.LOADING); + + tick(50); + + expect(store.folders().status).toBe(ComponentStatus.LOADED); + expect(store.folders().data).toEqual(TREE_SELECT_SITES_MOCK); + expect(store.folders().nodeExpaned).toBeNull(); + expect(dotBrowsingService.getSitesTreePath).toHaveBeenCalledWith({ + perPage: 1000, + filter: '*' + }); + })); + + it('should set folders status to ERROR on service error', fakeAsync(() => { + dotBrowsingService.getSitesTreePath.mockReturnValue( + throwError(() => new Error('error')) + ); + + store.loadFolders(); + tick(50); + + expect(store.folders().status).toBe(ComponentStatus.ERROR); + expect(store.folders().data).toEqual([]); + expect(store.folders().nodeExpaned).toBeNull(); + })); + + it('should reset nodeExpaned when folders are loaded', fakeAsync(() => { + const expandedNode = TREE_SELECT_MOCK[0]; + patchState(unprotected(store), { + folders: { ...store.folders(), nodeExpaned: expandedNode } + }); + + dotBrowsingService.getSitesTreePath.mockReturnValue(of(TREE_SELECT_SITES_MOCK)); + store.loadFolders(); + tick(50); + + expect(store.folders().nodeExpaned).toBeNull(); + })); + }); + + describe('Method: loadContent', () => { + it('should load content for a selected node', fakeAsync(() => { + const mockContentlets = [ + createFakeContentlet({ title: 'Content 1' }), + createFakeContentlet({ title: 'Content 2' }) + ]; + // Use timer to make the observable async so we can verify LOADING state + dotBrowsingService.getContentByFolder.mockReturnValue( + of(mockContentlets).pipe(delay(1)) + ); + + const params: ContentByFolderParams = { + hostFolderId: 'demo.dotcms.com', + mimeTypes: [] + }; + + store.loadContent(params); + expect(store.content().status).toBe(ComponentStatus.LOADING); + + tick(50); + + expect(store.content().status).toBe(ComponentStatus.LOADED); + expect(store.content().data).toEqual(mockContentlets); + expect(store.content().error).toBeNull(); + expect(dotBrowsingService.getContentByFolder).toHaveBeenCalledWith({ + hostFolderId: 'demo.dotcms.com', + mimeTypes: [] + }); + })); + + it('should preserve existing content data when setting status to LOADING', fakeAsync(() => { + const existingContent = [createFakeContentlet({ title: 'Existing' })]; + patchState(unprotected(store), { + content: { data: existingContent, status: ComponentStatus.LOADED, error: null } + }); + + const mockContentlets = [createFakeContentlet({ title: 'New' })]; + // Use timer to make the observable async so we can verify LOADING state + dotBrowsingService.getContentByFolder.mockReturnValue( + of(mockContentlets).pipe(delay(1)) + ); + + const params: ContentByFolderParams = { + hostFolderId: 'demo.dotcms.com', + mimeTypes: [] + }; + + store.loadContent(params); + // During loading, the data should still be preserved from previous state + expect(store.content().status).toBe(ComponentStatus.LOADING); + tick(50); + })); + + it('should load content using SYSTEM_HOST_ID when provided', fakeAsync(() => { + const mockContentlets = [createFakeContentlet()]; + // Use timer to make the observable async so we can verify LOADING state + dotBrowsingService.getContentByFolder.mockReturnValue( + of(mockContentlets).pipe(delay(1)) + ); + + const params: ContentByFolderParams = { + hostFolderId: SYSTEM_HOST_ID, + mimeTypes: [] + }; + + store.loadContent(params); + // Verify LOADING state before the observable completes + expect(store.content().status).toBe(ComponentStatus.LOADING); + + tick(50); + + expect(store.content().status).toBe(ComponentStatus.LOADED); + expect(store.content().data).toEqual(mockContentlets); + expect(dotBrowsingService.getContentByFolder).toHaveBeenCalledWith({ + hostFolderId: SYSTEM_HOST_ID, + mimeTypes: [] + }); + })); + + it('should use mimeTypes filter when loading content', fakeAsync(() => { + const mimeTypes = ['image/jpeg', 'image/png']; + const mockContentlets = [createFakeContentlet()]; + dotBrowsingService.getContentByFolder.mockReturnValue(of(mockContentlets)); + + const params: ContentByFolderParams = { + hostFolderId: 'demo.dotcms.com', + mimeTypes + }; + + store.loadContent(params); + tick(50); + + expect(dotBrowsingService.getContentByFolder).toHaveBeenCalledWith({ + hostFolderId: 'demo.dotcms.com', + mimeTypes + }); + })); + + it('should handle service error when loading content', fakeAsync(() => { + dotBrowsingService.getContentByFolder.mockReturnValue( + throwError(() => new Error('Service error')) + ); + + const params: ContentByFolderParams = { + hostFolderId: 'demo.dotcms.com', + mimeTypes: [] + }; + + store.loadContent(params); + tick(50); + + expect(store.content().status).toBe(ComponentStatus.ERROR); + expect(store.content().error).toBe( + 'dot.file.field.dialog.select.existing.file.table.error.content' + ); + expect(store.content().data).toEqual([]); + })); + }); + + describe('Method: loadChildren', () => { + it('should load children for a node', fakeAsync(() => { + // Clear previous mock calls + jest.clearAllMocks(); + + const mockChildren = { + parent: { + id: 'demo.dotcms.com', + hostName: 'demo.dotcms.com', + path: '', + type: 'site', + addChildrenAllowed: true + }, + folders: [...TREE_SELECT_SITES_MOCK] + }; + + dotBrowsingService.getFoldersTreeNode.mockReturnValue(of(mockChildren)); + + const node = { ...TREE_SELECT_MOCK[0] }; + const mockItem: TreeNodeSelectItem = { + originalEvent: createFakeEvent('click'), + node + }; + + store.loadChildren(mockItem); + tick(50); + + expect(node.children).toEqual(mockChildren.folders); + expect(node.loading).toBe(false); + expect(node.leaf).toBe(true); + expect(node.icon).toBe('pi pi-folder-open'); + expect(store.folders().nodeExpaned).toBe(node); + expect(dotBrowsingService.getFoldersTreeNode).toHaveBeenCalledTimes(1); + expect(dotBrowsingService.getFoldersTreeNode).toHaveBeenCalledWith('demo.dotcms.com/'); + })); + + it('should handle error when loading children', fakeAsync(() => { + // Clear previous mock calls + jest.clearAllMocks(); + + dotBrowsingService.getFoldersTreeNode.mockReturnValue( + throwError(() => new Error('error')) + ); + + const node = { ...TREE_SELECT_MOCK[0], children: [] }; + const mockItem: TreeNodeSelectItem = { + originalEvent: createFakeEvent('click'), + node + }; + + store.loadChildren(mockItem); + tick(50); + + expect(node.children).toEqual([]); + expect(node.loading).toBe(false); + })); + + it('should build correct path from hostname and path', fakeAsync(() => { + // Clear previous mock calls + jest.clearAllMocks(); + + const mockChildren = { + parent: { + id: 'folder-1', + hostName: 'demo.dotcms.com', + path: '/level1/', + type: 'folder', + addChildrenAllowed: true + }, + folders: [] + }; + + dotBrowsingService.getFoldersTreeNode.mockReturnValue(of(mockChildren)); + + const childNode = TREE_SELECT_MOCK[0].children?.[0]; + if (!childNode) { + throw new Error('Test setup error: child node not found'); + } + + const node: TreeNodeItem = { + ...childNode + }; + + const mockItem: TreeNodeSelectItem = { + originalEvent: createFakeEvent('click'), + node + }; + + store.loadChildren(mockItem); + tick(50); + + // The implementation creates path as `${hostname}/${path}` where path starts with `/` + // So it becomes `demo.dotcms.com//level1/` (double slash) + expect(dotBrowsingService.getFoldersTreeNode).toHaveBeenCalledTimes(1); + expect(dotBrowsingService.getFoldersTreeNode).toHaveBeenCalledWith( + 'demo.dotcms.com//level1/' + ); + })); + }); + + describe('onInit hook', () => { + it('should call loadFolders on initialization', () => { + // Store is created in beforeEach, which triggers onInit + // onInit completes in beforeEach via fakeAsync/tick + expect(dotBrowsingService.getSitesTreePath).toHaveBeenCalledWith({ + perPage: 1000, + filter: '*' + }); + expect(store.folders().status).toBe(ComponentStatus.LOADED); + expect(store.folders().data).toEqual(TREE_SELECT_SITES_MOCK); + }); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/store/select-existing-file.store.ts b/core-web/libs/ui/src/lib/components/dot-browser-selector/store/browser.store.ts similarity index 61% rename from core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/store/select-existing-file.store.ts rename to core-web/libs/ui/src/lib/components/dot-browser-selector/store/browser.store.ts index af7472df1d13..42ecb384fce3 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/components/dot-select-existing-file/store/select-existing-file.store.ts +++ b/core-web/libs/ui/src/lib/components/dot-browser-selector/store/browser.store.ts @@ -12,18 +12,19 @@ import { pipe } from 'rxjs'; import { computed, inject } from '@angular/core'; -import { exhaustMap, switchMap, tap, filter, map } from 'rxjs/operators'; - -import { ComponentStatus, DotCMSContentlet } from '@dotcms/dotcms-models'; +import { exhaustMap, switchMap, tap } from 'rxjs/operators'; import { + ComponentStatus, + ContentByFolderParams, + DotCMSContentlet, TreeNodeItem, TreeNodeSelectItem -} from '../../../../../models/dot-edit-content-host-folder-field.interface'; -import { DotEditContentService } from '../../../../../services/dot-edit-content.service'; +} from '@dotcms/dotcms-models'; -export const PEER_PAGE_LIMIT = 1000; +import { DotBrowsingService } from '../../../services/dot-browsing/dot-browsing.service'; +export const PEER_PAGE_LIMIT = 1000; export const SYSTEM_HOST_ID = 'SYSTEM_HOST'; export interface Content { @@ -34,7 +35,7 @@ export interface Content { lastModified: Date; } -export interface SelectExisingFileState { +export interface BrowserSelectorState { folders: { data: TreeNodeItem[]; status: ComponentStatus; @@ -48,10 +49,9 @@ export interface SelectExisingFileState { selectedContent: DotCMSContentlet | null; searchQuery: string; viewMode: 'list' | 'grid'; - mimeTypes: string[]; } -const initialState: SelectExisingFileState = { +const initialState: BrowserSelectorState = { folders: { data: [], status: ComponentStatus.INIT, @@ -64,80 +64,53 @@ const initialState: SelectExisingFileState = { }, selectedContent: null, searchQuery: '', - viewMode: 'list', - mimeTypes: [] + viewMode: 'list' }; -export const SelectExisingFileStore = signalStore( +export const DotBrowserSelectorStore = signalStore( withState(initialState), withComputed((state) => ({ foldersIsLoading: computed(() => state.folders().status === ComponentStatus.LOADING), contentIsLoading: computed(() => state.content().status === ComponentStatus.LOADING) })), withMethods((store) => { - const dotEditContentService = inject(DotEditContentService); + const dotBrowsingService = inject(DotBrowsingService); return { - setMimeTypes: (mimeTypes: string[]) => { - patchState(store, { - mimeTypes - }); - }, setSelectedContent: (selectedContent: DotCMSContentlet) => { patchState(store, { selectedContent }); }, - loadContent: rxMethod( + loadContent: rxMethod( pipe( tap(() => patchState(store, { content: { ...store.content(), status: ComponentStatus.LOADING } }) ), - map((event) => (event ? event?.node?.data?.id : SYSTEM_HOST_ID)), - filter((identifier) => { - const hasIdentifier = !!identifier; - - if (!hasIdentifier) { - patchState(store, { - content: { - data: [], - status: ComponentStatus.ERROR, - error: 'dot.file.field.dialog.select.existing.file.table.error.id' - } - }); - } - - return hasIdentifier; - }), - switchMap((identifier) => { - return dotEditContentService - .getContentByFolder({ - folderId: identifier, - mimeTypes: store.mimeTypes() + switchMap((params) => { + return dotBrowsingService.getContentByFolder(params).pipe( + tapResponse({ + next: (data) => { + patchState(store, { + content: { + data, + status: ComponentStatus.LOADED, + error: null + } + }); + }, + error: () => + patchState(store, { + content: { + data: [], + status: ComponentStatus.ERROR, + error: 'dot.file.field.dialog.select.existing.file.table.error.content' + } + }) }) - .pipe( - tapResponse({ - next: (data) => { - patchState(store, { - content: { - data, - status: ComponentStatus.LOADED, - error: null - } - }); - }, - error: () => - patchState(store, { - content: { - data: [], - status: ComponentStatus.ERROR, - error: 'dot.file.field.dialog.select.existing.file.table.error.content' - } - }) - }) - ); + ); }) ) ), @@ -149,7 +122,7 @@ export const SelectExisingFileStore = signalStore( }) ), switchMap(() => { - return dotEditContentService + return dotBrowsingService .getSitesTreePath({ perPage: PEER_PAGE_LIMIT, filter: '*' }) .pipe( tapResponse({ @@ -184,7 +157,7 @@ export const SelectExisingFileStore = signalStore( const fullPath = `${hostname}/${path}`; - return dotEditContentService.getFoldersTreeNode(fullPath).pipe( + return dotBrowsingService.getFoldersTreeNode(fullPath).pipe( tapResponse({ next: ({ folders: children }) => { node.loading = false; diff --git a/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.ts b/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.ts index b62d86eadf19..0eb3d5657d65 100644 --- a/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.ts +++ b/core-web/libs/ui/src/lib/components/dot-drop-zone/dot-drop-zone.component.ts @@ -86,6 +86,8 @@ export class DotDropZoneComponent { event.preventDefault(); const { dataTransfer } = event; + if (!dataTransfer) return; + const files = this.getFiles(dataTransfer); const file = files?.length === 1 ? files[0] : null; @@ -145,7 +147,7 @@ export class DotDropZoneComponent { return true; } - const extension = file.name.split('.').pop().toLowerCase(); + const extension = file.name.split('.').pop()?.toLowerCase() ?? ''; const mimeType = file.type.toLowerCase(); const isValidType = this._accept.some( diff --git a/core-web/libs/ui/src/lib/components/dot-form-dialog/dot-form-dialog.component.ts b/core-web/libs/ui/src/lib/components/dot-form-dialog/dot-form-dialog.component.ts index 63956e32a4ea..9cbb706e91f3 100644 --- a/core-web/libs/ui/src/lib/components/dot-form-dialog/dot-form-dialog.component.ts +++ b/core-web/libs/ui/src/lib/components/dot-form-dialog/dot-form-dialog.component.ts @@ -39,10 +39,10 @@ export class DotFormDialogComponent implements OnInit, OnDestroy { saveButtonLoading: boolean; @Output() - save: EventEmitter = new EventEmitter(null); + save: EventEmitter = new EventEmitter(); @Output() - cancel: EventEmitter = new EventEmitter(null); + cancel: EventEmitter = new EventEmitter(); ngOnInit(): void { const content = document.querySelector('p-dynamicdialog .p-dialog-content'); diff --git a/core-web/libs/edit-content/src/lib/pipes/truncate-path.pipe.ts b/core-web/libs/ui/src/lib/pipes/dot-truncate-path/dot-truncate-path.pipe.ts similarity index 80% rename from core-web/libs/edit-content/src/lib/pipes/truncate-path.pipe.ts rename to core-web/libs/ui/src/lib/pipes/dot-truncate-path/dot-truncate-path.pipe.ts index f80a28746683..58ffa32d4cea 100644 --- a/core-web/libs/edit-content/src/lib/pipes/truncate-path.pipe.ts +++ b/core-web/libs/ui/src/lib/pipes/dot-truncate-path/dot-truncate-path.pipe.ts @@ -8,10 +8,10 @@ import { Pipe, PipeTransform } from '@angular/core'; * @implements {PipeTransform} */ @Pipe({ - name: 'truncatePath', + name: 'dotTruncatePath', pure: true }) -export class TruncatePathPipe implements PipeTransform { +export class DotTruncatePathPipe implements PipeTransform { transform(value: string): string { const split = value.split('/').filter((item) => item !== ''); diff --git a/core-web/libs/ui/src/lib/pipes/dot-truncate-path/dot-truncate-path.spec.ts b/core-web/libs/ui/src/lib/pipes/dot-truncate-path/dot-truncate-path.spec.ts new file mode 100644 index 000000000000..5dae62f5aea5 --- /dev/null +++ b/core-web/libs/ui/src/lib/pipes/dot-truncate-path/dot-truncate-path.spec.ts @@ -0,0 +1,51 @@ +import { createPipeFactory, SpectatorPipe } from '@ngneat/spectator/jest'; + +import { DotTruncatePathPipe } from './dot-truncate-path.pipe'; + +describe('DotTruncatePathPipe', () => { + let spectator: SpectatorPipe; + + const createPipe = createPipeFactory({ + pipe: DotTruncatePathPipe + }); + + it('should return just the path with root level', () => { + spectator = createPipe(`{{ 'demo.com' | dotTruncatePath }}`); + expect(spectator.element).toHaveText('demo.com'); + }); + + it('should return just the path with one level', () => { + spectator = createPipe(`{{ 'demo.com/level1' | dotTruncatePath }}`); + expect(spectator.element).toHaveText('level1'); + }); + + it('should return just the path with one level ending in slash', () => { + spectator = createPipe(`{{ 'demo.com/level1/' | dotTruncatePath }}`); + expect(spectator.element).toHaveText('level1'); + }); + + it('should return just the path with two levels', () => { + spectator = createPipe(`{{ 'demo.com/level1/level2' | dotTruncatePath }}`); + expect(spectator.element).toHaveText('level2'); + }); + + it('should return just the path with two levels ending in slash', () => { + spectator = createPipe(`{{ 'demo.com/level1/level2/' | dotTruncatePath }}`); + expect(spectator.element).toHaveText('level2'); + }); + + it('should return just the path with path starting with slash', () => { + spectator = createPipe(`{{ '/demo.com/level1/level2' | dotTruncatePath }}`); + expect(spectator.element).toHaveText('level2'); + }); + + it('should return just the path with multiple consecutive slashes', () => { + spectator = createPipe(`{{ 'demo.com//level1//level2' | dotTruncatePath }}`); + expect(spectator.element).toHaveText('level2'); + }); + + it('should return just the path with three levels', () => { + spectator = createPipe(`{{ 'demo.com/level1/level2/level3' | dotTruncatePath }}`); + expect(spectator.element).toHaveText('level3'); + }); +}); diff --git a/core-web/libs/ui/src/lib/services/dot-browsing/dot-browsing.service.spec.ts b/core-web/libs/ui/src/lib/services/dot-browsing/dot-browsing.service.spec.ts new file mode 100644 index 000000000000..6cd3fb487ea5 --- /dev/null +++ b/core-web/libs/ui/src/lib/services/dot-browsing/dot-browsing.service.spec.ts @@ -0,0 +1,556 @@ +import { + createServiceFactory, + mockProvider, + SpectatorService, + SpyObject +} from '@ngneat/spectator/jest'; +import { of, throwError } from 'rxjs'; + +import { DotSiteService, DotFolderService } from '@dotcms/data-access'; +import { + DotFolder, + SiteEntity, + DotCMSContentlet, + ContentByFolderParams +} from '@dotcms/dotcms-models'; +import { createFakeSite, createFakeFolder, createFakeContentlet } from '@dotcms/utils-testing'; + +import { DotBrowsingService } from './dot-browsing.service'; + +describe('DotBrowsingService', () => { + let spectator: SpectatorService; + let dotSiteService: SpyObject; + let dotFolderService: SpyObject; + + const createService = createServiceFactory({ + service: DotBrowsingService, + providers: [mockProvider(DotSiteService), mockProvider(DotFolderService)] + }); + + beforeEach(() => { + spectator = createService(); + dotSiteService = spectator.inject(DotSiteService); + dotFolderService = spectator.inject(DotFolderService); + }); + + describe('getSitesTreePath', () => { + it('should transform sites into TreeNodeItems', (done) => { + const mockSites: SiteEntity[] = [ + createFakeSite({ identifier: 'site-1', hostname: 'example.com' }), + createFakeSite({ identifier: 'site-2', hostname: 'test.com' }) + ]; + + dotSiteService.getSites.mockReturnValue(of(mockSites)); + + spectator.service.getSitesTreePath({ filter: 'test' }).subscribe((result) => { + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + key: 'site-1', + label: 'example.com', + data: { + id: 'site-1', + hostname: 'example.com', + path: '', + type: 'site' + }, + expandedIcon: 'pi pi-folder-open', + collapsedIcon: 'pi pi-folder', + leaf: false + }); + expect(result[1]).toEqual({ + key: 'site-2', + label: 'test.com', + data: { + id: 'site-2', + hostname: 'test.com', + path: '', + type: 'site' + }, + expandedIcon: 'pi pi-folder-open', + collapsedIcon: 'pi pi-folder', + leaf: false + }); + expect(dotSiteService.getSites).toHaveBeenCalledWith('test', undefined, undefined); + done(); + }); + }); + + it('should pass perPage and page parameters to getSites', (done) => { + const mockSites: SiteEntity[] = [createFakeSite()]; + dotSiteService.getSites.mockReturnValue(of(mockSites)); + + spectator.service + .getSitesTreePath({ filter: 'test', perPage: 10, page: 2 }) + .subscribe(() => { + expect(dotSiteService.getSites).toHaveBeenCalledWith('test', 10, 2); + done(); + }); + }); + + it('should return empty array when no sites are found', (done) => { + dotSiteService.getSites.mockReturnValue(of([])); + + spectator.service.getSitesTreePath({ filter: 'test' }).subscribe((result) => { + expect(result).toEqual([]); + done(); + }); + }); + + it('should handle errors from getSites', (done) => { + const error = new Error('Failed to fetch sites'); + dotSiteService.getSites.mockReturnValue(throwError(error)); + + spectator.service.getSitesTreePath({ filter: 'test' }).subscribe({ + next: () => fail('should have thrown an error'), + error: (err) => { + expect(err).toBe(error); + done(); + } + }); + }); + }); + + describe('getFolders', () => { + it('should fetch folders by path using folderService', (done) => { + const mockFolders: DotFolder[] = [ + createFakeFolder({ + id: 'folder-1', + hostName: 'example.com', + path: '/folder1', + addChildrenAllowed: true + }), + createFakeFolder({ + id: 'folder-2', + hostName: 'example.com', + path: '/folder2', + addChildrenAllowed: false + }) + ]; + + dotFolderService.getFolders.mockReturnValue(of(mockFolders)); + + spectator.service.getFolders('/example.com/folder1').subscribe((result) => { + expect(result).toEqual(mockFolders); + expect(dotFolderService.getFolders).toHaveBeenCalledWith('/example.com/folder1'); + done(); + }); + }); + + it('should return empty array when no folders are found', (done) => { + dotFolderService.getFolders.mockReturnValue(of([])); + + spectator.service.getFolders('/example.com').subscribe((result) => { + expect(result).toEqual([]); + expect(dotFolderService.getFolders).toHaveBeenCalledWith('/example.com'); + done(); + }); + }); + + it('should handle errors from folderService', (done) => { + const error = new Error('Failed to fetch folders'); + dotFolderService.getFolders.mockReturnValue(throwError(error)); + + spectator.service.getFolders('/example.com').subscribe({ + next: () => fail('should have thrown an error'), + error: (err) => { + expect(err).toBe(error); + done(); + } + }); + }); + }); + + describe('getFoldersTreeNode', () => { + it('should transform folders into tree node structure', (done) => { + const mockFolders: DotFolder[] = [ + createFakeFolder({ + id: 'parent-1', + hostName: 'example.com', + path: '/parent', + addChildrenAllowed: true + }), + createFakeFolder({ + id: 'child-1', + hostName: 'example.com', + path: '/parent/child1', + addChildrenAllowed: true + }), + createFakeFolder({ + id: 'child-2', + hostName: 'example.com', + path: '/parent/child2', + addChildrenAllowed: false + }) + ]; + + dotFolderService.getFolders.mockReturnValue(of(mockFolders)); + + spectator.service.getFoldersTreeNode('example.com/parent').subscribe((result) => { + expect(result.parent).toEqual({ + id: 'parent-1', + hostName: 'example.com', + path: '/parent', + addChildrenAllowed: true + }); + expect(result.folders).toHaveLength(2); + expect(result.folders[0]).toEqual({ + key: 'child-1', + label: 'example.com/parent/child1', + data: { + id: 'child-1', + hostname: 'example.com', + path: '/parent/child1', + type: 'folder' + }, + expandedIcon: 'pi pi-folder-open', + collapsedIcon: 'pi pi-folder', + leaf: false + }); + expect(dotFolderService.getFolders).toHaveBeenCalledWith('//example.com/parent'); + done(); + }); + }); + + it('should filter out empty folder arrays', (done) => { + dotFolderService.getFolders.mockReturnValue(of([])); + + spectator.service.getFoldersTreeNode('example.com').subscribe({ + next: () => fail('should not emit when folders array is empty'), + error: () => fail('should not throw error'), + complete: () => { + // Observable completes without emitting due to filter + done(); + } + }); + }); + + it('should handle folders with only parent (no children)', (done) => { + const expectedParent = createFakeFolder({ + id: 'parent-1', + hostName: 'example.com', + path: '/parent', + addChildrenAllowed: true + }); + + const mockFolders: DotFolder[] = [expectedParent]; + + dotFolderService.getFolders.mockReturnValue(of([...mockFolders])); + + spectator.service.getFoldersTreeNode('example.com/parent').subscribe((result) => { + expect(result.parent).toEqual(expectedParent); + expect(result.folders).toEqual([]); + expect(dotFolderService.getFolders).toHaveBeenCalledWith('//example.com/parent'); + done(); + }); + }); + + it('should handle errors from folderService', (done) => { + const error = new Error('Failed to fetch folders'); + dotFolderService.getFolders.mockReturnValue(throwError(error)); + + spectator.service.getFoldersTreeNode('example.com').subscribe({ + next: () => fail('should have thrown an error'), + error: (err) => { + expect(err).toBe(error); + done(); + } + }); + }); + }); + + describe('buildTreeByPaths', () => { + it('should build hierarchical tree structure from path', (done) => { + const path = 'example.com/level1/level2'; + + // Mock responses for each path segment + const level2Folders: DotFolder[] = [ + createFakeFolder({ + id: 'parent-level2', + hostName: 'example.com', + path: '/level1/level2', + addChildrenAllowed: true + }), + createFakeFolder({ + id: 'child-level2', + hostName: 'example.com', + path: '/level1/level2/child', + addChildrenAllowed: true + }) + ]; + + const level1Folders: DotFolder[] = [ + createFakeFolder({ + id: 'parent-level1', + hostName: 'example.com', + path: '/level1', + addChildrenAllowed: true + }), + createFakeFolder({ + id: 'parent-level2', + hostName: 'example.com', + path: '/level1/level2', + addChildrenAllowed: true + }) + ]; + + const rootFolders: DotFolder[] = [ + createFakeFolder({ + id: 'root', + hostName: 'example.com', + path: '/', + addChildrenAllowed: true + }), + createFakeFolder({ + id: 'parent-level1', + hostName: 'example.com', + path: '/level1', + addChildrenAllowed: true + }) + ]; + + // Mock responses for each path in reverse order (as paths are reversed in the service) + dotFolderService.getFolders.mockImplementation((requestedPath: string) => { + if (requestedPath === '//example.com/level1/level2/') { + return of(level2Folders); + } else if (requestedPath === '//example.com/level1/') { + return of(level1Folders); + } else if (requestedPath === '//example.com/') { + return of(rootFolders); + } + + return of([]); + }); + + spectator.service.buildTreeByPaths(path).subscribe((result) => { + expect(result).toBeDefined(); + expect(result.tree).toBeDefined(); + expect(result.tree?.folders).toBeDefined(); + expect(dotFolderService.getFolders).toHaveBeenCalledTimes(3); + done(); + }); + }); + + it('should handle single level path', (done) => { + const path = 'example.com'; + + const rootFolders: DotFolder[] = [ + createFakeFolder({ + id: 'root', + hostName: 'example.com', + path: '/', + addChildrenAllowed: true + }) + ]; + + dotFolderService.getFolders.mockReturnValue(of(rootFolders)); + + spectator.service.buildTreeByPaths(path).subscribe((result) => { + expect(result).toBeDefined(); + expect(result.tree).toBeDefined(); + expect(dotFolderService.getFolders).toHaveBeenCalledWith('//example.com/'); + done(); + }); + }); + + it('should handle empty path segments', (done) => { + const path = 'example.com//level1'; + + const rootFolders: DotFolder[] = [ + createFakeFolder({ + id: 'root', + hostName: 'example.com', + path: '/', + addChildrenAllowed: true + }), + createFakeFolder({ + id: 'parent-level1', + hostName: 'example.com', + path: '/level1', + addChildrenAllowed: true + }) + ]; + + const level1Folders: DotFolder[] = [ + createFakeFolder({ + id: 'parent-level1', + hostName: 'example.com', + path: '/level1', + addChildrenAllowed: true + }) + ]; + + dotFolderService.getFolders.mockImplementation((requestedPath: string) => { + if (requestedPath === '//example.com/level1/') { + return of(level1Folders); + } else if (requestedPath === '//example.com/') { + return of(rootFolders); + } + + return of([]); + }); + + spectator.service.buildTreeByPaths(path).subscribe((result) => { + expect(result).toBeDefined(); + expect(dotFolderService.getFolders).toHaveBeenCalledTimes(2); + done(); + }); + }); + + it('should handle errors when building tree', (done) => { + const path = 'example.com/level1'; + const error = new Error('Failed to fetch folders'); + + dotFolderService.getFolders.mockReturnValue(throwError(error)); + + spectator.service.buildTreeByPaths(path).subscribe({ + next: () => fail('should have thrown an error'), + error: (err) => { + expect(err).toBe(error); + done(); + } + }); + }); + }); + + describe('getCurrentSiteAsTreeNodeItem', () => { + it('should transform current site into TreeNodeItem', (done) => { + const mockSite: SiteEntity = createFakeSite({ + identifier: 'site-1', + hostname: 'example.com' + }); + + dotSiteService.getCurrentSite.mockReturnValue(of(mockSite)); + + spectator.service.getCurrentSiteAsTreeNodeItem().subscribe((result) => { + expect(result).toEqual({ + key: 'site-1', + label: 'example.com', + data: { + id: 'site-1', + hostname: 'example.com', + path: '', + type: 'site' + }, + expandedIcon: 'pi pi-folder-open', + collapsedIcon: 'pi pi-folder', + leaf: false + }); + expect(dotSiteService.getCurrentSite).toHaveBeenCalled(); + done(); + }); + }); + + it('should handle errors from getCurrentSite', (done) => { + const error = new Error('Failed to fetch current site'); + dotSiteService.getCurrentSite.mockReturnValue(throwError(error)); + + spectator.service.getCurrentSiteAsTreeNodeItem().subscribe({ + next: () => fail('should have thrown an error'), + error: (err) => { + expect(err).toBe(error); + done(); + } + }); + }); + }); + + describe('getContentByFolder', () => { + it('should pass params directly to siteService', () => { + const mockContent: DotCMSContentlet[] = []; + const params: ContentByFolderParams = { + hostFolderId: '123' + }; + dotSiteService.getContentByFolder.mockReturnValue(of(mockContent)); + + spectator.service.getContentByFolder(params); + + expect(dotSiteService.getContentByFolder).toHaveBeenCalledWith(params); + }); + + it('should pass params with mimeTypes to siteService', () => { + const mockContent: DotCMSContentlet[] = []; + const mimeTypes = ['image/jpeg', 'image/png']; + const params: ContentByFolderParams = { + hostFolderId: '123', + mimeTypes + }; + dotSiteService.getContentByFolder.mockReturnValue(of(mockContent)); + + spectator.service.getContentByFolder(params); + + expect(dotSiteService.getContentByFolder).toHaveBeenCalledWith(params); + }); + + it('should pass params with all options to siteService', () => { + const mockContent: DotCMSContentlet[] = []; + const params: ContentByFolderParams = { + hostFolderId: '123', + showLinks: true, + showDotAssets: false, + showPages: true, + showFiles: false, + showFolders: true, + showWorking: false, + showArchived: true, + sortByDesc: false, + mimeTypes: ['image/jpeg'], + extensions: ['.jpg', '.png'] + }; + dotSiteService.getContentByFolder.mockReturnValue(of(mockContent)); + + spectator.service.getContentByFolder(params); + + expect(dotSiteService.getContentByFolder).toHaveBeenCalledWith(params); + }); + + it('should return content from siteService', (done) => { + const mockContent: DotCMSContentlet[] = [ + createFakeContentlet({ + inode: 'content-1', + title: 'Test Content', + identifier: 'content-1' + }) + ]; + const params: ContentByFolderParams = { + hostFolderId: '123' + }; + dotSiteService.getContentByFolder.mockReturnValue(of(mockContent)); + + spectator.service.getContentByFolder(params).subscribe((result) => { + expect(result).toEqual(mockContent); + done(); + }); + }); + + it('should handle errors from getContentByFolder', (done) => { + const error = new Error('Failed to fetch content'); + const params: ContentByFolderParams = { + hostFolderId: '123' + }; + dotSiteService.getContentByFolder.mockReturnValue(throwError(error)); + + spectator.service.getContentByFolder(params).subscribe({ + next: () => { + fail('should have thrown an error'); + }, + error: (err) => { + expect(err).toBe(error); + done(); + } + }); + }); + + it('should handle empty mimeTypes array', () => { + const mockContent: DotCMSContentlet[] = []; + const params: ContentByFolderParams = { + hostFolderId: '123', + mimeTypes: [] + }; + dotSiteService.getContentByFolder.mockReturnValue(of(mockContent)); + + spectator.service.getContentByFolder(params); + + expect(dotSiteService.getContentByFolder).toHaveBeenCalledWith(params); + }); + }); +}); diff --git a/core-web/libs/ui/src/lib/services/dot-browsing/dot-browsing.service.ts b/core-web/libs/ui/src/lib/services/dot-browsing/dot-browsing.service.ts new file mode 100644 index 000000000000..44f47a83eaf2 --- /dev/null +++ b/core-web/libs/ui/src/lib/services/dot-browsing/dot-browsing.service.ts @@ -0,0 +1,223 @@ +import { Observable, forkJoin } from 'rxjs'; + +import { Injectable, inject } from '@angular/core'; + +import { filter, map } from 'rxjs/operators'; + +import { DotSiteService, DotFolderService } from '@dotcms/data-access'; +import { + DotFolder, + TreeNodeItem, + CustomTreeNode, + ContentByFolderParams +} from '@dotcms/dotcms-models'; + +/** + * Provide util methods to get Tags available in the system. + * @export + * @class DotBrowsingService + */ +@Injectable({ + providedIn: 'root' +}) +export class DotBrowsingService { + readonly #siteService = inject(DotSiteService); + readonly #folderService = inject(DotFolderService); + /** + * Retrieves and transforms site data into TreeNode format for the site/folder field. + * Optionally filters out the System Host based on the isRequired parameter. + * + * @param {Object} data - The parameters for fetching sites + * @param {string} data.filter - Filter string to search sites + * @param {number} [data.perPage] - Number of items per page + * @param {number} [data.page] - Page number to fetch + * @param {boolean} data.isRequired - If true, excludes System Host from results + * @returns {Observable} Observable that emits an array of TreeNodeItems + */ + getSitesTreePath(data: { + filter: string; + perPage?: number; + page?: number; + }): Observable { + const { filter, perPage, page } = data; + + return this.#siteService.getSites(filter, perPage, page).pipe( + map((sites) => { + return sites.map((site) => ({ + key: site.identifier, + label: site.hostname, + data: { + id: site.identifier, + hostname: site.hostname, + path: '', + type: 'site' + }, + expandedIcon: 'pi pi-folder-open', + collapsedIcon: 'pi pi-folder', + leaf: false + })); + }) + ); + } + + /** + * + * + * @param {string} path + * @return {*} {Observable} + * @memberof DotEditContentService + */ + getFolders(path: string): Observable { + return this.#folderService.getFolders(path); + } + + /** + * Retrieves folders and transforms them into a tree node structure. + * The first folder in the response is considered the parent folder. + * + * @param {string} path - The path to fetch folders from + * @returns {Observable<{ parent: DotFolder; folders: TreeNodeItem[] }>} Observable that emits an object containing the parent folder and child folders as TreeNodeItems + */ + getFoldersTreeNode(path: string): Observable<{ parent: DotFolder; folders: TreeNodeItem[] }> { + return this.getFolders(`//${path}`).pipe( + filter((folders) => folders.length > 0), + map((folders) => { + const parent = folders.shift() as DotFolder; + + return { + parent, + folders: folders.map((folder) => ({ + key: folder.id, + label: `${folder.hostName}${folder.path}`, + data: { + id: folder.id, + hostname: folder.hostName, + path: folder.path, + type: 'folder' + }, + expandedIcon: 'pi pi-folder-open', + collapsedIcon: 'pi pi-folder', + leaf: false + })) + }; + }) + ); + } + + /** + * Builds a hierarchical tree structure based on the provided path. + * Splits the path into segments and creates a nested tree structure + * by making multiple API calls for each path segment. + * + * @param {string} path - The full path to build the tree from (e.g., 'hostname/folder1/folder2') + * @returns {Observable} Observable that emits a CustomTreeNode containing the complete tree structure and the target node + */ + buildTreeByPaths(path: string): Observable { + const paths = this.#createPaths(path); + + const requests = paths.reverse().map((path) => { + const split = path.split('/'); + const [hostName] = split; + const subPath = split.slice(1).join('/'); + + const fullPath = `${hostName}/${subPath}`; + + return this.getFoldersTreeNode(fullPath); + }); + + return forkJoin(requests).pipe( + map((response) => { + const [mainNode] = response; + + return response.reduce( + (rta, node, index, array) => { + const next = array[index + 1]; + if (next) { + const folder = next.folders.find((item) => item.key === node.parent.id); + if (folder) { + folder.children = node.folders; + if (mainNode.parent.id === folder.key) { + rta.node = folder; + } + } + } + + rta.tree = { path: node.parent.path, folders: node.folders }; + + return rta; + }, + { tree: null, node: null } as CustomTreeNode + ); + }) + ); + } + + /** + * Retrieves the current site and transforms it into a TreeNodeItem format. + * Useful for initializing the site/folder field with the current context. + * + * @returns {Observable} Observable that emits the current site as a TreeNodeItem + */ + getCurrentSiteAsTreeNodeItem(): Observable { + return this.#siteService.getCurrentSite().pipe( + map((site) => ({ + key: site.identifier, + label: site.hostname, + data: { + id: site.identifier, + hostname: site.hostname, + path: '', + type: 'site' + }, + expandedIcon: 'pi pi-folder-open', + collapsedIcon: 'pi pi-folder', + leaf: false + })) + ); + } + + /** + * Get content by folder + * + * @param {Object} options - The parameters for fetching content by folder + * @param {string} options.folderId - The folder ID + * @return {*} + * @memberof DotEditContentService + */ + getContentByFolder(params: ContentByFolderParams) { + return this.#siteService.getContentByFolder(params); + } + + /** + * Converts a JSON string into a JavaScript object. + * Create all paths based in a Path + * + * @param {string} path - the path + * @return {string[]} - An array with all posibles paths + * + * @usageNotes + * + * ### Example + * + * ```ts + * const path = 'demo.com/level1/level2'; + * const paths = createPaths(path); + * console.log(paths); // ['demo.com/', 'demo.com/level1/', 'demo.com/level1/level2/'] + * ``` + */ + #createPaths(path: string): string[] { + const split = path.split('/').filter((item) => item !== ''); + + return split.reduce((array, item, index) => { + const prev = array[index - 1]; + let path = `${item}/`; + if (prev) { + path = `${prev}${path}`; + } + + array.push(path); + + return array; + }, [] as string[]); + } +} diff --git a/core-web/libs/utils/src/index.ts b/core-web/libs/utils/src/index.ts index 483f33d48524..ff6a24a55cb3 100644 --- a/core-web/libs/utils/src/index.ts +++ b/core-web/libs/utils/src/index.ts @@ -5,3 +5,4 @@ export * from './lib/services/dot-loading-indicator.service'; export * from './lib/shared/const'; export * from './lib/shared/lodash/functions'; export * from './lib/shared/FieldUtil'; +export * from './lib/shared/contentlet.utils'; diff --git a/core-web/libs/utils/src/lib/shared/FieldUtil.ts b/core-web/libs/utils/src/lib/shared/FieldUtil.ts index a04f197cd33e..7e455f30f1ac 100644 --- a/core-web/libs/utils/src/lib/shared/FieldUtil.ts +++ b/core-web/libs/utils/src/lib/shared/FieldUtil.ts @@ -28,7 +28,7 @@ export const EMPTY_FIELD: DotCMSContentTypeField = { clazz: null, defaultValue: null, hint: null, - regexCheck: null, + regexCheck: undefined, values: null }; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/index.spec.ts b/core-web/libs/utils/src/lib/shared/contentlet.utils.spec.ts similarity index 99% rename from core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/index.spec.ts rename to core-web/libs/utils/src/lib/shared/contentlet.utils.spec.ts index 6f3a461191ac..12e1153c92c5 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/index.spec.ts +++ b/core-web/libs/utils/src/lib/shared/contentlet.utils.spec.ts @@ -1,6 +1,6 @@ import { DotCMSContentlet } from '@dotcms/dotcms-models'; -import { getFileMetadata, getFileVersion, cleanMimeTypes, checkMimeType } from './index'; +import { getFileMetadata, getFileVersion, cleanMimeTypes, checkMimeType } from './contentlet.utils'; import { NEW_FILE_MOCK, TEMP_FILE_MOCK } from '../../../utils/mocks'; diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/index.ts b/core-web/libs/utils/src/lib/shared/contentlet.utils.ts similarity index 99% rename from core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/index.ts rename to core-web/libs/utils/src/lib/shared/contentlet.utils.ts index a11130b67366..c3f5ed9fac60 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-file-field/utils/index.ts +++ b/core-web/libs/utils/src/lib/shared/contentlet.utils.ts @@ -1,5 +1,4 @@ import { DotCMSContentlet, DotFileMetadata, DotCMSTempFile } from '@dotcms/dotcms-models'; - /** * Returns the metadata associated with the given contentlet. * diff --git a/core-web/yarn.lock b/core-web/yarn.lock index fa82cd26bb06..eff38fb0033a 100644 --- a/core-web/yarn.lock +++ b/core-web/yarn.lock @@ -12087,7 +12087,7 @@ debug@^3.1.0, debug@^3.2.7: dependencies: ms "^2.1.1" -debuglog@*, debuglog@^1.0.1: +debuglog@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" integrity sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw== @@ -15429,7 +15429,7 @@ import-local@^3.0.2: pkg-dir "^4.2.0" resolve-cwd "^3.0.0" -imurmurhash@*, imurmurhash@^0.1.4: +imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== @@ -17769,11 +17769,6 @@ lodash-es@4.17.21, lodash-es@^4.17.21: resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== -lodash._baseindexof@*: - version "3.1.0" - resolved "https://registry.yarnpkg.com/lodash._baseindexof/-/lodash._baseindexof-3.1.0.tgz#fe52b53a1c6761e42618d654e4a25789ed61822c" - integrity sha512-bSYo8Pc/f0qAkr8fPJydpJjtrHiSynYfYBjtANIgXv5xEf1WlTC63dIDlgu0s9dmTvzRu1+JJTxcIAHe+sH0FQ== - lodash._baseuniq@~4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz#0ebb44e456814af7905c6212fa2c9b2d51b841e8" @@ -17782,33 +17777,11 @@ lodash._baseuniq@~4.6.0: lodash._createset "~4.0.0" lodash._root "~3.0.0" -lodash._bindcallback@*: - version "3.0.1" - resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e" - integrity sha512-2wlI0JRAGX8WEf4Gm1p/mv/SZ+jLijpj0jyaE/AXeuQphzCgD8ZQW4oSpoN8JAopujOFGU3KMuq7qfHBWlGpjQ== - -lodash._cacheindexof@*: - version "3.0.2" - resolved "https://registry.yarnpkg.com/lodash._cacheindexof/-/lodash._cacheindexof-3.0.2.tgz#3dc69ac82498d2ee5e3ce56091bafd2adc7bde92" - integrity sha512-S8dUjWr7SUT/X6TBIQ/OYoCHo1Stu1ZRy6uMUSKqzFnZp5G5RyQizSm6kvxD2Ewyy6AVfMg4AToeZzKfF99T5w== - -lodash._createcache@*: - version "3.1.2" - resolved "https://registry.yarnpkg.com/lodash._createcache/-/lodash._createcache-3.1.2.tgz#56d6a064017625e79ebca6b8018e17440bdcf093" - integrity sha512-ev5SP+iFpZOugyab/DEUQxUeZP5qyciVTlgQ1f4Vlw7VUcCD8fVnyIqVUEIaoFH9zjAqdgi69KiofzvVmda/ZQ== - dependencies: - lodash._getnative "^3.0.0" - lodash._createset@~4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/lodash._createset/-/lodash._createset-4.0.3.tgz#0f4659fbb09d75194fa9e2b88a6644d363c9fe26" integrity sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA== -lodash._getnative@*, lodash._getnative@^3.0.0: - version "3.9.1" - resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" - integrity sha512-RrL9VxMEPyDMHOd9uFbvMe8X55X16/cGM5IgOKgRElQZutpX89iS6vwl64duTV1/16w5JY7tuFNXqoekmh1EmA== - lodash._root@~3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/lodash._root/-/lodash._root-3.0.1.tgz#fba1c4524c19ee9a5f8136b4609f017cf4ded692" @@ -17884,11 +17857,6 @@ lodash.once@^4.0.0: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== -lodash.restparam@*: - version "3.6.1" - resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" - integrity sha512-L4/arjjuq4noiUJpt3yS6KIKDtJwNe2fIYgMqyYYKoeIfV1iEqvPwhCx23o+R9dzouGihDAPN1dTIRWa7zk8tw== - lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" @@ -23600,7 +23568,7 @@ string-length@^4.0.1, string-length@^4.0.2: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -23618,15 +23586,6 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^2.0.0, string-width@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" @@ -25776,7 +25735,7 @@ worker-farm@^1.6.0, worker-farm@^1.7.0: dependencies: errno "~0.1.7" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -25803,15 +25762,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" diff --git a/dotCMS/src/main/webapp/WEB-INF/velocity/static/htmlpage_assets/redirect_custom_field.vtl b/dotCMS/src/main/webapp/WEB-INF/velocity/static/htmlpage_assets/redirect_custom_field.vtl index 10f647553fa6..41fcfd62dad7 100644 --- a/dotCMS/src/main/webapp/WEB-INF/velocity/static/htmlpage_assets/redirect_custom_field.vtl +++ b/dotCMS/src/main/webapp/WEB-INF/velocity/static/htmlpage_assets/redirect_custom_field.vtl @@ -1,30 +1,5 @@ - - - - - - - -
\ No newline at end of file +#if( $structures.isNewEditModeEnabled() ) + #parse('/static/htmlpage_assets/redirect_custom_field_new.vtl') +#else + #parse('/static/htmlpage_assets/redirect_custom_field_old.vtl') +#end \ No newline at end of file diff --git a/dotCMS/src/main/webapp/WEB-INF/velocity/static/htmlpage_assets/redirect_custom_field_new.vtl b/dotCMS/src/main/webapp/WEB-INF/velocity/static/htmlpage_assets/redirect_custom_field_new.vtl new file mode 100644 index 000000000000..f9302cf8dfb5 --- /dev/null +++ b/dotCMS/src/main/webapp/WEB-INF/velocity/static/htmlpage_assets/redirect_custom_field_new.vtl @@ -0,0 +1,50 @@ + + +
+ + +
\ No newline at end of file diff --git a/dotCMS/src/main/webapp/WEB-INF/velocity/static/htmlpage_assets/redirect_custom_field_old.vtl b/dotCMS/src/main/webapp/WEB-INF/velocity/static/htmlpage_assets/redirect_custom_field_old.vtl new file mode 100644 index 000000000000..10f647553fa6 --- /dev/null +++ b/dotCMS/src/main/webapp/WEB-INF/velocity/static/htmlpage_assets/redirect_custom_field_old.vtl @@ -0,0 +1,30 @@ + + + + + + + +
\ No newline at end of file diff --git a/examples/angular/src/app/dotcms/pages/blog-listing/components/search/search.component.ts b/examples/angular/src/app/dotcms/pages/blog-listing/components/search/search.component.ts index 89cf25bdacb5..b1dc527fce9b 100644 --- a/examples/angular/src/app/dotcms/pages/blog-listing/components/search/search.component.ts +++ b/examples/angular/src/app/dotcms/pages/blog-listing/components/search/search.component.ts @@ -1,6 +1,6 @@ -import { Component, effect, input, model, output } from '@angular/core'; +import { Component, effect, input, output } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; -import { debounceTime, distinctUntilChanged, filter } from 'rxjs'; +import { debounceTime, distinctUntilChanged } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({