React app using router - render A12 models on certain routes only

In our react app, we are using react-router with several different routes.

What we want to achieve is to render A12 models in specific routes of our app, while others just render normal react components. We already got it working, but we are wondering if the current approach is really the best one that we could take.

So, our current approach looks like this:

  1. We have react-router which controls the routes: /projects, /project/<id>, etc.
<HashRouter>
      <Routes>
        <Route path="/" element={<DefaultLayout />}>
          <Route index element={<HomePage />} />
          <Route path="projects" element={<ProjectsPage />} />
          <Route path="projects/:projectId" element={<ProjectPage />} />
          ...
  1. Each route (e.g. /projects) consists of a React component which renders that corresponding page.
    As an example, here is the code for the “projects” case, which is a page that should render A12 models. Therefore it registers the respective module and launches the initial activity of it.
// ... imports omitted
import projectsModule from "@/modules/projects";
import { useRegisterModule } from "@/hooks/useRegisterModule";

export const ProjectsPage: FC = () => {
  const dispatch = useDispatch();
  // register the projects module for the lifetime of this page
  useRegisterModule([projectsModule()]);

  useMount(() => {
    // here, we launch the initial activity
    dispatch(ActivityActions.create({
      activityDescriptor: {
        module: "ProjectModule",
        engine: "overview",
      }
    }))
  })

  // taken from full-stack template
  return (
    <AuthenticatedPage layoutProvider={customLayoutProvider}/>
  );
}

Where useRegisterModule.ts looks like this:

// ... imports omitted
import { clearActivities } from "@/store/modules/app/actions";

const moduleRegistry = ModuleRegistryProvider.getInstance();

/**
 * Hook for registering given modules for the lifetime of the enclosing react
 * component. When the enclosing react component is destroyed, the modules are removed
 * from the registered modules again.
 * @param modules {Array} the desired modules instances
 */
export const useRegisterModule = (modules: Module[]) => {
  const dispatch = useDispatch();
  const modulesRegisterer = new ModulesRegisterer();

  useMount(() => {
    modulesRegisterer.initModules(modules);
    modulesRegisterer.register();
  });

  useUnmount(() => {
    // clears all activities
    dispatch(clearActivities());
    modulesRegisterer.unregister();
  })
};

/**
 * Class for registering a given modules
 */
export class ModulesRegisterer {
  modules: Module[];
  constructor() {
    this.modules = [];
  }

  /**
   * Sets initial modules array
   * @param modules
   */
  initModules(modules: Module[]) {
    this.modules = modules;
  }

  /**
   * Register given modules
   */
  register() {
    this.modules.forEach((module: Module) => {
      if (!moduleRegistry.getAllModules().includes(module)) {
        // module not registered yet -> register it
        moduleRegistry.addModule(module);
      }
    })
  }

  /**
   * Unregister given modules
   */
  unregister() {
    this.modules.forEach((module: Module) => {
      moduleRegistry.removeModule(module);
    })
  }
}

(useMount and useUnmount do just what their name implies: run once the component gets mounted/unmounted).

  1. For pages/routes that should render A12 models, we use a dedicated appmodel.json file (e.g. projects-appmodel.json). The appmodel is just like any installer-appmodel.json, containing some modules with their respective models.
  2. We define the A12 Module which imports the appmodel.
    The ProjectsModule which was previously registered within the page looks like this:
// ... imports omitted
import * as model from "./projects-appmodel.json";

const module = (): Module => ({
	id: "ProjectsModule",
	model: (): ApplicationModel => model as ApplicationModel,
});

export default module;

We repeat this procedure for every route/page, which should render A12 models.
The approach works, but uses also a lot of boilerplate code and still contains some dubious parts.

Now my questions are:

  • Has anybody tried to implement something similar?
  • Does anybody have some hints on how we could simplify our approach?
  • I know that A12 doesn’t work with react-router by default. But would it be possible to somehow integrate it in the future?

We wanted to do a similar thing in our app, implement a routing mechanism with the normal support of routing (browser history, navigation) that also triggers activity actions to render specific A12 views.
So we created a minimal module that does those 2 things, you can have a look in the a12-redux-router repository of MGM-PLAYGROUND, it’s a Bitbucket repo open to everyone.

Hi,

unfortunately we couldn’t find any way to access Bitbucket. Everywhere in the documentation it states that it is “mgm internal only”. So I suppose we can’t access it.

If this is really a public repo, could you provide us with the link to it? Or maybe share the repo code in another way with us?

Thanks

The Readme:

A12 Redux Router

This module handles the routing in a react-redux app. The goal is to implement a routing
mechanism, that supports redux actions at the same time.

This is a lightweight solution with a minimum effort to make it up and running.

Motivation

In A12 based projects we need to load views by dispatching activity actions. By doing so, there is
no intuitive way to use a classic react-routing module, because there is no actual component tree
that can correspond to the different routes. We delegate this issue by creating a routing module
that supports redux actions.

How it works

The main function that navigates to different URLs is the navigateToUrl. Whenever we need to navigate
into a URL after an action, we call this function.

For example, if we want to navigate to a URL after clicking a button, we add this function in the click handler:

function onClick() {
	navigateToUrl(routerConfig, `/#some-url?entityId=${id}`, dispatch);
}

Here the second parameter is clear, it is the URL we want to navigate into. The last parameter
is the dispatch function, which is used later by routerConfig in a callback, in order to dispatch actions for example.

The first parameter routerConfig is the main config of our router. Here we define the callbacks with dispatch actions
for each URL patter. For example:

const routerConfig: RouterConfig[] = [
	{
		route: "/#some-url",
		callback: (params, dispatch) => {
			dispatch(
				exampleAction({
					entityId: params["entityId"],
				})
			);
		},
	},
];

In the example above, whenever we use navigateToUrl with a URL that matches #some-url, the callback is called
to dispatch an exampleAction. We can extract the query parameters and access them through the params argument, which is
a simple map.

Initial redirect

We may also define an array with the base routes in our config for the initial redirect:

const SOME_URL = "#some-url";
const BASE_ROUTES = [SOME_URL];
const routerConfig: RouterConfig[] = [
	{
		route: `/${SOME_URL}`,
		callback: (params, dispatch) => {
			dispatch(
				exampleAction({
					entityId: params["entityId"],
				})
			);
		},
	},
];

Then, we can use this array for the initial redirect. This can be put for example in a saga after the login
or in the main component when it loads:

React.useEffect(() => {
	doInitialRedirect(BASE_ROUTES, routerConfig, dispatch);
}, [])

Asynchronous routing

If we need to handle additional things that may run asynchronously before navigating to a url, for example check if the id
of an element exists in the database or ask the user with a modal dialog if he or she wants to leave the page, then we can use
the asynchronous routing. There are three actions that a saga or a component can dispatch:

  • navigateToUrlTrigger, if we want to initiate the asyncronous routing.
  • navigateToUrlSuccess, if the result of the asynchronous action is success, this action will trigger the routing to the new url.
  • navigateToUrlFailure, if the result of the asynchronous action is failure, this action will stop the routing.

In order to enable the asynchronous routing, we need to declare the navigateToUrlSaga in our appsetup.

Asynchronous routing example

We can initiate the routing for example in a saga:

yield* put(
	navigateToUrlTrigger({
		routerConfig,
		pathname: `/#some-url?entityId=${id}`,
		dispatch: payload.dispatch,
	})
);

The dispatch function can be passed in the payload of the activity that triggers the saga.

When the operation is ok we can dispatch the success action:

yield* put(navigateToUrlSuccess());

Otherwise the fail action:

yield* put(navigateToUrlFailure());

Additional features

The module also supports back and forth browser history events, this is done by the history listener. We also have another
listener to warn us when we navigate out of the app or close the browser window. It is very simple to instantiate those listeners:

registerHistoryListener(routerConfig, config.store.dispatch);
registerLeaveThePageListener();

Example App

To see how the router module is configured in a simple react-redux app view this readme.

The module:

