Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Save & show relationships #31085

Merged
merged 17 commits into from
Jan 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion core-web/libs/dotcms-models/src/lib/dot-contentlet.model.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -18,7 +21,7 @@ export interface DotCMSContentlet {
inode: string;
image?: string;
languageId: number;
language?: string;
language?: string | DotLanguage;
nicobytes marked this conversation as resolved.
Show resolved Hide resolved
live: boolean;
locked: boolean;
mimeType?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ <h3 class="m-0 text-gray-700">{{ field.name }}</h3>
<dot-edit-content-relationship-field
[formControlName]="field.variable"
[attr.data-testId]="'field-' + field.variable"
[field]="field" />
[field]="field"
[contentlet]="contentlet" />
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,34 @@
<p-tableHeaderCheckbox />
}
</th>
<th scope="col" class="max-w-12rem" [pSortableColumn]="'title'">
<span class="capitalize">
{{ 'dot.file.relationship.dialog.table.title' | dm }}
</span>
<p-sortIcon [field]="'title'" />
</th>
@for (column of columns; track $index) {
<th class="max-w-12rem" scope="col" [pSortableColumn]="column.field">
<span class="capitalize">{{ column.header }}</span>
<p-sortIcon [field]="column.field" />
</th>
}
<th scope="col" class="max-w-12rem" [pSortableColumn]="'language'">
<span class="capitalize">
{{ 'dot.file.relationship.dialog.table.language' | dm }}
</span>
</th>
<th scope="col" class="max-w-12rem" [pSortableColumn]="'state'">
<span class="capitalize">
{{ 'dot.file.relationship.dialog.table.state' | dm }}
</span>
</th>
<th scope="col" class="max-w-12rem" [pSortableColumn]="'modDate'">
<span class="capitalize">
{{ 'dot.file.relationship.dialog.table.last.modified' | dm }}
</span>
<p-sortIcon [field]="'modDate'" />
</th>
</tr>
</ng-template>
<ng-template pTemplate="body" let-item let-columns="columns">
Expand All @@ -84,11 +106,26 @@
<p-tableRadioButton [value]="item" />
}
</td>
<td class="max-w-12rem">
<p class="truncate-text">{{ item.title }}</p>
</td>
@for (column of columns; track $index) {
<td class="max-w-12rem">
<p class="truncate-text">{{ item[column.field] }}</p>
</td>
}
<td class="max-w-12rem">
<p class="truncate-text">{{ item.language | language }}</p>
</td>
<td class="max-w-12rem">
@let status = item | contentletStatus;
<p-chip
[styleClass]="'p-chip-sm ' + status.classes"
[label]="status.label" />
</td>
<td class="max-w-12rem">
<p class="truncate-text">{{ item.modDate | date: 'longDate' }}</p>
</td>
</tr>
</ng-template>
</p-table>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,26 +18,32 @@ 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', () => {
let spectator: Spectator<DotSelectExistingContentComponent>;
let store: InstanceType<typeof ExistingContentStore>;
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'
Expand All @@ -46,7 +52,8 @@ describe('DotSelectExistingContentComponent', () => {
const mockDialogConfig = {
data: {
contentTypeId: 'test-content-type-id',
selectionMode: 'multiple'
selectionMode: 'multiple',
currentItemsIds: []
}
};

Expand Down Expand Up @@ -83,15 +90,19 @@ describe('DotSelectExistingContentComponent', () => {
spectator.component.ngOnInit();
expect(spy).toHaveBeenCalledWith({
contentTypeId: 'test-content-type-id',
selectionMode: 'multiple'
selectionMode: 'multiple',
currentItemsIds: []
});
});
});
});

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();
Expand All @@ -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);
Expand All @@ -137,15 +151,18 @@ 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();
expect(label).toBe('Apply 1 entry');
});

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();
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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({
Expand All @@ -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'],
Expand Down Expand Up @@ -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<RelationshipFieldItem[] | RelationshipFieldItem | null>(null);
$selectedItems = model<DotCMSContentlet[] | DotCMSContentlet | null>(null);

/**
* A computed signal that holds the items.
Expand Down Expand Up @@ -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<RelationshipFieldItem[]>();
constructor() {
effect(
() => {
this.$selectedItems.set(this.store.selectedItems());
},
{
allowSignalWrites: true
}
);
}

ngOnInit() {
const data: DialogData = this.#dialogConfig.data;
Expand All @@ -138,7 +153,8 @@ export class DotSelectExistingContentComponent implements OnInit {

this.store.initLoad({
contentTypeId: data.contentTypeId,
selectionMode: data.selectionMode
selectionMode: data.selectionMode,
currentItemsIds: data.currentItemsIds
});
}

Expand All @@ -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);
}
}
Loading
Loading