Master-Detail Layout component with Router integration

Hi everyone :wave:,
I tried the new Master-Detail Layout component in Hilla and could need a little help regarding the Router integration.

This is the code of my view:

import { ViewConfig } from '@vaadin/hilla-file-router/types.js';
import { AutoGrid } from '@vaadin/hilla-react-crud';
import { GridActiveItemChangedEvent, MasterDetailLayout } from '@vaadin/react-components';
import Task from 'Frontend/generated/de/rwi/hillamasterdetailpatternexample/Task';
import TaskModel from 'Frontend/generated/de/rwi/hillamasterdetailpatternexample/TaskModel';
import { TaskService } from 'Frontend/generated/endpoints';
import { useNavigate, useOutlet } from 'react-router';

export const config: ViewConfig = {
  title: 'MDL Router Integration Tasks',
};

export default function MDLRouterIntegrationTasksView() {
  const navigate = useNavigate();
  const childView = useOutlet();

  return (
    <MasterDetailLayout stackOverlay forceOverlay>
      <MasterDetailLayout.Master>
        <AutoGrid
          className="h-full"
          model={TaskModel}
          service={TaskService}
          onActiveItemChanged={(event: GridActiveItemChangedEvent<Task>) => {
            const item = event.detail.value;
            if (item?.id) {
              navigate(`/mdl-router-integration-tasks/${item.id}`);
            }
          }}
        />
      </MasterDetailLayout.Master>
      <MasterDetailLayout.Detail>{childView}</MasterDetailLayout.Detail>
    </MasterDetailLayout>
  );
}

This is the code for the child view that will be rendered in MasterDetailLayout.Detail:

import { ViewConfig } from '@vaadin/hilla-file-router/types.js';
import { AutoForm } from '@vaadin/hilla-react-crud';
import { useSignal } from '@vaadin/hilla-react-signals';
import { Button, Icon, VerticalLayout } from '@vaadin/react-components';
import Task from 'Frontend/generated/de/rwi/hillamasterdetailpatternexample/Task';
import TaskModel from 'Frontend/generated/de/rwi/hillamasterdetailpatternexample/TaskModel';
import { TaskService } from 'Frontend/generated/endpoints';
import { useCallback, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router';

export const config: ViewConfig = {
  menu: { exclude: true },
};

export default function MDLRouterIntegrationTaskView() {
  const navigate = useNavigate();
  const { taskId } = useParams();
  const task = useSignal<Task>();

  useEffect(() => {
    const getTask = async (id: number) => {
      task.value = await TaskService.get(id);
    };
    if (taskId && !Number.isNaN(taskId)) {
      getTask(Number.parseInt(taskId));
    }
  }, [task, taskId]);

  const navigateToMasterView = useCallback(() => {
    navigate('/mdl-router-integration-tasks');
  }, [navigate]);

  return (
    <VerticalLayout theme="margin">
      <Button aria-label="Navigate to master view" theme="icon" onClick={navigateToMasterView}>
        <Icon icon="lumo:arrow-left" />
      </Button>
      <AutoForm
        className="w-full"
        model={TaskModel}
        service={TaskService}
        item={task.value}
        deleteButtonVisible
        onDeleteSuccess={navigateToMasterView}
        onSubmitSuccess={navigateToMasterView}
      />
    </VerticalLayout>
  );
}

Does this example follow the intended usage of Router integration, or am I missing something or am I doing something wrong?

The example code works in terms of navigation, which means I can navigate between master and detail view. But unfortunately, the master view looses its state, when the detail view is rendered. I wonder if there is a way to avoid this?

If I use the Master-Detail Layout component without Router integration, the master view keeps its state when showing the detail view, which is good. But unfortunately, without the Router integration I won’t have deep-linking for the detail view.

Is there a way to use the Master-Detail Layout component with Router integration (for deep-linking support) and without losing the state of the master view when navigating to the detail view?

Just a quick note for now since the real experts on this topic are currently on vacation.

I would expect that your example with nested routes would work as expected so that the master view state would be preserved when navigating. In other words: it’s quite possible that you’ve found a bug.

1 Like

Thank you @Leif. I had a short conversation with @rofa about the Master-Detail Layout component, the Router integration and the state and he wrote the following:

if the MDL is set up as the parent route layout, its state should not be affected by changes to the inner route

I was able to make it work by implementing some changes to allow the router to correctly pick the navigation as child, which is the key issue here.

  • moved the full @index.tsx to @layout.tsx so that the {taskId}.tsx becomes a child route;
  • created a @index.tsx that returns null so that the detail is closed by default
  • changed the navigation as follows to force relative navigation:
onActiveItemChanged={({ detail: { value } }) => value?.id && navigate(`${value.id}`, { relative: 'route' })}

I also had some issues with everything being lazy by default (especially the layout, it seems to be more reliable with lazy: false), but your mileage may vary.

Also, make sure your service is memoized. In my dummy code I have

    const listService: ListService<Task> = useMemo(() => ({
        list: (pageable, filter) => TaskService.list(pageable, filter || {})
    }), []);

Let me know if it helps.

1 Like

Thank you, @Luciano_Vernaschi for your answer. These are quite a few modifications, and I’m not sure if I can apply all of them, but I will give it a try.

Are these modifications workarounds, or is this the intended way how the Router integration should work with the Master-Detail Layout component?

I applied the following changes

and I can confirm that the Master-View will keep it’s state when navigating to the Detail-View :slight_smile:

I do have the following views-folder structure now:

views/
  mdl-router-integration-tasks/
    {@taskId}/
      @index.tsx
    @index.tsx
    @layout.tsx

The file views/mdl-router-integration-tasks/{taskId}/@index.tsx remained unchanged.

The file views/mdl-router-integration-tasks/@index.tsx has the following content:

import { ViewConfig } from '@vaadin/hilla-file-router/types.js';

export const config: ViewConfig = {
  title: 'MDL Router Integration Tasks'
};

export default function MDLRouterIntegrationTasksView() {
  return null;
}

The file views/mdl-router-integration-tasks/@layout.tsx has the following content:

import { AutoGrid } from '@vaadin/hilla-react-crud';
import { GridActiveItemChangedEvent, MasterDetailLayout } from '@vaadin/react-components';
import Task from 'Frontend/generated/de/rwi/hillamasterdetailpatternexample/Task';
import TaskModel from 'Frontend/generated/de/rwi/hillamasterdetailpatternexample/TaskModel';
import { TaskService } from 'Frontend/generated/endpoints';
import { useNavigate, useOutlet } from 'react-router';

export default function MDLRouterIntegrationLayout() {
  const navigate = useNavigate();
  const childView = useOutlet();

  return (
    <MasterDetailLayout stackOverlay forceOverlay>
      <MasterDetailLayout.Master>
        <AutoGrid
          className="h-full"
          model={TaskModel}
          service={TaskService}
          onActiveItemChanged={(event: GridActiveItemChangedEvent<Task>) => {
            const item = event.detail.value;
            if (item?.id) {
              navigate(`${item.id}`, { relative: 'route' });
            }
          }}
        />
      </MasterDetailLayout.Master>
      <MasterDetailLayout.Detail>{childView}</MasterDetailLayout.Detail>
    </MasterDetailLayout>
  );
}

I was playing with the new master-detail for the first time, so take what I say as my personal opinion for now, but I think that this is the proper way to do that, at least with the file router, for two reasons:

  1. the outlet requires child views, and those are implemented using layouts in file router;
  2. the component is called MasterDetailLayout, so it’s a layout :slightly_smiling_face:

Concerning the lazy layout, it worked for me, and it seems to work for you also.

1 Like

I believe I used the same exact structure in StarPass (although it doesn’t yet use Master-Detail Layout) :+1:

1 Like

Created Explain how to use `MasterDetailLayout` with Hilla file router · Issue #4419 · vaadin/docs · GitHub to think about documentation.

1 Like

Thanks for your explanation @Luciano_Vernaschi and your feedback @Jouni1 :+1:

So far, I haven’t worked with additional @layout.tsx files on a subdirectory level, but if this is the way to go, then it seems to be fine. But to be honest, the content of views/mdl-router-integration-tasks/@index.tsx still looks weird to me:

import { ViewConfig } from '@vaadin/hilla-file-router/types.js';

export const config: ViewConfig = {
  title: 'MDL Router Integration Tasks',
};

export default function MDLRouterIntegrationTasksView() {
  return null;
}

Just for reference: The Router layout is briefly described here: How to use router layouts in Hilla | Vaadin.

Thank you for creating the ticket. I think the existing documentation could be a bit more detailed :sweat_smile: