Как сделать пользовательское отображение элементов приложений

Иногда бывает необходимо представить список элементов приложения в более удобном виде, чем он доступен по умолчанию. Более того, необходимо добавить данные из других приложений, связанные с этими элементами. Таким образом можно настроить динамические отчеты, дашборды и т. п.

Допустим, в компании есть приложение Материалы, в котором хранится список материалов для строительно-монтажных работ.

Также в компании есть приложение Заказы, в котором элементами являются заказы от клиентов на поставку материалов. На форме приложения есть свойство типа Таблица, которое заполняется заказанными товарами.

Необходимо создать отдельную сводную таблицу, в которой указано, какие материалы требуются по заказам. В этой таблице должно быть отображено, сколько каких материалов сейчас требуется по всем заказам в общем и по каждому в отдельности.

Для этого создадим новую страницу и назовем ее Требуемые материалы. Изначально на страницу вынесен только виджет Код, с помощью которого можно задать нужное отображение элементов.

Скрипт

Определение структуры данных

Для удобной верстки страницы сначала следует определить, в каком виде мы хотим получить результат. Исходя из необходимой структуры данных, создадим интерфейсы, которые можно будет дорабатывать в процессе. Например, мы знаем, что в первую очередь потребуется знать название каждого материала (materialName) и требуемое количество материала по заказам (quantity), а также видеть список заказов, содержащих этот материал (deals):

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

Детальной информации по заказам на этой странице отображать не требуется — необходимо знать только название заказа (dealName) и количество материала (quantity). Добавим также ID, чтобы в случае необходимости достоверно идентифицировать отдельный заказ:

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

Преобразуем основной интерфейс, указав, какого типа будет массив информации по заказам:

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

В дизайнере интерфейсов перейдем на вкладку Скрипты. В данном случае скрипт должен выполняться только на клиенте. Добавим созданные интерфейсы в код скрипта.

Для того чтобы хранить и обрабатывать список требуемых материалов в удобном виде, создадим свойство materialNeeds. Оно будет представлять собой массив требуемых материалов, благодаря чему для обработки информации станут доступны методы массивов. Их можно использовать, например, для фильтрации:

let materialNeeds: NeedForMaterials[];

Получение и обработка данных

Напишем функцию, которая будет заполнять список требуемых материалов данными:

  1. Получим все элементы приложения Заказы. В нём хранятся данные о необходимом количестве материалов. Также получим все элементы приложения Материалы. Ввиду малого количества данных и отсутствия реализации ленивой загрузки более оптимальным в данном примере будет сделать один запрос на получение всех данных.

Используя глобальную константу Namespace, вызовем у нужных приложений метод поиска элементов search(), где зададим ограничение для количества элементов в 1000 (максимально возможное ограничение равно 10000):

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

Получилось два асинхронных запроса, которые независимы друг от друга, и их необходимо выполнить одновременно. В связи с этим оптимальным будет запускать их параллельно. Используем метод Promise.all(), который объединяет массив нескольких обещаний в одно обещание. Дождемся результата и декомпозируем его.

const [materials, deals] = await Promise.all([
    Namespace.app.materials.search().size(1000).all(),
    Namespace.app.deals.search().size(1000).all(),
]);
  1. Любым способом перебираем полученные заказы. Если в каком-то заказе свойство, содержащее таблицу с материалами (materialy), является пустым, то пропускаем этот шаг для элемента и переходим к следующему. Если же значение свойства определено, необходимо перебрать строки таблицы. Они содержат неполную информацию, но из них можно извлечь идентификатор материала. По нему найдем нужный материал в списке.
for (const deal of deals) {
    if (!deal.data.materialy) {
        continue;
    }
    for (const tableItem of deal.data.materialy) {
        const material = materials.find(material => material.id === tableItem.materialy.id);
    }
}
  1. Формируем данные по требуемым для заказа материалам с помощью NeedByDeal:
