# Tutorial: Extending the App Builder

Apps for the Entando App Builder are developed as standalone applications which can be run using npm start in standalone mode.

Each application should be deployed in npm using the @entando namespace and will export into their dist folder several items used by the App Builder when integrating it into the full application.

# Creating a Basic Application

To create a basic application, use the entando fpg running the npx @entando/fpg ab-app <appName> command.

the appName should only contain alphanumeric characters and underscores, and should begin with a letter.

This will create inside the working directory, a boilerplate app-builder app in a directory named <appName> argument.

i.e.

using the command npm @entando/fpg ab-app testing inside your home directory, a directory named testing will be created containing the app.

All dependencies will already be installed allowing you to cd inside the project directory and run npm start to start the app running.

# Understanding the Stand Alone Environment

Each app for the App Builder, can run in both a stand alone mode and an integrated mode. Using npm start will use standalone mode.

In this environment you’ll be looking at the user interface of the app inside a default page. This page, which includes a default menu, will not be exported and therefore can be customized.

To better understand which elements and components are being exported to App Builder, it is best to understand the anatomy of the app.

# Exports

Each app will have a babel.js export file similar to:

    import menu from 'ui/common/LinkMenu';
    import { cms as state } from 'state/rootReducer';
    import { routes, routesDir } from 'ui/App';
    import en from 'locales/en';
    import it from 'locales/it';

    const testing = {
      id: 'testing',
      menu,
      state,
      routes,
      routesDir,
      locales: {
        en,
        it,
      },
    };

    export default testing;

# id: is the app id.

This parameter is used by App Builder to differentiate all integrated apps.

# menu: is a React component containing all the menu elements.

These elements are used inside the standalone environment and inside the integrated environment as a second level menu. The boilerplate app contains a basic menu.

    import React from 'react';
    import { LinkMenuItem } from '@entando/menu';
    import { FormattedMessage } from 'react-intl';

    const LinkMenu = () => (
      <>
        <LinkMenuItem
          id="menu-SECTION_ID"
          label={<FormattedMessage id="testing.menu.SECTION_NAME" defaultMessage="SECTION_NAME" />}
          to='/use/const/here/imported/from/routes'
        />
      </>
    );

    export default LinkMenu;

# Customizing the Menu

For this exercise, we will create two links inside the menu. The first will link to a page listing all the users inside the entando instance. The second will list all the existing page templates inside the Entando instance.

For this example we’re using existing APIs from the Entando core just for simplicity, you can instead call any API or data source.

In your app project, open src/ui/common/LinkMenu.js and update the const to the code below.

    const LinkMenu = () => (
      <>
        <LinkMenuItem
          id="menu-userList"
          label={<FormattedMessage id="testing.menu.userList" defaultMessage="User List" />}
          to='/use/const/here/imported/from/routes'
        />
        <LinkMenuItem
          id="menu-pageModelList"
          label={<FormattedMessage id="testing.menu.pageModelList" defaultMessage="Page Model List" />}
          to='/use/const/here/imported/from/routes'
        />
      </>
    );

it is important that both the <LinkMenuItem> id property and the `<FormattedMessage>`properties inside label have the correct values assigned, i.e.:

the LinkMenuItem id will be menu-userList while the FormattedMessage id will be testing.menu.userList and the defaultMessage will be User List.

# locales

The locales files are objects that contain all of the i18n locales of the app.

By default the boilerplate contains both the english and italian i18n files.

In your app project in src/locales/en.js and src/locales/it.js you can see your labels.

    export default {
      locale: 'en',
      messages: {
        'testing.title': '',
        'testing.label.errors': '',
        'testing.label.cancel': '',
        'testing.chooseAnOption': '',
        'testing.tip': '',
        'testing.new': '',
        'testing.save': '',
        'testing.saveAndApprove': '',
        'testing.unpublish': '',
        'testing.setContentAs': '',
        'testing.cancel': '',
        'testing.saveAndContinue': '',
        'testing.stickySave.status': '',
        'testing.stickySave.lastAutoSave': '',
      },
    };

While running in standalone mode the boilerplate does not offer a way for the user pick a locale, but both will be loaded inside app-builder and will be consumed as intended by it, using the correct one based on the user-picked language.

It is of course possible to change the standalone app to give the user the option to choose the locale in here as well, but this is not something will be covering in this tutorial.

# Customizing the menu labels

To customize the existing menu labels, we’ll add the new label ids inside both the english and Italian locale files:

Note

If you named your app something besides testing you’ll need to fix these tags to match the name of your app.

    ...
    messages: {
        ...
        'testing.menu.userList': 'List of Users',
        'testing.menu.pageModelList': 'Page Models',
        ...
    },
    ...

The key in the messages object matches the id of the `<FormattedMessage>`component we placed inside the menu, while its value is the actual string that will be displayed depending on the currently active language.

# Routes and RoutesDir

Both of these elements are imported from src/ui/App.js. The first one is a collection of actual <Route> components, and the second one is an object containing each route data, i.e.:

    export const routesDir = [
      {
        path: ROUTE_TESTING,
        component: <>app component</>,
      },
    ];

The constant ROUTE_TESTING is imported from src/app-init/routes.js

# Customizing the Routes

Next we will create the two routes for the two links we have created by creating first the two constants needed.

In your IDE open src/app-init/routes.js

    export const ROUTE_TESTING = '/testing';
    export const ROUTE_USER_LIST = '/testing/user-list';
    export const ROUTE_PAGE_MODELS = '/testing/page-models';

Note

Change the value of testing to what you selected for the name of your App extension.

The value of each constant will be the path of the route. It is important that each route is a subroute of the id of the app itself, otherwise this may cause name collision when running inside the integrated environment of app-builder.

Both routes are next imported inside App.js:

Update the imports with your new ROUTE tags.

    import {
      ROUTE_TESTING,
      ROUTE_USER_LIST,
      ROUTE_PAGE_MODELS,
    } from 'app-init/routes';

and then add to the routesDir constant:

    export const routesDir = [
      {
        path: ROUTE_TESTING,
        component: <>app component</>,
      },
      {
        path: ROUTE_USER_LIST,
        render: () => <>user list</>,
      },
      {
        path: ROUTE_PAGE_MODELS,
        render: () => <>page models</>,
      },
    ];

Next, import the routes constants inside LinkMenu.js and change accordingly the to property of the <LinkMenuItem> component:

    ...
    import {
      ROUTE_USER_LIST,
      ROUTE_PAGE_MODELS,
    } from 'app-init/routes';

    const LinkMenu = () => (
      <>
        <LinkMenuItem
          id="menu-userList"
          label={<FormattedMessage id="tatata.menu.userList" defaultMessage="User List" />}
          to={ROUTE_USER_LIST}
        />
        <LinkMenuItem
          id="menu-pageModelList"
          label={<FormattedMessage id="tatata.menu.pageModelList" defaultMessage="Page Model List" />}
          to={ROUTE_PAGE_MODELS}
        />
      </>
    );
    ...

Next clicks on the links in the menu will change the routes and display the content defined in the App.js file.

# state

The state in src/babel.js is the combined reducer of the app, the rootReducer.js contains the combined reducer of the app and exports it, but also contains the entire reducer of the app when running in standalone mode.

    export const testing = combineReducers({
      // implement here your app specific reducers
    });

    export default combineReducers({
      apps: combineReducers({ testing }),
      api,
      currentUser,
      form,
      loading,
      locale,
      messages,
      modal,
      pagination,
    });

The app specific reducers are stored inside the apps object, this is done to avoid possible name collisions with any reducer stored inside app-builder when running the app in integrated mode.

# Customizing the Reducers

Next we will be creating the two reducers for the user list and page models. They will be created inside two new directories src/state/apps/testing/userList/ and src/state/apps/testing/pageModels. The types.js files will contain the two action types that we’ll need.

userList/types.js

// eslint-disable-next-line import/prefer-default-export
export const ADD_USERS = 'apps/testing/add-users';

pageModels/types.js

// eslint-disable-next-line import/prefer-default-export
export const ADD_PAGE_MODELS = 'apps/testing/page-models/add-page-models';

The value of both constants contain the whole namespace apps/testing/REDUCER this is done to avoid any possible name collision when running the app in integrated mode.

Next create both actions files:

userList/actions.js

    import {
      ADD_USERS,
    } from 'state/apps/testing/userList/types';

    // eslint-disable-next-line import/prefer-default-export
    export const addUsers = users => ({
      type: ADD_USERS,
      payload: users,
    });

pageModels/actions.js

    import {
      ADD_PAGE_MODELS,
    } from 'state/apps/testing/pageModels/types';

    // eslint-disable-next-line import/prefer-default-export
    export const addPageModels = pageModels => ({
      type: ADD_PAGE_MODELS,
      payload: pageModels,
    });

