Compare commits
2 Commits
b2ffa7bb6b
...
fc04992ebb
| Author | SHA1 | Date | |
|---|---|---|---|
| fc04992ebb | |||
| 7d27d77629 |
@@ -14,6 +14,12 @@
|
|||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
@if (sidebarContent.template(); as sidebarTemplate) {
|
||||||
|
<div class="menu-page-layout__context">
|
||||||
|
<ng-container [ngTemplateOutlet]="sidebarTemplate" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="menu-page-layout__content">
|
<main class="menu-page-layout__content">
|
||||||
|
|||||||
@@ -40,6 +40,10 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__context {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
&__link {
|
&__link {
|
||||||
color: #888888;
|
color: #888888;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|||||||
@@ -1,11 +1,34 @@
|
|||||||
import { signal } from '@angular/core';
|
import { Component, signal } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { ActivatedRoute, provideRouter } from '@angular/router';
|
import { ActivatedRoute, provideRouter } from '@angular/router';
|
||||||
|
import { RouterTestingHarness } from '@angular/router/testing';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { Tenant } from '../../../../core/services/tenant.interface';
|
import { Tenant } from '../../../../core/services/tenant.interface';
|
||||||
import { TenantService } from '../../../../core/services/tenant.service';
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
import { MenuPageLayoutComponent } from './menu-page-layout.component';
|
import { MenuPageLayoutComponent } from './menu-page-layout.component';
|
||||||
|
import { MenuPageSidebarContentDirective } from './menu-page-sidebar-content.directive';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-sidebar-test-badge',
|
||||||
|
template: '<strong class="sidebar-test-badge">Componente contextual</strong>',
|
||||||
|
})
|
||||||
|
class SidebarTestBadgeComponent {}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [MenuPageSidebarContentDirective, SidebarTestBadgeComponent],
|
||||||
|
template: `
|
||||||
|
<p class="routed-page-content">Contenido principal</p>
|
||||||
|
|
||||||
|
<ng-template menuPageSidebarContent>
|
||||||
|
<p class="sidebar-context-copy">{{ contextualCopy }}</p>
|
||||||
|
<app-sidebar-test-badge />
|
||||||
|
</ng-template>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
class SidebarTestPageComponent {
|
||||||
|
protected readonly contextualCopy = 'HTML declarado por la página';
|
||||||
|
}
|
||||||
|
|
||||||
function createTenant(): Tenant {
|
function createTenant(): Tenant {
|
||||||
return {
|
return {
|
||||||
@@ -133,4 +156,44 @@ describe('MenuPageLayoutComponent', () => {
|
|||||||
'Consultas habituales',
|
'Consultas habituales',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders page-owned HTML and components below the sidebar navigation', async () => {
|
||||||
|
const tenant = signal(createTenant());
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideRouter([
|
||||||
|
{
|
||||||
|
path: 'mi-cuenta',
|
||||||
|
component: MenuPageLayoutComponent,
|
||||||
|
data: {
|
||||||
|
menuCode: 'account',
|
||||||
|
navigationLabel: 'Secciones de mi cuenta',
|
||||||
|
},
|
||||||
|
children: [{ path: 'tickets', component: SidebarTestPageComponent }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
provide: TenantService,
|
||||||
|
useValue: { tenant: tenant.asReadonly() },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
const harness = await RouterTestingHarness.create('/mi-cuenta/tickets');
|
||||||
|
const element = harness.routeNativeElement as HTMLElement;
|
||||||
|
const navigation = element.querySelector('.menu-page-layout__nav');
|
||||||
|
const context = element.querySelector('.menu-page-layout__context');
|
||||||
|
|
||||||
|
expect(context?.previousElementSibling).toBe(navigation);
|
||||||
|
expect(context?.querySelector('.sidebar-context-copy')?.textContent?.trim()).toBe(
|
||||||
|
'HTML declarado por la página',
|
||||||
|
);
|
||||||
|
expect(context?.querySelector('.sidebar-test-badge')?.textContent?.trim()).toBe(
|
||||||
|
'Componente contextual',
|
||||||
|
);
|
||||||
|
expect(element.querySelector('.routed-page-content')?.textContent?.trim()).toBe(
|
||||||
|
'Contenido principal',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
|
import { NgTemplateOutlet } from '@angular/common';
|
||||||
import { Component, computed, inject } from '@angular/core';
|
import { Component, computed, inject } from '@angular/core';
|
||||||
import { ActivatedRoute, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
import { ActivatedRoute, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
import { findMenu } from '../../../../core/services/menu.utils';
|
import { findMenu } from '../../../../core/services/menu.utils';
|
||||||
import { TenantService } from '../../../../core/services/tenant.service';
|
import { TenantService } from '../../../../core/services/tenant.service';
|
||||||
|
import { MenuPageSidebarContentService } from './menu-page-sidebar-content.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-menu-page-layout',
|
selector: 'app-menu-page-layout',
|
||||||
imports: [RouterLink, RouterLinkActive, RouterOutlet],
|
imports: [NgTemplateOutlet, RouterLink, RouterLinkActive, RouterOutlet],
|
||||||
|
providers: [MenuPageSidebarContentService],
|
||||||
templateUrl: './menu-page-layout.component.html',
|
templateUrl: './menu-page-layout.component.html',
|
||||||
styleUrl: './menu-page-layout.component.scss',
|
styleUrl: './menu-page-layout.component.scss',
|
||||||
})
|
})
|
||||||
export class MenuPageLayoutComponent {
|
export class MenuPageLayoutComponent {
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly tenantService = inject(TenantService);
|
private readonly tenantService = inject(TenantService);
|
||||||
|
protected readonly sidebarContent = inject(MenuPageSidebarContentService);
|
||||||
|
|
||||||
protected readonly navigationLabel =
|
protected readonly navigationLabel =
|
||||||
(this.route.snapshot.data['navigationLabel'] as string | undefined) ?? 'Secciones';
|
(this.route.snapshot.data['navigationLabel'] as string | undefined) ?? 'Secciones';
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Directive, inject, OnDestroy, OnInit, TemplateRef } from '@angular/core';
|
||||||
|
|
||||||
|
import { MenuPageSidebarContentService } from './menu-page-sidebar-content.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects page-owned content into the sidebar of the closest menu page layout.
|
||||||
|
* The template keeps the declaration context of the routed page, so it can use
|
||||||
|
* that page's fields, bindings and imported components.
|
||||||
|
*/
|
||||||
|
@Directive({
|
||||||
|
selector: 'ng-template[menuPageSidebarContent]',
|
||||||
|
})
|
||||||
|
export class MenuPageSidebarContentDirective implements OnInit, OnDestroy {
|
||||||
|
private readonly template = inject(TemplateRef<unknown>);
|
||||||
|
private readonly sidebarContent = inject(MenuPageSidebarContentService);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.sidebarContent.register(this.template);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.sidebarContent.unregister(this.template);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Injectable, signal, TemplateRef } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MenuPageSidebarContentService {
|
||||||
|
private readonly templateState = signal<TemplateRef<unknown> | null>(null);
|
||||||
|
|
||||||
|
readonly template = this.templateState.asReadonly();
|
||||||
|
|
||||||
|
register(template: TemplateRef<unknown>): void {
|
||||||
|
this.templateState.set(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
unregister(template: TemplateRef<unknown>): void {
|
||||||
|
if (this.templateState() === template) {
|
||||||
|
this.templateState.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<section class="ticket-event-context" aria-labelledby="ticket-event-context-title">
|
||||||
|
@if (event().image; as image) {
|
||||||
|
<img class="ticket-event-context__image" [src]="image" alt="" />
|
||||||
|
}
|
||||||
|
|
||||||
|
<h2 id="ticket-event-context-title" class="ticket-event-context__title">
|
||||||
|
{{ event().title }}
|
||||||
|
</h2>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-event-context {
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-event-context__image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket-event-context__title {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
color: #666666;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Component, input } from '@angular/core';
|
||||||
|
|
||||||
|
export interface TicketEventContext {
|
||||||
|
title: string;
|
||||||
|
image: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-ticket-event-context',
|
||||||
|
templateUrl: './ticket-event-context.component.html',
|
||||||
|
styleUrl: './ticket-event-context.component.scss',
|
||||||
|
})
|
||||||
|
export class TicketEventContextComponent {
|
||||||
|
readonly event = input.required<TicketEventContext>();
|
||||||
|
}
|
||||||
@@ -109,3 +109,9 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</app-menu-content-section>
|
</app-menu-content-section>
|
||||||
|
|
||||||
|
@if (eventId !== undefined) {
|
||||||
|
<ng-template menuPageSidebarContent>
|
||||||
|
<app-ticket-event-context [event]="eventContext()" />
|
||||||
|
</ng-template>
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
import { By } from '@angular/platform-browser';
|
import { By } from '@angular/platform-browser';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { TenantService } from '../../../../../../core/services/tenant.service';
|
import { TenantService } from '../../../../../../core/services/tenant.service';
|
||||||
import { ToastService } from '../../../../../../core/services/toast.service';
|
import { ToastService } from '../../../../../../core/services/toast.service';
|
||||||
import { ModalService } from '../../../../../../core/services/modal.service';
|
import { ModalService } from '../../../../../../core/services/modal.service';
|
||||||
|
import { MenuPageSidebarContentService } from '../../../../components/menu-page-layout/menu-page-sidebar-content.service';
|
||||||
import { TicketComponent } from './components/ticket/ticket.component';
|
import { TicketComponent } from './components/ticket/ticket.component';
|
||||||
import { TicketResponse, TicketService } from './ticket.service';
|
import { TicketResponse, TicketService } from './ticket.service';
|
||||||
import { TicketsPage } from './tickets-page';
|
import { TicketsPage } from './tickets-page';
|
||||||
|
|
||||||
function withCustomLoading<T extends object>(service: T): T & { withCustomLoading: () => T } {
|
function withCustomLoading<T extends object>(service: T): T & { withCustomLoading: () => T } {
|
||||||
return Object.assign(service, {
|
const enhancedService = Object.assign({ getTicketEvents: () => Promise.resolve([]) }, service);
|
||||||
withCustomLoading: () => service,
|
|
||||||
|
return Object.assign(enhancedService, {
|
||||||
|
withCustomLoading: () => enhancedService,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,4 +304,69 @@ describe('TicketsPage', () => {
|
|||||||
ticket: 'ticket-1',
|
ticket: 'ticket-1',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('declares contextual test data when tickets belong to an event', async () => {
|
||||||
|
const getTickets = vi.fn().mockResolvedValue([]);
|
||||||
|
const getTicketEvents = vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 42,
|
||||||
|
title: 'XX Campeonato Mundial de Pelota Vasca',
|
||||||
|
image: 'https://example.com/pelota-vasca.jpg',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [TicketsPage],
|
||||||
|
providers: [
|
||||||
|
MenuPageSidebarContentService,
|
||||||
|
{ provide: ToastService, useValue: { danger: vi.fn() } },
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: { snapshot: { paramMap: { get: () => '42' } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideComponent(TicketsPage, {
|
||||||
|
set: {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TicketService,
|
||||||
|
useValue: withCustomLoading({ getTickets, getTicketEvents }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(TicketsPage);
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
await vi.waitFor(() => expect(getTicketEvents).toHaveBeenCalledOnce());
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
fixture.componentInstance as unknown as { eventContext: () => { image: string | null } }
|
||||||
|
).eventContext().image,
|
||||||
|
).toBe('https://example.com/pelota-vasca.jpg'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sidebarContent = TestBed.inject(MenuPageSidebarContentService);
|
||||||
|
const sidebarView = sidebarContent.template()?.createEmbeddedView({});
|
||||||
|
sidebarView?.detectChanges();
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
sidebarView?.rootNodes.forEach((node) => container.appendChild(node));
|
||||||
|
|
||||||
|
expect(getTickets).toHaveBeenCalledWith(42);
|
||||||
|
expect(container.querySelector('.ticket-event-context')?.textContent).toContain(
|
||||||
|
'XX Campeonato Mundial de Pelota Vasca',
|
||||||
|
);
|
||||||
|
expect(container.querySelector<HTMLImageElement>('.ticket-event-context__image')?.src).toBe(
|
||||||
|
'https://example.com/pelota-vasca.jpg',
|
||||||
|
);
|
||||||
|
expect(container.querySelector('.ticket-event-context__details')).toBeNull();
|
||||||
|
expect(container.querySelector('.ticket-event-context__recommendation')).toBeNull();
|
||||||
|
|
||||||
|
sidebarView?.destroy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,12 +5,23 @@ import { ModalService } from '../../../../../../core/services/modal.service';
|
|||||||
import { ToastService } from '../../../../../../core/services/toast.service';
|
import { ToastService } from '../../../../../../core/services/toast.service';
|
||||||
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
|
import { IconButtonComponent } from '../../../../../../shared/components/icon-button/icon-button.component';
|
||||||
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
|
import { MenuContentSectionComponent } from '../../../../components/menu-content-section/menu-content-section.component';
|
||||||
|
import { MenuPageSidebarContentDirective } from '../../../../components/menu-page-layout/menu-page-sidebar-content.directive';
|
||||||
|
import {
|
||||||
|
TicketEventContext,
|
||||||
|
TicketEventContextComponent,
|
||||||
|
} from './components/ticket-event-context/ticket-event-context.component';
|
||||||
import { TicketComponent } from './components/ticket/ticket.component';
|
import { TicketComponent } from './components/ticket/ticket.component';
|
||||||
import { TicketResponse, TicketService } from './ticket.service';
|
import { TicketResponse, TicketService } from './ticket.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-tickets-page',
|
selector: 'app-tickets-page',
|
||||||
imports: [IconButtonComponent, TicketComponent, MenuContentSectionComponent],
|
imports: [
|
||||||
|
IconButtonComponent,
|
||||||
|
TicketComponent,
|
||||||
|
TicketEventContextComponent,
|
||||||
|
MenuContentSectionComponent,
|
||||||
|
MenuPageSidebarContentDirective,
|
||||||
|
],
|
||||||
providers: [TicketService],
|
providers: [TicketService],
|
||||||
templateUrl: './tickets-page.html',
|
templateUrl: './tickets-page.html',
|
||||||
styleUrl: './tickets-page.scss',
|
styleUrl: './tickets-page.scss',
|
||||||
@@ -21,6 +32,11 @@ export class TicketsPage implements OnInit {
|
|||||||
private readonly modalService = inject(ModalService);
|
private readonly modalService = inject(ModalService);
|
||||||
private readonly route = inject(ActivatedRoute, { optional: true });
|
private readonly route = inject(ActivatedRoute, { optional: true });
|
||||||
|
|
||||||
|
protected readonly eventId = this.eventIdFromRoute();
|
||||||
|
protected readonly eventContext = signal<TicketEventContext>({
|
||||||
|
title: 'XX Campeonato Mundial de Pelota Vasca',
|
||||||
|
image: null,
|
||||||
|
});
|
||||||
protected readonly tickets = signal<TicketResponse[]>([]);
|
protected readonly tickets = signal<TicketResponse[]>([]);
|
||||||
protected readonly activeTickets = computed(() =>
|
protected readonly activeTickets = computed(() =>
|
||||||
this.tickets().filter((ticket) => !this.isInactive(ticket)),
|
this.tickets().filter((ticket) => !this.isInactive(ticket)),
|
||||||
@@ -38,9 +54,25 @@ export class TicketsPage implements OnInit {
|
|||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
this.tickets.set(
|
const service = this.ticketService.withCustomLoading();
|
||||||
await this.ticketService.withCustomLoading().getTickets(this.eventIdFromRoute()),
|
this.tickets.set(await service.getTickets(this.eventId));
|
||||||
);
|
|
||||||
|
if (this.eventId !== undefined) {
|
||||||
|
void service
|
||||||
|
.getTicketEvents()
|
||||||
|
.then((events) => {
|
||||||
|
const event = events.find((candidate) => candidate.id === this.eventId);
|
||||||
|
|
||||||
|
if (event) {
|
||||||
|
this.eventContext.update((context) => ({
|
||||||
|
...context,
|
||||||
|
title: event.title,
|
||||||
|
image: event.image,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.toastService.danger('Hubo un error al cargar los tickets');
|
this.toastService.danger('Hubo un error al cargar los tickets');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user