Привет! Я Дима, разработчик онлайн-бухгалтерии. Предлагаю на примере простой задачи разобрать два подхода к созданию модальных окон, связанных с url: императивный и декларативный.

Часто на фронтенде нужно открывать модальные окна по определенному пути. Из коробки ангуляр не предоставляет такой возможности, так же как и популярные ui-kit-библиотеки. И разработчики каждый раз ищут способ, как это сделать.

Условие задачи

Нужно открыть по url /page/dialog диалог поверх страницы page.

Подход первый — императивный

На странице page в методе ngOnInit или конструкторе проверяем текущий url. Если там содержится путь до диалога — открываем диалог. Звучит понятно и просто, в коде выглядит так:

@Component({...})
class PageComponent implements OnInit {
	constructor(
		private readonly router: Router,
		private readonly tuiDialogService: TuiDialogService,
	) {}

	ngOnInit() {
		// Если это путь до диалога, то открываем его
		if (this.router.url.includes('dialog')) {
			this.tuiDialogService.open(DialogContentComponent).subscribe();
		}
	}
}

В Taiga UI диалоги открываются методом open, который возвращает Observable. Кстати, про холодный и горячий Observable в rxjs рассказывал мой коллега:

При подписке на Observable диалог открывается, а при отписке — закрывается. Чтобы по url на диалог открывалась страница page, нужно сконфигурировать роутер:

const router: Routes = [
	{
		path: 'page',
		component: PageComponent,
	},
	{
		// по прямому пути на диалог будет открываться страница
		path: 'page/dialog',
		component: PageComponent,
	}
]

Теперь при переходе по прямой ссылке /page/dialog откроется диалог поверх страницы. Важно учесть, что при закрытии диалога нужно менять роутинг обратно на родительскую страницу. Пример реализации:

@Component({…})
class DialogContentComponent {
	constructor(private readonly location: Location) {}

	 goBack(): void {
		this.location.back();
	}
}

При закрытии диалога вызывается метод, возвращающий на предыдущую страницу. Но есть неприятный нюанс: если открыть диалог по прямой ссылке, например, с главной страницы Google, то при закрытии случится редирект обратно на Google. Эту проблему можно исправить, если знать «обратный путь» от диалога до страницы.

@Component({...})
class DialogContentComponent {
	constructor(	
		private readonly router: Router,
		private readonly route: ActivatedRoute
	) {}

