-
Notifications
You must be signed in to change notification settings - Fork 470
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore(edit-content): add site field to search relationships (#31298)
### 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
Showing
11 changed files
with
865 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
44 changes: 44 additions & 0 deletions
44
...select-existing-content/components/search/components/site-field/site-field.component.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
} |
7 changes: 7 additions & 0 deletions
7
...select-existing-content/components/search/components/site-field/site-field.component.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
:host { | ||
::ng-deep { | ||
.p-treeselect { | ||
width: 100%; | ||
} | ||
} | ||
} |
198 changes: 198 additions & 0 deletions
198
...ect-existing-content/components/search/components/site-field/site-field.component.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.