How to set up custom view of app items

Sometimes the list of items in an app needs to be presented in a more convenient way than it can be displayed by default. Moreover, data from other apps associated with these items may need to be available as well. This can be needed to set up dynamic reports, dashboards, etc.

Let’s say there is an app named Materials in the company. It stores a list of materials for construction and installation works.

The company also has the Orders app that stores orders from clients for the delivery of materials. The app’s form includes a Table type property that is filled out with ordered goods.

We need to create a separate summary table that would show which materials are needed for different orders. This table needs to illustrate what amount of each material is needed to fulfill all orders and to fulfill each one separately.

To do that, let’s create a new page and name it Ordered materials. In the beginning, we only need the Code widget to be added to the page. It will be used to set the view of items that we need.

Script

Planning the organization of data

To make the coding of the page’s layout easier, we first need to determine what we need the result to look like. Based on the data organization we need, we’ll create interfaces that we can gradually enhance. For example, we know that we’ll need the names of materials (materialName) and the amount of each material needed to fulfill orders (quantity). We’ll also need to see the list of orders that include each material (deals).

interface NeedForMaterials {
    materialName: string;
    quantity: number;
    deals: any[];
}

We don’t need to display detailed information about orders on this page. We only need to know the name of each order (dealName) and the amount of each material (quantity). Let’s also add the order’s ID to accurately identify each order if needed:

interface NeedByDeal {
    dealId: string;
    dealName: string
    quantity: number;
}

Now we’ll modify the main interface, specifying which type the array of data on orders will have:

interface NeedForMaterials {
    materialName: string;
    quantity: number;
    deals: NeedByDeal[];
}

In the interface designer, we need to open the Scripts tab. In our case, the script only needs to run on the client side. Let’s add the interfaces we created to the code.

To store and process the list of ordered materials in a convenient manner, let’s create the materialNeeds property. It will be an array of ordered materials, which will allow us to use methods available for arrays. For instance, they can be used for filtering:

let materialNeeds: NeedForMaterials[];

Getting and processing data

Let’s write a function that fills the list of ordered goods with data:

  1. We need to get all items of the Orders app. It stores data on the amount of ordered goods. We also need all items of the Materials app. In our case, as the amount of data is relatively small, and lazy loading is not implemented, it makes sense to make one request for all data.

Using the Namespace global constant, let’s call the search() method for the apps we need, and limit the number of items to 1,000 (the maximum possible number is 10,000):

const materials = await Namespace.app.materials.search().size(1000).all();
const deals = await Namespace.app.deals.search().size(1000).all();

Now we have two independent asynchronous requests, and they need to be made at the same time. Here it is most efficient to run them simultaneously. Let’s use the Promise.all() method that unites an array of promises into one promise, wait for the result, and decompose it.

const [materials, deals] = await Promise.all([
    Namespace.app.materials.search().size(1000).all(),
    Namespace.app.deals.search().size(1000).all(),
]);
  1. We need to iterate over all obtained orders. If the property storing a table with required materials (materialsList) is empty in a certain order, this step will be skipped for this app item, and the next one will be checked. If the value of this property is defined, we need to iterate over the table’s rows. They don’t have all the data, but we can extract a material’s ID from them and use it to find a material in the list.
for (const deal of deals) {
    if (!deal.data.materialsList) {
        continue;
    }
    for (const tableItem of deal.data.materialsList) {
        const material = materials.find(material => material.id === tableItem.materialsList.id);
    }
}
  1. We need to organize data on materials required to fulfill an order using NeedByDeal:
const dealNeeds = <NeedByDeal> {
    dealId: deal.id,
    dealName: deal.data.__name,
    quantity: tableItem.materialAmount,
};
  1. We need to add data on the amount of each material needed for a specific order to the array of ordered materials (materialNeeds). In the row of the table tableItem, there is a column of the App type that is linked with Materials (materialsList). We can get the ID we need from it. Let’s use it to check whether a specific item has already been added to the list of ordered items. If it doesn’t exist in the list, let’s add it, specifying general information.

Then we need to organize information about materials needed to fulfill a specific order dealNeeds. Let’s add this information to the list of orders on a specific material. We need to increase the amount of a material that is needed for all orders by the amount needed for each specific order.

    const material = materials.find(material => material.id === tableItem.materialsList.id);
    let need: NeedForMaterials | undefined = materialNeeds.find(need => need.materialId === tableItem.materialsList.id);
    if (!need) {
        need = {
            materialId: tableItem.materialsList.id,
            materialName: material!.data.__name,
            quantity: 0,
            deals: [],
            showDeals: true,
        };
        materialNeeds.push(need);
    }

    const dealNeeds = <NeedByDeal> {
        dealId: deal.id,
        dealName: deal.data.__name,
        quantity: tableItem.materialAmount,
        clientId: deal.data.client?.id,
    };

    need.quantity += tableItem.materialAmount;
    need.deals.push(dealNeeds);

Generating the result

As we get items asynchronously in the function that fills the table, the function itself is asynchronous. But as the page is loading, we need to wait for the function to finish. Otherwise, nothing will be displayed in the table when the page loads, and then, we’ll need to update the table. In our case, it is not needed, so we’ll wait for the result while the page is initializing (loading).

To do that, we need to create another function in the widget’s scripts. This function needs to be executed upon the page’s initialization. There we’ll add the asynchronous function that fills out the table and wait for its result. Then all data will be prepared by the time the page is actually rendered.

To get data from scripts in a more convenient way, let’s add a function and name it getNeeds(). It will return the current value of materialNeeds.

The final version of the client script looks like this:

let materialNeeds: NeedForMaterials[];

interface NeedForMaterials {
    materialName: string;
    quantity: number;
    deals: NeedByDeal[];
}

interface NeedByDeal {
    dealId: string;
    dealName: string
    quantity: number;
}

async function onInit(): Promise<void> {
    await fillNeeds();
}

function getNeeds() {
    return materialNeeds;
}

async function fillNeeds() {
    materialNeeds = [];
    const [materials, deals] = await Promise.all([
        Namespace.app.materials.search().size(1000).all(),
        Namespace.app.deals.search().size(1000).all(),
    ]);
    for (const deal of deals) {
        if (!deal.data.materialsList) {
            continue;
        }
        for (const tableItem of deal.data.materialsList) {
            const material = materials.find(material => material.id === tableItem.materialsList.id);
            const dealNeeds = <NeedByDeal> {
                dealId: deal.id,
                dealName: deal.data.__name,
                quantity: tableItem.materialAmount,
            };
            if (!materialNeeds[tableItem.materialsList.id]) {
                materialNeeds[tableItem.materialsList.id] = <NeedForMaterials> {
                    materialName: material!.data.__name,
                    quantity: 0,
                    deals: [],
                }
            }
            materialNeeds[tableItem.materialsList.id].quantity += tableItem.materialAmount;
            materialNeeds[tableItem.materialsList.id].deals.push(dealNeeds);

        }
    }
}

Widget template

The Code widget is added to the page. It allows you to create any layout (including dynamic display of data) using HTML, CSS, and code embeddings. As an example, let’s display the ordered materials as a table. We need to create the table’s framework with two columns, Material and Amount. We’ll use the <tbody></tbody> container for the table’s body.

Inside tbody, we need to iterate over the list of ordered materials we got using the getNeeds() function. For each item, we need to render a template renderNeed. As the argument, we’ll pass the corresponding item from the list of ordered materials with the structure identical to NeedForMaterials:

<table>
    <thead>
        <tr>
            <th>Material</th>
            <th>Amount</th>
        </tr>
    </thead>
    <tbody>
        <% for (const need of getNeeds()) {
        %>
            <%= renderNeed(need) %>
        <% } %>
    </tbody>
</table>

In the renderNeed template, let’s add a table row <tr class='material-need-deals'> for each material. In td cells, we’ll add the name of a material need.materialName and the total amount needed for all orders need.quantity.

Then, if there are orders for a material need.deals, we’ll add a row for each one (<tr class='material-need-deals'>) using a loop. In its cells, we’ll specify the order’s name deal.dealName and the amount of material required to fulfil the order deal.quantity.

<% $template renderNeed(need) %>
    <tr class='material-need-title'>
        <td><%- need.materialName %></td>
        <td><%- need.quantity %></td>
    </tr>
    <% if (need.deals.length) { %>
        <% for (const need of getNeeds()) { %>
            <tr class='material-need-deals'>
                <td class='material-need-deals__name'><%- deal.dealName %></td>
                <td><%- deal.quantity %></td>
            </tr>
        <% } %>
    <% } %>
<% $endtemplate %>

The page is almost ready. We only need to improve its design so that it presents the data clearly. To do that, we can add CSS styles to the beginning of the Code widget in the <style> ... </style> container. It will be applied to the page via tags and element classes. We can set a separate class for the table or wrap it inside a container with the necessary class.

After applying styles and improving the layout, this is what the Code widget will look like:

<style>
    .needs-container table {
        width: 100%;
    }
    .needs-container thead {
        border-top: 1px solid #d9d9d9;
        border-bottom: 1px solid #d9d9d9;
    }
    .needs-container th, .needs-container td {
        padding: 18px 6px;
    }
    .needs-container th {
        color: #8c8c8c;
    }
    tr.material-need-title {
        font-weight: bold;
        background-color: #e3eef7;
    }
    tr.material-need-deals-title-row td {
        padding-top: 0px;
        padding-bottom: 0px;
    }
    td.material-need-deals__name {
        padding-left: 36px;
    }
</style>

<div class='needs-container'>
    <table>
        <thead>
            <tr>
                <th>Material</th>
                <th>Amount</th>
            </tr>
        </thead>
        <tbody>
            <% for (const need of getNeeds()) { %>
                <%= renderNeed(need) %>
            <% } %>
        </tbody>
    </table>
</div>

<% $template renderNeed(need) %>
    <tr class='material-need-title'>
        <td><%- need.materialName %></td>
        <td><%- need.quantity %></td>
    </tr>
    <% if (need.deals.length) { %>
        <% for (const deal of need.deals) {
        %>
            <tr class='material-need-deals'>
                <td class='material-need-deals__name'><%- deal.dealName %></td>
                <td><%- deal.quantity %></td>
            </tr>
        <% } %>
    <% } %>
<% $endtemplate %>

This is what the page will look like when it’s rendered:

Dynamic display of data

Let’s enhance the report we’ve created by adding dynamic display of data. First, let’s make it possible to view the material or order by clicking its name in the table. To do that, we’ll wrap the names of materials and orders in links, so that we get a string of the following format: (p:item/workspace_code/app_code/item_ID):

<a href='(p:item/installation_orders/materials/<%- need.materialId %>)'>
    <%- need.materialName %>
</a>
<a href='(p:item/installation_orders/deals/<%- deal.dealId %>)'>
    <%- deal.dealName %>
</a>

Now let’s add a way to expand and collapse the list of orders associated with a material:

  1. Let’s add the showDeals property of the Boolean type to the interface we created for the ordered materials list. This property will determine whether orders associated with a material need to be displayed:
interface NeedForMaterials {
    materialName: string;
    quantity: number;
    deals: NeedByDeal[];
    showDeals: boolean;
}
  1. In the template used to render a list item, before the link to the material’s name, let’s add the plus and minus symbols. One or the other will be displayed next to a material’s name, depending on the value of showDeals of the corresponding item in the list. Let’s call the toggleShowDeals function upon clicking on the <span> element that will be the button to expand and collapse the list. We need to pass the material’s ID to this function: onclick='<%= Scripts %>.toggleShowDeals('<%= need.materialId %>')'. Note that when you call a function from scripts, you can only pass a String type property in the onclick attribute, not the whole object. Otherwise, the < and > characters will be processed incorrectly.

  2. We also need to check the need.showDeals property in the condition that defines the visibility of the orders associated with a material: <% if (need.showDeals && need.deals.length) { %>.

The final version of the template used to render an item of the ordered materials list will look like this:

<% $template renderNeed(need) %>
    <tr class='material-need-title'>
        <td>
            <span
                class='show-deals-toggle'
                onclick='<%= Scripts %>.toggleShowDeals('<%= need.materialId %>')'
            >
                <% if (need.showDeals) { %>
                    -
                <% } else { %>
                    +
                <% } %>
            </span>
            <a href='(p:item/installation_orders/materials/<%- need.materialId %>)'>
                <%- need.materialName %>
            </a>
        </td>
        <td><%- need.quantity %></td>
    </tr>
    <% if (need.showDeals && need.deals.length) { %>
        <% for (const deal of need.deals) {
        %>
            <tr class='material-need-deals'>
                <td class='material-need-deals__name'>
                    <a href='(p:item/installation_orders/deals/<%- deal.dealId %>)'>
                        <%- deal.dealName %>
                    </a>
                </td>
                <td><%- deal.quantity %></td>
            </tr>
        <% } %>
    <% } %>
<% $endtemplate %>
  1. In the scripts, let’s add the toggleShowDeals function that receives a material’s ID from the Code widget, uses it to search for a corresponding item of the ordered materials list in the materialNeeds array, and changes the value of the showDeals property to the opposite one:
function toggleShowDeals (materialId: string) {
    const need = materialNeeds.find(need => need.materialId === materialId);

    if (!need) {
        return;
    }
    need.showDeals = !need.showDeals;
}

When data in the table changes, the widget needs to be updated. Whether a widget needs to be re-calculated and re-rendered is checked whenever the value of a context variable the widget is linked with changes. We’ll use this to update the information. Let’s add the property we are going to change to the Context tab of the interface designer. It can be a property of the Date/time type named timestamp.

Let’s use this property in the Code widget. It doesn’t matter how exactly it’s going to be used. For example, we can add an empty condition:

<% if (Context.data.timestamp) {} %>

In scripts, we’ll assign this property a new value whenever we need to render the widget again:

function toggleShowDeals (materialId: string) {
    const need = materialNeeds.find(need => need.materialId === materialId);

    if (!need) {
        return;
    }
    need.showDeals = !need.showDeals;
    Context.data.timestamp = new Datetime();
}

Now we can expand and collapse the list of orders associated with a material:

Add widgets in the Code widget

We can let users filter data in the table. To do that, let’s open the Context tab in the interface designer and add properties that will be used to enter filter conditions. They will only work correctly if their types correspond with the types of fields that rows need to be filtered by. Let’s create two properties, materialsOrder and contractor:

To manage filters, let’s add the Pop-up widget (Widget.popover) that will open when a user clicks the corresponding button. You can learn more about adding this widget or other widgets in this article. To make it possible to work with filters in a pop-up window, let’s change the table’s header and add the Pop-up widget consisting of the window opening element filtersOpener, the window’s content filtersContent, and UI.widget.popover( ... ) that adds the widget itself.

Let’s add a header to the pop-up window’s content and insert the added View context properties using UI.widget.viewContextRow( ... ). Let’s also add a button that will call the function that processes filter conditions (fields by which the data is filtered), <%= Scripts %>.applyFilters():

<thead>
    <tr>
        <th>
            Material

            <% $template filtersContent %>
                // Content of the pop-up window
                <h4> Filtering options: </h4>
                <%= UI.widget.viewContextRow('materialsOrder', { name: 'Order' }) %>
                <%= UI.widget.viewContextRow('contractor', { name: 'Client' }) %>
                <button
                    type='button'
                    class='btn btn-default'
                    onclick='<%= Scripts %>.applyFilters()'
                >
                    Apply
                </button>
            <% $endtemplate %>

            <% $template filtersOpener %>
                // Clicking on this container opens the pop-up
                <button type='button' class='btn btn-default btn-link'>Configure</button>
            <% $endtemplate %>

            // Adding the widget
            <%= UI.widget.popover(
                filtersOpener,
                {
                    type: 'widgets',
                    size: 'lg',
                    position: 'right',
                },
                filtersContent
            ) %>
        </th>
        <th>Amount</th>
    </tr>
</thead>

Now let’s add this function to the client script:

async function applyFilters () {
    // Let’s get the current values of fields used as filter conditions
    const materialsOrder = Context.data.materialsOrder;
    const contractor = Context.data.contractor;

    // Updating data before applying filters
    await fillNeeds();

    if (!materialsOrder && !contractor) {
        // If no filters are applied, the execution is stopped; all data will be displayed
        // Updating the table by assigning a new value to the property we created for updating
        Context.data.timestamp = new Datetime();
        return;
    }

    // Creating an empty array to fill with data
    const filteredNeeds: NeedForMaterials[] = [];
    for (const need of materialNeeds) {
        // For each material, we need to reset the amount and create a new array of filtered orders
        need.quantity = 0;
        const filteredDeals: NeedByDeal[] = [];
        for (const deal of need.deals) {
            if (
                (!materialsOrder || deal.dealId === materialsOrder.id) &&
                (!contractor || contractor.id === deal.clientId))
            {
                // If an order meets filter conditions, it is added to the list,
                // and the amount of the material required for it is entered
                filteredDeals.push(deal);
                need.quantity += deal.quantity;
            }
        }
        // Replacing the list of orders by the filtered orders
        need.deals = filteredDeals;
        if (need.quantity) {
            // If the amount of the material does not equal zero, it is added to the list
            filteredNeeds.push(need);
        }
    }
    // Updating the main list of ordered materials using the filtered values
    // and rendering the table again by assigning a new value to `timestamp`

    materialNeeds = filteredNeeds;
    Context.data.timestamp = new Datetime();
}

Let’s try to use the filters we created. When a user clicks the Configure button, a pop-up window with fields for filtering is opened near the table’s header. Let’s filter the data by the Client field, setting it to Client 2:

When the user clicks Apply, the filtering window closes, and the updated list only includes materials needed to fulfil orders issued by Client 2:

Let’s open the filtering window again and filter the data by the Order field, setting it to Order No. 2 from Client 2:

The updated table includes materials from Order No. 2 and orders issued by Client 2: