Skip to content

Commit

Permalink
feat(editor-content): implement breadcrumb navigation for categories (#…
Browse files Browse the repository at this point in the history
…29394)

### Parent Issue
#29184

### Proposed Changes
* Implement dot-collapse-breadcrumb in SideMenu for CategoryField
* Create getMenuItemsFromKeyParentPath util to get Breadcrumb navigation
* Use output()
* Use viewChild Signal
* Use model Signal


### Checklist
- [x] Tests
- [x] Translations
- [x] Security Implications Contemplated (add notes if applicable)

### References

<img width="675" alt="Screenshot 2024-07-10 at 12 16 04 PM"
src="https://github.com/dotCMS/core/assets/1909643/4e7c60f6-91ef-47ec-b8c4-68c96b8e3d01">
<img width="1022" alt="Screenshot 2024-07-10 at 12 16 12 PM"
src="https://github.com/dotCMS/core/assets/1909643/dfbd7d37-d2f2-4b26-9ead-27228d6e0795">
<img width="982" alt="Screenshot 2024-07-10 at 12 16 19 PM"
src="https://github.com/dotCMS/core/assets/1909643/838c292d-b993-41c1-a7bf-7434abe2707d">


### Screenshots


https://github.com/user-attachments/assets/602b18de-f2c3-4db7-837c-4485b56f0bc9
  • Loading branch information
nicobytes authored Aug 2, 2024
1 parent 3f7db97 commit 30d2813
Show file tree
Hide file tree
Showing 14 changed files with 554 additions and 389 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@use "variables" as *;

