Description
Display and edit a single menu item in the admin interface.
Nesting and drag & drop are handled by the surrounding
MenuItemListEditorComponent, which renders the whole (nested) menu as one flat list.
Example
|
allowEntityLinks
|
Type : boolean
|
Default value : true
|
|
|
Whether entity type links are allowed (false for shortcuts, true for admin menu)
|
|
hasSubItems
|
Type : boolean
|
Default value : false
|
|
|
whether this item has nested sub-items (which are rows of their own in the editor)
|
|
hasNoLinkWarning
|
Type : unknown
|
Default value : computed(() => {
const item = this.item();
return item ? isManualItemWithoutLink(item) && !this.hasSubItems() : false;
})
|
|
|
True when the item has no link and no sub-items,
meaning clicking it will have no visible effect.
|
|
itemToDisplay
|
Type : unknown
|
Default value : computed<MenuItem>(() => {
const item = this.item();
if (!item) {
return undefined;
}
const plainItem = this.menuService.generateMenuItemForEntityType(item);
delete plainItem.link;
delete plainItem.subMenu;
return plainItem;
})
|
|
|
import {
Component,
input,
computed,
inject,
model,
output,
ChangeDetectionStrategy,
} from "@angular/core";
import { EntityMenuItem, MenuItem } from "app/core/ui/navigation/menu-item";
import { MenuItemComponent } from "app/core/ui/navigation/menu-item/menu-item.component";
import { FaIconComponent } from "@fortawesome/angular-fontawesome";
import { MatFormFieldModule } from "@angular/material/form-field";
import { FormsModule } from "@angular/forms";
import { MatDialog } from "@angular/material/dialog";
import { MenuService } from "app/core/ui/navigation/menu.service";
import { firstValueFrom } from "rxjs";
import { AdminMenuItemDetailsComponent } from "../admin-menu-item-details/admin-menu-item-details.component";
import {
isManualItemWithoutLink,
MenuItemForAdminUi,
} from "../menu-item-for-admin-ui";
import { MatNavList } from "@angular/material/list";
import { MatIconButton } from "@angular/material/button";
import { MatTooltipModule } from "@angular/material/tooltip";
/**
* Display and edit a single menu item in the admin interface.
*
* Nesting and drag & drop are handled by the surrounding
* {@link MenuItemListEditorComponent}, which renders the whole (nested) menu as one flat list.
*/
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: "app-admin-menu-item",
imports: [
MatNavList,
MenuItemComponent,
FaIconComponent,
MatFormFieldModule,
FormsModule,
MatIconButton,
MatTooltipModule,
],
templateUrl: "./admin-menu-item.component.html",
styleUrls: [
"./admin-menu-item.component.scss",
"../../../ui/navigation/menu-item/menu-item.component.scss",
],
})
export class AdminMenuItemComponent {
private readonly dialog = inject(MatDialog);
private readonly menuService = inject(MenuService);
item = model.required<MenuItemForAdminUi>();
/** whether this item has nested sub-items (which are rows of their own in the editor) */
hasSubItems = input<boolean>(false);
itemToDisplay = computed<MenuItem>(() => {
const item = this.item();
if (!item) {
return undefined;
}
const plainItem = this.menuService.generateMenuItemForEntityType(item);
delete plainItem.link;
delete plainItem.subMenu;
return plainItem;
});
/**
* True when the item has no link and no sub-items,
* meaning clicking it will have no visible effect.
*/
hasNoLinkWarning = computed(() => {
const item = this.item();
return item ? isManualItemWithoutLink(item) && !this.hasSubItems() : false;
});
/** Whether entity type links are allowed (false for shortcuts, true for admin menu) */
allowEntityLinks = input<boolean>(true);
deleteItem = output<MenuItemForAdminUi>();
onDelete(item: MenuItemForAdminUi): void {
this.deleteItem.emit(item);
}
async editMenuItem(item: MenuItemForAdminUi) {
const updatedItem = await this.openEditDialog(item);
if (updatedItem) {
const mergedItem = { ...item, ...updatedItem };
if ("entityType" in item && !("entityType" in updatedItem)) {
delete (mergedItem as unknown as EntityMenuItem).entityType;
}
// If link was explicitly removed in the dialog (noLinkMode), ensure it is
// not re-introduced from the original item by the spread above.
if ("link" in item && !("link" in updatedItem)) {
delete mergedItem.link;
}
this.item.set(mergedItem);
}
}
private async openEditDialog(
item: MenuItemForAdminUi,
): Promise<MenuItemForAdminUi | undefined> {
const dialogRef = this.dialog.open(AdminMenuItemDetailsComponent, {
width: "600px",
data: {
item: item ? { ...item } : {},
allowEntityLinks: this.allowEntityLinks(),
},
});
return firstValueFrom(dialogRef.afterClosed());
}
}
<mat-nav-list class="limit-width">
@if (item()) {
<div class="flex-row menu-item-container">
<div class="flex-row">
<fa-icon icon="grip-vertical" class="drag-handle"></fa-icon>
<app-menu-item
[item]="itemToDisplay()"
class="menu-item-preview"
(click)="editMenuItem(item())"
></app-menu-item>
<div class="menu-actions flex-row gap-small align-center">
@if (hasNoLinkWarning()) {
<fa-icon
icon="triangle-exclamation"
class="color-error"
i18n-matTooltip
matTooltip="This item has no link and no sub-items — it will not be accessible by users"
></fa-icon>
}
<button
mat-icon-button
color="accent"
(click)="editMenuItem(item())"
i18n-aria-label
aria-label="Edit menu item"
>
<fa-icon aria-hidden="true" icon="pen"></fa-icon>
</button>
<button
mat-icon-button
(click)="onDelete(item())"
i18n-aria-label
aria-label="Remove menu item"
>
<fa-icon aria-hidden="true" icon="times"></fa-icon>
</button>
</div>
</div>
</div>
}
</mat-nav-list>
@use "variables/colors";
@use "variables/sizes";
.menu-item-container {
min-height: 50px;
position: relative;
display: block;
padding: 8px;
margin: auto;
border: 1px solid colors.$border-color;
}
/* Actions area on the right (edit & remove) */
.menu-actions {
margin-left: auto; /* Push actions to far right */
}
/* Drag handle (left) */
.drag-handle {
cursor: move;
color: colors.$accent;
display: flex;
align-items: center;
margin-right: 12px;
opacity: 0.5;
transition: opacity 0.2s;
}
.drag-handle:hover {
opacity: 1;
}
/* Allow <app-menu-item> to grow */
app-menu-item {
flex-grow: 1;
display: flex;
align-items: center;
min-width: 0; /* Allow flex items to shrink below content size */
overflow: hidden; /* Prevent overflow from child elements */
}
.limit-width {
max-width: 400px;
@media (max-width: 600px) {
max-width: 100%;
}
}
@use "../../../../../styles/variables/sizes";
@use "../../../../../styles/variables/colors";
/* ensures that all icons have the same width */
.nav-icon {
min-width: sizes.$max-icon-width;
}
.matched-background {
background-color: colors.$background !important;
}
.submenu {
padding-left: 20px;
}
.indent-item {
margin-left: 8px;
padding-left: 8px;
width: auto;
border-end-end-radius: 0;
border-start-end-radius: 0;
}
.menu-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
Legend
Html element with directive