then the selectors:

userList/selectors.js

    import { createSelector } from 'reselect';

    export const getUserList = state => state.apps.testing.userList;
    export const getList = createSelector(getUserList, userList => userList.list);

pageModels/selectors.js

    import { createSelector } from 'reselect';

    export const getPageModels = state => state.apps.testing.pageModels;
    export const getList = createSelector(getPageModels, pageModels => pageModels.list);

And finally the reducers. The default state is going to contain some sample data for us to display.

userList/reducer.js

    import { ADD_USERS } from 'state/apps/testing/userList/types';

    const defaultState = {
      list: [
        {
          username: 'admin',
          registration: '2018-01-08 00:00:00',
          lastLogin: '2018-01-08 00:00:00',
          lastPasswordChange: '2018-01-08 00:00:00',
          status: 'active',
          passwordChangeRequired: true,
          profileAttributes: {
            fullName: 'admin',
            email: 'admin@entando.com',
          },
        },
        {
          username: 'user1',
          registration: '2018-01-08 00:00:00',
          lastLogin: '2018-01-08 00:00:00',
          lastPasswordChange: '2018-01-08 00:00:00',
          status: 'disabled',
          passwordChangeRequired: true,
          profileAttributes: {
            fullName: 'User Name',
            email: 'user1@entando.com',
          },
        },
      ],
    };

    const reducer = (state = defaultState, action = {}) => {
      switch (action.type) {
        case ADD_USERS: {
          return { ...state, list: action.payload };
        }

        default: return state;
      }
    };

    export default reducer;

pageModels/reducer.js

    import { ADD_PAGE_MODELS } from 'state/apps/testing/pageModels/types';

    const defaultState = {
      list: [
        {
          code: 'home',
          descr: 'Home Page',
          configuration: {
            frames: [
              {
                pos: 0,
                descr: 'Navbar',
                sketch: {
                  x1: 0,
                  y1: 0,
                  x2: 2,
                  y2: 0,
                },
              },
              {
                pos: 1,
                descr: 'Navbar 2',
                sketch: {
                  x1: 3,
                  y1: 0,
                  x2: 5,
                  y2: 0,
                },
              },
            ],
          },
          template: '<html></html>',
        },
        {
          code: 'service',
          descr: 'Service Page',
          configuration: {
            frames: [
              {
                pos: 0,
                descr: 'Navbar',
                sketch: {
                  x1: 0,
                  y1: 0,
                  x2: 2,
                  y2: 0,
                },
              },
              {
                pos: 1,
                descr: 'Navbar 2',
                sketch: {
                  x1: 3,
                  y1: 0,
                  x2: 5,
                  y2: 0,
                },
              },
            ],
          },
          template: '<html></html>',
        },
      ],
    };

    const reducer = (state = defaultState, action = {}) => {
      switch (action.type) {
        case ADD_PAGE_MODELS: {
          return { ...state, list: action.payload };
        }

        default: return state;
      }
    };

    export default reducer;

Last, we can add the two reducers just created to the src/state/rootReducer.js

    ...
    import userList from 'state/apps/testing/userList/reducer';
    import pageModels from 'state/apps/testing/pageModels/reducer';

    export const testing = combineReducers({
      pageModels,
      userList,
    });
    ...

we will now be able to see with the reduxDevTools in our browser. To view this state in your reduxDevTools go to:

State -→ apps -→ testing -→ pageModels and State -→ apps -→ testing -→ userList

# Creating the UI Components

At this point, both routes created should be rendering a simple string. We will next create the actual component that will be rendered inside the page.

# userList

Inside src/ui/userList/ create the List component. Create the userList directory and List.js file in that directory.

    import React from 'react';

    import {
      Grid,
      TablePfProvider,
    } from 'patternfly-react';

    const List = () => {
      const data = [
        {
          username: 'admin',
          registration: '2018-01-08 00:00:00',
        },
        {
          username: 'user1',
          registration: '2018-01-08 00:00:00',
        },
      ];

      const tr = data.map(row => (
        <tr>
          <td>{row.username}</td>
          <td>{row.registration}</td>
        </tr>
      ));

      return (
        <Grid fluid>
          <TablePfProvider
            striped
            bordered
            hover
          >
            <thead>
              <tr>
                <td>username</td>
                <td>registration</td>
              </tr>
            </thead>
            <tbody>
              {tr}
            </tbody>
          </TablePfProvider>
        </Grid>
      );
    };

    export default List;

