Skip to content

Commit

Permalink
chore(edit-content): add site field to search relationships (#31298)
Browse files Browse the repository at this point in the history
### Parent Issue

#31280 

### Proposed Changes

This pull request introduces a new `SiteFieldComponent` for selecting a
site from a tree structure in the `dot-edit-content-relationship-field`
module. The changes include the implementation of the component, its
associated styles, unit tests, and a store for managing the component's
state.

Key changes:

### Component Implementation:
*
[`site-field.component.ts`](diffhunk://#diff-e4382792ef202b90112a6556f3c1d63c21e8f7c8f747f1cb09b77d25bfbccb1bR1-R129):
Added the `SiteFieldComponent` which implements `ControlValueAccessor`
to work with Angular forms and uses PrimeNG's `TreeSelect` component for
the UI.
*
[`site-field.component.html`](diffhunk://#diff-c61c7bf501226714ed8accbb0b61a025e57c0179affa0a1fc53159f1c05b76b2R1-R44):
Created the template for the `SiteFieldComponent` using `p-treeSelect`
for the tree structure and added error handling.
*
[`site-field.component.scss`](diffhunk://#diff-06646f7c6ecfc10412b89a98dd4935436d2e7d23feea988b0f4a557018d57b63R1-R7):
Added styles to ensure the `TreeSelect` component spans the full width
of its container.

### Unit Tests:
*
[`site-field.component.spec.ts`](diffhunk://#diff-520d6e974cad3c205627cc77b5a0ec56b334303f941a74a67554c2609dabdfddR1-R198):
Added comprehensive unit tests for the `SiteFieldComponent`, covering
initial state, control value accessor implementation, and edge cases.
*
[`site-field.store.spec.ts`](diffhunk://#diff-810b776b9ac7916a02ef0ab369f0584f5018a2a7d8750e18c0ea99e8ec344cf4R1-R253):
Added unit tests for the `SiteFieldStore`, ensuring proper loading of
sites, handling of errors, and state management.

### Checklist
- [x] Tests
- [x] Translations
- [x] Security Implications Contemplated (add notes if applicable)
  • Loading branch information
nicobytes authored Feb 4, 2025
1 parent 6fb9fc7 commit 6525f41
Show file tree
Hide file tree
Showing 11 changed files with 865 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,9 @@ export class DotContentletThumbnail {
}

private getIcon() {
return this.contentlet?.baseType !== 'FILEASSET'
? this.contentlet?.contentTypeIcon
: this.contentlet?.__icon__;
return this.contentlet?.baseType === 'FILEASSET'
? this.contentlet?.__icon__
: this.contentlet?.contentTypeIcon;
}

private shouldShowVideoThumbnail() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<label for="language-field" class="mb-2 inline-block">
{{ 'dot.file.relationship.dialog.search.site.label' | dm }}
</label>

<p-treeSelect
(onNodeSelect)="store.chooseNode($event)"
(onNodeExpand)="store.loadChildren($event)"
[formControl]="siteControl"
data-testid="site-field-search"
[filter]="true"
[options]="store.tree()"
[virtualScroll]="true"
[virtualScrollItemSize]="50"
[scrollHeight]="'450px'"
[placeholder]="'dot.file.relationship.dialog.search.site.placeholder' | dm"
[virtualScrollOptions]="{
autoSize: true,
style: {
width: '100%',
height: '450px',
minHeight: '200px'
}
}"
filterBy="label"
filterMode="lenient"
selectionMode="single">
<ng-template let-node pTemplate="default">
<span>{{ node?.label | truncatePath }}</span>
</ng-template>
<ng-template let-item pTemplate="value">
@if (item?.label) {
<span>//{{ item?.label }}</span>
} @else {
<span>{{ 'dot.file.relationship.dialog.search.site.placeholder' | dm }}</span>
}
</ng-template>
</p-treeSelect>

@let error = store.error();
@if (error) {
<div class="text-red-500 text-sm mt-2">
{{ error | dm }}
</div>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
:host {
::ng-deep {
.p-treeselect {
width: 100%;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { createFakeEvent } from '@ngneat/spectator';
import { Spectator, createComponentFactory, mockProvider } from '@ngneat/spectator/jest';
import { of } from 'rxjs';

import { ReactiveFormsModule } from '@angular/forms';

import { TreeSelectModule } from 'primeng/treeselect';

import { DotMessageService } from '@dotcms/data-access';
import {
TreeNodeItem,
TreeNodeSelectItem
} from '@dotcms/edit-content/models/dot-edit-content-host-folder-field.interface';
import { TruncatePathPipe } from '@dotcms/edit-content/pipes/truncate-path.pipe';
import { DotEditContentService } from '@dotcms/edit-content/services/dot-edit-content.service';
import { DotMessagePipe } from '@dotcms/ui';
import { MockDotMessageService } from '@dotcms/utils-testing';

import { SiteFieldComponent } from './site-field.component';
import { SiteFieldStore } from './site-field.store';

describe('SiteFieldComponent', () => {
let spectator: Spectator<SiteFieldComponent>;
let component: SiteFieldComponent;
let store: InstanceType<typeof SiteFieldStore>;

const messageServiceMock = new MockDotMessageService({
'dot.file.relationship.dialog.search.language.failed': 'Failed to load languages'
});

const mockSites: TreeNodeItem[] = [
{
label: 'demo.dotcms.com',
data: {
id: '123',
hostname: 'demo.dotcms.com',
path: '',
type: 'site'
},
icon: 'pi pi-globe',
leaf: false,
children: []
}
];

const mockFolders = {
parent: {
id: 'parent-id',
hostName: 'demo.dotcms.com',
path: '/parent',
addChildrenAllowed: true
},
folders: [
{
label: 'folder1',
data: {
id: 'folder1',
hostname: 'demo.dotcms.com',
path: 'folder1',
type: 'folder' as const
},
icon: 'pi pi-folder',
leaf: true,
children: []
}
]
};

const createComponent = createComponentFactory({
component: SiteFieldComponent,
imports: [ReactiveFormsModule, TreeSelectModule, TruncatePathPipe, DotMessagePipe],
componentProviders: [SiteFieldStore],
providers: [
{ provide: DotMessageService, useValue: messageServiceMock },
mockProvider(DotEditContentService, {
getSitesTreePath: jest.fn().mockReturnValue(of(mockSites)),
getFoldersTreeNode: jest.fn().mockReturnValue(of(mockFolders))
})
]
});

beforeEach(() => {
spectator = createComponent({
detectChanges: false
});
component = spectator.component;
store = spectator.inject(SiteFieldStore, true);
});

it('should create', () => {
spectator.detectChanges();

expect(component).toBeTruthy();
});

describe('Initial State', () => {
it('should initialize with empty site control', () => {
spectator.detectChanges();

expect(component.siteControl.value).toBe('');
});

it('should load sites on init', () => {
const loadSitesSpy = jest.spyOn(store, 'loadSites');

spectator.detectChanges();

expect(loadSitesSpy).toHaveBeenCalled();
});
});

describe('ControlValueAccessor Implementation', () => {
const testValue = 'test-site-id';

it('should write value to form control', () => {
spectator.detectChanges();

component.writeValue(testValue);
expect(component.siteControl.value).toBe('');
});

it('should register onChange callback', () => {
const onChangeSpy = jest.fn();
component.registerOnChange(onChangeSpy);

const mockEvent: TreeNodeSelectItem = {
originalEvent: createFakeEvent('click'),
node: {
label: 'Test Node',
data: { id: '123', hostname: 'test.com', path: 'test', type: 'folder' }
}
};

store.chooseNode(mockEvent);
spectator.detectChanges();

expect(onChangeSpy).toHaveBeenCalledWith(mockEvent.node.data.id);
});

it('should register onTouched callback', () => {
const onTouchedSpy = jest.fn();
component.registerOnTouched(onTouchedSpy);

// Trigger touched state
component.siteControl.markAsTouched();
spectator.detectChanges();

// Note: Since we're not explicitly calling onTouched in the component,
// this test mainly verifies the registration
expect(onTouchedSpy).not.toHaveBeenCalled();
});

describe('Disabled State', () => {
it('should disable the form control', () => {
component.setDisabledState(true);
expect(component.siteControl.disabled).toBe(true);
});

it('should enable the form control', () => {
// First disable
component.setDisabledState(true);
expect(component.siteControl.disabled).toBe(true);

// Then enable
component.setDisabledState(false);
expect(component.siteControl.disabled).toBe(false);
});
});
});

describe('Edge Cases', () => {
it('should handle undefined value in writeValue', () => {
component.writeValue(undefined);
expect(component.siteControl.value).toBe('');
});

it('should handle null value in writeValue', () => {
component.writeValue(null);
expect(component.siteControl.value).toBe('');
});

it('should handle empty string in writeValue', () => {
component.writeValue('');
expect(component.siteControl.value).toBe('');
});

it('should not emit change when writeValue is called with same value', () => {
const onChangeSpy = jest.fn();
component.registerOnChange(onChangeSpy);

const testValue = 'test-value';
component.writeValue(testValue);
component.writeValue(testValue);

expect(onChangeSpy).not.toHaveBeenCalled();
});
});
});
Loading

0 comments on commit 6525f41

Please sign in to comment.