import { AnyAction, Dispatch } from "redux";
import actionCreatorFactory, { ActionCreator, Action } from "typescript-fsa";
import { ApplicationSaga } from "@com.mgmtp.a12.client/client-core/lib/core/application";
import { Activity } from "@com.mgmtp.a12.client/client-core/lib/core/activity";
import { race, SagaGenerator, take } from "typed-redux-saga";

export interface RouterConfig {
	route: string;
	callback(params: PathParameters, dispatch: Dispatch): void;
}

export type PathParameters = { [key: string]: string };

export interface NavigateToUrlPayload {
	routerConfig: RouterConfig[];
	pathname: string;
	dispatch?: Dispatch;
}

const factory = actionCreatorFactory("NAVIGATE_TO_URL");

/**
 * Action to initiate an asynchronous routing.
 */
export const navigateToUrlTrigger: ActionCreator<NavigateToUrlPayload> = factory<NavigateToUrlPayload>("TRIGGER");

/**
 * Action to report success for an asynchronous routing.
 */
export const navigateToUrlSuccess: ActionCreator<void> = factory<void>("SUCCESS");

/**
 * Action to report failure for an asynchronous routing.
 */
export const navigateToUrlFailure: ActionCreator<void> = factory<void>("FAILURE");

/**
 * Takes a pathname as argument and checks if it matches with any
 * route template in the configurations. If there is a match it calls
 * the callback with the extracted path parameters as argument.
 *
 * @param routerConfig the routing configuration
 * @param pathname the url pathname
 * @param dispatch redux dispatch to trigger actions
 */
export function navigateToUrl(routerConfig: RouterConfig[], pathname: string, dispatch?: Dispatch, goBack?: boolean) {
	for (const router of routerConfig) {
		const params = extractParams(pathname, router.route);
		if (params) {
			if (!goBack) {
				history.pushState({}, "", pathname);
			}
			if (dispatch) {
				router.callback(params, dispatch);
			}
		}
	}
}

/**
 * Register this saga if you want to use the {@link navigateToUrlTrigger}, {@link navigateToUrlSuccess}
 * and {@link navigateToUrlFailure} actions for async url navigation.
 */
export function navigateToUrlSaga(): ApplicationSaga.Descriptor {
	return {
		canHandle: (_ad: Activity.Descriptor, action: AnyAction) => {
			return navigateToUrlTrigger.match(action);
		},
		handle: function* (action: Action<NavigateToUrlPayload>): SagaGenerator<void> {
			for (const router of action.payload.routerConfig) {
				const params = extractParams(action.payload.pathname, router.route);
				if (params) {
					if (action.payload.dispatch) {
						router.callback(params, action.payload.dispatch);
					}
				}
			}
			const { success } = yield* race({
				success: take(navigateToUrlSuccess),
				failure: take(navigateToUrlFailure),
			});
			if (success) {
				history.pushState({}, "", action.payload.pathname);
			}
		},
	};
}

/**
 * Navigates to a URL from the config based on the window location hash.
 * Use this function in a place where your app first loads.
 * 
 * @param baseRoutes the base routes from your config
 * @param routerConfig the router config
 * @param dispatch the dispatch redux function
 */
export function doInitialRedirect(baseRoutes: string[], routerConfig: RouterConfig[], dispatch: Dispatch) {
	if (baseRoutes.some(br => window.location.hash.startsWith(br))) {
		navigateToUrl(routerConfig, `/${window.location.hash}`, dispatch);
	}
}

/**
 * Registers a listener to listen for browser history back
 * and forward events, then calls the appropriate handler for
 * the url path.
 *
 * @param routerConfig the router configuration
 * @param dispatch redux dispatch to trigger actions
 */
export function registerHistoryListener(routerConfig: RouterConfig[], dispatch: Dispatch) {
	window.addEventListener("popstate", () => {
		const destination = window.location.pathname + window.location.hash;
		navigateToUrl(routerConfig, destination, dispatch, true);
	});
}

/**
 * Adds an event listener to warn the user when leaving the page.
 */
export function registerLeaveThePageListener() {
	window.addEventListener("beforeunload", e => {
		// the message here does not affect the dialog message because all major browsers don't
		// support custom messages for the onbeforeunload event anymore
		const confirmationMessage = "Show warning";
		(e || window.event).returnValue = confirmationMessage; //Gecko + IE
		return confirmationMessage;
	});
}

/**
 * Extracts the url parameters from a url pathname if it matches the
 * given route. For example:
 *
 * - /#items?itemid=12
 * - /#customer?customerId=2&departmentId=3
 *
 * @param pathname the url pathname
 * @param route a route template
 * @returns the resolved parameters or undefined
 */
function extractParams(pathname: string, route: string): PathParameters | undefined {
	const basePathAndParams = pathname.split("?");
	const basePath = basePathAndParams[0];
	const routeMatches = basePath === route;
	if (routeMatches && basePathAndParams.length > 1) {
		const params: PathParameters = {};
		const paramsString = basePathAndParams[1];
		const keyValuePairs = paramsString.split("&");
		for (const pair of keyValuePairs) {
			const keyValue = pair.split("=");
			params[keyValue[0]] = keyValue[1];
		}
		return params;
	}
	return routeMatches ? {} : undefined;
}

Thank you!

Can you provide us with the example app also?
That would be very helpful.

@theodossios-steep-crest ?

Router config:

import { RouterConfig } from "a12-redux-router";
import { selectItem } from "../sidebarSlice";

const QUOTE_A = "#quote-a";
const QUOTE_B = "#quote-b";
const QUOTE_C = "#quote-c";

export const BASE_ROUTES = [QUOTE_A, QUOTE_B, QUOTE_C]

export const routerConfig: RouterConfig[] = [
    {
        route: `/${QUOTE_A}`,
        callback: (params, dispatch) => {
            dispatch(selectItem("A"))
        }
    },
    {
        route: `/${QUOTE_B}`,
        callback: (params, dispatch) => {
            dispatch(selectItem("B"))
        }
    },
    {
        route: `/${QUOTE_C}`,
        callback: (params, dispatch) => {
            dispatch(selectItem("C"))
        }
    }
]

index.tsx:

import * as React from "react";
import * as ReactDOM from "react-dom";
import App from "./App";
import { Provider } from "react-redux";
import { StyleSheetManager, ThemeProvider } from "styled-components";
import { defaultTheme } from "@com.mgmtp.a12.widgets/widgets-core/lib/theme/default/default-theme";
import { GlobalStyles } from "@com.mgmtp.a12.widgets/widgets-core/lib/theme/base";
import { registerHistoryListener } from "a12-redux-router";
import { store } from "./store";
import { routerConfig } from "./router/config";

registerHistoryListener(routerConfig, store.dispatch);

ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
      <StyleSheetManager disableVendorPrefixes>
        <ThemeProvider theme={defaultTheme}>
          <GlobalStyles />
          <App />
        </ThemeProvider>
      </StyleSheetManager>
    </Provider>
  </React.StrictMode>, document.getElementById('root')
);

App.tsx:

import * as React from "react";
import { useSelector, useDispatch } from "react-redux";
import { ApplicationFrame } from "@com.mgmtp.a12.widgets/widgets-core";
import { Header } from "./Header";
import { Sidebar } from "./Sidebar";
import { Content } from "./Content";
import { RootState } from "./store";
import { doInitialRedirect, navigateToUrl } from "a12-redux-router";
import { BASE_ROUTES, routerConfig } from "./router/config";

export const sidebarItems = [
	{
		id: "A",
		quote: "Life is short, smile while you still have teeth."
	},
	{
		id: "B",
		quote: "If two wrongs don't make a right, try three."
	},
	{
		id: "C",
		quote: "I am not lazy, I am on energy saving mode."
	}
];


export default function App() {
	const dispatch = useDispatch();
	const selectedItemId = useSelector((state: RootState) => state.sidebar.selectedItemId);

	const items = sidebarItems.map((item, index) => ({
		label: item.id,
		selected: item.id === selectedItemId,
		onClick: () => navigateToUrl(routerConfig, `/#quote-${item.id.toLowerCase()}`, dispatch)
	}));

	React.useEffect(() => {
		doInitialRedirect(BASE_ROUTES, routerConfig, dispatch);
	}, [])

	const selectedSidebarItem = sidebarItems.find(i => i.id === selectedItemId);
	if (!selectedSidebarItem) {
		return <></>;
	}
	const content = selectedSidebarItem.quote;
	return (
		<ApplicationFrame
			main={<Header />}
			sub={<Sidebar items={items}/>}
			content={<Content title={selectedSidebarItem.id} text={content}/>}
		/>
	);
}

