Любой более менее опытный фронтендер, работающий с Angular, умеет пользоваться роутером. Тут путь. Здесь компонент. Не забудь положить router-outlet в темплейт в нужном месте и вуаля.

И это покрывает 95% всех кейсов любого приложения. Остальное можно подпереть костылями. Одни из них хрупки, как китайский фарфор. Другие вполне себе претендуют на решение, достойное самого ядра приложения.

Давайте представим не такой уж редкий случай: онлайн магазин, выбрали покупки, посмотрели корзину, приступаем к оформлению.

И тут дизайнер поменял наркотики: все формы оформления заказа отрисованы в диалоговом окне. На вопрос “Зачем?” получаем отсутствующий взгляд и глупое хихиканье. Заказчик не добавляет позитива и соглашается во всем с дизайнером.

Что надо сделать?

По сути, надо просто заполнять пошагово формы.

В позитивном сценарии мы прошли все шаги, безошибочно заполнив данные и последовали к подтверждению.

Однако, может случится так, что на этапе конфирмации диагностировали болезнь толстых пальцев. Надо вернуться и исправить.

И все бы ничего, но мы в диалоговом окне. Хорошо, если у нас есть жирнющая кнопка "Назад". Но пользовательский опыт пальцем не задавишь: кнопки браузерной навигации - самое понятное пользователю действие.

Включаем костыли

Первый подход (интуитивный): создаем компонент-контейнер (OrderDetailsComponent) (aka router-outlet на минималках) и начинаем следить за роутером и подменять вьюхи соответственно.

// app-routing.module.ts
const routes = [
	...
  {
  	path: 'order',
    component: OrderComponent,
    // можно в гарды положить логику открытия/закрытия диалога
    // и убрать из инита/дестроя - дело вкуса
    children: [
    	path: '**',
      component: null
    ]
  }
];

// order.component.html
<router-outlet></router-outlet>

// order.component.ts
...
	ngOnInit(): void {
  	this._ref = this._dialog.open(OrderDetailsComponent, {
    	data: { activatedRoute: this._route }
      ...
    });
  }

// order-details.component.ts - компонент, который будет отрисован в диалоге
// imports

const STEP_MAP = {
	'personal-details': PersonalDetailsComponent,
  'delivery-details': DeliveryDetailsComponent,
  'payment-details': PaymentDetailsComponent,
  'order-confirmation': OrderConfirmationComponent
};

@Component({
	selector: 'app-order-details',
  template: '<ng-container #ref></ng-container>'
  styleUrls: ['./order-details.scss']
})
export class OrderDetails implements OnInit, OnDestroy {
	private _destroyed$ = new Subject<void>();
  
  @ViewChild('ref', { read: ViewContainerRef }) private _vcr: ViewContainerRef: 

	constructor(
  	private _vcr: ViewContainerRef,
    private _router: Router,
    @Inject(MAT_DIALOG_DATA) private _d: unknown
  ) {}
  
  ngOnInit(): void {
  	this._router.events
    	.pipe(
   			filter(evt => evt instanceof NavigationEnd),
        map(evt => {
        	const tree = this._router.parseUrl(this._router.url);
          const { segments } = tree.root.children.primary;
          return segments[segments.length - 1]?.path;
        }),
        distinctUntilChanged(),
  			takeUntil(this._destroyed$)
    	)
      .subscribe(this._renderStep.bind(this));
      
    // start with first step
    this._router.navigate(['personal-details'], { relativeTo: this._d.activatedRoute }); 
  }
  
  ngOnDestroy(): void {
  	this._destroyed$.next();
    this._destroyed$.complete();
  }
  
  private _renderStep(stepId: keyof typeof STEP_MAP): void {
  	...
  }
}

Достаточно прямолинейно, что уже хорошо. Компонент OrderDetailsComponent можно генерализировать во что-то переиспользуемое. Но что в этом всем плохо?

  • не масштабируемо. Все, что не уложится в линейную схему роутинга, будет вызывать зубную боль;

  • парсинг урла будет работать условно хорошо только на один уровень. Дальше - мрак;

  • в _renderStep мы фактически пишем свой роутер, только без блэкджека и шл...

  • конфигурация роутинга нам ни о чем не расскажет. Нужно лезть внутрь компонентов, чтобы понять, что и как устроено.

Второй подход (гениально-очевидный): ничего не делаем сами, и наслаждаемся дарами Angular.

// app-routing.module.ts
const routes = [
	...
  {
  	path: 'order',
    component: OrderComponent,
    children: [
    	{
        path: '',
        pathMatch: 'full',
        redirectTo: 'personal-details'
      },
      {
      	... конфигурируем остальные роуты
      }
    ]
  }
];

// order.component.html
<ng-template><router-outlet></router-outlet></ng-template>

// order.component.ts
@ViewChild(TemplateRef) _tpl!: TemplateRef<unknown>;
	...
ngAfterViewInit(): void {
  this._ref = this._dialog.open(this._tpl);
}
  
ngOnDestroy(): void {
  this._ref?.close();
}

Весь трюк в том, чтобы router-outlet ворочался в диалоговом окне. Объявляем его в компоненте-плейсхолдере OrderComponent внутри ng-template, забираем после инициализации вьюхи наш темплейт и тулим его сразу в диалог.

Выводы

Никакой смены парадигмы, работаем в тех же координатах.

Комментарии (3)


  1. mrychagov
    21.10.2021 07:26
    +1

    Была однажды подобная задачка, только по UI это выглядело не как модальное окно, а как выезжающий сбоку sidebar и в нем были табы с разным содержимым.

    Мы пошли третьим путем, в сравнении с вашим.

    Объявили именованный outler на странице и просто производили всю навигацию в контексте этого самого outlet:

    // page.html
    <router-outlet name="modal"></router-outlet>
    
    // app-routing.module.ts
    const routes = [
    	...
      {
      	path: 'order',
        outlet: 'modal'
        component: OrderComponent,
        children: [
        	...
        ]
      }
    ];
    

    В итоге это работало максимально нативно, но с двумя неудобствами:

    • URL выглядел не user friendly из-за двойной навигации в нем, что-то вроде `/foo/bar(sidebar:/baz)`, уже точно не помню как он формируется

    • Роуту приходило помогать - где-то сбрасывать outlet руками при навигации, если изменился именно promary route, где-то наборот делать навигацию явно указывая нужный outlet. Несколько хелперов решили все проблемы, а у вас вообще модальное окно и у пользователя не будет возможности менять primary route не закрыв outlet


  1. kovalevsky
    21.10.2021 11:08
    +1

    Неплохая идея, единственное, следовало бы упомянуть, что после того, как диалог закроется - начинаются пляски с бубном, т.к. состояние адресной строки и того, что находится на странице больше не соответствует действительности :)


    1. yugo_fx Автор
      21.10.2021 15:26

      Хорошее замечание, если в диалоге есть еще какая-то кнопка закрывашка. Но закрывашка в таком диалоге работает как routerLink. Уходя с роута компонента-диалога у нас закрывается сам диалог. Рассинхрона нет.

      Что-то иное тоже можно просто обработать

      this._ref.afterClosed().pipe(
        // топорно
        this.location.back()
        // кастомно
        this.router.navigate([...])
      ).subscribe()