-
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
Changes from 2 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,23 @@ | ||
import {Component} from '@angular/core'; | ||
import {ActivatedRoute} from '@angular/router'; | ||
import {TitleDetails} from '../user-title.interface'; | ||
import {TitleService} from './user-title.service'; | ||
|
||
@Component({ | ||
selector: 'lib-user-title', | ||
templateUrl: './user-title.component.html', | ||
}) | ||
export class UserTitleComponent { | ||
title: TitleDetails; | ||
constructor( | ||
private readonly route: ActivatedRoute, | ||
private readonly titleService: TitleService, | ||
) { | ||
const id = this.route.snapshot.paramMap.get('id'); | ||
if (id) { | ||
this.titleService.getTitleById(id).subscribe(title => { | ||
this.title = title; | ||
}); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import {catchError, delay, map, Observable, of} from 'rxjs'; | ||
import {TITLES} from '../mock-data.constants'; | ||
import {TitleDetails} from '../user-title.interface'; | ||
|
||
export class TitleService { | ||
private readonly titles = TITLES; | ||
|
||
getTitleById(id: string): Observable<TitleDetails> { | ||
const title = this.titles.find(u => u.id === id); | ||
return of(title); | ||
} | ||
getTitleNameForBreadcrumb(id: string): Observable<string> { | ||
return this.getTitleById(id).pipe( | ||
map(titles => titles?.title || `Document #${id}`), | ||
catchError(() => of(`Document #${id}`)), | ||
delay(2000), // Simulating network delay | ||
); | ||
} | ||
} |
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,27 @@ | ||
import {CommonModule} from '@angular/common'; | ||
import {Component} from '@angular/core'; | ||
import {ActivatedRoute, RouterModule} from '@angular/router'; | ||
import {UserDetails} from '../user-title.interface'; | ||
import {UserService} from './user.service'; | ||
|
||
@Component({ | ||
selector: 'lib-user', | ||
standalone: true, | ||
templateUrl: './user.component.html', | ||
imports: [CommonModule, RouterModule], | ||
}) | ||
export class UserComponent { | ||
user: UserDetails; | ||
|
||
constructor( | ||
private readonly route: ActivatedRoute, | ||
private readonly userService: UserService, | ||
) { | ||
const id = this.route.snapshot.paramMap.get('id'); | ||
if (id) { | ||
this.userService.getUserById(id).subscribe(user => { | ||
this.user = user; | ||
}); | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import {catchError, delay, map, Observable, of} from 'rxjs'; | ||
import {USERS} from '../mock-data.constants'; | ||
import {UserDetails} from '../user-title.interface'; | ||
|
||
export class UserService { | ||
private readonly users = USERS; | ||
|
||
getUserById(id: string): Observable<UserDetails> { | ||
const user = this.users.find(u => u.id === id); | ||
return of(user); | ||
} | ||
getUserNameForBreadcrumb(id: string): Observable<string> { | ||
return this.getUserById(id).pipe( | ||
map(user => user?.name || `User #${id}`), | ||
catchError(() => of(`User #${id}`)), | ||
delay(4000), | ||
); | ||
} | ||
} |
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> | ||
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. what if a breacrumb label is too long? Shouldn't we trim it ? 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! |
||
</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; | ||
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. Shouldn't we use rem for responsive design? 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! |
||
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 = []; | ||
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. where is this being used in component? 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. as of now, this input is not being used anywhere inside the component. 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. but they don't seem to be used |
||
@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 = !this.expanded; | ||
} | ||
} |
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,109 @@ | ||
import {Injectable, Injector} 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, | ||
private readonly injector: Injector, | ||
) { | ||
this.router.events | ||
.pipe(filter(event => event instanceof NavigationEnd)) | ||
.subscribe(async () => { | ||
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 || ''; | ||
|
||
// Replace route params like :id with actual values | ||
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, nextUrl); | ||
const skipLink = route.routeConfig.data?.['skipLink'] ?? false; | ||
|
||
if (label && label.trim() !== '') { | ||
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, | ||
currentUrl: string, | ||
): string { | ||
const data = route.routeConfig?.data; | ||
|
||
//async breadcrumb logic | ||
const asyncConfig = data?.asyncBreadcrumb; | ||
if (asyncConfig?.service && asyncConfig?.method) { | ||
const params = route.paramMap; | ||
const paramValue = params.get('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. id shouldn't be hardcoded. 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 ! |
||
const fallback = | ||
asyncConfig.fallbackLabel?.(params) || this._toTitleCase(path); | ||
const loadingLabel = asyncConfig.loadingLabel || fallback; | ||
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. Do we have a reusable loader service? If yes, then I think it will be better to show loader instead of a text 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! 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. need to discuss 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. discussed |
||
|
||
setTimeout(async () => { | ||
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. why setTimeout? 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. With setTimeout, the breadcrumb shows loadingLabel immediately, and updates asynchronously when the result arrives. 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. i removed seTimeout.Now, The updated implementation now uses RxJS operators like tap, catchError, and finalize inside the observable pipeline. |
||
try { | ||
const serviceInstance = this.injector.get(asyncConfig.service); | ||
const result$ = serviceInstance[asyncConfig.method](paramValue); | ||
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. This seems a little restrictive as the method can only have 1 required argument of string type. |
||
const result = await result$.toPromise(); | ||
this.updateBreadcrumbLabel(currentUrl, result); | ||
} catch (error) { | ||
console.warn('Async breadcrumb load failed:', error); | ||
} | ||
}, 0); | ||
|
||
return loadingLabel; | ||
} | ||
const breadcrumbData = data?.['breadcrumb']; | ||
|
||
if (typeof breadcrumbData === 'string') { | ||
return breadcrumbData; | ||
} | ||
|
||
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 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! |
||
} | ||
updateBreadcrumbLabel(url: string, newLabel: string): void { | ||
const currentBreadcrumbs = this.breadcrumbs$.getValue(); | ||
const index = currentBreadcrumbs.findIndex(bc => bc.url === url); | ||
if (index !== -1) { | ||
currentBreadcrumbs[index] = { | ||
...currentBreadcrumbs[index], | ||
label: newLabel, | ||
}; | ||
this.breadcrumbs$.next([...currentBreadcrumbs]); | ||
} | ||
} | ||
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