diff --git a/.gitignore b/.gitignore index 054c298a51fc..6ebe9ab28c4f 100644 --- a/.gitignore +++ b/.gitignore @@ -179,3 +179,5 @@ dotCMS/dependencies.gradle !/examples/nextjs/.npmrc .nx/ +.cursorrules +.cursorignore \ No newline at end of file diff --git a/core-web/libs/dotcms-scss/shared/_colors.scss b/core-web/libs/dotcms-scss/shared/_colors.scss index a97692781437..a1173a3e7d35 100644 --- a/core-web/libs/dotcms-scss/shared/_colors.scss +++ b/core-web/libs/dotcms-scss/shared/_colors.scss @@ -23,7 +23,7 @@ $color-palette-primary-op-90: var(--color-palette-primary-op-90); $color-palette-primary-shade: $color-palette-primary-600; $color-palette-primary: $color-palette-primary-500; -$color-palette-primary-tint: $color-palette-primary-200; +$color-palette-primary-tint: $color-palette-primary-100; $color-palette-secondary-100: var(--color-palette-secondary-100); $color-palette-secondary-200: var(--color-palette-secondary-200); 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 115d82c9bd5a..0d95a67ff9a0 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 @@ -151,6 +151,6 @@ } } } -@if (field.hint) { +@if (field.hint && field.fieldType !== fieldTypes.RELATIONSHIP) { {{ field.hint }} } diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts index 874b2ba95039..a59d75fd05a7 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.spec.ts @@ -4,7 +4,8 @@ import { EditorComponent } from '@tinymce/tinymce-angular'; import { MockComponent } from 'ng-mocks'; import { of } from 'rxjs'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { Provider, signal, Type } from '@angular/core'; import { ControlContainer, FormGroupDirective } from '@angular/forms'; import { By } from '@angular/platform-browser'; @@ -213,7 +214,7 @@ describe.each([...FIELDS_TO_BE_RENDER])('DotEditContentFieldComponent all fields let spectator: Spectator; const createComponent = createComponentFactory({ - imports: [HttpClientTestingModule, ...(fieldTestBed?.imports || [])], + imports: [...(fieldTestBed?.imports || [])], declarations: [...(fieldTestBed?.declarations || [])], component: DotEditContentFieldComponent, componentViewProviders: [ @@ -222,7 +223,13 @@ describe.each([...FIELDS_TO_BE_RENDER])('DotEditContentFieldComponent all fields useValue: createFormGroupDirectiveMock() } ], - providers: [FormGroupDirective, mockProvider(DotHttpErrorManagerService)] + providers: [ + FormGroupDirective, + provideHttpClient(), + provideHttpClientTesting(), + ...(fieldTestBed?.providers || []), + mockProvider(DotHttpErrorManagerService) + ] }); beforeEach(async () => { @@ -242,11 +249,13 @@ describe.each([...FIELDS_TO_BE_RENDER])('DotEditContentFieldComponent all fields expect(label?.textContent).toContain(fieldMock.name); }); - it('should render the hint if present', () => { - spectator.detectChanges(); - const hint = spectator.query(byTestId(`hint-${fieldMock.variable}`)); - expect(hint?.textContent).toContain(fieldMock.hint); - }); + if (fieldMock.fieldType !== FIELD_TYPES.RELATIONSHIP) { + it('should render the hint if present', () => { + spectator.detectChanges(); + const hint = spectator.query(byTestId(`hint-${fieldMock.variable}`)); + expect(hint?.textContent).toContain(fieldMock.hint); + }); + } it('should render the correct field type', () => { spectator.detectChanges(); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/pagination/pagination.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/pagination/pagination.component.html deleted file mode 100644 index 1be37dd3eabb..000000000000 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/pagination/pagination.component.html +++ /dev/null @@ -1,26 +0,0 @@ -@let currentPage = $currentPage(); -@let totalPages = $totalPages(); - -
- - {{ currentPage }} of {{ totalPages }} - -
- - -
-
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 0d942a65b77d..474a86d1fc92 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 @@ -5,6 +5,7 @@ [header]="'dot.file.relationship.dialog.select.existing.content' | dm" [modal]="true" [(visible)]="$visible" + (onHide)="emitSelectedItems()" dataKey="id" appendTo="body" width="90%" @@ -14,9 +15,9 @@ [value]="data" selectionMode="multiple" [(selection)]="$selectedItems" - [first]="pagination.offset" [loading]="store.isLoading()" [paginator]="true" + [first]="pagination.offset" [rows]="pagination.rowsPerPage" [globalFilterFields]="['title', 'step', 'description']" styleClass="p-datatable-sm p-datatable-existing-content"> @@ -35,13 +36,12 @@

-
- -
+ @@ -111,7 +111,10 @@ (onClick)="closeDialog()" [text]="true" severity="primary" /> - + 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 new file mode 100644 index 000000000000..ec2a9dc5ed84 --- /dev/null +++ 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 @@ -0,0 +1,88 @@ +import { Spectator, createComponentFactory } from '@ngneat/spectator/jest'; + +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 { DotSelectExistingContentComponent } from './dot-select-existing-content.component'; +import { ExistingContentStore } from './store/existing-content.store'; + +describe('DotSelectExistingContentComponent', () => { + let spectator: Spectator; + let store: InstanceType; + + const mockRelationshipItem = (id: string): RelationshipFieldItem => ({ + id, + title: `Test Content ${id}`, + language: '1', + state: { + label: 'Published', + styleClass: 'small-chip' + }, + description: 'Test description', + step: 'Step 1', + lastUpdate: 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' + }); + + const createComponent = createComponentFactory({ + component: DotSelectExistingContentComponent, + componentProviders: [ExistingContentStore], + providers: [{ provide: DotMessageService, useValue: messageServiceMock }], + detectChanges: false + }); + + beforeEach(() => { + spectator = createComponent(); + store = spectator.inject(ExistingContentStore, true); + spectator.detectChanges(); + }); + + it('should create', () => { + expect(spectator.component).toBeTruthy(); + expect(store).toBeTruthy(); + }); + + describe('Dialog Visibility', () => { + it('should set visibility to false when closeDialog is called', () => { + spectator.component.$visible.set(true); + spectator.component.closeDialog(); + expect(spectator.component.$visible()).toBeFalsy(); + }); + }); + + describe('Selected Items State', () => { + it('should disable apply button when no items are selected', () => { + spectator.component.$selectedItems.set([]); + expect(spectator.component.$isApplyDisabled()).toBeTruthy(); + }); + + it('should enable apply button when items are selected', () => { + const mockContent = [mockRelationshipItem('1')]; + spectator.component.$selectedItems.set(mockContent); + expect(spectator.component.$isApplyDisabled()).toBeFalsy(); + }); + }); + + describe('Apply Button Label', () => { + it('should show singular label when one item is selected', () => { + const mockContent = [mockRelationshipItem('1')]; + spectator.component.$selectedItems.set(mockContent); + + const label = spectator.component.$applyLabel(); + expect(label).toBe('Apply 1 entry'); + }); + + it('should show plural label when multiple items are selected', () => { + const mockContent = [mockRelationshipItem('1'), mockRelationshipItem('2')]; + spectator.component.$selectedItems.set(mockContent); + + const label = spectator.component.$applyLabel(); + expect(label).toBe('Apply 2 entries'); + }); + }); +}); 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 bb471f25be86..0e066ed75105 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,5 +1,5 @@ import { DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, inject, model } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, model, output } from '@angular/core'; import { ButtonModule } from 'primeng/button'; import { DialogModule } from 'primeng/dialog'; @@ -14,9 +14,11 @@ import { TableModule } from 'primeng/table'; import { DotMessageService } from '@dotcms/data-access'; import { DotMessagePipe } from '@dotcms/ui'; -import { PaginationComponent } from './components/pagination/pagination.component'; import { SearchComponent } from './components/search/search.compoment'; -import { Content, ExistingContentStore } from './store/existing-content.store'; +import { ExistingContentStore } from './store/existing-content.store'; + +import { RelationshipFieldItem } from '../../models/relationship.models'; +import { PaginationComponent } from '../pagination/pagination.component'; @Component({ selector: 'dot-select-existing-content', @@ -65,7 +67,7 @@ export class DotSelectExistingContentComponent { * A signal that holds the selected items. * It is used to store the selected content items. */ - $selectedItems = model([]); + $selectedItems = model([]); /** * A computed signal that determines if the apply button is disabled. @@ -88,6 +90,12 @@ export class DotSelectExistingContentComponent { return this.#dotMessage.get(messageKey, selectedItems.length.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(); + /** * A method that closes the existing content dialog. * It sets the visibility signal to false, hiding the dialog. @@ -95,4 +103,13 @@ export class DotSelectExistingContentComponent { closeDialog() { this.$visible.set(false); } + + /** + * Closes the existing content dialog and sends the selected items to the parent component. + * It sets the visibility signal to false, hiding the dialog, and emits the selected items + * through the "selectItems" output signal. + */ + emitSelectedItems() { + this.onSelectItems.emit(this.$selectedItems()); + } } 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 88ff348283d2..c65414f675bc 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 @@ -1,4 +1,4 @@ -import { faker } from '@faker-js/faker'; +import { tapResponse } from '@ngrx/operators'; import { patchState, signalStore, @@ -7,21 +7,19 @@ import { withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { pipe } from 'rxjs'; -import { computed } from '@angular/core'; +import { computed, inject } from '@angular/core'; -import { ComponentStatus } from '@dotcms/dotcms-models'; +import { tap, switchMap } from 'rxjs/operators'; -export interface Content { - id: string; - title: string; - step: string; - description: string; - lastUpdate: string; -} +import { ComponentStatus } from '@dotcms/dotcms-models'; +import { RelationshipFieldItem } 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: Content[]; + data: RelationshipFieldItem[]; status: ComponentStatus; pagination: { offset: number; @@ -50,38 +48,54 @@ export const ExistingContentStore = signalStore( isLoading: computed(() => state.status() === ComponentStatus.LOADING), totalPages: computed(() => Math.ceil(state.data().length / state.pagination().rowsPerPage)) })), - withMethods((store) => ({ - loadContent() { - const mockData = Array.from({ length: 100 }, () => ({ - id: faker.string.uuid(), - title: faker.lorem.sentence(), - step: faker.helpers.arrayElement(['Draft', 'Published', 'Archived']), - description: faker.lorem.paragraph(), - lastUpdate: faker.date.recent().toISOString() - })); - patchState(store, { - data: mockData - }); - }, - nextPage() { - patchState(store, { - pagination: { - ...store.pagination(), - offset: store.pagination().offset + store.pagination().rowsPerPage, - currentPage: store.pagination().currentPage + 1 - } - }); - }, - previousPage() { - patchState(store, { - pagination: { - ...store.pagination(), - offset: store.pagination().offset - store.pagination().rowsPerPage, - currentPage: store.pagination().currentPage - 1 - } - }); - } - })), + withMethods((store) => { + const relationshipFieldService = inject(RelationshipFieldService); + + return { + /** + * Initiates the loading of content by setting the status to LOADING and fetching content from the service. + * @returns {Observable} An observable that completes when the content has been loaded. + */ + loadContent: rxMethod( + pipe( + tap(() => patchState(store, { status: ComponentStatus.LOADING })), + switchMap(() => + relationshipFieldService.getContent().pipe( + tapResponse({ + next: (data) => + patchState(store, { data, status: ComponentStatus.LOADED }), + error: () => patchState(store, { status: ComponentStatus.ERROR }) + }) + ) + ) + ) + ), + /** + * Advances the pagination to the next page and updates the state accordingly. + */ + nextPage: () => { + patchState(store, { + pagination: { + ...store.pagination(), + offset: store.pagination().offset + store.pagination().rowsPerPage, + currentPage: store.pagination().currentPage + 1 + } + }); + }, + /** + * Moves the pagination to the previous page and updates the state accordingly. + */ + previousPage: () => { + patchState(store, { + pagination: { + ...store.pagination(), + offset: store.pagination().offset - store.pagination().rowsPerPage, + currentPage: store.pagination().currentPage - 1 + } + }); + } + }; + }), withHooks({ onInit: (store) => { store.loadContent(); diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/pagination/pagination.component.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/pagination/pagination.component.html new file mode 100644 index 000000000000..ee6cb1baa8b0 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/pagination/pagination.component.html @@ -0,0 +1,55 @@ +@let currentPage = $currentPage(); +@let totalPages = $totalPages(); +@let currentPageReportLayout = $currentPageReportLayout(); + +
+ @switch ($currentPageReportLayout()) { + @case ('center') { + + + {{ currentPage }} of {{ totalPages }} + + + } + @default { + + {{ currentPage }} of {{ totalPages }} + +
+ + +
+ } + } +
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/pagination/pagination.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/pagination/pagination.component.ts similarity index 77% rename from core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/pagination/pagination.component.ts rename to core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/pagination/pagination.component.ts index bc683e77c09a..54542646e9de 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/pagination/pagination.component.ts +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/pagination/pagination.component.ts @@ -21,6 +21,14 @@ export class PaginationComponent { */ $currentPage = input.required({ alias: 'currentPage' }); + /** + * A signal that holds the current page report layout. + * It is used to determine the layout of the current page report in the pagination component. + */ + $currentPageReportLayout = input<'center' | 'left'>('center', { + alias: 'currentPageReportLayout' + }); + /** * An output signal that emits when the previous page button is clicked. */ 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 f8c09e9d590d..ccc2e97d92f6 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 @@ -1,21 +1,41 @@ - +@let pagination = store.pagination(); +@let hitText = $hitText(); +@let showPagination = store.totalPages() > 1; + + - + - {{ 'dot.file.relationship.field.table.title' | dm }} - {{ 'dot.file.relationship.field.table.language' | dm }} - {{ 'dot.file.relationship.field.table.state' | dm }} + + + {{ 'dot.file.relationship.field.table.title' | dm }} + + + {{ 'dot.file.relationship.field.table.language' | dm }} + + + {{ 'dot.file.relationship.field.table.state' | dm }} + + + + - +

{{ 'dot.file.relationship.field.empty.message' | dm }}

@@ -23,13 +43,50 @@ - - - {{ item.title }} - {{ item.language }} - {{ item.state }} + + + + + + +

{{ item.title }}

+ + {{ item.language }} + + + + + +
+ @if (hitText || showPagination) { + +
+ @if (hitText) { + {{ hitText }} + } + @if (showPagination) { + + } +
+
+ } - + diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component.scss index 82bedffdcf4a..a213bc8cdabd 100644 --- a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component.scss +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component.scss @@ -2,12 +2,23 @@ ::ng-deep { p-table { - .p-datatable-relationship { - border: 1px solid $color-palette-gray-300; - border-radius: $border-radius-md; + .p-datatable-relationship.p-datatable-sm { .p-datatable-header { background-color: $color-palette-gray-100; } + .p-datatable-wrapper { + border: 1px solid $color-palette-gray-300; + border-radius: $border-radius-md; + } + .p-paginator { + display: none; + } + .p-datatable-footer { + border: 0; + padding-left: 0; + padding-right: 0; + font-weight: lighter; + } } } } 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 a11efaa55429..d413299dc68c 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 @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, + computed, forwardRef, inject, input, @@ -10,6 +11,7 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { MenuItem } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; +import { ChipModule } from 'primeng/chip'; import { DialogService } from 'primeng/dynamicdialog'; import { MenuModule } from 'primeng/menu'; import { TableModule } from 'primeng/table'; @@ -19,6 +21,7 @@ import { 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 { DotMessagePipe } from '@dotcms/ui'; +import { PaginationComponent } from './components/pagination/pagination.component'; import { RelationshipFieldStore } from './store/relationship-field.store'; @Component({ @@ -29,7 +32,9 @@ import { RelationshipFieldStore } from './store/relationship-field.store'; ButtonModule, MenuModule, DotSelectExistingContentComponent, - DotMessagePipe + DotMessagePipe, + ChipModule, + PaginationComponent ], providers: [ RelationshipFieldStore, @@ -92,6 +97,16 @@ export class DotEditContentRelationshipFieldComponent implements ControlValueAcc */ $field = input.required({ alias: 'field' }); + /** + * A computed signal that holds the hint text for the relationship field. + * This text is displayed in the table header to provide additional information about the field. + */ + $hitText = computed(() => { + const field = this.$field(); + + return field.hint || null; + }); + /** * Set the value of the field. * If the value is empty, nothing happens. @@ -134,4 +149,13 @@ export class DotEditContentRelationshipFieldComponent implements ControlValueAcc * A callback function that is called when the field is touched. */ private onTouched: (() => void) | null = null; + + /** + * Deletes an item from the store at the specified index. + * + * @param index - The index of the item to delete. + */ + deleteItem(id: string) { + this.store.deleteItem(id); + } } 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 new file mode 100644 index 000000000000..d0edddf7596b --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/models/relationship.models.ts @@ -0,0 +1,12 @@ +export interface RelationshipFieldItem { + id: string; + title: string; + language: string; + description: string; + step: string; + lastUpdate: string; + state: { + label: string; + styleClass: string; + }; +} 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 new file mode 100644 index 000000000000..410ff294095d --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/services/relationship-field.service.ts @@ -0,0 +1,100 @@ +import { faker } from '@faker-js/faker'; +import { Observable, of } from 'rxjs'; + +import { Injectable } from '@angular/core'; + +import { DotCMSContentlet } from '@dotcms/dotcms-models'; +import { RelationshipFieldItem } from '@dotcms/edit-content/fields/dot-edit-content-relationship-field/models/relationship.models'; + +@Injectable({ + providedIn: 'root' +}) +export class RelationshipFieldService { + /** + * Gets relationship content items + * @returns Observable of RelationshipFieldItem array + */ + getContent(count = 100): Observable { + const contentlets = this.#generateMockContentlets(count); + const relationshipContent = this.#mapContentletsToRelationshipItems(contentlets); + + return of(relationshipContent); + } + + /** + * Generates mock contentlets for testing purposes + * @returns Array of DotCMSContentlet + */ + #generateMockContentlets(count: number): DotCMSContentlet[] { + return faker.helpers.multiple(() => this.#createMockContentlet(), { count }); + } + + /** + * Creates a single mock contentlet + * @returns DotCMSContentlet + */ + #createMockContentlet(): DotCMSContentlet { + return { + identifier: faker.string.uuid(), + inode: faker.string.uuid(), + title: faker.lorem.words({ min: 1, max: 10 }), + contentType: faker.helpers.arrayElement(['News', 'Blog', 'Product', 'Event']), + baseType: 'CONTENT', + languageId: faker.number.int({ min: 1, max: 5 }), + folder: faker.system.directoryPath(), + hostName: faker.internet.domainName(), + modUser: faker.internet.userName(), + modDate: faker.date.recent().toISOString(), + owner: faker.internet.userName(), + sortOrder: faker.number.int({ min: 1, max: 100 }), + live: faker.datatype.boolean(), + working: true, + archived: false, + locked: faker.datatype.boolean(), + hasLiveVersion: faker.datatype.boolean(), + hasTitleImage: faker.datatype.boolean(), + url: faker.internet.url(), + titleImage: faker.image.url(), + stInode: faker.string.uuid(), + host: faker.internet.domainName(), + modUserName: faker.internet.userName() + }; + } + + /** + * Maps contentlets to relationship field items + * @param contentlets Array of DotCMSContentlet to be mapped + * @returns Array of RelationshipFieldItem + */ + #mapContentletsToRelationshipItems(contentlets: DotCMSContentlet[]): RelationshipFieldItem[] { + return contentlets.map((content) => ({ + id: content.identifier, + title: content.title, + language: faker.helpers.arrayElement([ + 'English (en-us)', + 'Spanish (es-es)', + 'French (fr-fr)' + ]), + state: this.#getRandomState(), + description: faker.lorem.sentence(3), + step: faker.helpers.arrayElement(['Published', 'Editing', 'Archived', 'QA', 'New']), + lastUpdate: content.modDate + })); + } + + #getRandomState(): { label: string; styleClass: string } { + const label = faker.helpers.arrayElement(['Changed', 'Published', 'Draft', 'Archived']); + + const styleClasses = { + Changed: 'p-chip-sm p-chip-blue', + Published: 'p-chip-sm p-chip-success', + Draft: 'p-chip-sm p-chip-warning', + Archived: 'p-chip-sm p-chip-error' + }; + + return { + label, + styleClass: styleClasses[label] + }; + } +} 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 b7d0440cf54e..2cd8977cfa54 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 @@ -1,13 +1,23 @@ -import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { tapResponse } from '@ngrx/operators'; +import { + patchState, + signalStore, + withComputed, + withHooks, + withMethods, + withState +} from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { pipe } from 'rxjs'; + +import { computed, inject } from '@angular/core'; + +import { switchMap, tap } from 'rxjs/operators'; import { ComponentStatus } from '@dotcms/dotcms-models'; -export interface RelationshipFieldItem { - id: string; - title: string; - language: string; - state: string; -} +import { RelationshipFieldItem } from '../models/relationship.models'; +import { RelationshipFieldService } from '../services/relationship-field.service'; export interface RelationshipFieldState { data: RelationshipFieldItem[]; @@ -25,7 +35,7 @@ const initialState: RelationshipFieldState = { pagination: { offset: 0, currentPage: 1, - rowsPerPage: 10 + rowsPerPage: 6 } }; @@ -34,12 +44,93 @@ const initialState: RelationshipFieldState = { * This store manages the state and actions related to the relationship field. */ export const RelationshipFieldStore = signalStore( + { providedIn: 'root' }, withState(initialState), - withMethods((store) => ({ - setData(data: RelationshipFieldItem[]) { - patchState(store, { - data - }); + withComputed((state) => ({ + totalPages: computed(() => Math.ceil(state.data().length / state.pagination().rowsPerPage)) + })), + withMethods((store) => { + const relationshipFieldService = inject(RelationshipFieldService); + + return { + /** + * Sets the data in the state. + * @param {RelationshipFieldItem[]} data - The data to be set. + */ + setData(data: RelationshipFieldItem[]) { + patchState(store, { + 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] + }); + }, + /** + * Deletes an item from the store at the specified index. + * @param index - The index of the item to delete. + */ + deleteItem(id: string) { + patchState(store, { + data: store.data().filter((item) => item.id !== id) + }); + }, + /** + * Loads the data for the relationship field by fetching content from the service. + * It updates the state with the loaded data and sets the status to LOADED. + */ + loadData: rxMethod( + pipe( + tap(() => patchState(store, { status: ComponentStatus.LOADING })), + switchMap(() => + relationshipFieldService.getContent(10).pipe( + tapResponse({ + next: (data) => + patchState(store, { data, status: ComponentStatus.LOADED }), + error: () => patchState(store, { status: ComponentStatus.ERROR }) + }) + ) + ) + ) + ), + /** + * Advances the pagination to the next page and updates the state accordingly. + */ + nextPage: () => { + patchState(store, { + pagination: { + ...store.pagination(), + offset: store.pagination().offset + store.pagination().rowsPerPage, + currentPage: store.pagination().currentPage + 1 + } + }); + }, + /** + * Moves the pagination to the previous page and updates the state accordingly. + */ + previousPage: () => { + patchState(store, { + pagination: { + ...store.pagination(), + offset: store.pagination().offset - store.pagination().rowsPerPage, + currentPage: store.pagination().currentPage - 1 + } + }); + } + }; + }), + withHooks({ + onInit: (store) => { + store.loadData(); } - })) + }) ); diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index e5486c3538be..f35021eb43b2 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -1221,7 +1221,7 @@ dot.file.field.import.from.url.error.file.not.supported.message=This type of fil dot.file.field.no.link.found=No link found dot.file.relationship.dialog.select.existing.content=Select Existing Content dot.file.relationship.dialog.per.page={0} per page -dot.file.relationship.dialog.selected.items=Selected {0} items +dot.file.relationship.dialog.selected.items=Show selected entries: {0} dot.file.relationship.dialog.apply.one.entry=Apply 1 entry dot.file.relationship.dialog.apply.entries=Apply {0} entries dot.file.relationship.field.empty.message=Relate content by clicking on the Plus Button