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