Привет! Я Дима, разработчик онлайн-бухгалтерии. Предлагаю на примере простой задачи разобрать два подхода к созданию модальных окон, связанных с 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
, нужно менять код самого компонента, а это нарушение границ ответственности компонента диалога. По-хорошему, компонент не должен знать, как именно ему вернуться.
Минусы решения:
Копирование кода из
ngOnInit
при добавлении диалога на новую страницу — не переиспользуемо.Диалог должен знать путь до родителя — нарушение границ ответственности.
Страница-родитель должна знать путь до диалога.
Тянутся лишние зависимости в компонент страницы.
Вывод: можно выбирать императивный подход, если не планируется переиспользовать такое решение в будущем.
Подход второй — декларативный
При реализации переиспользуемого механизма модальных окон, связанных с url
, нужно задать два вопроса:
Можно ли сделать проще?
Можно ли использовать готовые механизмы роутинга в ангуляре?
Навигация для страниц выглядит так:
// 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'
.
Еще один важный момент — открытие диалога поверх страницы, а не вместо нее.
Добиться этого можно в два шага:
Добавить на страницу
<router-outlet>
, чтобы появилась возможность рендерить контент поверх страницы:
// page.component.html
<router-outlet></router-outlet>
Разместить конфигурацию роута на диалог в 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('/')
}
}
}
Хелпер помогает в двух моментах:
Скрывает детали реализации
backUrl
.Позволяет правильно формировать роут, не давая ошибиться программисту.
В результате итоговый конфиг роутера для страницы с диалогом будет таким:
const routes: Routes = [
{
path: 'page',
component: PageComponent,
children: [
tuiGenerateDialogableRoute(DialogContentComponent, 'path/to/dialog')
]
}
]
Что получилось
Теперь на любой странице можно отобразить любой диалог по любому пути. При этом конфигурация будет в привычном декларативном стиле с минимальным количеством кода.
О возвращении можно не задумываться: все скрыто за деталями реализации. И такое решение можно переиспользовать по всему проекту.
Это решение мы сделали в онлайн-бухгалтерии и перенесли в UI Kit — Taiga UI. Посмотреть документацию о routable dialog можно в описании пользовательского интерфейса. Там же есть и вариант для lazy-loading-диалогов.
Если есть вопросы — жду в комментариях, давайте обсудим!
Basters
Дмитрий, спасибо за статью!
Мы пару лет назад пришли точно к такому же решению, но вот есть один момент, который отличается и хочу обратить на него ваше внимание.
tuiGenerateDialogableRoute - принимает только два нужных параметра — компонент и путь.
Не уверен, что этот подход хорош, т.к. на самом деле, существует еще множество параметров, которые нужны. Например Resolver, либо же guard.
У нас хелпер принимает интерфейс Route из angular/route, тем самым мы не ограничиваем разработчика своими обертками, а лишь помогаем ему в реализации его планов.
Что думаете?
Reactiver Автор
Здорово, что пришли к одинаковому решению!
В статье я чуть упростил интерфейс функции, у нас он еще принимает конфиг по диалогу.
Но вот резолверы и гварды, к сожалению, нет. Предложение 100% валидное. Я добавлю поддержку интерфейса
Route
в следующей версии.Спасибо за наводку!