Next, change the route inside src/ui/App.js. Add the import below and update the component to reference the List component created in the prior step.

    ...
    import List from 'ui/userList/List';
    ...
      {
        path: ROUTE_USER_LIST,
        component: List,
      },
    ...

The table will now display correctly when clicking on the menu item.

# connecting to the store

Next let’s connect the component to the store to get the data from the reducer.

The first step is creating the ListContainer.js next to the List component file.

    import { connect } from 'react-redux';
    import { getList } from 'state/apps/testing/userList/selectors';

    import List from 'ui/userList/List';

    export const mapStateToProps = state => ({
      data: getList(state),
    });

    export default connect(
      mapStateToProps,
      null,
    )(List);

And then update the List component to receive the property. The List file should now look like this:

    import React from 'react';
    import PropTypes from 'prop-types';

    import {
      Grid,
      TablePfProvider,
    } from 'patternfly-react';

    const List = ({ data }) => {
      const tr = data.map(row => (
        <tr>
          <td>{row.username}</td>
          <td>{row.registration}</td>
        </tr>
      ));

      return (
        <Grid fluid>
          <TablePfProvider
            striped
            bordered
            hover
          >
            <thead>
            <tr>
              <td>username</td>
              <td>registration</td>
            </tr>
            </thead>
            <tbody>
            {tr}
            </tbody>
          </TablePfProvider>
        </Grid>
      );
    };

    export default List;

Make sure that you remove the predefined data const since the data will now be coming from the reducer, on top of defining PropTypes rules for validating and giving a default for the injected property data.

Once complete, update the component used in the route inside App.js. Update the import to the container and update the component in ROUTE_USER_LIST to the new ListContainer.

    ...
    import ListContainer from 'ui/userList/ListContainer';
    ...
      {
        path: ROUTE_USER_LIST,
        component: ListContainer,
      },
    ...

Go back to your app. We will now see the data inside the table reflecting the content of the storage.

# Page Models

inside src/ui/pageModels/ we are going to create the List component

    import React from 'react';
    import PropTypes from 'prop-types';

    import {
      Grid,
      TablePfProvider,
    } from 'patternfly-react';

    const List = ({ data }) => {
      const tr = data.map(row => (
        <tr>
          <td>{row.username}</td>
          <td>{row.registration}</td>
        </tr>
      ));

      return (
        <Grid fluid>
          <TablePfProvider
            striped
            bordered
            hover
          >
            <thead>
            <tr>
              <td>username</td>
              <td>registration</td>
            </tr>
            </thead>
            <tbody>
            {tr}
            </tbody>
          </TablePfProvider>
        </Grid>
      );
    };

    List.propTypes = {
      data: PropTypes.arrayOf(PropTypes.shape({})),
    };

    List.defaultProps = {
      data: [],
    };

    export default List;

Next change the route inside src/ui/App.js

    ...
    import ListPageModels from 'ui/pageModels/List';
    ...
      {
        path: ROUTE_PAGE_MODELS,
        component: ListPageModels,
      },
    ...

The table will now be displayed correctly when clicking on the menu item.

# Connecting to the Store

Next, connect the component to the store to get the data from the reducer.

The very first thing we’ll do is create the ListContainer.js next to the List component file.

    import { connect } from 'react-redux';
    import { getList } from 'state/apps/testing/pageModels/selectors';

    import List from 'ui/pageModels/List';

    export const mapStateToProps = state => ({
      data: getList(state),
    });

    export default connect(
      mapStateToProps,
      null,
    )(List);

And then update the List component to receive the property. The whole List component will have this content:

    import React from 'react';
    import PropTypes from 'prop-types';

    import {
      Grid,
      TablePfProvider,
    } from 'patternfly-react';

    const List = ({ data }) => {
      const tr = data.map(row => (
        <tr>
          <td>{row.code}</td>
          <td>{row.descr}</td>
        </tr>
      ));


      return (
        <Grid fluid>
          <TablePfProvider
            striped
            bordered
            hover
          >
            <thead>
            <tr>
              <td>code</td>
              <td>descr</td>
            </tr>
            </thead>
            <tbody>
            {tr}
            </tbody>
          </TablePfProvider>
        </Grid>
      );
    };

    List.propTypes = {
      data: PropTypes.arrayOf(PropTypes.shape({})),
    };

    List.defaultProps = {
      data: [],
    };
    export default List;

