diff --git a/core-web/libs/dotcms-models/src/lib/dot-content-types.model.ts b/core-web/libs/dotcms-models/src/lib/dot-content-types.model.ts index a56a1895e959..6534e53ba2a7 100644 --- a/core-web/libs/dotcms-models/src/lib/dot-content-types.model.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-content-types.model.ts @@ -61,6 +61,7 @@ export interface DotCMSContentTypeField { variable: string; forceIncludeInApi?: boolean; fieldContentTypeProperties?: string[]; + skipRelationshipCreation?: boolean; metadata?: { [key: string]: string | number | boolean }; } diff --git a/core-web/libs/dotcms-scss/shared/_colors.scss b/core-web/libs/dotcms-scss/shared/_colors.scss index 441cea8acc30..a97692781437 100644 --- a/core-web/libs/dotcms-scss/shared/_colors.scss +++ b/core-web/libs/dotcms-scss/shared/_colors.scss @@ -226,7 +226,17 @@ $success: $color-accessible-text-green; --color-palette-primary-800: hsl(var(--color-primary-h) var(--color-primary-s) 27%); --color-palette-primary-900: hsl(var(--color-primary-h) var(--color-primary-s) 21%); - --color-palette-primary-op-10: hsla(var(--color-primary-h), var(--color-primary-s), 60%, 0.1); + --primary-100: var(--color-palette-primary-100); + --primary-200: var(--color-palette-primary-200); + --primary-300: var(--color-palette-primary-300); + --primary-400: var(--color-palette-primary-400); + --primary-500: var(--color-palette-primary-500); + --primary-600: var(--color-palette-primary-600); + --primary-700: var(--color-palette-primary-700); + --primary-800: var(--color-palette-primary-800); + --primary-900: var(--color-palette-primary-900); + + --color-palette-primary-op-10: var(--color-palette-primary-op-10); --color-palette-primary-op-20: hsla(var(--color-primary-h), var(--color-primary-s), 60%, 0.2); --color-palette-primary-op-30: hsla(var(--color-primary-h), var(--color-primary-s), 60%, 0.3); --color-palette-primary-op-40: hsla(var(--color-primary-h), var(--color-primary-s), 60%, 0.4); 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 84e0eeb93eb8..115d82c9bd5a 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 @@ -142,6 +142,14 @@ [field]="field" /> } } + @case (fieldTypes.RELATIONSHIP) { + @defer (on immediate) { + + } + } } @if (field.hint) { {{ 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 9b8ab39beb1d..874b2ba95039 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 @@ -34,6 +34,7 @@ import { DotEditContentJsonFieldComponent } from '../../fields/dot-edit-content- import { DotEditContentKeyValueComponent } from '../../fields/dot-edit-content-key-value/dot-edit-content-key-value.component'; import { DotEditContentMultiSelectFieldComponent } from '../../fields/dot-edit-content-multi-select-field/dot-edit-content-multi-select-field.component'; import { DotEditContentRadioFieldComponent } from '../../fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component'; +import { DotEditContentRelationshipFieldComponent } from '../../fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component'; import { DotEditContentSelectFieldComponent } from '../../fields/dot-edit-content-select-field/dot-edit-content-select-field.component'; import { DotEditContentTagFieldComponent } from '../../fields/dot-edit-content-tag-field/dot-edit-content-tag-field.component'; import { DotEditContentTextAreaComponent } from '../../fields/dot-edit-content-text-area/dot-edit-content-text-area.component'; @@ -73,6 +74,9 @@ declare module '@tiptap/core' { const FIELD_TYPES_COMPONENTS: Record | DotEditFieldTestBed> = { // We had to use unknown because components have different types. [FIELD_TYPES.TEXT]: DotEditContentTextFieldComponent, + [FIELD_TYPES.RELATIONSHIP]: { + component: DotEditContentRelationshipFieldComponent + }, [FIELD_TYPES.FILE]: { component: DotEditContentFileFieldComponent, providers: [ diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts index 511ad1382b37..86ffe887a20a 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-field/dot-edit-content-field.component.ts @@ -3,24 +3,25 @@ import { ControlContainer, ReactiveFormsModule } from '@angular/forms'; import { BlockEditorModule } from '@dotcms/block-editor'; import { DotCMSContentlet, DotCMSContentTypeField } from '@dotcms/dotcms-models'; +import { DotEditContentBinaryFieldComponent } from '@dotcms/edit-content/fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component'; +import { DotEditContentCalendarFieldComponent } from '@dotcms/edit-content/fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component'; +import { DotEditContentCategoryFieldComponent } from '@dotcms/edit-content/fields/dot-edit-content-category-field/dot-edit-content-category-field.component'; +import { DotEditContentCheckboxFieldComponent } from '@dotcms/edit-content/fields/dot-edit-content-checkbox-field/dot-edit-content-checkbox-field.component'; +import { DotEditContentCustomFieldComponent } from '@dotcms/edit-content/fields/dot-edit-content-custom-field/dot-edit-content-custom-field.component'; +import { DotEditContentFileFieldComponent } from '@dotcms/edit-content/fields/dot-edit-content-file-field/dot-edit-content-file-field.component'; +import { DotEditContentHostFolderFieldComponent } from '@dotcms/edit-content/fields/dot-edit-content-host-folder-field/dot-edit-content-host-folder-field.component'; +import { DotEditContentJsonFieldComponent } from '@dotcms/edit-content/fields/dot-edit-content-json-field/dot-edit-content-json-field.component'; +import { DotEditContentKeyValueComponent } from '@dotcms/edit-content/fields/dot-edit-content-key-value/dot-edit-content-key-value.component'; +import { DotEditContentMultiSelectFieldComponent } from '@dotcms/edit-content/fields/dot-edit-content-multi-select-field/dot-edit-content-multi-select-field.component'; +import { DotEditContentRadioFieldComponent } from '@dotcms/edit-content/fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component'; +import { DotEditContentRelationshipFieldComponent } from '@dotcms/edit-content/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component'; +import { DotEditContentSelectFieldComponent } from '@dotcms/edit-content/fields/dot-edit-content-select-field/dot-edit-content-select-field.component'; +import { DotEditContentTagFieldComponent } from '@dotcms/edit-content/fields/dot-edit-content-tag-field/dot-edit-content-tag-field.component'; +import { DotEditContentTextAreaComponent } from '@dotcms/edit-content/fields/dot-edit-content-text-area/dot-edit-content-text-area.component'; +import { DotEditContentTextFieldComponent } from '@dotcms/edit-content/fields/dot-edit-content-text-field/dot-edit-content-text-field.component'; +import { DotEditContentWYSIWYGFieldComponent } from '@dotcms/edit-content/fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component'; import { DotFieldRequiredDirective } from '@dotcms/ui'; -import { DotEditContentBinaryFieldComponent } from '../../fields/dot-edit-content-binary-field/dot-edit-content-binary-field.component'; -import { DotEditContentCalendarFieldComponent } from '../../fields/dot-edit-content-calendar-field/dot-edit-content-calendar-field.component'; -import { DotEditContentCategoryFieldComponent } from '../../fields/dot-edit-content-category-field/dot-edit-content-category-field.component'; -import { DotEditContentCheckboxFieldComponent } from '../../fields/dot-edit-content-checkbox-field/dot-edit-content-checkbox-field.component'; -import { DotEditContentCustomFieldComponent } from '../../fields/dot-edit-content-custom-field/dot-edit-content-custom-field.component'; -import { DotEditContentFileFieldComponent } from '../../fields/dot-edit-content-file-field/dot-edit-content-file-field.component'; -import { DotEditContentHostFolderFieldComponent } from '../../fields/dot-edit-content-host-folder-field/dot-edit-content-host-folder-field.component'; -import { DotEditContentJsonFieldComponent } from '../../fields/dot-edit-content-json-field/dot-edit-content-json-field.component'; -import { DotEditContentKeyValueComponent } from '../../fields/dot-edit-content-key-value/dot-edit-content-key-value.component'; -import { DotEditContentMultiSelectFieldComponent } from '../../fields/dot-edit-content-multi-select-field/dot-edit-content-multi-select-field.component'; -import { DotEditContentRadioFieldComponent } from '../../fields/dot-edit-content-radio-field/dot-edit-content-radio-field.component'; -import { DotEditContentSelectFieldComponent } from '../../fields/dot-edit-content-select-field/dot-edit-content-select-field.component'; -import { DotEditContentTagFieldComponent } from '../../fields/dot-edit-content-tag-field/dot-edit-content-tag-field.component'; -import { DotEditContentTextAreaComponent } from '../../fields/dot-edit-content-text-area/dot-edit-content-text-area.component'; -import { DotEditContentTextFieldComponent } from '../../fields/dot-edit-content-text-field/dot-edit-content-text-field.component'; -import { DotEditContentWYSIWYGFieldComponent } from '../../fields/dot-edit-content-wysiwyg-field/dot-edit-content-wysiwyg-field.component'; import { CALENDAR_FIELD_TYPES } from '../../models/dot-edit-content-field.constant'; import { FIELD_TYPES } from '../../models/dot-edit-content-field.enum'; @@ -54,10 +55,10 @@ import { FIELD_TYPES } from '../../models/dot-edit-content-field.enum'; DotEditContentCategoryFieldComponent, DotFieldRequiredDirective, BlockEditorModule, - DotEditContentBinaryFieldComponent, DotEditContentKeyValueComponent, DotEditContentWYSIWYGFieldComponent, - DotEditContentFileFieldComponent + DotEditContentFileFieldComponent, + DotEditContentRelationshipFieldComponent ] }) export class DotEditContentFieldComponent { diff --git a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/utils.ts b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/utils.ts index 8a9d760c60af..d879cde01f78 100644 --- a/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/utils.ts +++ b/core-web/libs/edit-content/src/lib/components/dot-edit-content-form/utils.ts @@ -62,5 +62,6 @@ export const resolutionValue: Record = { } return field.defaultValue ?? []; - } + }, + [FIELD_TYPES.RELATIONSHIP]: defaultResolutionFn }; 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 new file mode 100644 index 000000000000..1be37dd3eabb --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/pagination/pagination.component.html @@ -0,0 +1,26 @@ +@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/components/pagination/pagination.component.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/pagination/pagination.component.ts new file mode 100644 index 000000000000..bc683e77c09a --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/pagination/pagination.component.ts @@ -0,0 +1,33 @@ +import { Component, input, output } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; + +@Component({ + selector: 'dot-pagination', + standalone: true, + imports: [ButtonModule], + templateUrl: './pagination.component.html' +}) +export class PaginationComponent { + /** + * A signal that holds the total number of pages. + * It is used to display the total number of pages in the pagination component. + */ + $totalPages = input.required({ alias: 'totalPages' }); + + /** + * A signal that holds the current page number. + * It is used to display the current page number in the pagination component. + */ + $currentPage = input.required({ alias: 'currentPage' }); + + /** + * An output signal that emits when the previous page button is clicked. + */ + previousPage = output(); + + /** + * An output signal that emits when the next page button is clicked. + */ + nextPage = output(); +} diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/search.compoment.html b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/search.compoment.html new file mode 100644 index 000000000000..afb2448dd39a --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/search.compoment.html @@ -0,0 +1,50 @@ + + + + + + +
+
+
+ + + +
+ +
+ + + +
+
+
+
diff --git a/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/search.compoment.ts b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/search.compoment.ts new file mode 100644 index 000000000000..6a64d8187821 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/components/search/search.compoment.ts @@ -0,0 +1,44 @@ +import { Component, inject, output } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { DropdownModule } from 'primeng/dropdown'; +import { InputGroupModule } from 'primeng/inputgroup'; +import { InputTextModule } from 'primeng/inputtext'; +import { OverlayPanelModule } from 'primeng/overlaypanel'; + +import { DotMessagePipe } from '@dotcms/ui'; + +@Component({ + selector: 'dot-search', + standalone: true, + imports: [ + InputTextModule, + ButtonModule, + InputGroupModule, + OverlayPanelModule, + DotMessagePipe, + DropdownModule, + ReactiveFormsModule + ], + templateUrl: './search.compoment.html' +}) +export class SearchComponent { + /** + * An output signal that emits when the search input is changed. + */ + onSearch = output(); + + /** + * Injects FormBuilder to create form control groups. + */ + readonly #formBuilder = inject(FormBuilder); + + /** + * Initializes the form group with default values for language and site. + */ + readonly form = this.#formBuilder.group({ + language: [''], + site: [''] + }); +} 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 new file mode 100644 index 000000000000..0d942a65b77d --- /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.html @@ -0,0 +1,118 @@ +@let data = store.data(); +@let pagination = store.pagination(); + + + + +
+
+
+ +
+
+

+ {{ + 'dot.file.relationship.dialog.per.page' + | dm: [pagination.rowsPerPage.toString()] + }} +

+
+
+
+ +
+
+
+ + + + + Title + + + + Step + + + + Description + + + + Last Update + + + Menu + + + + + +
+

Relate content by clicking on the Plus Button

+
+ + +
+ + + + + + +

{{ item.title }}

+ + {{ item.step }} + +

{{ item.description }}

+ + {{ item.lastUpdate | date }} + + + + +
+
+ +
+
+

+ {{ + 'dot.file.relationship.dialog.selected.items' + | dm: [$selectedItems().length.toString()] + }} +

+
+
+ + +
+
+
+
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.scss b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/dot-select-existing-content.component.scss new file mode 100644 index 000000000000..c34af3986231 --- /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.scss @@ -0,0 +1,28 @@ +@use "variables" as *; + +::ng-deep { + p-table { + .p-datatable-sm.p-datatable-existing-content { + .p-datatable-header { + background-color: $white; + border: 0px; + padding-left: 0px; + padding-right: 0px; + } + .p-datatable-table { + border: 1px solid $color-palette-gray-300; + border-radius: $border-radius-md; + overflow: hidden; + } + .p-datatable-thead { + th { + font-weight: $font-weight-bold; + background-color: $color-palette-gray-100; + } + } + .p-inputtext { + height: auto; + } + } + } +} 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 new file mode 100644 index 000000000000..bb471f25be86 --- /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.ts @@ -0,0 +1,98 @@ +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, computed, inject, model } from '@angular/core'; + +import { ButtonModule } from 'primeng/button'; +import { DialogModule } from 'primeng/dialog'; +import { IconFieldModule } from 'primeng/iconfield'; +import { InputGroupModule } from 'primeng/inputgroup'; +import { InputIconModule } from 'primeng/inputicon'; +import { InputTextModule } from 'primeng/inputtext'; +import { MenuModule } from 'primeng/menu'; +import { OverlayPanelModule } from 'primeng/overlaypanel'; +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'; + +@Component({ + selector: 'dot-select-existing-content', + standalone: true, + imports: [ + TableModule, + ButtonModule, + MenuModule, + DotMessagePipe, + DialogModule, + IconFieldModule, + InputIconModule, + InputTextModule, + DatePipe, + PaginationComponent, + InputGroupModule, + OverlayPanelModule, + SearchComponent + ], + templateUrl: './dot-select-existing-content.component.html', + styleUrls: ['./dot-select-existing-content.component.scss'], + providers: [ExistingContentStore], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotSelectExistingContentComponent { + /** + * A readonly instance of the ExistingContentStore injected into the component. + * This store is used to manage the state and actions related to the existing content. + */ + readonly store = inject(ExistingContentStore); + + /** + * A readonly instance of the DotMessageService injected into the component. + * This service is used to get localized messages. + */ + readonly #dotMessage = inject(DotMessageService); + + /** + * A signal that controls the visibility of the existing content dialog. + * When true, the dialog is shown allowing users to select existing content. + * When false, the dialog is hidden. + */ + $visible = model(false, { alias: 'visible' }); + + /** + * A signal that holds the selected items. + * It is used to store the selected content items. + */ + $selectedItems = model([]); + + /** + * A computed signal that determines if the apply button is disabled. + * It is disabled when no items are selected. + */ + $isApplyDisabled = computed(() => this.$selectedItems().length === 0); + + /** + * A computed signal that determines the label for the apply button. + * It is used to display the appropriate message based on the number of selected items. + */ + $applyLabel = computed(() => { + const selectedItems = this.$selectedItems(); + + const messageKey = + selectedItems.length === 1 + ? 'dot.file.relationship.dialog.apply.one.entry' + : 'dot.file.relationship.dialog.apply.entries'; + + return this.#dotMessage.get(messageKey, selectedItems.length.toString()); + }); + + /** + * A method that closes the existing content dialog. + * It sets the visibility signal to false, hiding the dialog. + */ + closeDialog() { + this.$visible.set(false); + } +} 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 new file mode 100644 index 000000000000..88ff348283d2 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/components/dot-select-existing-content/store/existing-content.store.ts @@ -0,0 +1,90 @@ +import { faker } from '@faker-js/faker'; +import { + patchState, + signalStore, + withComputed, + withHooks, + withMethods, + withState +} from '@ngrx/signals'; + +import { computed } from '@angular/core'; + +import { ComponentStatus } from '@dotcms/dotcms-models'; + +export interface Content { + id: string; + title: string; + step: string; + description: string; + lastUpdate: string; +} + +export interface ExistingContentState { + data: Content[]; + status: ComponentStatus; + pagination: { + offset: number; + currentPage: number; + rowsPerPage: number; + }; +} + +const initialState: ExistingContentState = { + data: [], + status: ComponentStatus.INIT, + pagination: { + offset: 0, + currentPage: 1, + rowsPerPage: 50 + } +}; + +/** + * Store for the ExistingContent component. + * This store manages the state and actions related to the existing content. + */ +export const ExistingContentStore = signalStore( + withState(initialState), + withComputed((state) => ({ + 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 + } + }); + } + })), + withHooks({ + onInit: (store) => { + store.loadContent(); + } + }) +); 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 new file mode 100644 index 000000000000..f8c09e9d590d --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component.html @@ -0,0 +1,35 @@ + + + + + + {{ '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 }}

+
+ + +
+ + + {{ item.title }} + {{ item.language }} + {{ item.state }} + + +
+ + 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 new file mode 100644 index 000000000000..82bedffdcf4a --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component.scss @@ -0,0 +1,24 @@ +@use "variables" as *; + +::ng-deep { + p-table { + .p-datatable-relationship { + border: 1px solid $color-palette-gray-300; + border-radius: $border-radius-md; + .p-datatable-header { + background-color: $color-palette-gray-100; + } + } + } +} +.add-item-btn { + position: absolute; + top: 0.4rem; + right: 0.4rem; +} +.relationship-field__table_header { + th { + font-weight: $font-weight-bold; + background-color: $color-palette-gray-100; + } +} 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 new file mode 100644 index 000000000000..a11efaa55429 --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/dot-edit-content-relationship-field.component.ts @@ -0,0 +1,137 @@ +import { + ChangeDetectionStrategy, + Component, + forwardRef, + inject, + input, + signal +} from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { MenuItem } from 'primeng/api'; +import { ButtonModule } from 'primeng/button'; +import { DialogService } from 'primeng/dynamicdialog'; +import { MenuModule } from 'primeng/menu'; +import { TableModule } from 'primeng/table'; + +import { DotMessageService } from '@dotcms/data-access'; +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 { RelationshipFieldStore } from './store/relationship-field.store'; + +@Component({ + selector: 'dot-edit-content-relationship-field', + standalone: true, + imports: [ + TableModule, + ButtonModule, + MenuModule, + DotSelectExistingContentComponent, + DotMessagePipe + ], + providers: [ + RelationshipFieldStore, + DialogService, + { + multi: true, + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DotEditContentRelationshipFieldComponent) + } + ], + templateUrl: './dot-edit-content-relationship-field.component.html', + styleUrls: ['./dot-edit-content-relationship-field.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotEditContentRelationshipFieldComponent implements ControlValueAccessor { + /** + * A readonly private field that injects the DotMessageService. + * This service is used for handling message-related functionalities within the component. + */ + readonly #dotMessageService = inject(DotMessageService); + + /** + * A signal that controls the visibility of the existing content dialog. + * When true, the dialog is shown allowing users to select existing content. + * When false, the dialog is hidden. + */ + $showExistingContentDialog = signal(false); + + /** + * A signal that holds the menu items for the relationship field. + * These items control the visibility of the existing content dialog and the creation of new content. + */ + $menuItems = signal([ + { + label: this.#dotMessageService.get( + 'dot.file.relationship.field.table.existing.content' + ), + command: () => { + this.$showExistingContentDialog.update((value) => !value); + } + }, + { + label: this.#dotMessageService.get('dot.file.relationship.field.table.new.content'), + command: () => { + // TODO: Implement new content + } + } + ]); + + /** + * A readonly instance of the RelationshipFieldStore injected into the component. + * This store is used to manage the state and actions related to the relationship field. + */ + readonly store = inject(RelationshipFieldStore); + + /** + * DotCMS Content Type Field + * + * @memberof DotEditContentFileFieldComponent + */ + $field = input.required({ alias: 'field' }); + + /** + * Set the value of the field. + * If the value is empty, nothing happens. + * If the value is not empty, the store is called to get the asset data. + * + * @param value the value to set + */ + writeValue(value: string): void { + if (!value) { + return; + } + } + /** + * Registers a callback function that is called when the control's value changes in the UI. + * This function is passed to the {@link NG_VALUE_ACCESSOR} token. + * + * @param fn The callback function to register. + */ + registerOnChange(fn: (value: string) => void) { + this.onChange = fn; + } + + /** + * Registers a callback function that is called when the control is marked as touched in the UI. + * This function is passed to the {@link NG_VALUE_ACCESSOR} token. + * + * @param fn The callback function to register. + */ + registerOnTouched(fn: () => void) { + this.onTouched = fn; + } + + /** + * A callback function that is called when the value of the field changes. + * It is used to update the value of the field in the parent component. + */ + private onChange: ((value: string) => void) | null = null; + + /** + * A callback function that is called when the field is touched. + */ + private onTouched: (() => void) | null = null; +} 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 new file mode 100644 index 000000000000..b7d0440cf54e --- /dev/null +++ b/core-web/libs/edit-content/src/lib/fields/dot-edit-content-relationship-field/store/relationship-field.store.ts @@ -0,0 +1,45 @@ +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; + +import { ComponentStatus } from '@dotcms/dotcms-models'; + +export interface RelationshipFieldItem { + id: string; + title: string; + language: string; + state: string; +} + +export interface RelationshipFieldState { + data: RelationshipFieldItem[]; + status: ComponentStatus; + pagination: { + offset: number; + currentPage: number; + rowsPerPage: number; + }; +} + +const initialState: RelationshipFieldState = { + data: [], + status: ComponentStatus.INIT, + pagination: { + offset: 0, + currentPage: 1, + rowsPerPage: 10 + } +}; + +/** + * Store for the RelationshipField component. + * This store manages the state and actions related to the relationship field. + */ +export const RelationshipFieldStore = signalStore( + withState(initialState), + withMethods((store) => ({ + setData(data: RelationshipFieldItem[]) { + patchState(store, { + data + }); + } + })) +); diff --git a/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.enum.ts b/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.enum.ts index 2f071ef11027..c23acf2ec028 100644 --- a/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.enum.ts +++ b/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.enum.ts @@ -30,5 +30,6 @@ export enum FIELD_TYPES { TEXT = 'Text', TEXTAREA = 'Textarea', TIME = 'Time', - WYSIWYG = 'WYSIWYG' + WYSIWYG = 'WYSIWYG', + RELATIONSHIP = 'Relationship' } diff --git a/core-web/libs/edit-content/src/lib/utils/mocks.ts b/core-web/libs/edit-content/src/lib/utils/mocks.ts index 036abef0a68c..94bb16290940 100644 --- a/core-web/libs/edit-content/src/lib/utils/mocks.ts +++ b/core-web/libs/edit-content/src/lib/utils/mocks.ts @@ -537,6 +537,36 @@ export const FILE_FIELD_MOCK: DotCMSContentTypeField = { hint: 'Helper label to be displayed below the field' }; +export const RELATIONSHIP_FIELD_MOCK: DotCMSContentTypeField = { + clazz: 'com.dotcms.contenttype.model.field.ImmutableRelationshipField', + contentTypeId: 'd68af52828a53805a1716e68cd902560', + dataType: 'SYSTEM', + fieldType: 'Relationship', + fieldTypeLabel: 'Relationships Field', + fieldVariables: [], + fixed: false, + forceIncludeInApi: false, + iDate: 1732655273000, + id: '306fe444c1ae1fd063c02d8812903fc9', + indexed: true, + listed: false, + modDate: 1732660181000, + name: 'Relationship Field', + readOnly: false, + relationships: { + cardinality: 0, + isParentField: true, + velocityVar: 'AllTypes' + }, + required: false, + searchable: false, + skipRelationshipCreation: false, + sortOrder: 6, + unique: false, + variable: 'relationshipField', + hint: 'Helper label to be displayed below the field' +}; + export const CUSTOM_FIELD_MOCK: DotCMSContentTypeField = { id: '64d5c84f04df900c79a94e087c6fed05', clazz: 'com.dotcms.contenttype.model.field.ImmutableCustomField', @@ -734,7 +764,8 @@ export const FIELDS_MOCK: DotCMSContentTypeField[] = [ HOST_FOLDER_TEXT_MOCK, CATEGORY_MOCK, CONSTANT_FIELD_MOCK, - HIDDEN_FIELD_MOCK + HIDDEN_FIELD_MOCK, + RELATIONSHIP_FIELD_MOCK ]; export const FIELD_MOCK: DotCMSContentTypeField = TEXT_FIELD_MOCK; diff --git a/dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/ImportContentletsProcessor.java b/dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/ImportContentletsProcessor.java index bf42bf0fc249..12d1a8b70fde 100644 --- a/dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/ImportContentletsProcessor.java +++ b/dotCMS/src/main/java/com/dotcms/jobs/business/processor/impl/ImportContentletsProcessor.java @@ -223,15 +223,15 @@ && getWorkflowActionId(parameters).isEmpty()) { * {@link JobValidationException} is thrown.

* * @param parameters The job parameters containing the fields to validate - * @param contentTypeFound The content type to validate the fields against + * @param contentType The content type to validate the fields against * @throws JobValidationException if any field specified in the parameters is not found in the content type */ - private void validateFields(final Map parameters, final ContentType contentTypeFound) { - var fields = contentTypeFound.fields(); - for (String field : getFields(parameters)) { - if (fields.stream().noneMatch(f -> Objects.equals(f.variable(), field))) { + private void validateFields(final Map parameters, final ContentType contentType) { + var contentTypeFields = contentType.fields(); + for (String providedField : getFields(parameters)) { + if (contentTypeFields.stream().noneMatch(field -> Objects.equals(field.id(), providedField))) { final var errorMessage = String.format( - "Field [%s] not found in Content Type [%s].", field, contentTypeFound.variable() + "Field [%s] not found in Content Type [%s].", providedField, contentType.variable() ); Logger.error(this, errorMessage); throw new JobValidationException(errorMessage); diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/dotimport/ContentImportParamsSchema.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/dotimport/ContentImportParamsSchema.java index 9e59db2ac3ae..4daddf0a3ac0 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/dotimport/ContentImportParamsSchema.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/content/dotimport/ContentImportParamsSchema.java @@ -28,7 +28,7 @@ public class ContentImportParamsSchema { " \"contentType\": \"activity\",\n" + " \"language\": \"en-US\",\n" + " \"workflowActionId\": \"1234\",\n" + - " \"fields\": [\"title\"]\n" + + " \"fields\": [\"e1f99107-fd0e-49d4-a099-1cc10aa284d8\"]\n" + "}" ) private String form; diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index b9bb8ab55baf..3bcd36a3b631 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -1219,6 +1219,18 @@ dot.file.field.file.dimension=Dimension dot.file.field.file.bytes=Bytes dot.file.field.import.from.url.error.file.not.supported.message=This type of file is not supported, Please import a {0} file. 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.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 +dot.file.relationship.field.table.title=Title +dot.file.relationship.field.table.language=Language +dot.file.relationship.field.table.state=State +dot.file.relationship.field.table.existing.content=Existing Content +dot.file.relationship.field.table.new.content=New Content +dot.file.relationship.dialog.search=Search dot.common.apply=Apply dot.common.archived=Archived dot.common.cancel=Cancel diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/content/dotimport/ContentImportResourceIntegrationTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/content/dotimport/ContentImportResourceIntegrationTest.java index bd50dd777cc7..398d918fa8cb 100644 --- a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/content/dotimport/ContentImportResourceIntegrationTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/content/dotimport/ContentImportResourceIntegrationTest.java @@ -57,6 +57,7 @@ public class ContentImportResourceIntegrationTest extends Junit5WeldBaseTest { private static File csvFile; private static ContentType contentType; + private static String fieldId; @Inject ContentImportHelper contentImportHelper; @@ -73,6 +74,8 @@ static void setUp() throws Exception { defaultLanguage = APILocator.getLanguageAPI().getDefaultLanguage(); contentType = TestDataUtils.getRichTextLikeContentType(); + fieldId = contentType.fields().get(0).id(); + assert fieldId != null; csvFile = createTestCsvFile(); } @@ -101,11 +104,11 @@ static void cleanup() { */ @Test public void test_import_content_with_valid_params() throws IOException, DotDataException { - ContentImportForm form = createContentImportForm(contentType.name(), String.valueOf(defaultLanguage.getId()), WORKFLOW_PUBLISH_ACTION_ID, List.of("title")); + ContentImportForm form = createContentImportForm(contentType.name(), String.valueOf(defaultLanguage.getId()), WORKFLOW_PUBLISH_ACTION_ID, List.of(fieldId)); ContentImportParams params = createContentImportParams(csvFile, form); Response importContentResponse = importResource.importContent(request, response, params); - validateSuccessfulResponse(importContentResponse, contentType.name(), String.valueOf(defaultLanguage.getId()), List.of("title"), WORKFLOW_PUBLISH_ACTION_ID, CMD_PUBLISH); + validateSuccessfulResponse(importContentResponse, contentType.name(), String.valueOf(defaultLanguage.getId()), List.of(fieldId), WORKFLOW_PUBLISH_ACTION_ID, CMD_PUBLISH); } @@ -119,11 +122,11 @@ public void test_import_content_with_valid_params() throws IOException, DotDataE */ @Test public void test_import_content_validate_with_valid_params() throws IOException, DotDataException { - ContentImportForm form = createContentImportForm(contentType.name(), String.valueOf(defaultLanguage.getId()), WORKFLOW_PUBLISH_ACTION_ID, List.of("title")); + ContentImportForm form = createContentImportForm(contentType.name(), String.valueOf(defaultLanguage.getId()), WORKFLOW_PUBLISH_ACTION_ID, List.of(fieldId)); ContentImportParams params = createContentImportParams(csvFile, form); Response importContentResponse = importResource.validateContentImport(request, response, params); - validateSuccessfulResponse(importContentResponse, contentType.name(), String.valueOf(defaultLanguage.getId()), List.of("title"), WORKFLOW_PUBLISH_ACTION_ID, CMD_PREVIEW); + validateSuccessfulResponse(importContentResponse, contentType.name(), String.valueOf(defaultLanguage.getId()), List.of(fieldId), WORKFLOW_PUBLISH_ACTION_ID, CMD_PREVIEW); } /** @@ -136,11 +139,11 @@ public void test_import_content_validate_with_valid_params() throws IOException, */ @Test public void test_import_content_with_language_iso_code() throws IOException, DotDataException { - ContentImportForm form = createContentImportForm(contentType.name(), defaultLanguage.getIsoCode(), WORKFLOW_PUBLISH_ACTION_ID, List.of("title")); + ContentImportForm form = createContentImportForm(contentType.name(), defaultLanguage.getIsoCode(), WORKFLOW_PUBLISH_ACTION_ID, List.of(fieldId)); ContentImportParams params = createContentImportParams(csvFile, form); Response importContentResponse = importResource.importContent(request, response, params); - validateSuccessfulResponse(importContentResponse, contentType.name(), defaultLanguage.getIsoCode(), List.of("title"), WORKFLOW_PUBLISH_ACTION_ID, CMD_PUBLISH); + validateSuccessfulResponse(importContentResponse, contentType.name(), defaultLanguage.getIsoCode(), List.of(fieldId), WORKFLOW_PUBLISH_ACTION_ID, CMD_PUBLISH); } @@ -154,11 +157,11 @@ public void test_import_content_with_language_iso_code() throws IOException, Dot */ @Test public void test_import_content__validate_with_language_iso_code() throws IOException, DotDataException { - ContentImportForm form = createContentImportForm(contentType.name(), defaultLanguage.getIsoCode(), WORKFLOW_PUBLISH_ACTION_ID, List.of("title")); + ContentImportForm form = createContentImportForm(contentType.name(), defaultLanguage.getIsoCode(), WORKFLOW_PUBLISH_ACTION_ID, List.of(fieldId)); ContentImportParams params = createContentImportParams(csvFile, form); Response importContentResponse = importResource.validateContentImport(request, response, params); - validateSuccessfulResponse(importContentResponse, contentType.name(), defaultLanguage.getIsoCode(), List.of("title"), WORKFLOW_PUBLISH_ACTION_ID, CMD_PREVIEW); + validateSuccessfulResponse(importContentResponse, contentType.name(), defaultLanguage.getIsoCode(), List.of(fieldId), WORKFLOW_PUBLISH_ACTION_ID, CMD_PREVIEW); } /** diff --git a/dotcms-postman/src/main/resources/postman/ContentImportResource.postman_collection.json b/dotcms-postman/src/main/resources/postman/ContentImportResource.postman_collection.json index 25cebbf18a26..45d5294c190c 100644 --- a/dotcms-postman/src/main/resources/postman/ContentImportResource.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/ContentImportResource.postman_collection.json @@ -11,7 +11,30 @@ "name": "pre-execution-scripts", "item": [ { - "name": "Create ContentType Copy", + "name": "Create ContentType", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();", + "", + "pm.test(\"Status code should be ok 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"fields check\", function () {", + " pm.expect(jsonData.entity[0].fields.length).to.eql(8);", + " pm.expect(jsonData.entity[0].fields[3].variable).to.eql('title');", + " pm.collectionVariables.set(\"fields\", JSON.stringify([jsonData.entity[0].fields[3].id]))", + "});", + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], "request": { "method": "POST", "header": [], @@ -1913,7 +1936,7 @@ }, { "key": "fields", - "value": "[\"title\"]", + "value": "", "type": "string" } ]