::ng-deep dot-crumbtrail .p-breadcrumb {
:host ::ng-deep .p-breadcrumb {
ul li {
&:first-child {
font-size: $font-size-lg;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
@if ($categories().length) {
<div class="w-full category-list__header">Root</div>
<div class="w-full category-list__header">
<dot-collapse-breadcrumb data-testId="breadcrumb-menu" [model]="$breadcrumbsMenu()" />
</div>
<div
[ngClass]="{ 'no-overflow-x-yet': $emptyColumns().length }"
class="flex-1 category-list__category-list">
Expand All @@ -16,7 +18,7 @@
[ngClass]="{ 'category-list__item--selected': item.clicked }"
(click)="rowClicked.emit({ index, item })">
<p-checkbox
[(ngModel)]="itemsSelected"
[(ngModel)]="$selected"
[value]="item.key"
(onChange)="itemChecked.emit({ selected: $event.checked, item })" />

Expand Down Expand Up @@ -50,5 +52,5 @@
}
</div>
} @else {
<dot-empty-container [configuration]="emptyState" [hideContactUsLink]="true" />
<dot-empty-container [configuration]="$emptyState()" [hideContactUsLink]="true" />
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('DotCategoryFieldCategoryListComponent', () => {
spectator.setInput('categories', CATEGORY_LIST_MOCK_TRANSFORMED_MATRIX);
spectator.setInput('selected', SELECTED_LIST_MOCK);
spectator.setInput('isLoading', false);
spectator.setInput('breadcrumbs', []);

spectator.detectChanges();
});
Expand Down Expand Up @@ -77,8 +78,6 @@ describe('DotCategoryFieldCategoryListComponent', () => {
});

it('should apply selected class to the correct item', () => {
// spectator = createComponent();

spectator.setInput('categories', [CATEGORY_MOCK_TRANSFORMED]);
spectator.setInput('selected', SELECTED_LIST_MOCK);
spectator.setInput('isLoading', false);
Expand Down Expand Up @@ -115,4 +114,54 @@ describe('DotCategoryFieldCategoryListComponent', () => {

expect(spectator.query(DotCategoryFieldListSkeletonComponent)).not.toBeNull();
});

describe('with breadcrumbs', () => {
it('should generate the breadcrumb menu according to the breadcrumb input', () => {
spectator.setInput('breadcrumbs', CATEGORY_MOCK_TRANSFORMED);
spectator.detectChanges();

expect(spectator.component.$breadcrumbsMenu().length).toBe(
CATEGORY_MOCK_TRANSFORMED.length + 1
);
});

it('should render the breadcrumbs menu', () => {
spectator.setInput('breadcrumbs', []);
spectator.detectChanges();

const breadcrumbs = spectator.queryAll('dot-collapse-breadcrumb .p-menuitem-link');

expect(breadcrumbs.length).toBe(1);
});

it('should emit the correct item when root breadcrumb clicked', () => {
spectator.setInput('breadcrumbs', []);
spectator.detectChanges();

const emitSpy = jest.spyOn(spectator.component.rowClicked, 'emit');
const breadcrumbs = spectator.queryAll('dot-collapse-breadcrumb .p-menuitem-link');
spectator.click(breadcrumbs[0]);

expect(emitSpy).toHaveBeenCalledWith({ index: 0 });
});

it('should render the correct number of breadcrumbs', () => {
spectator.setInput('breadcrumbs', CATEGORY_MOCK_TRANSFORMED);
spectator.detectChanges();
const breadcrumbs = spectator.queryAll('dot-collapse-breadcrumb .p-menuitem-link');

expect(breadcrumbs.length).toBe(CATEGORY_MOCK_TRANSFORMED.length + 1);
});

it('should emit the correct item when breadcrumb clicked', () => {
spectator.setInput('breadcrumbs', CATEGORY_MOCK_TRANSFORMED);
spectator.detectChanges();

const emitSpy = jest.spyOn(spectator.component.rowClicked, 'emit');
const breadcrumbs = spectator.queryAll('dot-collapse-breadcrumb .p-menuitem-link');
spectator.click(breadcrumbs[1]);

expect(emitSpy).toHaveBeenCalledWith({ index: 0, item: CATEGORY_MOCK_TRANSFORMED[0] });
});
});
});
Original file line number Diff line number Diff line change
@@ -1,32 +1,35 @@
import { CommonModule } from '@angular/common';
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
DestroyRef,
effect,
ElementRef,
EventEmitter,
inject,
input,
Output,
QueryList,
ViewChildren
model,
output,
signal,
viewChildren
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';

import { ButtonModule } from 'primeng/button';
import { CheckboxModule } from 'primeng/checkbox';
import { TreeModule } from 'primeng/tree';

import { DotMessageService } from '@dotcms/data-access';
import { DotEmptyContainerComponent, PrincipalConfiguration } from '@dotcms/ui';
import {
DotEmptyContainerComponent,
PrincipalConfiguration,
DotCollapseBreadcrumbComponent
} from '@dotcms/ui';

import { CATEGORY_FIELD_EMPTY_MESSAGES } from '../../../../models/dot-edit-content-field.constant';
import { DotCategoryFieldKeyValueObj } from '../../models/dot-category-field.models';
import {
DotCategoryFieldItem,
DotCategoryFieldKeyValueObj
} from '../../models/dot-category-field.models';
import { DotCategoryFieldListSkeletonComponent } from '../dot-category-field-list-skeleton/dot-category-field-list-skeleton.component';

export const MINIMUM_CATEGORY_COLUMNS = 4;
Expand All @@ -36,7 +39,6 @@ const MINIMUM_CATEGORY_WITHOUT_SCROLLING = 3;
/**
* Represents the Dot Category Field Category List component.
* @class
* @implements {AfterViewInit}
*/
@Component({
selector: 'dot-category-field-category-list',
Expand All @@ -48,6 +50,7 @@ const MINIMUM_CATEGORY_WITHOUT_SCROLLING = 3;
ButtonModule,
FormsModule,
DotCategoryFieldListSkeletonComponent,
DotCollapseBreadcrumbComponent,
DotEmptyContainerComponent
],
templateUrl: './dot-category-field-category-list.component.html',
Expand All @@ -57,11 +60,15 @@ const MINIMUM_CATEGORY_WITHOUT_SCROLLING = 3;
class: 'category-list__wrapper'
}
})
export class DotCategoryFieldCategoryListComponent implements AfterViewInit {
export class DotCategoryFieldCategoryListComponent {
/**
* Represents the DotMessageService instance.
*/
readonly #dotMessageService = inject(DotMessageService);
/**
* Represent the columns of categories
*/
@ViewChildren('categoryColumn') categoryColumns: QueryList<ElementRef>;
$categoryColumns = viewChildren<ElementRef<HTMLDivElement>>('categoryColumn');

/**
* Represents the variable 'categories' which is of type 'DotCategoryFieldCategory[][]'.
Expand All @@ -71,7 +78,7 @@ export class DotCategoryFieldCategoryListComponent implements AfterViewInit {
/**
* Represent the selected item saved in the contentlet
*/
$selected = input<string[]>([], { alias: 'selected' });
$selected = model<string[]>([], { alias: 'selected' });

/**
* Generate the empty columns
Expand All @@ -90,55 +97,67 @@ export class DotCategoryFieldCategoryListComponent implements AfterViewInit {
*/
$isLoading = input<boolean>(true, { alias: 'isLoading' });

/**
* Represents the breadcrumbs to display
*/
$breadcrumbs = input<DotCategoryFieldKeyValueObj[]>([], { alias: 'breadcrumbs' });

/**
* Represents the breadcrumbs menu to display
*
* @memberof DotCategoryFieldCategoryListComponent
*/
$breadcrumbsMenu = computed(() => {
const currentItems = this.$breadcrumbs().map((item, index) => {
return {
label: item.value,
command: () => {
this.rowClicked.emit({ index, item });
}
};
});

return [
{
label: this.#dotMessageService.get(
'edit.content.category-field.category.root-name'
),
command: () => {
this.rowClicked.emit({ index: 0 });
}
},
...currentItems
];
});

/**
* Emit the item clicked to the parent component
*/
@Output() rowClicked = new EventEmitter<{ index: number; item: DotCategoryFieldKeyValueObj }>();
rowClicked = output<DotCategoryFieldItem>();

/**
* Emit the item checked or selected to the parent component
*/
@Output() itemChecked = new EventEmitter<{
itemChecked = output<{
selected: string[];
item: DotCategoryFieldKeyValueObj;
}>();

/**
* Model of the items selected
*/
itemsSelected: string[];

readonly #messageService = inject(DotMessageService);
#cdr = inject(ChangeDetectorRef);
readonly #destroyRef = inject(DestroyRef);
readonly #effectRef = effect(() => {
// Todo: change itemsSelected to use model when update Angular to >17.3
// Initial selected items from the contentlet
this.itemsSelected = this.$selected();
this.#cdr.markForCheck(); // force refresh
});

emptyState: PrincipalConfiguration = {
title: this.#messageService.get(CATEGORY_FIELD_EMPTY_MESSAGES.empty.title),
$emptyState = signal<PrincipalConfiguration>({
title: this.#dotMessageService.get(CATEGORY_FIELD_EMPTY_MESSAGES.empty.title),
icon: CATEGORY_FIELD_EMPTY_MESSAGES.empty.icon,
subtitle: this.#messageService.get(CATEGORY_FIELD_EMPTY_MESSAGES.empty.subtitle)
};

ngAfterViewInit() {
// Handle the horizontal scroll to make visible the last column
this.categoryColumns.changes.pipe(takeUntilDestroyed(this.#destroyRef)).subscribe(() => {
this.scrollHandler();
});
subtitle: this.#dotMessageService.get(CATEGORY_FIELD_EMPTY_MESSAGES.empty.subtitle)
});

this.#destroyRef.onDestroy(() => {
this.#effectRef.destroy();
constructor() {
effect(() => {
const columnsArray = this.$categoryColumns();
this.scrollHandler(columnsArray);
});
}

private scrollHandler() {
private scrollHandler(columnsArray: readonly ElementRef<HTMLDivElement>[]) {
try {
const columnsArray = this.categoryColumns.toArray();

if (columnsArray.length === 0) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { SkeletonModule } from 'primeng/skeleton';
standalone: true,
imports: [CommonModule, SkeletonModule],
template: `
<ul class="m-0 p-1 list-none">
<ul class="m-0 p-1 list-none fadein animation-duration-500">
@for (_ of $rows(); track $index) {
<li class="flex">
<p-skeleton size="1rem" styleClass="mr-2"></p-skeleton>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
(rowClicked)="store.getCategories($event)"
[categories]="store.categoryList()"
[isLoading]="store.isListLoading()"
[selected]="store.selectedCategoriesValues()" />
[selected]="store.selectedCategoriesValues()"
[breadcrumbs]="store.breadcrumbMenu()" />
} @else {
<dot-category-field-search-list
@fadeAnimation
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
@use "variables" as *;

:host ::ng-deep .p-breadcrumb {
> ul > li {
&:last-child a {
pointer-events: none;
.p-menuitem-text {
color: $black;
font-weight: $font-weight-bold;
}
}
}
}

.category-field__header {
align-items: center;
flex-wrap: wrap;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export type HierarchyParent = Pick<DotCategory, 'inode' | 'parentList' | 'key'>
/**
* Represents an clicked item in a DotCategoryField.
*/
export type DotCategoryFieldItem = { index: number; item: DotCategoryFieldKeyValueObj };
export type DotCategoryFieldItem = { index: number; item?: DotCategoryFieldKeyValueObj };

/**
* Represents an event when a row is selected in a table.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import {
removeItemByKey,
transformCategories,
transformToSelectedObject,
updateChecked
updateChecked,
getMenuItemsFromKeyParentPath
} from '../utils/category-field.utils';

export type CategoryFieldState = {
Expand Down Expand Up @@ -117,7 +118,17 @@ export const CategoryFieldStore = signalStore(
store
.searchCategories()
.map((column) => transformCategories(column, store.keyParentPath()))
)
),

/**
* Transform the selected categories to a breadcrumb menu
*/
breadcrumbMenu: computed(() => {
const categories = store.categories();
const keyParentPath = store.keyParentPath();

return getMenuItemsFromKeyParentPath(categories, keyParentPath);
})
})),
withMethods(
(
Expand Down Expand Up @@ -263,13 +274,13 @@ export const CategoryFieldStore = signalStore(
filter(
(event) =>
!event ||
(event.item.hasChildren &&
!store.keyParentPath().includes(event.item.key))
(event?.item?.hasChildren &&
!store.keyParentPath().includes(event?.item?.key))
),
tap(() => patchState(store, { state: ComponentStatus.LOADING })),
switchMap((event) => {
const categoryInode: string = event
? event.item.inode
? event?.item.inode
: store.rootCategoryInode();

return categoryService.getChildren(categoryInode).pipe(
Expand Down
Loading

0 comments on commit 30d2813

Please sign in to comment.