Sidebar.tsx:

import * as React from "react";
import { MenuItem, SlidingMenu } from "@com.mgmtp.a12.widgets/widgets-core";

export interface SidebarProps {
	items: MenuItem[];
}

export function Sidebar(props: SidebarProps): React.ReactElement<SidebarProps> {
	return <SlidingMenu collapsed items={props.items} scrollToSelectedItem mainContainerLabel="Main navigation" />;
}

Content.tsx:

import * as React from "react";
import { ActionContentbox, ContentBoxElements } from "@com.mgmtp.a12.widgets/widgets-core";

export interface ContentProps {
	title: string;
	text: string;
}

export function Content(props: ContentProps): React.ReactElement<ContentProps> {
	return (
		<ActionContentbox
			headingElements={<ContentBoxElements.Title text={props.title}/>}
		>
			{props.text}
		</ActionContentbox>
	);
}

Header.tsx:

import * as React from "react";
import { ApplicationHeader } from "@com.mgmtp.a12.widgets/widgets-core";

export function Header(): React.ReactElement {
	return (
		<div>
			<ApplicationHeader leftSlots="A12 Redux Router"/>
		</div>
	);
}

sidebarSlice.ts:

import { createSlice } from "@reduxjs/toolkit";
import type { PayloadAction } from '@reduxjs/toolkit'

export interface SidebarState {
    selectedItemId: string;
}

const initialState: SidebarState = {
    selectedItemId: "A"
};

const sidebarSlice = createSlice({
    name: "sidebar",
    initialState,
    reducers: {
        selectItem: (state, action: PayloadAction<string>) => {
            state.selectedItemId = action.payload
        }
    }
});

export const { selectItem } = sidebarSlice.actions;

export default sidebarSlice.reducer;

store.ts:

import { configureStore } from '@reduxjs/toolkit'
import sidebarReducer from './sidebarSlice'

export const store = configureStore({
  reducer: {
    sidebar: sidebarReducer
  },
})

// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch

package.json

{
  "name": "example-app",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    "@com.mgmtp.a12.widgets/widgets-core": "34.5.0",
    "@com.mgmtp.a12.widgets/widgets-utils": "34.5.0",
    "@com.mgmtp.a12/devtools": "^4.2.0",
    "@reduxjs/toolkit": "^1.8.6",
    "@types/draft-js": "^0.11.9",
    "@types/node": "^10.17.28",
    "@types/react": "17.0.37",
    "@types/react-dom": "17.0.16",
    "@types/recharts": "1.8.14",
    "a12-redux-router": "file:../../a12-redux-router",
    "lodash": "^4.17.20",
    "moment": "^2.24.0",
    "moment-timezone": "^0.5.34",
    "react": "17.0.2",
    "react-dnd": "^15.1.2",
    "react-dom": "17.0.2",
    "react-redux": "^8.0.4",
    "styled-components": "^5.3.6"
  },
  "scripts": {
    "start": "webpack-dev-server --progress --config ./webpack.config.js"
  },
  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "devDependencies": {
    "@types/styled-components": "^5.1.26",
    "css-loader": "^6.7.0",
    "html-webpack-plugin": "^5.5.0",
    "mini-css-extract-plugin": "^2.6.0",
    "ts-loader": "^9.2.7",
    "ts-node": "^10.6.0",
    "typescript": "4.5.2",
    "webpack": "^5.70.0",
    "webpack-cli": "^4.9.2",
    "webpack-dev-server": "^4.7.4"
  }
}

Would it be possible to get the whole repository, including the example, as a zip?

because we are missing the whole a12-react-router dependency, so it won’t be possible for us to run the project…

@theodossios-steep-crest ?

@anon61393032 can you share your email address so I can send you that zip file?