const dealNeeds = <NeedByDeal> {
    dealId: deal.id,
    dealName: deal.data.__name,
    quantity: tableItem.kolichestvo,
};
  1. Добавляем в общие потребности materialNeeds данные о том, сколько данного материала требуется по конкретному заказу. В строке таблицы tableItem есть столбец типа Приложение, ссылающийся на Материалы (materialy). Из него мы можем получить нужный идентификатор. Используя его, проверим, есть ли этот материал в списке требуемых. Если нет, то добавляем его, указывая общую информацию.

Далее формируем информацию о требуемых материалах по конкретной сделке dealNeeds. Добавляем эту информацию в список заказов по данному материалу. Увеличиваем общее количество требуемого материала на количество по данному заказу.

    const material = materials.find(material => material.id === tableItem.materialy.id);
    let need: NeedForMaterials | undefined = materialNeeds.find(need => need.materialId === tableItem.materialy.id);
    if (!need) {
        need = {
            materialId: tableItem.materialy.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.kolichestvo,
        clientId: deal.data.client?.id,
    };

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

Формирование результата

Так как в функции заполнения данных есть асинхронное получение элементов приложений, то и сама функция будет асинхронной. Однако нам необходимо при загрузке страницы дождаться завершения этой асинхронной функции, иначе вначале на странице не будет ничего отображаться, а затем потребуется обновление. В данном случае это не нужно, поэтому дождемся результата еще на этапе инициализации (загрузки) страницы.

Для этого в скриптах создадим функцию, которая будет автоматически выполняться при инициализации. В ней необходимо выполнить асинхронную функцию заполнения данными с ожиданием результата. Тогда к моменту непосредственного рендера страницы все данные уже будут подготовлены.

Для удобства получения данных из скриптов добавим функцию getNeeds(), которая возвращает текущее значение materialNeeds.

Итоговый результат клиентского скрипта:

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.materialy) {
            continue;
        }
        for (const tableItem of deal.data.materialy) {
            const material = materials.find(material => material.id === tableItem.materialy.id);
            const dealNeeds = <NeedByDeal> {
                dealId: deal.id,
                dealName: deal.data.__name,
                quantity: tableItem.kolichestvo,
            };
            if (!materialNeeds[tableItem.materialy.id]) {
                materialNeeds[tableItem.materialy.id] = <NeedForMaterials> {
                    materialName: material!.data.__name,
                    quantity: 0,
                    deals: [],
                }
            }
            materialNeeds[tableItem.materialy.id].quantity += tableItem.kolichestvo;
            materialNeeds[tableItem.materialy.id].deals.push(dealNeeds);
        }
    }
}

Шаблон виджета

Добавляем на страницу виджет Код. С его помощью можно воплотить любые идеи по внешнему виду страницы, в том числе динамическое отображение данных, с помощью HTML, CSS и вставки кода. Для примера попробуем отобразить требуемые материалы в виде таблицы. Добавим основу таблицы с двумя колонками — Материал и Количество. Используем контейнер <tbody></tbody> для тела таблицы.

Внутри tbody используем цикл для обработки требуемых материалов, полученных с помощью функции getNeeds(). По каждому элементу рендерим шаблон renderNeed. В качестве аргумента передаем сам элемент списка требуемых материалов, который имеет структуру, соответствующую NeedForMaterials:

<table>
    <thead>
        <tr>
            <th>Материал</th>
            <th>Количество</th>
        </tr>
    </thead>
    <tbody>
        <% for (const need of getNeeds()) {
        %>
            <%= renderNeed(need) %>
        <% } %>
    </tbody>
</table>

В шаблоне renderNeed по каждому материалу добавим строку таблицы <tr class="material-need-deals">, а в ячейках td добавим название материала need.materialName и общее количество по заказам need.quantity.

Затем, если по данному материалу есть заказы need.deals, то по каждому из них циклом добавляем строку <tr class="material-need-deals">. В её ячейках указываем название заказа deal.dealName и требуемое количество материала по нему 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 %>