Next make sure that you remove the predefined data const since the data will be coming from the reducer, on top of defining PropTypes rules for validating and giving a default for the injected property data.

Once complete, update the component used in the route inside App.js

    ...
    import PageModelsListContainer from 'ui/pageModels/ListContainer';
    ...
      {
        path: ROUTE_PAGE_MODELS,
        component: PageModelsListContainer,
      },
    ...

You will now see the data inside the table reflecting the content of the storage.

# Connecting the app to an Entando core instance

By default the app is using mocks and not connecting to any Entando core instance.

Because the app is making use of @entando/apimanager we can easily change this by setting up two .env variables inside the .env file in the project root:

    REACT_APP_DOMAIN=http://localhost:8080/entando-app
    REACT_APP_USE_MOCKS=false

The REACT_APP_DOMAIN must pointing towards the domain and container where the Entando instance is running and must not contain trailing slashes.

Once this is done to make the change happen we will have to stop the app using ctrl + c and re run it with npm start.

Now the toast stating This application is using mocks won’t be popping up anymore.

You can make sure that the configuration is correct by looking at the network section in the browser dev tools. By default the app automatically makes an admin login against a plain Entando instance to authenticate the user and to be able to consume any protected api.

This is not an ideal scenario and it is meant to be used only for debugging purposes for many reasons:

  • the username and password should never be hardcoded in your app

  • if authentication is required the user should be the one performing the login action

  • the plain default passwords in use won’t be useful against a proper production instance of Entando

# Adding the API Calls

We are now going to add api calls for both users and page models to retrieve the data live instead of relying on our store’s default state.

Inside src/api create the users.js file:

    import { makeRequest, METHODS } from '@entando/apimanager';

    // eslint-disable-next-line import/prefer-default-export
    export const getUsers = (page = { page: 1, pageSize: 10 }, params = '') => (
      makeRequest(
        {
          uri: `/api/users${params}`,
          method: METHODS.GET,
          mockResponse: {},
          useAuthentication: true,
        },
        page,
      )
    );

and then create the pageModels.js file:

    import { makeRequest, METHODS } from '@entando/apimanager';

    // eslint-disable-next-line import/prefer-default-export
    export const getPageModels = (page = { page: 1, pageSize: 10 }, params = '') => makeRequest({
      uri: `/api/pageModels${params}`,
      method: METHODS.GET,
      mockResponse: {},
      useAuthentication: true,
    }, page);

# Creating the Thunk

In order to use the api call we next create a thunk action, which is a redux action with side effects, like an API call.

inside the src/state/apps/testing/userList/actions.js file we are going to add the new action:

    ...
    import { addErrors } from '@entando/messages';
    import {
      getUsers,
    } from 'api/users';
    ...

    // thunks

    export const fetchUsers = (page = { page: 1, pageSize: 10 }, params = '') => dispatch => (
      new Promise((resolve) => {
        getUsers(page, params).then((response) => {
          response.json().then((json) => {
            if (response.ok) {
              dispatch(addUsers(json.payload));
            } else {
              dispatch(addErrors(json.errors.map(err => err.message)));
            }
            resolve();
          });
        }).catch(() => {});
      })
    );

Next do the same inside src/state/apps/testing/pageModels/actions.js:

    ...
    import { addErrors } from '@entando/messages';
    import {
      getPageModels,
    } from 'api/pageModels';
    ...

    // thunks

    export const fetchPageModels = (page = { page: 1, pageSize: 10 }, params = '') => dispatch => (
      new Promise((resolve) => {
        getPageModels(page, params).then((response) => {
          response.json().then((data) => {
            if (response.ok) {
              dispatch(addPageModels(data.payload));
              resolve();
            } else {
              dispatch(addErrors(data.errors.map(err => err.message)));
              resolve();
            }
          });
        }).catch(() => {});
      })
    );

Now with two exports, it is safe to remove the eslint-disable-next-line comment on line 5 of both files.

# changing the mapDispatchToProps in the containers

Next, in order to pass the newly created thunk to both List components, we’ll update the containers accordingly, as:

src/ui/userList/ListContainer.js

    ...
    import { fetchUsers } from 'state/apps/testing/userList/actions';
    ...
    export const mapDispatchToProps = dispatch => ({
      fetch: () => dispatch(fetchUsers()),
    });

    export default connect(
      mapStateToProps,
      mapDispatchToProps,
    )(List);

src/ui/pageModels/ListContainer.js

    ...
    import { fetchPageModels } from 'state/apps/testing/pageModels/actions';
    ...
    export const mapDispatchToProps = dispatch => ({
      fetch: () => dispatch(fetchPageModels()),
    });

    export default connect(
      mapStateToProps,
      mapDispatchToProps,
    )(List);

# Updating the List components

Both List components were simple components with only a render method, therefore could be declared as simple constants.

Next we will fetch data during the componentDidMount life cycle event which will require we turn the constant into a class on top of changing the PropTypes to add the new fetch method passed down to the component.

src/ui/userList/List.js

    import React, { Component } from 'react';
    ...
    class List extends Component {
      componentDidMount() {
        const { fetch } = this.props;
        fetch();
      }

      render() {
        const { data } = this.props;
        const tr = data.map(row => (
          <tr>
            <td>{row.username}</td>
            <td>{row.registration}</td>
          </tr>
        ));

        return (
          <Grid fluid>
            <TablePfProvider
              striped
              bordered
              hover
            >
              <thead>
                <tr>
                  <td>username</td>
                  <td>registration</td>
                </tr>
              </thead>
              <tbody>
                {tr}
              </tbody>
            </TablePfProvider>
          </Grid>
        );
      }
    }

    List.propTypes = {
      data: PropTypes.arrayOf(PropTypes.shape({})),
      fetch: PropTypes.func,
    };

    List.defaultProps = {
      data: [],
      fetch: () => {},
    };

src/ui/pageModels/List.js

    import React, { Component } from 'react';
    ...
    class List extends Component {
      componentDidMount() {
        const { fetch } = this.props;
        fetch();
      }

      render() {
        const { data } = this.props;
        const tr = data.map(row => (
          <tr>
            <td>{row.code}</td>
            <td>{row.descr}</td>
          </tr>
        ));

        return (
          <Grid fluid>
            <TablePfProvider
              striped
              bordered
              hover
            >
              <thead>
                <tr>
                  <td>code</td>
                  <td>descr</td>
                </tr>
              </thead>
              <tbody>
                {tr}
              </tbody>
            </TablePfProvider>
          </Grid>
        );
      }
    }

    List.propTypes = {
      data: PropTypes.arrayOf(PropTypes.shape({})),
      fetch: PropTypes.func,
    };

    List.defaultProps = {
      data: [],
      fetch: () => {},
    };

# clear the default value of the reducer

Now we should be fetching data from the server, therefore we can safely make the list key in the defaultState object an empty array:

src/state/apps/testing/userList/reducer.js

    ...
    const defaultState = {
      list: [],
    };
    ...

src/state/apps/testing/pageModels/reducer.js

    ...
    const defaultState = {
      list: [],
    };
    ...

# adding additional dependencies

It may be necessary to set additional dependencies for your project. If the need arises, it is important to remember a few rules:

Only actual dependencies that are not already included in app-builder can be added as pure dependencies. Every other dependency must either be a devDependency or peerDependency.

If you are not careful you may end up with duplicated dependencies that will result in errors manifesting themselves when running the app inside App Builder.

# running the app in integrated mode within App Builder

After running npm install in the App Builder, the user can run the npm run app-install <appId> command to install the app.

This command will trigger a download of the app from npm and the installation of its component within App Builder. After the installation process is complete, it will be possible to either npm start or npm build App Builder.

To install a dev app, like the one developed in this tutorial which have not been previously published on npm, you will need to use additional flags and will have to run a few additional commands.

Before running the Install command make sure that you have uninstalled all existing peer and dev dependencies to avoid collision with app builder. To do so, from the app builder app directory (testing, in this tutorial) just run in the correct order the following commands:

npm run babel

npm i --only=production

The first will create the dist directory that will be needed by App Builder while the second one will uninstall anything but production dependencies.

Next, from the App Builder directory, run the install command with these additional flags:

  • -d specify the relative path where the app is installed. When using this flag the appId should be the normalized app name, without the @entando/ prefix.

  • -p specify the package name if it is different from the appId

to use flags you will have to use the double dash in the command:

npm run app-install —  cms -d ../testing -p @entando/testing

the value in the -p flag should always match the actual name of the app that is going to be installed inside App Builder. You can check your app name inside the package.json file of your app.

If you experience problems after running the build command delete the node_modules directory before running the second command.