implement configurable menu

This commit is contained in:
Roland Schneider 2025-11-20 09:30:35 +01:00
parent 3e2bdaebc6
commit b08663fb28
17 changed files with 256 additions and 57 deletions

View File

@ -44,7 +44,7 @@
<!-- Main Content -->
<main class="flex-1 p-6 bg-base-100 overflow-y-auto">
<ng-content></ng-content>
<ng-content select="[main-content]"></ng-content>
</main>
<!-- Footer -->
@ -57,27 +57,7 @@
<!-- Sidebar -->
<div class="drawer-side">
<label for="my-drawer-2" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu p-4 w-80 min-h-full bg-base-300 text-base-content">
<!-- Sidebar content here -->
<li class="text-lg font-bold p-4">My App</li>
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Dashboard</a></li>
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Analytics</a></li>
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Reports</a></li>
</ul>
<ng-content select="[menu-content]"></ng-content>
</div>
</div>

View File

@ -1,3 +1,10 @@
<rs-daisy-admin-layout-rs1 (clickEvent)="logout()" [loggedIn]="loggedIn()">
<router-outlet />
<app-menu
[title]="menuTitle"
[menuItems]="menuConfig"
[userRoles]="currentUserRoles"
menu-content>
</app-menu>
<router-outlet main-content />
</rs-daisy-admin-layout-rs1>

View File

@ -1,23 +1,36 @@
import { Component, inject, signal } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router';
import { MainMenu } from './components/main-menu/main-menu';
import { AuthService } from './auth/auth.service';
import { AdminLayout } from './layout/admin-layout/admin-layout';
import { finalize } from 'rxjs/operators';
import {Button} from '@rschneider/ng-daisyui';
import { AdminLayoutRs1 } from '../../projects/rschneider/ng-daisyui/src/lib/layout';
import { Menu, MenuItem } from './components/menu/menu';
@Component({
selector: 'app-root',
imports: [RouterOutlet, MainMenu, Button, AdminLayoutRs1],
imports: [RouterOutlet, AdminLayoutRs1, Menu],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected readonly title = signal('admin');
protected menuConfig: MenuItem[] = [
];
protected currentUserRoles: string[] = ['admin'];
protected menuTitle: string | undefined = 'Menü';
constructor(private authService: AuthService, private router: Router) {}
constructor(private authService: AuthService, private router: Router) {
this.menuConfig = [
{
menuText: 'Esemény típusok',
targetUrl: '/event-type/table',
svgIcon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.429 9.75 2.25 12l4.179 2.25m0-4.5 5.571 3 5.571-3m-11.142 0L2.25 7.5 12 2.25l9.75 5.25-4.179 2.25m0 0L21.75 12l-4.179 2.25m0 0 4.179 2.25L12 21.75 2.25 16.5l4.179-2.25m11.142 0-5.571 3-5.571-3" />
</svg>
`
}
]
}
logout(): void {
// With the interceptor fixed, this is now the correct and robust way.

View File

@ -1,5 +1,5 @@
import { Component, inject, input, OnInit } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { Router } from '@angular/router';
export interface ActionDefinition<T> {
action: string;
@ -10,7 +10,6 @@ export interface ActionDefinition<T> {
@Component({
selector: 'app-generic-action-column',
imports: [
RouterLink,
],
templateUrl: './generic-action-column.html',
styleUrl: './generic-action-column.css',

View File

@ -84,5 +84,9 @@
</div>
</div>
}
} @else {
<div class="text-center p-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
}
</div>

View File

@ -1 +0,0 @@
<p>main-menu works!</p>

View File

@ -1,11 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-main-menu',
imports: [],
templateUrl: './main-menu.html',
styleUrl: './main-menu.css',
})
export class MainMenu {
}

View File

@ -0,0 +1,52 @@
<!--
<ul class="menu p-4 w-80 min-h-full bg-base-300 text-base-content">
<li class="text-lg font-bold p-4">My App</li>
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Dashboard</a></li>
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Analytics</a></li>
<li><a>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Reports</a></li>
</ul>
-->
<ul class="menu p-4 w-80 min-h-full bg-base-300 text-base-content">
@if(title()){
<li class="text-lg font-bold p-4">{{ title() }}</li>
}
@for (item of menuItems(); track item.menuText) {
@if (isItemVisible(item)) {
<li [ngClass]="getItemStyleClass(item)">
@if (item.children && item.children.length > 0) {
<details [open]="isSubMenuActive(item)">
<summary>
<!-- Using [innerHTML] for SVG. Be cautious with non-trusted sources. -->
<span [innerHTML]="item.svgIcon | safeHtml"></span>
{{ item.menuText }}
</summary>
<!-- Recursive call to the menu component for nested items -->
<app-menu [menuItems]="item.children" [userRoles]="userRoles()"></app-menu>
</details>
} @else {
<a (click)="handleItemClick($event, item)" [class.menu-active]="isItemActive(item)">
<span [innerHTML]="item.svgIcon | safeHtml"></span>
{{ item.menuText }}
</a>
}
</li>
}
}
</ul>

View File

@ -1,18 +1,18 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MainMenu } from './main-menu';
import { Menu } from './menu';
describe('MainMenu', () => {
let component: MainMenu;
let fixture: ComponentFixture<MainMenu>;
describe('Menu', () => {
let component: Menu;
let fixture: ComponentFixture<Menu>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MainMenu]
imports: [Menu]
})
.compileComponents();
fixture = TestBed.createComponent(MainMenu);
fixture = TestBed.createComponent(Menu);
component = fixture.componentInstance;
fixture.detectChanges();
});

View File

@ -0,0 +1,82 @@
import { Component, inject, input } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
import { CommonModule } from '@angular/common';
import { SafeHtmlPipe } from '../../pipes/safe-html-pipe';
export interface MenuItem {
id?: string;
menuText: string;
targetUrl?: string;
onClick?: (event: MouseEvent, item: MenuItem) => void;
roles?: string[];
children?: MenuItem[];
getStyleClass?: (item: MenuItem) => string;
isActive?: (item: MenuItem, router: Router) => boolean;
svgIcon?: string;
}
@Component({
selector: 'app-menu',
imports: [CommonModule, RouterModule, SafeHtmlPipe],
templateUrl: './menu.html',
styleUrl: './menu.css',
})
export class Menu {
title = input<string>();
// Use the input() function instead of the @Input() decorator.
// input.required makes sure that the menuItems array is always passed.
menuItems = input.required<MenuItem[]>();
// You can also provide a default value with input().
// In a real app, this would likely come from an authentication service.
userRoles = input<string[]>(['admin', 'user']);
// Use inject() for dependency injection instead of the constructor.
private router = inject(Router);
isItemVisible(item: MenuItem): boolean {
if (!item.roles || item.roles.length === 0) {
return true;
}
// Access the value of the signal by calling it as a function: this.userRoles()
return item.roles.some(role => this.userRoles().includes(role));
}
handleItemClick(event: MouseEvent, item: MenuItem): void {
if (item.onClick) {
item.onClick(event, item);
} else if (item.targetUrl) {
this.router.navigate([item.targetUrl]);
}
}
getItemStyleClass(item: MenuItem): string {
return item.getStyleClass ? item.getStyleClass(item) : '';
}
isItemActive(item: MenuItem): boolean {
if (item.isActive) {
return item.isActive(item, this.router);
}
if (item.targetUrl) {
// router.isActive is a boolean check, perfect for this use case.
return this.router.isActive(item.targetUrl, {
paths: 'exact',
queryParams: 'exact',
fragment: 'ignored',
matrixParams: 'ignored'
});
}
return false;
}
// Helper function to determine if a dropdown should be open by default
isSubMenuActive(item: MenuItem): boolean {
if (!item.children) {
return false;
}
// Recursively check if any child or grandchild is active
return item.children.some(child => this.isItemActive(child) || this.isSubMenuActive(child));
}
}

View File

@ -0,0 +1,8 @@
import { SafeHtmlPipe } from './safe-html-pipe';
describe('SafeHtmlPipe', () => {
it('create an instance', () => {
const pipe = new SafeHtmlPipe();
expect(pipe).toBeTruthy();
});
});

View File

@ -0,0 +1,16 @@
import { inject, Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Pipe({
name: 'safeHtml'
})
export class SafeHtmlPipe implements PipeTransform {
private sanitized = inject(DomSanitizer);
transform(value: string | undefined): SafeHtml {
if (!value) return '';
return this.sanitized.bypassSecurityTrustHtml(value);
}
}

View File

@ -1,8 +1,11 @@
// dvbooking-cli/src/templates/nestjs/entity.ts.tpl
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
import { IsString, IsNumber, IsBoolean, IsDate, IsOptional } from 'class-validator';
@Entity({ name: 'event_type' })
export class EventType {
@PrimaryGeneratedColumn()
id: number;
@ -20,4 +23,4 @@ export class EventType {
@IsString()
color: string | null;
}
}

View File

@ -1,8 +1,11 @@
// dvbooking-cli/src/templates/nestjs/entity.ts.tpl
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
import { IsString, IsNumber, IsBoolean, IsDate, IsOptional } from 'class-validator';
@Entity({ name: 'products' })
export class Product {
@PrimaryGeneratedColumn()
id: number;
@ -20,4 +23,4 @@ export class Product {
@IsBoolean()
is_available: boolean | null = true;
}
}

View File

@ -1,10 +1,29 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, ParseIntPipe, DefaultValuePipe } from '@nestjs/common';
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
ParseIntPipe,
DefaultValuePipe,
UseGuards,
} from '@nestjs/common';
import { EventTypesService } from './event-type.service';
import { CreateEventTypeDto } from './dto/create-event-type.dto';
import { UpdateEventTypeDto } from './dto/update-event-type.dto';
import { QueryEventTypeDto } from './dto/query-event-type.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { Roles } from '../auth/roles.decorator';
import { Role } from '../auth/role.enum';
import { RolesGuard } from '../auth/roles.guard';
@Controller('event-type')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.Admin)
export class EventTypesController {
constructor(private readonly eventTypesService: EventTypesService) {}
@ -33,7 +52,10 @@ export class EventTypesController {
}
@Patch(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() updateEventTypeDto: UpdateEventTypeDto) {
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateEventTypeDto: UpdateEventTypeDto,
) {
return this.eventTypesService.update(id, updateEventTypeDto);
}

View File

@ -1,10 +1,29 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, ParseIntPipe, DefaultValuePipe } from '@nestjs/common';
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
Query,
ParseIntPipe,
DefaultValuePipe,
UseGuards,
} from '@nestjs/common';
import { ProductsService } from './products.service';
import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-product.dto';
import { QueryProductDto } from './dto/query-product.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { Roles } from '../auth/roles.decorator';
import { Role } from '../auth/role.enum';
import { RolesGuard } from '../auth/roles.guard';
@Controller('products')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(Role.Admin)
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@ -33,7 +52,10 @@ export class ProductsController {
}
@Patch(':id')
update(@Param('id', ParseIntPipe) id: number, @Body() updateProductDto: UpdateProductDto) {
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateProductDto: UpdateProductDto,
) {
return this.productsService.update(id, updateProductDto);
}
@ -41,4 +63,4 @@ export class ProductsController {
remove(@Param('id', ParseIntPipe) id: number) {
return this.productsService.remove(id);
}
}
}