diff --git a/core-web/libs/dotcms-models/src/lib/dot-contentlet.model.ts b/core-web/libs/dotcms-models/src/lib/dot-contentlet.model.ts index 38a918b3e029..66d0b95de217 100644 --- a/core-web/libs/dotcms-models/src/lib/dot-contentlet.model.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-contentlet.model.ts @@ -1,4 +1,7 @@ // Beware while using this type, since we have a [key: string]: any; it can be used to store any kind of data and you can write wrong properties and it will not fail + +import { DotLanguage } from './dot-language.model'; + // Maybe we need to refactor this to a generic type that extends from unknown when missing the generic type export interface DotCMSContentlet { archived: boolean; @@ -18,7 +21,7 @@ export interface DotCMSContentlet { inode: string; image?: string; languageId: number; - language?: string; + language?: string | DotLanguage; live: boolean; locked: boolean; mimeType?: string; diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html index 009ae6942425..f4670631f4f8 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.html @@ -171,7 +171,8 @@

{{ field.name }}

+ [field]="field" + [contentlet]="contentlet" /> } } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/dot-select-existing-content.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/dot-select-existing-content.component.html index 1c185109de44..a8d20f3372af 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/dot-select-existing-content.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/dot-select-existing-content.component.html @@ -67,12 +67,34 @@ } + + + {{ 'dot.file.relationship.dialog.table.title' | dm }} + + + @for (column of columns; track $index) { {{ column.header }} } + + + {{ 'dot.file.relationship.dialog.table.language' | dm }} + + + + + {{ 'dot.file.relationship.dialog.table.state' | dm }} + + + + + {{ 'dot.file.relationship.dialog.table.last.modified' | dm }} + + + @@ -84,11 +106,26 @@ } + +

{{ item.title }}

+ @for (column of columns; track $index) {

{{ item[column.field] }}

} + +

{{ item.language | language }}

+ + + @let status = item | contentletStatus; + + + +

{{ item.modDate | date: 'longDate' }}

+
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/dot-select-existing-content.component.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/dot-select-existing-content.component.spec.ts index 41c060e8e055..9ce28b8d117d 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/dot-select-existing-content.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/dot-select-existing-content.component.spec.ts @@ -4,8 +4,8 @@ import { of } from 'rxjs'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { DotMessageService } from '@dotcms/data-access'; -import { RelationshipFieldItem } from '@dotcms/edit-content/fields/dot-edit-content-relationship-field/models/relationship.models'; -import { MockDotMessageService } from '@dotcms/utils-testing'; +import { DotCMSContentlet } from '@dotcms/dotcms-models'; +import { createFakeContentlet, MockDotMessageService, mockLocales } from '@dotcms/utils-testing'; import { DotSelectExistingContentComponent } from './dot-select-existing-content.component'; import { ExistingContentStore } from './store/existing-content.store'; @@ -18,10 +18,25 @@ const mockColumns: Column[] = [ { field: 'modDate', header: 'Mod Date' } ]; -const mockData: RelationshipFieldItem[] = [ - { id: '1', title: 'Content 1', language: '1', modDate: new Date().toISOString() }, - { id: '2', title: 'Content 2', language: '1', modDate: new Date().toISOString() }, - { id: '3', title: 'Content 3', language: '1', modDate: new Date().toISOString() } +const mockData: DotCMSContentlet[] = [ + createFakeContentlet({ + title: 'Content 1', + inode: '1', + identifier: 'id-1', + languageId: mockLocales[0].id + }), + createFakeContentlet({ + title: 'Content 2', + inode: '2', + identifier: 'id-2', + languageId: mockLocales[1].id + }), + createFakeContentlet({ + title: 'Content 3', + inode: '3', + identifier: 'id-3', + languageId: mockLocales[0].id + }) ]; describe('DotSelectExistingContentComponent', () => { @@ -29,15 +44,6 @@ describe('DotSelectExistingContentComponent', () => { let store: InstanceType; let dialogRef: DynamicDialogRef; - const mockRelationshipItem = (id: string): RelationshipFieldItem => ({ - id, - title: `Test Content ${id}`, - language: '1', - description: 'Test description', - step: 'Step 1', - modDate: new Date().toISOString() - }); - const messageServiceMock = new MockDotMessageService({ 'dot.file.relationship.dialog.apply.one.entry': 'Apply 1 entry', 'dot.file.relationship.dialog.apply.entries': 'Apply {0} entries' @@ -46,7 +52,8 @@ describe('DotSelectExistingContentComponent', () => { const mockDialogConfig = { data: { contentTypeId: 'test-content-type-id', - selectionMode: 'multiple' + selectionMode: 'multiple', + currentItemsIds: [] } }; @@ -83,7 +90,8 @@ describe('DotSelectExistingContentComponent', () => { spectator.component.ngOnInit(); expect(spy).toHaveBeenCalledWith({ contentTypeId: 'test-content-type-id', - selectionMode: 'multiple' + selectionMode: 'multiple', + currentItemsIds: [] }); }); }); @@ -91,7 +99,10 @@ describe('DotSelectExistingContentComponent', () => { describe('Dialog Behavior', () => { it('should close dialog with selected items', () => { - const mockItems = [mockRelationshipItem('1'), mockRelationshipItem('2')]; + const mockItems = [ + createFakeContentlet({ inode: '1' }), + createFakeContentlet({ inode: '2' }) + ]; spectator.component.$selectedItems.set(mockItems); spectator.component.closeDialog(); @@ -115,20 +126,23 @@ describe('DotSelectExistingContentComponent', () => { }); it('should enable apply button when items are selected', () => { - const mockContent = [mockRelationshipItem('1')]; + const mockContent = [createFakeContentlet({ inode: '1' })]; spectator.component.$selectedItems.set(mockContent); expect(spectator.component.$items().length).toBe(1); }); it('should handle single item selection', () => { - const singleItem = mockRelationshipItem('1'); + const singleItem = createFakeContentlet({ inode: '1' }); spectator.component.$selectedItems.set(singleItem); expect(spectator.component.$items().length).toBe(1); expect(spectator.component.$items()[0]).toEqual(singleItem); }); it('should handle multiple items selection', () => { - const multipleItems = [mockRelationshipItem('1'), mockRelationshipItem('2')]; + const multipleItems = [ + createFakeContentlet({ inode: '1' }), + createFakeContentlet({ inode: '2' }) + ]; spectator.component.$selectedItems.set(multipleItems); expect(spectator.component.$items().length).toBe(2); expect(spectator.component.$items()).toEqual(multipleItems); @@ -137,7 +151,7 @@ describe('DotSelectExistingContentComponent', () => { describe('Apply Button Label', () => { it('should show singular label when one item is selected', () => { - const mockContent = [mockRelationshipItem('1')]; + const mockContent = [createFakeContentlet({ inode: '1' })]; spectator.component.$selectedItems.set(mockContent); const label = spectator.component.$applyLabel(); @@ -145,7 +159,10 @@ describe('DotSelectExistingContentComponent', () => { }); it('should show plural label when multiple items are selected', () => { - const mockContent = [mockRelationshipItem('1'), mockRelationshipItem('2')]; + const mockContent = [ + createFakeContentlet({ inode: '1' }), + createFakeContentlet({ inode: '2' }) + ]; spectator.component.$selectedItems.set(mockContent); const label = spectator.component.$applyLabel(); @@ -161,7 +178,7 @@ describe('DotSelectExistingContentComponent', () => { describe('Item Selection', () => { it('should return true when content is in selectedContent array', () => { - const testContent = mockRelationshipItem('1'); + const testContent = createFakeContentlet({ inode: '1' }); spectator.component.$selectedItems.set([testContent]); const result = spectator.component.checkIfSelected(testContent); @@ -170,8 +187,8 @@ describe('DotSelectExistingContentComponent', () => { }); it('should return false when content is not in selectedContent array', () => { - const testContent = mockRelationshipItem('123'); - const differentContent = mockRelationshipItem('456'); + const testContent = createFakeContentlet({ inode: '123' }); + const differentContent = createFakeContentlet({ inode: '456' }); spectator.component.$selectedItems.set([differentContent]); const result = spectator.component.checkIfSelected(testContent); @@ -180,7 +197,7 @@ describe('DotSelectExistingContentComponent', () => { }); it('should return false when selectedContent is empty', () => { - const testContent = mockRelationshipItem('123'); + const testContent = createFakeContentlet({ inode: '123' }); spectator.component.$selectedItems.set([]); const result = spectator.component.checkIfSelected(testContent); @@ -189,7 +206,7 @@ describe('DotSelectExistingContentComponent', () => { }); it('should handle null selectedContent', () => { - const testContent = mockRelationshipItem('123'); + const testContent = createFakeContentlet({ inode: '123' }); spectator.component.$selectedItems.set(null); const result = spectator.component.checkIfSelected(testContent); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/dot-select-existing-content.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/dot-select-existing-content.component.ts index b4d7079e8d0d..cf3655c18656 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/dot-select-existing-content.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/dot-select-existing-content.component.ts @@ -1,14 +1,16 @@ +import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, computed, inject, model, - output, - OnInit + OnInit, + effect } from '@angular/core'; import { ButtonModule } from 'primeng/button'; +import { ChipModule } from 'primeng/chip'; import { DialogModule } from 'primeng/dialog'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { IconFieldModule } from 'primeng/iconfield'; @@ -20,17 +22,21 @@ import { OverlayPanelModule } from 'primeng/overlaypanel'; import { TableModule } from 'primeng/table'; import { DotMessageService } from '@dotcms/data-access'; +import { DotCMSContentlet } from '@dotcms/dotcms-models'; +import { ContentletStatusPipe } from '@dotcms/edit-content/pipes/contentlet-status.pipe'; +import { LanguagePipe } from '@dotcms/edit-content/pipes/language.pipe'; import { DotMessagePipe } from '@dotcms/ui'; import { SearchComponent } from './components/search/search.compoment'; import { ExistingContentStore } from './store/existing-content.store'; -import { RelationshipFieldItem, SelectionMode } from '../../models/relationship.models'; +import { SelectionMode } from '../../models/relationship.models'; import { PaginationComponent } from '../pagination/pagination.component'; type DialogData = { contentTypeId: string; selectionMode: SelectionMode; + currentItemsIds: string[]; }; @Component({ @@ -48,7 +54,11 @@ type DialogData = { PaginationComponent, InputGroupModule, OverlayPanelModule, - SearchComponent + SearchComponent, + ContentletStatusPipe, + LanguagePipe, + DatePipe, + ChipModule ], templateUrl: './dot-select-existing-content.component.html', styleUrls: ['./dot-select-existing-content.component.scss'], @@ -85,7 +95,7 @@ export class DotSelectExistingContentComponent implements OnInit { * A signal that holds the selected items. * It is used to store the selected content items. */ - $selectedItems = model(null); + $selectedItems = model(null); /** * A computed signal that holds the items. @@ -119,11 +129,16 @@ export class DotSelectExistingContentComponent implements OnInit { return this.#dotMessage.get(messageKey, count.toString()); }); - /** - * A signal that sends the selected items when the dialog is closed. - * It is used to notify the parent component that the user has selected content items. - */ - onSelectItems = output(); + constructor() { + effect( + () => { + this.$selectedItems.set(this.store.selectedItems()); + }, + { + allowSignalWrites: true + } + ); + } ngOnInit() { const data: DialogData = this.#dialogConfig.data; @@ -138,7 +153,8 @@ export class DotSelectExistingContentComponent implements OnInit { this.store.initLoad({ contentTypeId: data.contentTypeId, - selectionMode: data.selectionMode + selectionMode: data.selectionMode, + currentItemsIds: data.currentItemsIds }); } @@ -155,9 +171,9 @@ export class DotSelectExistingContentComponent implements OnInit { * @param item - The item to check. * @returns True if the item is selected, false otherwise. */ - checkIfSelected(item: RelationshipFieldItem) { + checkIfSelected(item: DotCMSContentlet) { const items = this.$items(); - return items.some((selectedItem) => selectedItem.id === item.id); + return items.some((selectedItem) => selectedItem.inode === item.inode); } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/store/existing-content.store.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/store/existing-content.store.spec.ts index 45b755d9edd7..29306204db71 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/store/existing-content.store.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/store/existing-content.store.spec.ts @@ -5,13 +5,13 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { delay } from 'rxjs/operators'; -import { ComponentStatus } from '@dotcms/dotcms-models'; +import { ComponentStatus, DotCMSContentlet } from '@dotcms/dotcms-models'; import { RelationshipFieldService } from '@dotcms/edit-content/fields/dot-edit-content-relationship-field/services/relationship-field.service'; +import { createFakeContentlet, mockLocales } from '@dotcms/utils-testing'; import { ExistingContentStore } from './existing-content.store'; import { Column } from '../../../models/column.model'; -import { RelationshipFieldItem } from '../../../models/relationship.models'; describe('ExistingContentStore', () => { let store: InstanceType; @@ -22,10 +22,19 @@ describe('ExistingContentStore', () => { { field: 'modDate', header: 'Mod Date' } ]; - const mockData: RelationshipFieldItem[] = [ - { id: '1', title: 'Content 1', language: '1', modDate: new Date().toISOString() }, - { id: '2', title: 'Content 2', language: '1', modDate: new Date().toISOString() }, - { id: '3', title: 'Content 3', language: '1', modDate: new Date().toISOString() } + const mockData: DotCMSContentlet[] = [ + createFakeContentlet({ + id: '1', + inode: '1', + identifier: 'id-1', + languageId: mockLocales[0].id + }), + createFakeContentlet({ + id: '2', + inode: '2', + identifier: 'id-2', + languageId: mockLocales[1].id + }) ]; beforeEach(() => { @@ -43,7 +52,7 @@ describe('ExistingContentStore', () => { describe('State Management', () => { it('should handle empty contentTypeId', fakeAsync(() => { - store.initLoad({ contentTypeId: null, selectionMode: 'single' }); + store.initLoad({ contentTypeId: null, selectionMode: 'single', currentItemsIds: [] }); tick(); expect(store.status()).toBe(ComponentStatus.ERROR); @@ -54,7 +63,7 @@ describe('ExistingContentStore', () => { it('should load content successfully', fakeAsync(() => { service.getColumnsAndContent.mockReturnValue(of([mockColumns, mockData])); - store.initLoad({ contentTypeId: '123', selectionMode: 'single' }); + store.initLoad({ contentTypeId: '123', selectionMode: 'single', currentItemsIds: [] }); tick(); expect(store.status()).toBe(ComponentStatus.LOADED); @@ -68,7 +77,7 @@ describe('ExistingContentStore', () => { throwError(() => new Error('Server Error')) ); - store.initLoad({ contentTypeId: '123', selectionMode: 'single' }); + store.initLoad({ contentTypeId: '123', selectionMode: 'single', currentItemsIds: [] }); tick(); expect(store.status()).toBe(ComponentStatus.ERROR); @@ -130,12 +139,12 @@ describe('ExistingContentStore', () => { describe('Computed Properties', () => { it('should compute loading state correctly', fakeAsync(() => { const mockObservable = of([mockColumns, mockData]).pipe(delay(100)) as Observable< - [Column[], RelationshipFieldItem[]] + [Column[], DotCMSContentlet[]] >; service.getColumnsAndContent.mockReturnValue(mockObservable); - store.initLoad({ contentTypeId: '123', selectionMode: 'single' }); + store.initLoad({ contentTypeId: '123', selectionMode: 'single', currentItemsIds: [] }); expect(store.isLoading()).toBe(true); tick(100); @@ -145,7 +154,7 @@ describe('ExistingContentStore', () => { it('should compute total pages correctly', fakeAsync(() => { service.getColumnsAndContent.mockReturnValue(of([mockColumns, mockData])); - store.initLoad({ contentTypeId: '123', selectionMode: 'single' }); + store.initLoad({ contentTypeId: '123', selectionMode: 'single', currentItemsIds: [] }); tick(); expect(store.totalPages()).toBe(1); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/store/existing-content.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/store/existing-content.store.ts index 2c27e88d1fd9..deabc3bd17bf 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/store/existing-content.store.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/store/existing-content.store.ts @@ -7,20 +7,18 @@ import { computed, inject } from '@angular/core'; import { tap, switchMap, filter } from 'rxjs/operators'; -import { ComponentStatus } from '@dotcms/dotcms-models'; +import { ComponentStatus, DotCMSContentlet } from '@dotcms/dotcms-models'; import { Column } from '@dotcms/edit-content/fields/dot-edit-content-relationship-field/models/column.model'; -import { - RelationshipFieldItem, - SelectionMode -} from '@dotcms/edit-content/fields/dot-edit-content-relationship-field/models/relationship.models'; +import { SelectionMode } from '@dotcms/edit-content/fields/dot-edit-content-relationship-field/models/relationship.models'; import { RelationshipFieldService } from '@dotcms/edit-content/fields/dot-edit-content-relationship-field/services/relationship-field.service'; export interface ExistingContentState { - data: RelationshipFieldItem[]; + data: DotCMSContentlet[]; status: ComponentStatus; selectionMode: SelectionMode | null; errorMessage: string | null; columns: Column[]; + currentItemsIds: string[]; pagination: { offset: number; currentPage: number; @@ -38,7 +36,8 @@ const initialState: ExistingContentState = { offset: 0, currentPage: 1, rowsPerPage: 50 - } + }, + currentItemsIds: [] }; /** @@ -48,8 +47,26 @@ const initialState: ExistingContentState = { export const ExistingContentStore = signalStore( withState(initialState), withComputed((state) => ({ + /** + * Computes whether the content is currently loading. + * @returns {boolean} True if the content is loading, false otherwise. + */ isLoading: computed(() => state.status() === ComponentStatus.LOADING), - totalPages: computed(() => Math.ceil(state.data().length / state.pagination().rowsPerPage)) + /** + * Computes the total number of pages based on the data and rows per page. + * @returns {number} The total number of pages. + */ + totalPages: computed(() => Math.ceil(state.data().length / state.pagination().rowsPerPage)), + /** + * Computes the selected items based on the current items IDs. + * @returns {DotCMSContentlet[]} The selected items. + */ + selectedItems: computed(() => { + const data = state.data(); + const currentItemsIds = state.currentItemsIds(); + + return data.filter((item) => currentItemsIds.includes(item.inode)); + }) })), withMethods((store) => { const relationshipFieldService = inject(RelationshipFieldService); @@ -62,6 +79,7 @@ export const ExistingContentStore = signalStore( initLoad: rxMethod<{ contentTypeId: string; selectionMode: SelectionMode; + currentItemsIds: string[]; }>( pipe( tap(({ selectionMode }) => @@ -76,14 +94,15 @@ export const ExistingContentStore = signalStore( } }), filter(({ contentTypeId }) => !!contentTypeId), - switchMap(({ contentTypeId }) => + switchMap(({ contentTypeId, currentItemsIds }) => relationshipFieldService.getColumnsAndContent(contentTypeId).pipe( tapResponse({ next: ([columns, data]) => { patchState(store, { columns, data, - status: ComponentStatus.LOADED + status: ComponentStatus.LOADED, + currentItemsIds }); }, error: () => diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component.html index c31a022b46cc..db09fbe6da23 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component.html +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component.html @@ -54,12 +54,12 @@

{{ item.title }}

- {{ item.language }} - {{ item.modDate }} - + {{ item.language | language }} + + + @let status = item | contentletStatus; + + (onClick)="deleteItem(item.inode)" /> diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component.ts index 9995c8a76c2d..3a24d072ab18 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component.ts @@ -21,13 +21,14 @@ import { TableModule } from 'primeng/table'; import { filter } from 'rxjs/operators'; import { DotMessageService } from '@dotcms/data-access'; -import { DotCMSContentTypeField } from '@dotcms/dotcms-models'; +import { DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; import { DotSelectExistingContentComponent } from '@dotcms/edit-content/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/dot-select-existing-content.component'; +import { ContentletStatusPipe } from '@dotcms/edit-content/pipes/contentlet-status.pipe'; +import { LanguagePipe } from '@dotcms/edit-content/pipes/language.pipe'; import { DotMessagePipe } from '@dotcms/ui'; import { HeaderComponent } from './components/header/header.component'; import { PaginationComponent } from './components/pagination/pagination.component'; -import { RelationshipFieldItem } from './models/relationship.models'; import { RelationshipFieldStore } from './store/relationship-field.store'; @Component({ @@ -39,7 +40,9 @@ import { RelationshipFieldStore } from './store/relationship-field.store'; MenuModule, DotMessagePipe, ChipModule, - PaginationComponent + PaginationComponent, + ContentletStatusPipe, + LanguagePipe ], providers: [ RelationshipFieldStore, @@ -119,6 +122,13 @@ export class DotEditContentRelationshipFieldComponent implements ControlValueAcc */ $field = input.required({ alias: 'field' }); + /** + * DotCMS Contentlet + * + * @memberof DotEditContentRelationshipFieldComponent + */ + $contentlet = input.required({ alias: 'contentlet' }); + /** * Creates an instance of DotEditContentRelationshipFieldComponent. * It sets the cardinality of the relationship field based on the field's cardinality. @@ -129,14 +139,19 @@ export class DotEditContentRelationshipFieldComponent implements ControlValueAcc effect( () => { const field = this.$field(); + const contentlet = this.$contentlet(); const cardinality = field?.relationships?.cardinality ?? null; - if (cardinality === null) { + if (cardinality === null || !field?.variable) { return; } - this.store.setCardinality(cardinality); + this.store.initialize({ + cardinality, + contentlet, + variable: field?.variable + }); }, { allowSignalWrites: true @@ -213,8 +228,8 @@ export class DotEditContentRelationshipFieldComponent implements ControlValueAcc * * @param index - The index of the item to delete. */ - deleteItem(id: string) { - this.store.deleteItem(id); + deleteItem(inode: string) { + this.store.deleteItem(inode); } /** @@ -235,7 +250,8 @@ export class DotEditContentRelationshipFieldComponent implements ControlValueAcc style: { 'max-width': '1040px', 'max-height': '800px' }, data: { contentTypeId: this.$attributes().contentTypeId, - selectionMode: this.store.selectionMode() + selectionMode: this.store.selectionMode(), + currentItemsIds: this.store.data().map((item) => item.inode) }, templates: { header: HeaderComponent @@ -247,7 +263,7 @@ export class DotEditContentRelationshipFieldComponent implements ControlValueAcc filter((file) => !!file), takeUntilDestroyed(this.#destroyRef) ) - .subscribe((items: RelationshipFieldItem[]) => { + .subscribe((items: DotCMSContentlet[]) => { this.store.addData(items); }); } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.constants.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.constants.ts index de1950717c44..ba28a7b8459c 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.constants.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.constants.ts @@ -1,15 +1,5 @@ import { RelationshipTypes } from './models/relationship.models'; -export const MANDATORY_FIELDS = { - title: 'title', - language: 'language', - modDate: 'modDate' -}; - -export const MANDATORY_FIRST_COLUMNS = [MANDATORY_FIELDS.title]; - -export const MANDATORY_LAST_COLUMNS = [MANDATORY_FIELDS.language, MANDATORY_FIELDS.modDate]; - export const RELATIONSHIP_OPTIONS = { 0: RelationshipTypes.ONE_TO_MANY, 1: RelationshipTypes.MANY_TO_MANY, diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/models/relationship.models.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/models/relationship.models.ts index 9f0756fb210e..aabe37420ca8 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/models/relationship.models.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/models/relationship.models.ts @@ -1,12 +1,3 @@ -import { MANDATORY_FIELDS } from '../dot-edit-content-relationship-field.constants'; - -export type MandatoryFields = typeof MANDATORY_FIELDS; - -export interface RelationshipFieldItem extends MandatoryFields { - id: string; - [key: string]: string; -} - export enum RelationshipTypes { ONE_TO_ONE = '1-1', ONE_TO_MANY = '1-n', diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/services/relationship-field.service.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/services/relationship-field.service.spec.ts index 6d42e16978d5..6613f04f8e81 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/services/relationship-field.service.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/services/relationship-field.service.spec.ts @@ -6,8 +6,6 @@ import { } from '@ngneat/spectator/jest'; import { of } from 'rxjs'; -import { formatDate } from '@angular/common'; - import { DotContentSearchService, DotFieldService, @@ -15,14 +13,10 @@ import { DotLanguagesService } from '@dotcms/data-access'; import { DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; +import { createFakeContentlet, mockLocales } from '@dotcms/utils-testing'; import { RelationshipFieldService } from './relationship-field.service'; -import { - MANDATORY_FIRST_COLUMNS, - MANDATORY_LAST_COLUMNS -} from '../dot-edit-content-relationship-field.constants'; - describe('RelationshipFieldService', () => { let spectator: SpectatorService; let dotFieldService: SpyObject; @@ -36,13 +30,7 @@ describe('RelationshipFieldService', () => { }), mockProvider(DotFieldService), mockProvider(DotContentSearchService), - mockProvider(DotLanguagesService, { - get: () => - of([ - { id: 1, language: 'English', isoCode: 'en' }, - { id: 2, language: 'Spanish', isoCode: 'es' } - ]) - }) + mockProvider(DotLanguagesService, { get: () => of(mockLocales) }) ] }); @@ -69,11 +57,10 @@ describe('RelationshipFieldService', () => { const contentTypeId = '123'; - const expectedColumns = [ - ...MANDATORY_FIRST_COLUMNS.map((field) => ({ field, header: field })), - ...mockFields.map((field) => ({ field: field.variable, header: field.name })), - ...MANDATORY_LAST_COLUMNS.map((field) => ({ field, header: field })) - ]; + const expectedColumns = mockFields.map((field) => ({ + field: field.variable, + header: field.name + })); spectator.service.getColumns(contentTypeId).subscribe((columns) => { expect(dotFieldService.getFields).toHaveBeenCalledWith( @@ -90,17 +77,12 @@ describe('RelationshipFieldService', () => { const contentTypeId = '123'; - const expectedColumns = [ - ...MANDATORY_FIRST_COLUMNS.map((field) => ({ field, header: field })), - ...MANDATORY_LAST_COLUMNS.map((field) => ({ field, header: field })) - ]; - spectator.service.getColumns(contentTypeId).subscribe((columns) => { expect(dotFieldService.getFields).toHaveBeenCalledWith( contentTypeId, 'SHOW_IN_LIST' ); - expect(columns).toEqual(expectedColumns); + expect(columns).toEqual([]); done(); }); }); @@ -120,11 +102,7 @@ describe('RelationshipFieldService', () => { const contentTypeId = '123'; - const expectedColumns = [ - ...MANDATORY_FIRST_COLUMNS.map((field) => ({ field, header: field })), - { field: 'field1', header: 'Field 1' }, - ...MANDATORY_LAST_COLUMNS.map((field) => ({ field, header: field })) - ]; + const expectedColumns = [{ field: 'field1', header: 'Field 1' }]; spectator.service.getColumns(contentTypeId).subscribe((columns) => { expect(dotFieldService.getFields).toHaveBeenCalledWith( @@ -205,10 +183,8 @@ describe('RelationshipFieldService', () => { ] as DotCMSContentTypeField[]; const expectedColumns = [ - ...MANDATORY_FIRST_COLUMNS.map((field) => ({ field, header: field })), { field: 'field', header: 'Field' }, - { field: 'description', header: 'Description' }, - ...MANDATORY_LAST_COLUMNS.map((field) => ({ field, header: field })) + { field: 'description', header: 'Description' } ]; const mockContentlets = [ @@ -252,21 +228,33 @@ describe('RelationshipFieldService', () => { // Verify relationship items expect(items.length).toBe(2); - expect(items[0]).toEqual({ - id: '123', + const item0 = { + identifier: items[0].identifier, + title: items[0].title, + field: items[0].field, + description: items[0].description, + language: items[0].language + }; + const item1 = { + identifier: items[1].identifier, + title: items[1].title, + field: items[1].field, + description: items[1].description, + language: items[1].language + }; + expect(item0).toEqual({ + identifier: '123', title: 'Test Content 1', field: 'Field 1', description: 'Description 1', - language: 'English (en)', - modDate: formatDate(mockContentlets[0].modDate, 'short', 'en-US') + language: mockLocales[0] }); - expect(items[1]).toEqual({ - id: '456', + expect(item1).toEqual({ + identifier: '456', title: 'Test Content 2', field: 'Field 2', description: 'Description 2', - language: 'Spanish (es)', - modDate: formatDate(mockContentlets[1].modDate, 'short', 'en-US') + language: mockLocales[1] }); // Verify service calls @@ -301,49 +289,15 @@ describe('RelationshipFieldService', () => { }); }); - it('should handle content with missing fields', (done) => { - const contentWithMissingFields = [ - { - identifier: '789', - title: 'Test Content 3', - field: 'Field 3', - languageId: 1, - modDate: '2024-01-03T00:00:00Z' - // description is missing - } - ] as unknown as DotCMSContentlet[]; - - dotContentSearchService.get.mockReturnValue( - of({ - jsonObjectView: { - contentlets: contentWithMissingFields - } - }) - ); - - spectator.service.getColumnsAndContent(mockContentTypeId).subscribe(([_, items]) => { - expect(items[0]).toEqual({ - id: '789', - title: 'Test Content 3', - field: 'Field 3', - description: '', // Should be empty string for missing field - language: 'English (en)', - modDate: formatDate(contentWithMissingFields[0].modDate, 'short', 'en-US') - }); - done(); + it('should handle content without title', () => { + const contentWithoutTitle = createFakeContentlet({ + identifier: '789', + title: null, + description: 'Description 3', + field: 'Field 3', + languageId: mockLocales[0].id, + modDate: '2024-01-03T00:00:00Z' }); - }); - - it('should handle content without title', (done) => { - const contentWithoutTitle = [ - { - identifier: '789', - description: 'Description 3', - field: 'Field 3', - languageId: 1, - modDate: '2024-01-03T00:00:00Z' - } - ] as unknown as DotCMSContentlet[]; dotContentSearchService.get.mockReturnValue( of({ @@ -354,15 +308,20 @@ describe('RelationshipFieldService', () => { ); spectator.service.getColumnsAndContent(mockContentTypeId).subscribe(([_, items]) => { - expect(items[0]).toEqual({ - id: '789', + const item = { + identifier: items[0].identifier, + title: items[0].title, + field: items[0].field, + description: items[0].description, + language: items[0].language + }; + expect(item).toEqual({ + identifier: '789', title: '789', // Should use identifier when title is null field: 'Field 3', description: 'Description 3', - language: 'English (en)', - modDate: formatDate(contentWithoutTitle[0].modDate, 'short', 'en-US') + language: mockLocales[0] }); - done(); }); }); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/services/relationship-field.service.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/services/relationship-field.service.ts index 09a92f001b38..334ad181c625 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/services/relationship-field.service.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/services/relationship-field.service.ts @@ -1,6 +1,5 @@ import { forkJoin, Observable } from 'rxjs'; -import { formatDate } from '@angular/common'; import { HttpErrorResponse } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; @@ -12,16 +11,11 @@ import { DotHttpErrorManagerService, DotLanguagesService } from '@dotcms/data-access'; -import { DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; -import { RelationshipFieldItem } from '@dotcms/edit-content/fields/dot-edit-content-relationship-field/models/relationship.models'; +import { DotCMSContentlet, DotCMSContentTypeField, DotLanguage } from '@dotcms/dotcms-models'; -import { - MANDATORY_FIRST_COLUMNS, - MANDATORY_LAST_COLUMNS -} from '../dot-edit-content-relationship-field.constants'; import { Column } from '../models/column.model'; -type LanguagesMap = Record; +type LanguagesMap = Record; @Injectable({ providedIn: 'root' @@ -52,9 +46,7 @@ export class RelationshipFieldService { * @param contentTypeId The content type ID * @returns Observable of [Column[], RelationshipFieldItem[]] */ - getColumnsAndContent( - contentTypeId: string - ): Observable<[Column[], RelationshipFieldItem[]] | null> { + getColumnsAndContent(contentTypeId: string): Observable<[Column[], DotCMSContentlet[]] | null> { return forkJoin([ this.getColumns(contentTypeId), this.getContent(contentTypeId), @@ -62,7 +54,7 @@ export class RelationshipFieldService { ]).pipe( map(([columns, content, languages]) => [ columns, - this.#matchColumnsWithContent(columns, content, languages) + this.#prepareContent(content, languages) ]), catchError((error: HttpErrorResponse) => { return this.#httpErrorManagerService.handle(error).pipe(map(() => null)); @@ -89,8 +81,7 @@ export class RelationshipFieldService { return this.#dotLanguagesService.get().pipe( map((languages) => languages.reduce((acc, lang) => { - const code = lang.isoCode || lang.languageCode; - acc[lang.id] = `${lang.language} (${code})`; + acc[lang.id] = { ...lang }; return acc; }, {}) @@ -104,66 +95,25 @@ export class RelationshipFieldService { * @returns Array of Column */ #buildColumns(columns: DotCMSContentTypeField[]): Column[] { - const firstColumnsMap = new Map( - MANDATORY_FIRST_COLUMNS.map((field) => [field, { field, header: field }]) - ); - - const lastColumnsMap = new Map( - MANDATORY_LAST_COLUMNS.map((field) => [field, { field, header: field }]) - ); - - const contentColumnsMap = new Map( - columns - .filter((column) => column.variable && column.name) - .map((column) => [ - column.variable, - { - field: column.variable, - header: column.name - } - ]) - ); - - // Merge maps while preserving order and removing duplicates - const uniqueColumns = new Map([ - ...firstColumnsMap, - ...contentColumnsMap, - ...lastColumnsMap - ]); - - return Array.from(uniqueColumns.values()); + return columns + .filter((column) => column.variable && column.name) + .map((column) => ({ + field: column.variable, + header: column.name + })); } /** - * Maps contentlets to relationship field items - * @param columns The columns to map - * @param content The contentlets to map - * @returns Array of RelationshipFieldItem + * Prepares the content for the relationship field + * @param content The contentlets to prepare + * @param languages The languages to prepare + * @returns Array of DotCMSContentlet */ - #matchColumnsWithContent( - columns: Column[], - content: DotCMSContentlet[], - languages: LanguagesMap - ): RelationshipFieldItem[] { - return content.map((item) => { - const dynamicColumns = columns.reduce((acc, column) => { - const key = column.field; - const value = item[key]; - - acc[key] = value ?? ''; - - return acc; - }, {}); - - const relationshipItem: RelationshipFieldItem = { - ...dynamicColumns, - id: item.identifier, - title: item.title || item.identifier, - language: languages[item.languageId] || '', - modDate: formatDate(item.modDate, 'short', 'en-US') - }; - - return relationshipItem; - }); + #prepareContent(content: DotCMSContentlet[], languages: LanguagesMap): DotCMSContentlet[] { + return content.map((item) => ({ + ...item, + title: item.title || item.identifier, + language: languages[item.languageId] + })); } } diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/store/relationship-field.store.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/store/relationship-field.store.spec.ts index 1b55434b4ef9..69e8afefd7bb 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/store/relationship-field.store.spec.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/store/relationship-field.store.spec.ts @@ -1,20 +1,34 @@ import { TestBed } from '@angular/core/testing'; import { ComponentStatus } from '@dotcms/dotcms-models'; +import { createFakeContentlet } from '@dotcms/utils-testing'; import { RelationshipFieldStore } from './relationship-field.store'; -import { RelationshipFieldItem } from '../models/relationship.models'; - describe('RelationshipFieldStore', () => { let store: InstanceType; - const mockData: RelationshipFieldItem[] = [ - { id: '1', title: 'Content 1', language: '1', modDate: new Date().toISOString() }, - { id: '2', title: 'Content 2', language: '1', modDate: new Date().toISOString() }, - { id: '3', title: 'Content 3', language: '1', modDate: new Date().toISOString() } + const mockData = [ + createFakeContentlet({ + inode: 'inode1', + id: '1' + }), + createFakeContentlet({ + inode: 'inode2', + id: '2' + }), + createFakeContentlet({ + inode: 'inode3', + id: '3' + }) ]; + const mockContentlet = createFakeContentlet({ + id: '123', + inode: '123', + variable: 'relationship_field' + }); + beforeEach(() => { TestBed.configureTestingModule({ providers: [RelationshipFieldStore] @@ -41,48 +55,55 @@ describe('RelationshipFieldStore', () => { }); describe('State Management', () => { - describe('setData', () => { - it('should set data correctly', () => { - store.setData(mockData); - expect(store.data()).toEqual(mockData); - }); - }); - - describe('setCardinality', () => { + describe('initialize', () => { it('should set single selection mode for ONE_TO_ONE relationship', () => { - store.setCardinality(2); // ONE_TO_ONE cardinality + store.initialize({ + cardinality: 2, + contentlet: mockContentlet, + variable: 'relationship_field' + }); expect(store.selectionMode()).toBe('single'); }); it('should set multiple selection mode for other relationship types', () => { - store.setCardinality(0); // ONE_TO_MANY cardinality + store.initialize({ + cardinality: 0, + contentlet: mockContentlet, + variable: 'relationship_field' + }); expect(store.selectionMode()).toBe('multiple'); }); - it('should throw error for invalid cardinality', () => { - expect(() => store.setCardinality(999)).toThrow('Invalid relationship type'); + it('should initialize data from contentlet', () => { + store.initialize({ + cardinality: 0, + contentlet: mockContentlet, + variable: 'relationship_field' + }); + expect(store.data()).toBeDefined(); }); }); - describe('addData', () => { - it('should add new unique data to existing data', () => { - const initialData = [mockData[0]]; - const newData = [mockData[1], mockData[2]]; - - store.setData(initialData); - store.addData(newData); - - expect(store.data()).toEqual([...initialData, ...newData]); + describe('setData', () => { + it('should set data correctly', () => { + store.setData(mockData); + expect(store.data()).toEqual(mockData); }); + }); - it('should not add duplicate data', () => { - const initialData = [mockData[0]]; - const newData = [mockData[0], mockData[1]]; - - store.setData(initialData); - store.addData(newData); + describe('addData', () => { + it('should add new data', () => { + store.addData(mockData); + expect(store.data()).toEqual(mockData); + }); + }); - expect(store.data()).toEqual([mockData[0], mockData[1]]); + describe('deleteItem', () => { + it('should delete item by inode', () => { + store.setData(mockData); + store.deleteItem('inode1'); + expect(store.data().length).toBe(2); + expect(store.data().find((item) => item.inode === 'inode1')).toBeUndefined(); }); }); @@ -121,19 +142,30 @@ describe('RelationshipFieldStore', () => { }); describe('isDisabledCreateNewContent', () => { + beforeEach(() => { + store.initialize({ + cardinality: 2, + contentlet: mockContentlet, + variable: 'relationship_field' + }); + }); + it('should disable for single mode with one item', () => { - store.setCardinality(2); // ONE_TO_ONE store.setData([mockData[0]]); expect(store.isDisabledCreateNewContent()).toBe(true); }); it('should not disable for single mode with no items', () => { - store.setCardinality(2); // ONE_TO_ONE + store.setData([]); expect(store.isDisabledCreateNewContent()).toBe(false); }); it('should not disable for multiple mode regardless of items', () => { - store.setCardinality(0); // ONE_TO_MANY + store.initialize({ + cardinality: 0, + contentlet: mockContentlet, + variable: 'relationship_field' + }); store.setData(mockData); expect(store.isDisabledCreateNewContent()).toBe(false); }); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/store/relationship-field.store.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/store/relationship-field.store.ts index 9d25daf886e0..31364dafd261 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/store/relationship-field.store.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/store/relationship-field.store.ts @@ -2,17 +2,13 @@ import { patchState, signalStore, withComputed, withMethods, withState } from '@ import { computed } from '@angular/core'; -import { ComponentStatus } from '@dotcms/dotcms-models'; +import { ComponentStatus, DotCMSContentlet } from '@dotcms/dotcms-models'; -import { RELATIONSHIP_OPTIONS } from '../dot-edit-content-relationship-field.constants'; -import { - RelationshipFieldItem, - RelationshipTypes, - SelectionMode -} from '../models/relationship.models'; +import { SelectionMode } from '../models/relationship.models'; +import { getRelationshipFromContentlet, getSelectionModeByCardinality } from '../utils'; export interface RelationshipFieldState { - data: RelationshipFieldItem[]; + data: DotCMSContentlet[]; status: ComponentStatus; selectionMode: SelectionMode | null; pagination: { @@ -76,7 +72,7 @@ export const RelationshipFieldStore = signalStore( * Sets the data in the state. * @param {RelationshipFieldItem[]} data - The data to be set. */ - setData(data: RelationshipFieldItem[]) { + setData(data: DotCMSContentlet[]) { patchState(store, { data }); @@ -85,40 +81,35 @@ export const RelationshipFieldStore = signalStore( * Sets the cardinality of the relationship field. * @param {number} cardinality - The cardinality of the relationship field. */ - setCardinality(cardinality: number) { - const relationshipType = RELATIONSHIP_OPTIONS[cardinality]; + initialize(params: { + cardinality: number; + contentlet: DotCMSContentlet; + variable: string; + }) { + const { cardinality, contentlet, variable } = params; - if (!relationshipType) { - throw new Error('Invalid relationship type'); - } - - const selectionMode: SelectionMode = - relationshipType === RelationshipTypes.ONE_TO_ONE ? 'single' : 'multiple'; + const data = getRelationshipFromContentlet({ contentlet, variable }); + const selectionMode = getSelectionModeByCardinality(cardinality); patchState(store, { - selectionMode + selectionMode, + data }); }, /** * Adds new data to the existing data in the state. * @param {RelationshipFieldItem[]} data - The new data to be added. */ - addData(data: RelationshipFieldItem[]) { - const currentData = store.data(); - - const existingIds = new Set(currentData.map((item) => item.id)); - const uniqueNewData = data.filter((item) => !existingIds.has(item.id)); - patchState(store, { - data: [...currentData, ...uniqueNewData] - }); + addData(data: DotCMSContentlet[]) { + patchState(store, { data }); }, /** * Deletes an item from the store at the specified index. * @param index - The index of the item to delete. */ - deleteItem(id: string) { + deleteItem(inode: string) { patchState(store, { - data: store.data().filter((item) => item.id !== id) + data: store.data().filter((item) => item.inode !== inode) }); }, /** diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/utils/index.spec.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/utils/index.spec.ts new file mode 100644 index 000000000000..679e1bb0faea --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/utils/index.spec.ts @@ -0,0 +1,94 @@ +import { createFakeContentlet } from '@dotcms/utils-testing'; + +import { getSelectionModeByCardinality, getRelationshipFromContentlet } from './index'; + +import { RELATIONSHIP_OPTIONS } from '../dot-edit-content-relationship-field.constants'; +import { RelationshipTypes } from '../models/relationship.models'; + +const mockContentlet = createFakeContentlet(); + +describe('Relationship Field Utils', () => { + describe('getSelectionModeByCardinality', () => { + it('should return "single" for ONE_TO_ONE relationship', () => { + // Find the cardinality value for ONE_TO_ONE + const oneToOneCardinality = Object.entries(RELATIONSHIP_OPTIONS).find( + ([_, value]) => value === RelationshipTypes.ONE_TO_ONE + )?.[0]; + + const result = getSelectionModeByCardinality(Number(oneToOneCardinality)); + expect(result).toBe('single'); + }); + + it('should return "multiple" for ONE_TO_MANY relationship', () => { + // Find the cardinality value for ONE_TO_MANY + const oneToManyCardinality = Object.entries(RELATIONSHIP_OPTIONS).find( + ([_, value]) => value === RelationshipTypes.ONE_TO_MANY + )?.[0]; + + const result = getSelectionModeByCardinality(Number(oneToManyCardinality)); + expect(result).toBe('multiple'); + }); + + it('should throw error for invalid cardinality', () => { + expect(() => getSelectionModeByCardinality(999)).toThrow('Invalid relationship type'); + }); + }); + + describe('getRelationshipFromContentlet', () => { + it('should return empty array when contentlet is null', () => { + const result = getRelationshipFromContentlet({ + contentlet: null, + variable: 'testVar' + }); + expect(result).toEqual([]); + }); + + it('should return empty array when variable is empty', () => { + const result = getRelationshipFromContentlet({ + contentlet: mockContentlet, + variable: '' + }); + expect(result).toEqual([]); + }); + + it('should return array of relationships when contentlet has array relationship', () => { + const relationships = [createFakeContentlet(), createFakeContentlet()]; + const contentlet = { + ...mockContentlet, + testVar: relationships + }; + + const result = getRelationshipFromContentlet({ + contentlet, + variable: 'testVar' + }); + expect(result).toEqual(relationships); + }); + + it('should return empty array when relationship is not an array', () => { + const contentlet = { + ...mockContentlet, + testVar: 'not an array' + }; + + const result = getRelationshipFromContentlet({ + contentlet, + variable: 'testVar' + }); + expect(result).toEqual([]); + }); + + it('should return empty array when relationship variable does not exist', () => { + const contentlet = { + ...mockContentlet, + otherVar: [] + }; + + const result = getRelationshipFromContentlet({ + contentlet, + variable: 'testVar' + }); + expect(result).toEqual([]); + }); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/utils/index.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/utils/index.ts new file mode 100644 index 000000000000..52dc8c60fcb1 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/utils/index.ts @@ -0,0 +1,42 @@ +import { DotCMSContentlet } from '@dotcms/dotcms-models'; + +import { RELATIONSHIP_OPTIONS } from '../dot-edit-content-relationship-field.constants'; +import { RelationshipTypes } from '../models/relationship.models'; + +/** + * Get the selection mode by cardinality. + * + * @param cardinality - The cardinality of the relationship. + * @returns The selection mode. + */ +export function getSelectionModeByCardinality(cardinality: number) { + const relationshipType = RELATIONSHIP_OPTIONS[cardinality]; + + if (!relationshipType) { + throw new Error(`Invalid relationship type for cardinality: ${cardinality}`); + } + + return relationshipType === RelationshipTypes.ONE_TO_ONE ? 'single' : 'multiple'; +} + +/** + * Get the relationship from the contentlet. + * + * @param contentlet - The contentlet. + * @returns The relationship. + */ +export function getRelationshipFromContentlet({ + contentlet, + variable +}: { + contentlet: DotCMSContentlet; + variable: string; +}): DotCMSContentlet[] { + if (!contentlet || !variable) { + return []; + } + + const relationship = Array.isArray(contentlet[variable]) ? contentlet[variable] : []; + + return relationship; +} diff --git a/core-web/libs/edit-content/src/lib/pipes/language.pipe.spec.ts b/core-web/libs/edit-content/src/lib/pipes/language.pipe.spec.ts new file mode 100644 index 000000000000..092a669fe4c9 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/pipes/language.pipe.spec.ts @@ -0,0 +1,82 @@ +import { SpectatorPipe, createPipeFactory } from '@ngneat/spectator/jest'; + +import { DotLanguage } from '@dotcms/dotcms-models'; + +import { LanguagePipe } from './language.pipe'; + +describe('LanguagePipe', () => { + let spectator: SpectatorPipe; + const createPipe = createPipeFactory({ + pipe: LanguagePipe, + template: '{{ prop | language }}' + }); + + it('should create', () => { + spectator = createPipe(); + expect(spectator.element).toBeTruthy(); + }); + + it('should return empty string when language is null', () => { + spectator = createPipe({ + hostProps: { + prop: null + } + }); + expect(spectator.element.textContent).toBe(''); + }); + + it('should return empty string when language is undefined', () => { + spectator = createPipe({ + hostProps: { + prop: undefined + } + }); + expect(spectator.element.textContent).toBe(''); + }); + + it('should format language with languageCode', () => { + const language: DotLanguage = { + language: 'English', + languageCode: 'en', + id: 1, + countryCode: 'US' + }; + spectator = createPipe({ + hostProps: { + prop: language + } + }); + expect(spectator.element.textContent).toBe('English (en)'); + }); + + it('should format language with isoCode when languageCode is empty', () => { + const language: DotLanguage = { + language: 'Spanish', + languageCode: '', + isoCode: 'es', + id: 2, + countryCode: 'ES' + }; + spectator = createPipe({ + hostProps: { + prop: language + } + }); + expect(spectator.element.textContent).toBe('Spanish (es)'); + }); + + it('should format language without code when neither languageCode nor isoCode has value', () => { + const language: DotLanguage = { + language: 'French', + languageCode: '', + id: 3, + countryCode: 'FR' + }; + spectator = createPipe({ + hostProps: { + prop: language + } + }); + expect(spectator.element.textContent).toBe('French'); + }); +}); diff --git a/core-web/libs/edit-content/src/lib/pipes/language.pipe.ts b/core-web/libs/edit-content/src/lib/pipes/language.pipe.ts new file mode 100644 index 000000000000..260266154dad --- /dev/null +++ b/core-web/libs/edit-content/src/lib/pipes/language.pipe.ts @@ -0,0 +1,35 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { DotLanguage } from '@dotcms/dotcms-models'; + +/** + * Pipe to format the language into a human-readable string. + * Takes a DotLanguage object and returns a formatted string with language name and code. + * Example: { language: 'English', languageCode: 'en' } -> 'English (en)' + * Falls back to isoCode if languageCode is empty, or empty parentheses if neither exists. + */ +@Pipe({ + name: 'language', + standalone: true +}) +export class LanguagePipe implements PipeTransform { + /** + * Transform the language to a string. + * + * @param {DotLanguage} language - The language to transform. + * @returns {string} The transformed language. + */ + transform(language: DotLanguage): string { + if (!language?.language) { + return ''; + } + + const code = language.languageCode || language.isoCode || ''; + + if (code) { + return `${language.language} (${code})`; + } + + return language.language; + } +} diff --git a/core-web/libs/ui/src/lib/components/dot-asset-search/components/dot-asset-card/dot-asset-card.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-asset-search/components/dot-asset-card/dot-asset-card.component.spec.ts index 55b2cd24ddd9..114d8192fa37 100644 --- a/core-web/libs/ui/src/lib/components/dot-asset-search/components/dot-asset-card/dot-asset-card.component.spec.ts +++ b/core-web/libs/ui/src/lib/components/dot-asset-search/components/dot-asset-card/dot-asset-card.component.spec.ts @@ -42,7 +42,8 @@ describe('DotAssetCardComponent', () => { const language = spectator.query('[data-testId="dot-card-language"]'); expect(title.innerHTML.trim()).toBe(contentlet.fileName); - expect(language.innerHTML.trim()).toBe(contentlet.language); + const languageContent = contentlet.language as string; + expect(language.innerHTML.trim()).toBe(languageContent); }); it('should display the contentlet title when the fileName property is empty', () => { diff --git a/core-web/libs/utils-testing/src/lib/dot-contentlet.mock.ts b/core-web/libs/utils-testing/src/lib/dot-contentlet.mock.ts index 38d795ce88e4..482d74f305d8 100644 --- a/core-web/libs/utils-testing/src/lib/dot-contentlet.mock.ts +++ b/core-web/libs/utils-testing/src/lib/dot-contentlet.mock.ts @@ -1,5 +1,9 @@ +import { faker } from '@faker-js/faker'; + import { DotCMSContentlet, StructureType, StructureTypeView } from '@dotcms/dotcms-models'; +import { createFakeLanguage } from './dot-language.mock'; + export const mockDotContentlet: StructureTypeView[] = [ { name: 'CONTENT', @@ -154,3 +158,49 @@ export const EMPTY_IMAGE_CONTENTLET: DotCMSContentlet = { fileAsset: 'https://cdn.pixabay.com/photo/2015/04/23/22/00/tree-736885__480.jpg', ...EMPTY_CONTENTLET }; + +/** + * Creates a fake contentlet with optional overrides. This function generates a contentlet + * with predefined fake data that can be overridden by passing specific properties. + * + * @param {Partial} overrides - Optional overrides for default contentlet properties. + * @return {DotCMSContentlet} - The fake contentlet with applied overrides. + */ +export function createFakeContentlet(overrides: Partial = {}): DotCMSContentlet { + const language = createFakeLanguage(); + + const defaultContentlet: DotCMSContentlet = { + id: faker.string.uuid(), + title: faker.lorem.sentence(), + language: language, + languageId: language.id, + modDate: new Date().toISOString(), + inode: faker.string.uuid(), + archived: faker.datatype.boolean(), + baseType: 'content', + contentType: 'test', + folder: 'test', + host: 'test', + identifier: faker.string.uuid(), + live: faker.datatype.boolean(), + locked: faker.datatype.boolean(), + owner: 'test', + permissions: [], + working: true, + contentTypeId: 'test', + url: 'test', + hasLiveVersion: true, + deleted: false, + hasTitleImage: false, + hostName: 'test', + modUser: 'test', + modUserName: 'test', + publishDate: new Date().toISOString(), + sortOrder: 0, + versionType: 'test', + stInode: 'test', + titleImage: 'test' + }; + + return { ...defaultContentlet, ...overrides }; +} diff --git a/core-web/libs/utils-testing/src/lib/dot-language.mock.ts b/core-web/libs/utils-testing/src/lib/dot-language.mock.ts index 21495244f588..7cf8a5799cbf 100644 --- a/core-web/libs/utils-testing/src/lib/dot-language.mock.ts +++ b/core-web/libs/utils-testing/src/lib/dot-language.mock.ts @@ -56,3 +56,12 @@ export const mockLanguagesISO: DotLanguagesISO = { { code: 'es', name: 'Spanish' } ] }; + +/** + * Creates a fake language with optional overrides. + * @param overrides - Partial overrides for the default language properties. + * @returns {DotLanguage} - The fake language with applied overrides. + */ +export function createFakeLanguage(overrides: Partial = {}): DotLanguage { + return { ...mockDotLanguage, ...overrides }; +} diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 316ee4aa4de0..66d00b8244b2 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -1235,6 +1235,10 @@ dot.file.relationship.dialog.search.empty.content=No related content available dot.file.relationship.dialog.content.id.required=ContentId is required dot.file.relationship.dialog.content.request.failed=Failed to load content dot.file.relationship.dialog.menu.column=Menu +dot.file.relationship.dialog.table.title=Title +dot.file.relationship.dialog.table.language=Language +dot.file.relationship.dialog.table.state=State +dot.file.relationship.dialog.table.last.modified=Last Modified dot.common.apply=Apply dot.common.archived=Archived dot.common.cancel=Cancel