	goBack(): void {
		this.router.navigate(['..', {relativeTo: this.route});
	}
}

Сейчас метод возвращения сработает ожидаемо. Если открыть прямую ссылку на диалог и потом закрыть диалог, то браузер отобразит страницу Page и не выбросит обратно из нашего приложения.

Но метод возвращения должен знать конкретный путь до диалога. Если нужно будет поменять путь до диалога, например, на page/path/to/dialog — придется менять и код возвращения: с this.router.navigate(['..', {relativeTo: this.route}) на this.router.navigate(['../../..', {relativeTo: this.route}).

Такой диалог может понадобиться и на другой странице. Когда добавляется диалог на новую страницу под новым url, нужно менять код самого компонента, а это нарушение границ ответственности компонента диалога. По-хорошему, компонент не должен знать, как именно ему вернуться.

Минусы решения:

  1. Копирование кода из ngOnInit при добавлении диалога на новую страницу — не переиспользуемо.

  2. Диалог должен знать путь до родителя — нарушение границ ответственности.

  3. Страница-родитель должна знать путь до диалога.

  4. Тянутся лишние зависимости в компонент страницы.

Вывод: можно выбирать императивный подход, если не планируется переиспользовать такое решение в будущем.

Подход второй — декларативный

При реализации переиспользуемого механизма модальных окон, связанных с url, нужно задать два вопроса:

  1. Можно ли сделать проще?

  2. Можно ли использовать готовые механизмы роутинга в ангуляре?

Навигация для страниц выглядит так:

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

// app-routing.module.ts
const routes: Routes = [
	{
		path: 'home',
		component: HomePageComponent,
	},
	{
		path: 'main',
		component: MainPageComponent
	}
]

В app-component находится <router-outlet>, в который рендерится компонент текущей страницы по конфигу из роутера. Подход декларативный, понятный, и хочется сделать решение с навигируемыми диалогами в подобном формате с условиями:

  • Диалог открывается поверх страницы.

  • Удобно конфигурировать.

  • Удобно возвращаться.

  • Универсально для любых диалогов.

Роутинг ангуляра в минимальном варианте требует два параметра: path и component, или модуль страницы вместо компонента. Особенность в том, что роутинг может работать только с двумя сущностями: компонентом и lazy-loading-модулем, который все равно рендерит компонент. Получается, что роутеру в любом случае нужно подавать компонент.

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

С компонентом-оберткой понятно, а как задавать path для диалога? Под path можно использовать путь относительно родителя — path: 'path/to/dialog'.

Еще один важный момент — открытие диалога поверх страницы, а не вместо нее.

Добиться этого можно в два шага:

  1. Добавить на страницу <router-outlet>, чтобы появилась возможность рендерить контент поверх страницы:

// page.component.html
<router-outlet></router-outlet>
  1. Разместить конфигурацию роута на диалог в children-свойстве конфига страницы. В противном случае диалог будет рендериться в <router-outlet> на уровне выше вместо текущей страницы:

// app-routing.module.ts
const routes: Routes = [
	{
		path: 'page',
		component: PageComponent,
		children: [
			{
				path: 'path/to/dialog',
				
				// компонент-обертка для открытия диалогов
				component: RoutableDialogComponent,

				// поле data будет доступно через ActivatedRoute
                // в компоненте-обертке
				data: {
					// передаем нужный компонент диалога
					dialog: DialogContentComponent,
				}
			}
		]
    }
]

Теперь реализуем компонент-обертку, открывающую переданный компонент диалога:

@Component({...})
class RoutableDialogComponent {
	constructor(
	    tuiDialogService: TuiDialogService,
	    route: ActivatedRoute
	) {
		tuiDialogService.open(
			new PolymorpheusComponent(
				route.snapshot.data['dialog']
			)
		).subscribe();
	}
}

Вуаля! Можно открывать любой диалог по любому пути на любой странице. Но осталась проблема с закрытием диалога и возвращением на родительский url.

В TaigaUI диалоги открываются подпиской на метод открытия диалогов. При закрытии диалога подписка завершается. Используем этот факт для возвращения:

@Component({...})
class RoutableDialogComponent {
	constructor(...) {
		tuiDialogService
			.open(...)
			.subscribe({
				// при завершении потока возвращаемся
				complete: () => this.navigateToParent(),
			});
	}

	
	private navigateToParent(): void { 
		...
	}
}

Чтобы отвязать компонент-обертку от конкретных путей, вынесем параметр backUrl в конфигурацию роутера:

// routable-dialog.component.ts
private navigateToParent(): void {
	this.router.navigate([this.route.snapshot.data['backUrl']], {
		relativeTo: this.route
	});
}

// app-routing.module.ts
const routes: Routes = [
	{
		path: 'page',
		component: PageComponent,
		children: [
			{
				path: 'path/to/dialog',
				component: RoutableDialogComponent,
				data: {
					dialog: DialogContentComponent,
					
					// указываем обратный путь
					backUrl: '../../..'
				}
			}
		]
    }
]

Было бы здорово не писать backUrl руками, а вычислять из пути. Для этого можно сделать хелпер-генератор роута:

// хелпер принимает только два нужных параметра — компонент и путь
function tuiGenerateDialogableRoute(component, path): Route {
	return {
		path,
		component: RoutableDialogComponent,
		data: {
			dialog: component,
			// вычисляет обратный путь заменой сегментов пути на две точки
			backUrl: path.split('/').map(() => '..').join('/')
		}
	}
}

Хелпер помогает в двух моментах:

  1. Скрывает детали реализации backUrl.

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

В результате итоговый конфиг роутера для страницы с диалогом будет таким:

const routes: Routes = [
  {
    path: 'page',
	component: PageComponent,
	children: [
  	  tuiGenerateDialogableRoute(DialogContentComponent, 'path/to/dialog')
	]
  }
]

Что получилось

Теперь на любой странице можно отобразить любой диалог по любому пути. При этом конфигурация будет в привычном декларативном стиле с минимальным количеством кода.

О возвращении можно не задумываться: все скрыто за деталями реализации. И такое решение можно переиспользовать по всему проекту.

Это решение мы сделали в онлайн-бухгалтерии и перенесли в UI Kit — Taiga UI. Посмотреть документацию о routable dialog можно в описании пользовательского интерфейса. Там же есть и вариант для lazy-loading-диалогов.

Если есть вопросы — жду в комментариях, давайте обсудим!

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


  1. Basters
    26.04.2023 11:04
    +1

    Дмитрий, спасибо за статью!

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

    tuiGenerateDialogableRoute - принимает только два нужных параметра — компонент и путь.

    Не уверен, что этот подход хорош, т.к. на самом деле, существует еще множество параметров, которые нужны. Например Resolver, либо же guard.

    У нас хелпер принимает интерфейс Route из angular/route, тем самым мы не ограничиваем разработчика своими обертками, а лишь помогаем ему в реализации его планов.

    Что думаете?




    1. Reactiver Автор
      26.04.2023 11:04

      Здорово, что пришли к одинаковому решению!

      В статье я чуть упростил интерфейс функции, у нас он еще принимает конфиг по диалогу.
      Но вот резолверы и гварды, к сожалению, нет. Предложение 100% валидное. Я добавлю поддержку интерфейса Route в следующей версии.

      Спасибо за наводку!