Страница практически готова, но нужно доработать её внешний вид, чтобы облегчить восприятие информации. Для этого в начало содержимого виджета Код можно добавить CSS-стили внутри контейнера <style> ... </style>, который будет применяться к таблице по тэгам и классам элементов. Чтобы было удобнее, можно назначить отдельный класс для таблицы или же обернуть её в контейнер с нужным классом.

Рассмотрим полное содержимое виджета Код после применения стилей и улучшения внешнего вида:

<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>Материал</th>
                <th>Количество</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 %>

Результат рендера на странице:

Динамика

Попробуем доработать созданный отчет и, чтобы было удобнее с ним работать, добавим немного динамики. Для начала дадим возможность перейти к просмотру нужного материала или заказа при нажатии на его название в таблице. Для этого мы обернем вставку названия материала и заказа в ссылку, чтобы получилась строка типа: «(p:item/код_раздела/код_приложения/идентификатор_элемента)»:

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

Теперь добавим возможность сворачивать и разворачивать список заказов по материалу:

  1. Добавим в интерфейс списка требуемых материалов свойство showDeals типа boolean, которое будет отвечать за видимость заказов по данному материалу:
interface NeedForMaterials {
    materialName: string;
    quantity: number;
    deals: NeedByDeal[];
    showDeals: boolean;
}
  1. В шаблоне рендера пункта списка требуемых материалов перед ссылкой на название материала добавим символы плюс и минус, один из которых отображается в зависимости от значения свойства showDeals данного пункта списка. По клику на элемент <span>, который будет кнопкой для разворачивания и сворачивания списка заказов, добавим вызов функции toggleShowDeals и передадим в нее идентификатор материала: onclick="<%= Scripts %>.toggleShowDeals('<%= need.materialId %>')". Стоит обратить внимание, что при вызове функции из скриптов в атрибуте onclick можно передать только отдельное строковое свойство, а не весь объект целиком. Иначе будет некорректно воспринято написание символов < и > в аргументах функции.

  2. В условие видимости блока с выводом заказов по материалу добавим проверку свойства need.showDeals: <% if (need.showDeals && need.deals.length) { %>.

В итоге шаблон рендера пункта списка требуемых материалов будет выглядеть следующим образом:

<% $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. В скрипте добавим функцию toggleShowDeals, которая принимает идентификатор материала из виджета Код, по нему ищет в массиве materialNeeds пункт списка требуемых материалов и меняет значение его свойства showDeals на обратное:
function toggleShowDeals (materialId: string) {
    const need = materialNeeds.find(need => need.materialId === materialId);
    if (!need) {
        return;
    }
    need.showDeals = !need.showDeals;
}

При этом у нас должен обновиться внешний вид виджета. Для этого используем то, что при изменении значений свойств контекста, с которыми связан виджет, проверяется необходимость его полного пересчета и перерисовки. Добавим на вкладке Контекст дизайнера интерфейсов свойство, значение которого будем изменять — например, свойство timestamp типа Дата/время.

Применим это свойство в виджете Код — неважно, как именно оно будет использовано, например, можно добавить пустое условие:

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

В скриптах будем присваивать этому свойству новое значение в тот момент, когда необходима перерисовка виджета:

function toggleShowDeals (materialId: string) {
    const need = materialNeeds.find(need => need.materialId === materialId);
    if (!need) {
        return;
    }
    need.showDeals = !need.showDeals;
    Context.data.timestamp = new Datetime();
}

В результате в нашей таблице появляется возможность сворачивать и разворачивать список заказов по отдельному материалу:

Добавление виджетов в виджете «Код»

Добавим возможность фильтровать данные в таблице. Для этого перейдем во вкладку Контекст дизайнера и добавим свойства, которые будут использоваться для ввода значений фильтров. Для правильной работы их типы должны соответствовать типам полей, по которым необходимо фильтровать данные. Создадим свойства zakaz и zakazchik:

Для управления фильтрами добавим виджет Выпадающее окно (Widget.popover), которое будет открываться при нажатии на кнопку. Как добавить этот виджет или некоторые другие, описано в статье о виджетах. Для этого изменим заголовок таблицы и добавим виджет Выпадающее окно, который состоит из элемента открывания окна filtersOpener, содержимого окна filtersContent и добавления самого виджета через UI.widget.popover( ... ).

В содержимое окна добавим заголовок и вставим добавленные свойства View-контекста с помощью UI.widget.viewContextRow( ... ). Также добавим кнопку, нажатие на которую будет выполнять функцию, обрабатывающую значения полей для фильтрации <%= Scripts %>.applyFilters():

<thead>
    <tr>
        <th>
            Материал

            <% $template filtersContent %>
                // Содержимое выпадающего окна
                <h4> Настройка фильтрации: </h4>
                <%= UI.widget.viewContextRow('zakaz', { name: 'Заказ' }) %>
                <%= UI.widget.viewContextRow('zakazchik', { name: 'Заказчик' }) %>
                <button
                    type="button"
                    class="btn btn-default"
                    onclick="<%= Scripts %>.applyFilters()"
                >
                    Применить
                </button>
            <% $endtemplate %>

            <% $template filtersOpener %>
                // Контейнер, клик по которому откроет окно
                <button type="button" class="btn btn-default btn-link">Настроить</button>
            <% $endtemplate %>

            // Добавление виджета
            <%= UI.widget.popover(
                filtersOpener,
                {
                    type: 'widgets',
                    size: 'lg',
                    position: 'right',
                },
                filtersContent
            ) %>
        </th>
        <th>Количество</th>
    </tr>
</thead>

Теперь добавим функцию в клиентский скрипт:

async function applyFilters () {
    // Получаем текущие значения полей для фильтрации
    const zakaz = Context.data.zakaz;
    const zakazchik = Context.data.zakazchik;

    // Обновляем данные без фильтрации
    await fillNeeds();

    if (!zakaz && !zakazchik) {
        // Если не выбран ни один из фильтров, то прекращаем выполнение — будут отображены все данные
        // Обновляем отображение, присвоив новое значение свойству 
        Context.data.timestamp = new Datetime();
        return;
    }

    // Создаем пустой массив для заполнения
    const filteredNeeds: NeedForMaterials[] = [];
    for (const need of materialNeeds) {
        // Для каждого материала обнуляем количество и создаем массив для отфильтрованных заказов
        need.quantity = 0;
        const filteredDeals: NeedByDeal[] = [];
        for (const deal of need.deals) {
            if (
                (!zakaz || deal.dealId === zakaz.id) &&
                (!zakazchik || zakazchik.id === deal.clientId))
            {
                // Если заказ соответствует условиям фильтрации, добавляем в список
                // и вставляем количество требуемого материала по сделке.
                filteredDeals.push(deal);
                need.quantity += deal.quantity;
            }
        }
        // Обновляем заказы отфильтрованным списком
        need.deals = filteredDeals;
        if (need.quantity) {
            // Если требуемое количество данного материала не равно нулю, добавляем в список
            filteredNeeds.push(need);
        }
    }
    // Обновляем основной список требуемых материалов, используя отфильтрованные значения
    // и обновляем отображение, присвоив новое значение свойству `timestamp`
    materialNeeds = filteredNeeds;
    Context.data.timestamp = new Datetime();
}

Попробуем использовать созданные фильтры. При нажатии на ссылку Настроить в заголовке таблицы открывается окно с полями для фильтрации. Для начала установим фильтрацию по полю Заказчик, равное Заказчик 2:

После нажатия на кнопку Применить окно с фильтрами закрывается, а обновленный список содержит требуемые материалы только по заказам Заказчика 2:

Снова откроем окно с фильтрами и включим фильтрацию по полю Заказ, введя Заказ №2 с Заказчик 2:

Обновленная таблица содержит требуемые материалы только по Заказу №2 и Заказчику 2: