-
Notifications
You must be signed in to change notification settings - Fork 1
feat(arc): implement breadcrumb feature #128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import {TitleDetails, UserDetails} from './user-title.interface'; | ||
|
||
export const USERS: UserDetails[] = [ | ||
{id: '123', name: 'John Doe', email: 'john.doe123@example.com'}, | ||
{id: '124', name: 'Jane Smith', email: 'jane.smith124@example.com'}, | ||
]; | ||
export const TITLES: TitleDetails[] = [ | ||
{id: '1', title: 'Contract.pdf'}, | ||
{id: '2', title: 'Appointment.pdf'}, | ||
]; | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
export interface UserDetails { | ||
id: string; | ||
name: string; | ||
email: string; | ||
} | ||
export interface TitleDetails { | ||
id: string; | ||
title: string; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
<div class="container mt-4"> | ||
<h2>Documentation</h2> | ||
<p><strong>ID:</strong>{{ title?.id }}</p> | ||
<p><strong>Title:</strong>{{ title?.title }}</p> | ||
</div> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import {Component} from '@angular/core'; | ||
import {ActivatedRoute} from '@angular/router'; | ||
import {TitleDetails} from '../user-title.interface'; | ||
|
||
@Component({ | ||
selector: 'lib-user-title', | ||
templateUrl: './user-title.component.html', | ||
}) | ||
export class UserTitleComponent { | ||
title: TitleDetails; | ||
constructor(private readonly route: ActivatedRoute) { | ||
this.title = this.route.snapshot.data['document']; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import {Injectable} from '@angular/core'; | ||
import {ActivatedRouteSnapshot} from '@angular/router'; | ||
import {Observable} from 'rxjs'; | ||
import {TitleService} from './user-title.service'; | ||
import {TitleDetails} from '../user-title.interface'; | ||
|
||
@Injectable() | ||
export class TitleResolver { | ||
constructor(private readonly titleService: TitleService) {} | ||
|
||
resolve(route: ActivatedRouteSnapshot): Observable<TitleDetails> { | ||
const id = route.paramMap.get('id'); | ||
console.log(this.titleService.getTitleById(id)); | ||
return this.titleService.getTitleById(id); | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. use linter |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import {Injectable} from '@angular/core'; | ||
import {Observable, of} from 'rxjs'; | ||
import {TITLES} from '../mock-data.constants'; | ||
import {TitleDetails} from '../user-title.interface'; | ||
|
||
@Injectable() | ||
export class TitleService { | ||
private readonly titles = TITLES; | ||
|
||
getTitleById(id: string): Observable<TitleDetails> { | ||
const title = this.titles.find(u => u.id === id); | ||
return of(title); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
<div class="container mt-4"> | ||
<h2>User Details</h2> | ||
<p><strong>ID:</strong> {{ user?.id }}</p> | ||
<p><strong>Name:</strong> {{ user?.name }}</p> | ||
<p><strong>Email:</strong> {{ user?.email }}</p> | ||
|
||
<router-outlet></router-outlet> | ||
</div> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import {CommonModule} from '@angular/common'; | ||
import {Component} from '@angular/core'; | ||
import {ActivatedRoute, RouterModule} from '@angular/router'; | ||
import {UserDetails} from '../user-title.interface'; | ||
import {UserResolver} from './user.resolver'; | ||
|
||
@Component({ | ||
selector: 'lib-user', | ||
standalone: true, | ||
templateUrl: './user.component.html', | ||
imports: [CommonModule, RouterModule], | ||
}) | ||
export class UserComponent { | ||
user: UserDetails; | ||
|
||
constructor(private readonly route: ActivatedRoute) { | ||
this.user = this.route.snapshot.data['user']; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import {Injectable} from '@angular/core'; | ||
import {ActivatedRouteSnapshot} from '@angular/router'; | ||
import {Observable} from 'rxjs'; | ||
import {UserService} from './user.service'; | ||
import {UserDetails} from '../user-title.interface'; | ||
|
||
@Injectable() | ||
export class UserResolver { | ||
constructor(private readonly userService: UserService) {} | ||
|
||
resolve(route: ActivatedRouteSnapshot): Observable<UserDetails> { | ||
const id = route.paramMap.get('id'); | ||
return this.userService.getUserById(id); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import {Injectable} from '@angular/core'; | ||
import {Observable, of} from 'rxjs'; | ||
import {USERS} from '../mock-data.constants'; | ||
import {UserDetails} from '../user-title.interface'; | ||
|
||
@Injectable() | ||
export class UserService { | ||
private readonly users = USERS; | ||
|
||
getUserById(id: string): Observable<UserDetails> { | ||
const user = this.users.find(u => u.id === id); | ||
return of(user); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
<nav aria-label="breadcrumb" *ngIf="breadcrumbs$ | async as breadcrumbs"> | ||
<ul class="breadcrumb"> | ||
<ng-container | ||
*ngIf="breadcrumbs.length > maxItems && !expanded; else fullBreadcrumb" | ||
> | ||
<li class="breadcrumb-item"> | ||
<ng-container *ngIf="!breadcrumbs[0].skipLink; else noLinkFirst"> | ||
<a [routerLink]="breadcrumbs[0].url">{{ breadcrumbs[0].label }}</a> | ||
</ng-container> | ||
<ng-template #noLinkFirst> | ||
<span>{{ breadcrumbs[0].label }}</span> | ||
</ng-template> | ||
</li> | ||
<span class="separator">{{ separator }}</span> | ||
|
||
<li class="breadcrumb-item clickable" (click)="toggleExpand()">...</li> | ||
<span class="separator">{{ separator }}</span> | ||
|
||
<li class="breadcrumb-item" [class.active]="true"> | ||
<ng-container | ||
*ngIf="!breadcrumbs[breadcrumbs.length - 1].skipLink; else noLinkLast" | ||
> | ||
<a [routerLink]="breadcrumbs[breadcrumbs.length - 1].url"> | ||
{{ breadcrumbs[breadcrumbs.length - 1].label }} | ||
</a> | ||
</ng-container> | ||
<ng-template #noLinkLast> | ||
<span>{{ breadcrumbs[breadcrumbs.length - 1].label }}</span> | ||
</ng-template> | ||
</li> | ||
</ng-container> | ||
|
||
<ng-template #fullBreadcrumb> | ||
<ng-container *ngFor="let breadcrumb of breadcrumbs; let last = last"> | ||
<li class="breadcrumb-item" [class.active]="last"> | ||
<ng-container *ngIf="!breadcrumb.skipLink && !last; else noLink"> | ||
<a [routerLink]="breadcrumb.url">{{ breadcrumb.label }}</a> | ||
</ng-container> | ||
<ng-template #noLink> | ||
<span>{{ breadcrumb.label }}</span> | ||
</ng-template> | ||
</li> | ||
<span *ngIf="!last" class="separator">{{ separator }}</span> | ||
</ng-container> | ||
</ng-template> | ||
</ul> | ||
</nav> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
.breadcrumb { | ||
display: flex; | ||
flex-wrap: wrap; | ||
list-style: none; | ||
padding: 0; | ||
margin: 0; | ||
background-color: transparent; | ||
|
||
.breadcrumb-item { | ||
display: flex; | ||
align-items: center; | ||
font-size: 14px; | ||
color: #555; | ||
|
||
a { | ||
color: #007bff; | ||
text-decoration: none; | ||
|
||
&:hover { | ||
text-decoration: underline; | ||
} | ||
} | ||
|
||
&.active { | ||
font-weight: 600; | ||
color: #333; | ||
} | ||
&.clickable { | ||
cursor: pointer; | ||
color: #007bff; | ||
text-decoration: underline; | ||
|
||
&:hover { | ||
color: #0056b3; | ||
} | ||
} | ||
} | ||
|
||
.separator { | ||
margin: 0 6px; | ||
color: #aaa; | ||
font-size: 14px; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import {Component, Input, isDevMode, OnInit} from '@angular/core'; | ||
import {CommonModule} from '@angular/common'; | ||
import {Breadcrumb} from './breadcrumb.interface'; | ||
import {BreadcrumbService} from './breadcrumb.service'; | ||
import {Observable, Subject, takeUntil} from 'rxjs'; | ||
import {RouterModule} from '@angular/router'; | ||
|
||
@Component({ | ||
selector: 'app-breadcrumb', | ||
templateUrl: './breadcrumb.component.html', | ||
standalone: true, | ||
imports: [CommonModule, RouterModule], | ||
styleUrls: ['./breadcrumb.component.scss'], | ||
}) | ||
export class BreadcrumbComponent implements OnInit { | ||
breadcrumbs$: Observable<Breadcrumb[]> = this.breadcrumbService.breadcrumbs; | ||
|
||
@Input() staticBreadcrumbs = []; | ||
@Input() separator = '>'; | ||
@Input() maxItems = 8; | ||
@Input() separatorClass = 'separator'; | ||
@Input() itemClass = 'breadcrumb-item'; | ||
|
||
expanded = false; | ||
private destroy$ = new Subject<void>(); | ||
constructor(private readonly breadcrumbService: BreadcrumbService) {} | ||
ngOnInit(): void { | ||
this.breadcrumbs$.pipe(takeUntil(this.destroy$)).subscribe(breadcrumbs => { | ||
if (isDevMode()) { | ||
console.log('Breadcrumbs:', breadcrumbs); | ||
} | ||
}); | ||
} | ||
ngOnDestroy(): void { | ||
this.destroy$.next(); | ||
this.destroy$.complete(); | ||
} | ||
toggleExpand() { | ||
this.expanded = true; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
export interface Breadcrumb { | ||
label: string; | ||
url: string; | ||
skipLink?: boolean; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
import {Injectable} from '@angular/core'; | ||
import {ActivatedRouteSnapshot, NavigationEnd, Router} from '@angular/router'; | ||
import {BehaviorSubject} from 'rxjs'; | ||
import {filter} from 'rxjs/operators'; | ||
import {Breadcrumb} from './breadcrumb.interface'; | ||
|
||
@Injectable({providedIn: 'root'}) | ||
export class BreadcrumbService { | ||
private readonly breadcrumbs$ = new BehaviorSubject<Breadcrumb[]>([]); | ||
|
||
constructor(private readonly router: Router) { | ||
this.router.events | ||
.pipe(filter(event => event instanceof NavigationEnd)) | ||
.subscribe(() => { | ||
const root = this.router.routerState.snapshot.root; | ||
const breadcrumbs = this.buildBreadcrumbs(root); | ||
this.breadcrumbs$.next(breadcrumbs); | ||
}); | ||
} | ||
|
||
private buildBreadcrumbs( | ||
route: ActivatedRouteSnapshot, | ||
url = '', | ||
breadcrumbs: Breadcrumb[] = [], | ||
): Breadcrumb[] { | ||
if (!route.routeConfig) { | ||
return route.firstChild | ||
? this.buildBreadcrumbs(route.firstChild, url, breadcrumbs) | ||
: breadcrumbs; | ||
} | ||
|
||
let path = route.routeConfig.path || ''; | ||
Object.keys(route.params).forEach(key => { | ||
path = path.replace(`:${key}`, route.params[key]); | ||
}); | ||
const nextUrl = path ? `${url}/${path}` : url; | ||
const label = this._resolveLabel(route, path); | ||
const skipLink = route.routeConfig.data?.['skipLink'] ?? false; | ||
|
||
if (label) { | ||
breadcrumbs.push({label, url: nextUrl, skipLink}); | ||
} | ||
|
||
return route.firstChild | ||
? this.buildBreadcrumbs(route.firstChild, nextUrl, breadcrumbs) | ||
: breadcrumbs; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. modify this function to keep it clean, readable and remove if else if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done mam |
||
|
||
private _toTitleCase(str: string): string { | ||
return str.replace(/-/g, ' ').replace(/\b\w/g, char => char.toUpperCase()); | ||
} | ||
|
||
private _resolveLabel(route: ActivatedRouteSnapshot, path: string): string { | ||
const breadcrumbData = route.routeConfig.data?.['breadcrumb']; | ||
|
||
const conditions: [boolean, () => string][] = [ | ||
[ | ||
typeof breadcrumbData === 'function', | ||
() => breadcrumbData(route.data, route.paramMap, route), | ||
], | ||
[typeof breadcrumbData === 'string', () => breadcrumbData], | ||
[ | ||
route.routeConfig.path?.startsWith(':'), | ||
() => { | ||
const paramName = route.routeConfig.path.slice(1); | ||
return route.params[paramName] ?? paramName; | ||
}, | ||
], | ||
]; | ||
|
||
for (const [condition, action] of conditions) { | ||
if (condition) return action(); | ||
} | ||
|
||
return this._toTitleCase(path); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remove so many ifs, use array instead |
||
} | ||
|
||
get breadcrumbs() { | ||
return this.breadcrumbs$.asObservable(); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use types here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
done mam