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

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

Попробуем разобрать такой достаточно простой пример. Допустим у нас есть приложение Материалы, которое содержит материалы для строительно—монтажных работ.

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

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

Для этого создадим новую страницу и назовем ее «Потребность материалов». На странице у нас пока будет пока только виджет Код, в котором мы сделаем нужное нам отображение элементов.

Сценарий

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

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

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

По заказам материала нам много здесь видеть не надо, собственно название заказа dealName, необходимое количество материала quantity, ну и желательно идентификатор — для возможности достоверно идентифицировать отдельный заказ в случае необходимости:

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);

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

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

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

Для удобства получения данных из сценариев добавим функцию 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, мы можем передать только отдельное строковое свойство, а не весь объект целиком, иначе будет некорректно воспринято написание символов «<», «>» в аргументах функции.

В условие отображения блока с выводом строк заказов по потребности материала, добавим проверку свойства 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: